
本文要点
由于重试、背压和启动延迟,事件驱动架构经常在压力下崩溃,尤其是在负载高峰期间。
延迟并不总是问题所在;弹性取决于跨队列、消费者和可观测性的系统范围协调。
混洗分片、资源调配和快速失败等模式显著提高了持久性和成本效益。
常见的故障模式包括针对平均工作负载进行设计、配置错误的重试以及平等对待所有事件。
弹性设计意味着预测运维边缘情况,而不仅仅是优化快乐路径。
事件驱动架构(Event-driven architectures,EDA)在纸面上看起来很棒,因为它们具有解耦的生产者、可扩展的消费者和干净的异步流。但实际的系统要比这混乱得多。
考虑这样的一个常见的场景:在黑色星期五活动期间,你的支付处理服务要接收到的流量是正常流量的五倍。当这种情况发生时,你的无服务器架构就会遇到边缘情况。例如,Lambda 函数的冷启动,你的简单队列服务(SQS)队列因此而积压,同时,你会看到 DynamoDB 的限流。在这种混乱中,客户订单开始出现问题。这不是一个理论问题,对许多团队来说,这是很正常的一天。
而且,这并不仅限于电子商务。在 SaaS 平台中,功能发布会导致后端配置激增。在金融科技行业,在一些欺诈活动期间会出现大量事件,即使是几毫秒的差异也会产生重大的影响。我们可以在日常生活中找到类似的例子(流行的媒体广播、像超级碗这样的现场活动),它们遵循着类似的模式。
如果从系统的高层次图景来看,它基本上分可为三个部分:生产者、中间缓冲区和消费者。

当你谈论这些系统的弹性时,它不仅仅是关于保持可用性的;也是关于在压力下保持可预测性的。由于某些上游集成,下游遇到瓶颈,或者某些组件进行无界重试而导致的流量激增,都将测试你的架构是否能够处理好峰值。但实际的系统有它们自己的观点。
在本文中,我们将讨论如何思考构建弹性和可扩展的事件处理系统。我们将研究影响可靠性和规模的不同操作事件,并从中吸取教训,设计出更好的系统。
延迟并不是唯一的问题
通常情况下,当人们谈论事件驱动系统中的性能时,他们谈论的是延迟。但他们忘记了,延迟只是故事的一部分。对于弹性系统来说,系统的吞吐量、资源的利用率以及数据在组件之间的流动同样重要。
让我们考虑一下这个例子。你拥有一个服务,其底层基础设施依赖于一个 SQS 队列。突然间,流量激增,使下游系统不堪重负,导致它们完全或部分失败,而这种失败导致大量的重试,随后导致监控数据出现失真。此外,如果你的消费者有很高的启动时间(无论是由于冷启动还是容器加载时间),那么现在,需要快速处理的消息和仍在准备中的基础设施之间存在争用。现在,如果你仔细想想,失败模式不是超时。相反,失败是表现为延迟、重试和增加客户成本增加的设置。
现在,让我们添加死信队列(DLQs)、指数退避、限流策略或流分区到混合中,然后问题就变得更加复杂了。因此,与其调试一个函数,你不如弄清楚不同的契约是什么,以及可能发生什么。
为了设计弹性,我们需要将延迟视为系统中压力积聚的信号。它不应该只是关于最小化它的。需要这种思维的转变。
鉴于所有这些,让我们来看看可以用来解决已识别问题的一些实际方法:
在压力下的扩展模式
当谈论到弹性时,我希望你不仅仅考虑诸如减少延迟、调优重试或降低失败之类的问题。考虑设计一个系统,当遇到未预见到的场景时,它可以优雅地降级并自动恢复。让我们来谈谈在架构的不同层次上的一些模式:
设计模式
分片和混洗分片
建设弹性系统的基本概念之一是在控制爆炸半径的同时优雅地降级。一种方法是对客户进行细分,并确保有问题的客户不会拖垮整个机群(fleet)。你可以进一步设计,并添加混洗分片(Shuffle Sharding)。混洗分片是将客户分配给一个随机的分片子集,通过这样做,减少表现良好的客户与嘈杂的客户完全冲突的概率。例如,由队列支持的异步系统通常将所有客户散列到少数几个队列中。当一个嘈杂的客户出现时,它会压垮队列,进而影响其他也被散列到同一个队列上的客户。通过应用混洗分片,一个嘈杂的客户落在与另一个客户相同的分片上的概率大大降低了;隔离的故障最小化了对其他人的影响。你可以在这篇博客文章中看到这一概念的实际应用:处理数十亿次调用——来自AWS Lambda的最佳实践。
为延迟敏感的工作负载提供资源
资源配置意味着预先分配资源。它类似于预先保留 EC2 容量。这是有成本的,所以你要小心。并非所有的工作负载都需要配置并发性,但有些工作负载可能需要。例如,在金融科技行业,欺诈检测系统通常依赖于实时信号,因此,如果欺诈性交易没有在几秒钟内被标记出来,它可能会损害整个系统。在这种情况下,识别秒数重要的路径,并相应地进行投资。如果工作负载非常高,并且你对时间敏感,那么你可以在预置的并发性中使用自动伸缩,从而进一步提高成本效益。你可以在这篇博客文章中看到这一概念的实际应用:Smartsheet如何在他们的无服务器架构中降低延迟并优化成本。
基础设施模式
使用队列和缓冲区进行解耦
弹性系统吸收负载,而不是拒绝它。像 SQS、Kafka 和 Kinesis 这样的队列,以及像 EventBridge 这样的缓冲区,充当生产者和消费者之间的减震器。它们保护消费者免受突发峰值的影响,并提供自然的重试和重放语义。
使用 Amazon SQS,你可以获得强大的控制功能,如控制重试行为的可见性超时、用于重新处理的消息张力、用于隔离毒丸消息的 DLQs,以及用于提高效率和降低成本的批处理/长轮询。如果你需要排序和精确的一次处理,FIFO 队列是一个更好的选择。类似地,Kafka 和 Kinesis 通过分区提供了高吞吐量,同时保持了每个分片或分区内的记录顺序。
例如,广告技术平台上的实时竞价系统通过使用 Kinesis 和区域 id(region-id)进行分片来解耦高容量的点击流数据。另一方面,计费事件通过 FIFO 队列路由,以保证顺序并避免重复收费(特别是在重试期间)。这种模式确保每种工作负载类型都可以独立的扩展或失败,而不会对整个系统造成连锁反应。
运维模式
快速失败并进行中断
这不仅是 Meta/Facebook 的工程原则,也是关于弹性思维的。在这种情况下,如果你的消费者知道自己遇到了麻烦(例如,无法连接到数据库或获取配置),那么就快速失败。这有助于避免可见性超时、来自毒丸(poison-pill)记录的重试,并且也有助于向平台发出信号,让其尽早退出。我曾经调试过一个问题,其中一个基于容器的消费者会在失败的 DB 身份验证调用上挂起 30 秒。一旦我们添加了 5 秒超时和显式错误信号,可见性超时错误就会减少,重试也不再被添加到失败中。这样的例子数不胜数。另一个常见的例子是在没有任何严格超时的情况下完成队列消息处理的头部,从而导致积压的积累。这种模式不是要让系统变得激进,而是要让系统更具可预测性和可恢复性。
其他设计工具,如使用批处理和轮询间隔来帮助减轻开销,或者使用延迟初始化来避免在不需要时加载大型依赖项,都非常有用,这有助于提高整体的弹性。
常见的陷阱(如何处理它们)
弹性系统经常崩溃,不是因为一次大的中断,而是因为架构债务的缓慢积累。几年前我读到的一篇论文很好地阐述了这个观点,这篇论文很好地解释了亚稳态系统以及它们何时会崩溃并产生灾难性的影响。在论文中,他们特别讨论了系统如何在负载下从稳定状态转变到脆弱状态,然后发展到亚稳态,在通常需要人工干预之前观察到持久的影响。我不想说太多细节,但我只想强调一下,这是一种类似的思维转变,以避免痛苦的服务中断。
让我们来看一些可以导致这种情况的特征:
过度指标化平均负载,而不是尖峰行为
现实世界的流量很难平滑;它基本上是不可预测的。如果将批大小、内存或并发性调优到第五十百分位,则系统将在第九十百分位或更高时中断。即使是架构良好的系统,如果设计时没有考虑和吸收不可预测的负载,也可能在压力下崩溃。这不是一个“如果”的问题,而是一个“何时”的问题;关键是你应该为崩溃做好准备。大多数时候,有很多方法可以让你做好准备。考虑通过 AWS Lambda 函数处理对延迟敏感的工作负载的案例。你可以通过查看不同的 cloudwatch 指标(如调用错误、延迟或队列深度)来设置自动扩缩策略,以调整预配置的并发配置。你可以在你的测试环境中生成一个负载测试,它可以帮助你使用更高的百分位数(p95, p99)用例。
把重试当作灵丹妙药
重试次数很便宜,直到它们失败为止。如果重试是你唯一的防线,那么它们可能还不够。它们还有可能会使失败成倍地增加。重试会使下游系统不堪重负;当重试逻辑不明智时,创建不可见的流量循环太容易了。这种重试逻辑经常出现在这样的系统中,即每个错误(无论是暂时的还是非瞬时的)都可以无限制地重试,没有延迟,也没有上下文感知。不幸的是,这种方法会导致数据库节流、延迟增加,甚至整个系统崩溃等问题。
相反,你需要的是有限的重试以避免无限的失败循环,或者如果你重试,使用带有抖动的指数退避来避免争用。使用这种方法时,你应该始终牢记上下文。将你的错误分为可重试和不可重试的两类,并智能地重试。当上游系统宕机时,如果你继续以相同的速度访问网络,你将不会得到太多帮助。它也不会帮助服务更快地恢复,反而可能导致导致恢复延迟,因为重试会造成的额外压力。我在文章《克服分布式系统中的重试困境》中更详细地讨论了重试和随之而来的困境。
不把可观测性当回事
期待系统的弹性是一回事;了解你的系统是否真的具有弹性则是另一回事。我经常提醒团队:
“可观测性要将意图与实际情况区分开来”。
你可能希望你的系统具有弹性,但只有可观测性才能确认这一点。仅仅监控延迟或错误指标是不够的。弹性系统需要有明确的弹性指标,这些指标超越了表面级别的监控。这些指标应该提出更深入的问题。你检测失败的速度有多快(检测时间)?恢复速度有多快(恢复时间)?系统是否优雅地失败?影响范围是否被限制在租户、可用区或区域内?重试尝试是在帮助还是只是在掩盖真正的问题?系统如何处理背压或上游中断?这些都是高层次的信号,它们在压力下测试你的架构;只有将它们放在一起查看才有意义,而不是单独查看。
你可以使用 CloudWatch 队列深度指标、重试模式的 Log Insights 以及用于追踪跨服务的请求流的 X-ray 来实现这些见解。例如,在某个案例中,客户的系统运行平稳,直到一个 Lambda 错误开始默默地将消息推送到死信队列(DLQ)。一切看起来都是绿色的(正常的),直到用户报告数据丢失。这个问题直到几个小时后才被发现,因为没有人对 DLQ 大小设置预警。之后,团队增加了 DLQ 预警,并将其集成到他们的内部服务水平目标(SLO)仪表板中。
可观测性为你提供了唯一的视角来提问:“即使在压力下,系统是否在做我所期望的事情?”如果答案是“我不知道”,那么是时候升级了!
平等地对待所有事件
并非所有事件都是平等的。客户订单事件与日志事件不同。如果你的架构平等对待它们,那么你要么是在浪费资源/计算,要么至少是在引入风险。考虑一个支付确认事件的示例,该事件在队列中被数百个低优先级日志事件阻塞,最终影响了业务结果。更糟糕的是,这些低优先级事件可能因为某些原因被重试或重新处理,从而剥夺了关键事件的资源。你需要有一种方法来区分关键和低优先级事件。
要么建立不同的队列(高优先级和低优先级),要么建立事件路由规则,将这些事件过滤到两个不同的 Lambda 中。这种过滤还有助于仅对高优先级队列使用按需模式,而对其他队列不使用,以提高成本效益。团队发现这些问题的时候通常都太晚了,比如在成本飙升、重试次数螺旋上升或 SLA 中断时。但是有了正确的信号和架构意图,大多数问题都可以在早期避免,或者至少可以预测性地恢复。
最终的想法
当我们在大规模地构建事件驱动系统时,弹性不是要避免失败,而是要接受失败。我们不是在追求某种神话般的“完美”系统。相反,我们正在构建能够承受打击并继续运行的系统。
想想看:健壮的重试机制,不会引发系统范围的故障,弹性可以轻松吸收流量峰值,以及可预测和管理的失败模式。这就是我们的目标。但如果你刚刚开始,构建一个弹性系统可能会让你感到不知所措。你甚至不知道从哪里开始?
从小处做起!尝试使用 Amazon SQS 和 AWS Lambda 构建一个简单的事件驱动应用程序示例。一开始不要尝试任何花哨的东西。只是一个简单的队列和一个 Lambda 函数。一旦你你做到了这一点,你就可以探索其他功能,如 DLQs、故障处理等。你可以使用 EventBridge 事件总线,学习如何使用规则将事件路由到不同的目标。一旦适应了,就可以使用像混洗分片和指标自动扩缩预配置并发等技术对其进行分层。
如果你正在寻找实用的示例和教程,Serverless Land是探索模式、代码及针对 AWS Native EDA 系统量身定制的架构指导的好案例。
构建弹性不是一个简单的步骤,而是一种思维。从小处开始,从你的系统行为中学习,并随着你的信心增长,逐步增加复杂性!
原文链接:
https://www.infoq.com/articles/scalable-resilient-event-systems/
评论