跳至正文
Starone

架构

构建可扩展的事件驱动架构

事件驱动系统扩展性好,失败方式也具体而可预期:双写导致的事件丢失、重复、乱序和 schema 漂移。本指南讲解应对每一种问题的机制,以及哪些情况下同步调用才是更好的设计。

· 阅读约 5 分钟 · Starone 工程团队

在事件驱动架构中,一个服务记录下某件事已经发生,并把这一事实发布出去。其他服务按各自的节奏作出反应。生产者不知道谁在监听,也不等待它们。这种解耦正是可扩展性的来源:消费者可以新增、扩容和下线,而无需改动生产者;突发负载由日志吸收,而不会沿着一串同步调用层层传导。

代价是正确性不再来自单个事务。它必须由投递保证、幂等性和对顺序的显式处理共同构建出来。我们的观点是,只有在这些特性被有意识地工程化的地方,事件驱动设计才物有所值;如果只是给一个仍然假定即时一致性的系统加上一个 broker,它反而是负担。

事件、命令,以及 payload 里该放什么

事件是用过去时陈述的一句话,说的是生产者自己负责的事情:OrderPlaced、PaymentCaptured。命令则是请求另一个服务采取行动。把两者混在一起,就会出现 SendWelcomeEmail 这样的事件,它把生产者与某个特定消费者耦合在一起,也让发布失去了意义。

payload 设计是一种取舍。只携带标识符的轻量通知,会迫使每个消费者回调查询详情,这等于重新制造了同步耦合和负载。事件携带状态转移(event-carried state transfer)则把相关数据一并带上,让消费者可以构建自己的读模型,代价是消息更大、需要维护的契约更宽。我们默认携带消费者采取行动所需的状态,但绝不暴露生产者内部的表结构。

事件一旦发布,就会有未知的消费者依赖它,旧事件也会留在日志里,因此 schema 是一份公开契约。使用 Avro、Protobuf 或 JSON Schema,并通过 schema registry 在构建流水线中做兼容性检查,可以阻止生产者发布会破坏读取方的变更。安全的变更是增量式的:新增带默认值的可选字段。重命名或删除字段,或者改变字段含义,则需要一个新的事件类型,以及一段新旧事件同时发布的迁移期。

先解决双写问题

一个服务先向自己的数据库提交、再向 broker 发布,这是没有共享事务的两次写入。如果进程在两者之间失败,要么状态变了而事件从未发出,要么事件发出了而对应的变更已经回滚。这是事件驱动系统中隐性不一致最常见的来源。

transactional outbox 模式可以消除这个问题。服务在同一个数据库事务中写入业务变更和一行 outbox 记录。由一个独立的 relay 把 outbox 记录发布到 broker,方式可以是轮询,也可以是借助 Debezium 这类工具从数据库日志做变更数据捕获(CDC)。这样一来,事件当且仅当变更已提交时才会被发布,并且至少发布一次。

假定投递语义是至少一次

broker、relay 和消费者都会重试,所以重复是常态。Kafka 的精确一次(exactly-once)语义适用于 Kafka 事务内部的读和写;它并不延伸到调用外部 API 或写入另一个数据库的消费者。可靠的做法是让消费者具备幂等性。

  • 为每个事件分配唯一标识符,并在消费者状态变更的同一个事务中记录已处理的标识符。
  • 优先采用天然幂等的操作,例如设置状态或按键 upsert,而不是递增。
  • 在聚合上携带版本号或序列号,以便检测并忽略过时的事件。
  • 把幂等键一路传递给消费者调用的任何外部系统。

顺序只在同一个 key 内成立,而非全局

Kafka 这类分区日志只保证分区内的顺序。同一实体的事件必须使用相同的分区键,通常是聚合标识符,否则消费者可能先看到 OrderCancelled,后看到 OrderPlaced。全局有序需要单一分区,而这会把吞吐量限制在一个消费者的水平。

分区键还决定了负载如何分布。分布倾斜的键,例如某个体量极大的租户,会形成一个持续积压的热点分区,而其他分区却处于空闲。重试让顺序问题更加复杂:如果一条失败的消息被搁置,而同一个 key 的后续消息继续处理,消费者实际上已经把它们的顺序打乱了。应当按 topic 逐一决定:是在失败时阻塞该分区,还是接受乱序并依靠版本检查。

故障处理与背压

对于无法处理的消息,消费者需要一套策略,而且要在第一条这样的消息到来之前就定好。

最关键的健康信号是消费延迟(consumer lag):每个消费者组落后于日志头部多远,既按消息数衡量,也按时间衡量。延迟持续增长,说明消费者资源不足或已经卡住。应依据消费延迟来扩展消费者,上限是分区数;同时在 header 中传递关联标识符,实现事件的端到端追踪。

  • 瞬时故障采用带退避的有限次重试,而不是无限循环。
  • 格式错误或永久失败的消息,也就是毒丸消息(poison message),应转入死信 topic,并附带足以诊断和重放的上下文;否则它会无限期阻塞所在分区。
  • 死信 topic 需要告警和负责人,因为一个没人监控的死信 topic,不过是多绕了几步的数据丢失。
  • 下游依赖变慢时应暂停消费,让积压留在日志里,而不是把工作堆进内存。

跨服务的工作流

一个跨越多个服务的业务流程,如果使用分布式事务,就必须放弃当初促成这一设计的独立性。saga 模式以一系列本地事务取而代之,每个本地事务都配有一个在失败时将其撤销的补偿动作。协同式(choreography)由各服务对彼此的事件作出反应,适合较短的流程。超过三四步之后,流程就变得难以看清,这时由一个显式持有状态机的编排器(orchestrator)来协调,更便于推理、测试和运维。

什么时候应当保持同步

当调用方必须拿到答案才能继续时,事件就是错误的工具,例如在购买的那一刻校验银行卡或检查库存。对于只有一个团队、一个数据库的小系统,事件的性价比也很低,因为一个事务就能以少得多的机制提供更强的保证。

最终一致性既是技术决策,也是产品决策。如果用户无法容忍在写入之后立刻读到旧数据,要么让这条路径保持同步,要么让界面设计体现出待处理状态。大多数构建得当的系统都是混合的:在用户等待的边缘侧同步,在其后可以延后处理的部分异步。

正在做类似的事情

我们很乐意交流心得。欢迎与撰写本文的工程师聊聊。