驯服异步混沌:构建可靠事件驱动系统的架构模式
事件驱动系统扩展迅速——但其崩溃也同样迅速。诀窍在于预先预料到混沌,并通过设计模式和可观测性来驯服它。
为何选择事件驱动?
在一个用户的每次点击、物联网传感器的每次信号、以及每个AI模型请求或更新都要求近乎即时响应的世界里,传统的同步请求/响应模式开始失效。事件驱动架构提供了一种引人注目的解决方案:
- 松耦合的服务
- 弹性可扩展性
- 近乎即时的响应能力
这使得EDA自然适合微服务架构、推荐引擎、物联网工作流、AI/ML推理管道以及交互密集型平台。
然而,一旦引入消息队列和重试机制,你就引入了新的故障模式和复杂性。除非你有意识地通过模式和规范重新引入它们,否则关于顺序处理、原子性等的保证就会消失。缺乏这种刻意的努力,系统可能会在你意识到之前就陷入脆弱和不可靠的状态。
事件驱动系统的潜在挑战
1. 重复和乱序事件
大多数消息队列支持至少一次交付。这确保了容错性,但也意味着你可能不止一次或乱序地接收到同一事件。幂等性确保了无论事件到达多少次,最终状态都是相同的。
想象一下因为同一笔购买被重复扣款——没人想在信用卡账单上看到这种意外。这正是可能发生的情况,例如,如果一个PaymentConfirmed事件被处理了多次。这不仅限于计费;如果取消事件在OrderShipped事件被处理后、延迟到达,你最终可能会发运客户不再需要的产品。这些不仅仅是边缘情况,而是异步系统中因重复或不正确的乱序事件处理而引发的现实问题。
模式:幂等消费者
|
|
2. 重播风暴
当错误或短暂的下游故障导致积压时,简单或无限制的重试模式不仅无法解决问题,反而可能使情况变得更糟。结果是故障被放大、延迟增加以及更高的停机风险。
建议
- 与其无休止地重试失败事件,不如在固定次数的重试后,使用死信队列将问题消息转移出去。你还可以添加警报机制,以便在出现此类情况时及时获知,并在系统恢复后触发这些失败事件的重新处理。
- 利用具有指数退避的速率感知重试,确保最终停止重试。
- 如果需要,还可以添加TTL(生存时间),并确保每条消息有最大投递尝试次数。
3. 可观测性与可调试性
异步处理使得理解系统内发生的情况变得更加困难,因为事件会异步地跨越服务边界移动。调试通常始于那个令人头疼的问题:“我的事件去哪儿了?”为了保持可见性,至关重要的是沿事件传播上下文并进行结构化日志记录。这使得开发者能够追踪事件的完整生命周期,包括其父事件和子事件。
通过利用上下文构建可视化事件流的高级仪表板,可以显著简化调试。你还可以选择深入链接到相关的追踪或日志,为工程师在问题出现时提供快速访问根本原因的途径。
最佳实践
- 在服务之间传递关联ID。
- 为每个事件使用包含丰富元数据的结构化日志记录。
- 使用如 OpenTelemetry、Zipkin 等工具,甚至通过手动查询数据来可视化流程。
4. 扇出与反压
EDA通常涉及扇出,即向多个下游消费者发送一个事件,或将单个事件拆分为多个子事件。但如果一个消费者落后,队列就会积压,影响整个系统。这就是所谓的反压。
示例:用户预订房源后发出的事件会触发三个下游服务:
- 通知服务
- 分析管道
- 合作伙伴同步 API
如果合作伙伴API缓慢或不可用,可能导致重试或积压累积。
建议
- 扇出隔离:通过独立的队列和专用的消费者基础设施解耦消费者。
- 批处理:最小化每个事件的开销。
- 监控和自动扩缩容:在消费者延迟或队列深度增加时触发自动扩容。
- 熔断器和回退机制:防止一个消费者的问题级联扩散,并确保拥有可靠的调和机制,使系统能够优雅地恢复、追赶进度,并与准确的数据保持同步。
有所帮助的架构模式
考虑到事件驱动架构固有的约束和运营挑战,采用正确的设计模式变得至关重要。以下模式是我在实践中发现特别有效的,它们可以极大地帮助你的系统更具可预测性、弹性,并且更容易在大规模下操作。
事件版本控制
版本控制不仅仅是个可有可无的功能;它能使长期运行的系统对变化更具弹性。事件模式会随着时间的推移而演变,保持向前和向后兼容性至关重要,这样新旧版本才能共存而不会破坏系统。像 Protocol Buffers 或 JSON Schema 这样的工具能以灵活性来强制执行结构。
专业建议:将新字段添加为可选项。除非有迁移计划,否则永远不要重命名或删除字段。
幂等事件消费者
通过唯一ID、时间戳甚至校验和来跟踪重复项。目标是确保无论事件被重试多少次,你的系统最终都应始终处于相同的一致状态。
专业建议:将已处理的事件ID存储在低延迟存储(如Redis、DynamoDB)中,以容错的方式避免重复处理。
乱序处理
如上所述,乱序消息处理可能导致系统状态不一致和糟糕的用户体验。为每个事件分配一个序列号或时间戳,并确保你的系统只应用最新的一个。在某些情况下,一个短暂的缓冲窗口可以帮助事件按顺序到达,而调和作业可以在它们不按顺序到达时清理不一致的情况。这里的关键是依赖传递的上下文,不要只信任事件到达的顺序。
有界重试处理
确保你的系统有重试次数限制、指数退避和死信队列,以防止故障蔓延。为了额外安全,设置TTL,以便过时的事件被丢弃,而不是堵塞系统。
审计跟踪/事件存储
记录过去的事件,以便你可以调试问题、重放工作流或模拟新变化。它对于满足合规需求和从故障中恢复也极具价值。
扇出隔离
通过为每个下游消费者分配其专用的队列或主题,防止一个缓慢的消费者拖垮整个系统。
专业建议:监控各个队列的消费者延迟。这是出现反压的第一个信号。
发件箱模式
通常,事件处理涉及更新数据库,然后将另一个事件重新排队以进行进一步处理。发件箱模式有助于确保数据库更新和事件发布之间的一致性——而无需依赖分布式事务。
通过作为同一事务的一部分将更改写入“发件箱”表,来保持数据库更新和事件发布的同步。然后,一个单独的中继服务可以发布这些事件,从而避免分布式事务的麻烦。
专业建议:通过归档旧条目来保持发件箱表的精简。否则,发布延迟会赶上你。
结论:为混沌而架构
事件驱动系统解锁了响应能力和扩展性,但代价是复杂性。现实世界是嘈杂、不稳定和不可预测的,尤其是在涉及硬件、第三方API或全球分布式用户时。
要取得成功:
- 防御性地构建你的系统,即预期会出现重复、重试和乱序事件。
- 尽早投资于可观测性。
- 确保有可靠的防护措施和回退策略。