跳至正文
Starone

后端

Kafka 与事件驱动架构

Kafka 是一个分区的、多副本的日志,它的大部分保证和大部分意外都源自这一个事实。本指南涵盖顺序、消费者组、投递语义、outbox 模式、schema 与保留策略,以及什么时候更简单的队列才是更好的选择。

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

Kafka 常被当作消息队列来介绍,这让团队期待它表现得像队列,然后感到意外。它更接近一个分布式提交日志:生产者把记录追加到分区,记录在配置的期限内一直保留,不管有没有人读过,而每个消费者各自跟踪自己的位置。

当系统需要的正是这种模型时,我们才使用 Kafka:同一条流有多个相互独立的消费者,能够重放历史,以及在高数据量下按实体有序处理。如果没有这些需求,我们会反对使用它。下面各节说明的这些机制,决定了一个事件驱动设计能否在生产环境中站得住。

分区决定顺序

Kafka 只保证分区内的顺序,除此之外不做任何保证。记录的 key 决定它进入哪个分区,所以选择 key 就是选择顺序的单位。以账户标识作为 key,意味着同一账户的所有事件按生产顺序被消费,而不同账户的事件则并行处理。

由此产生两个后果。倾斜的 key,例如一个体量特别大的租户,会形成热点分区,只能由单个消费者独自消化。此外,日后增加分区数会改变 key 到分区的映射,因此既有 key 的顺序在变更前后会被打破。我们在规划分区数时会把增长考虑进去,并把增加分区当作一次迁移,而不是改一个配置项。

消费者组与再均衡

同一个组内的消费者分摊一个 topic 的各个分区,每个分区在任一时刻只归一个成员所有。因此并行度的上限就是分区数;多出来的消费者只会闲置。不同的组各自独立地读取同一个 topic,正因如此,新服务可以消费一条已有的流而无需改动生产者。

当有成员加入、离开或停止 poll 时,消费者组会发生再均衡(rebalance),分区随之转移。一个消费者处理一批数据的时间如果超过 max.poll.interval.ms,就会被认定为已失效并被移出,它的分区被重新分配,尚未提交的工作会在别处再处理一遍。所以处理逻辑过慢会引发看起来像宕机的再均衡循环。协作式增量再均衡和静态组成员身份可以减轻部署期间的扰动,而小批次加上有界的处理时间,则能从源头上避免这种循环。

至少一次投递与幂等消费者

实践中的默认语义是至少一次(at-least-once):消费者先处理一条记录,再提交 offset。如果在两者之间崩溃,重启后这条记录会被再处理一次。先提交则得到至多一次(at-most-once),崩溃时会丢记录,对业务事件来说很少能接受。

Kafka 的幂等生产者可以消除由生产者重试造成的重复,事务则让“消费、转换、生产”这一循环在 Kafka 内部具有原子性。但两者都管不到 Kafka 之外的副作用。一旦处理逻辑写了数据库、调用了 API 或发了邮件,重复就有可能发生,消费者必须做到幂等。

  • 把已处理的事件标识与状态变更记录在同一个数据库事务里,并跳过任何已经见过的标识。
  • 优先选用天然幂等的操作,例如设置状态或按 key 做 upsert,而不是做增量累加。
  • 在事件中携带按实体划分的版本号或序列号,以便识别并丢弃过时或被重放的事件。
  • 把反复失败的记录连同错误上下文路由到死信 topic,避免一条毒丸记录无限期地阻塞整个分区。

Outbox 模式

一个服务先更新自己的数据库、再发布事件,是在没有共同事务的情况下做了两次写入。如果进程在两者之间挂掉,要么状态已变而事件永远没有发出,要么事件已经发出而对应的变更却回滚了。这种双写问题,是事件驱动系统中静默不一致最常见的来源。

Outbox 模式消除了这个问题。服务在与业务变更相同的本地事务里,把事件插入一张 outbox 表。随后由一个独立的 relay 把 outbox 中的行发布到 Kafka,可以通过轮询,也可以使用 Debezium 这类工具,基于数据库预写日志(WAL)做变更数据捕获(CDC)。从 outbox 出发的投递是至少一次的,这可以接受,因为消费者本来就是幂等的。代价是多一张表、多一个需要运维的 relay,以及一点发布延迟。

Schema 演进

事件是一份公开契约,对方是生产者可能并不知晓的消费者,而且它的寿命会长于最初写出它的那段代码。我们用 Avro 或 Protobuf 这类 schema 语言定义事件,把它们注册到 schema registry,并在 CI 中强制做兼容性检查,让不兼容的变更在构建阶段失败,而不是让某个消费者在运行时失败。

实际操作的规则很简单:新增字段要设为可选并带默认值,绝不挪用已有字段或改变其类型,只要还有消费者在读某个字段就不要删除它。无法以兼容方式完成的变更,就使用新的 topic 或新的事件类型,并在过渡期间同时发布两者。

保留与压缩

保留策略按 topic 以时间或大小配置,它决定了消费者最远能重放到多久以前。如果消费者落后的幅度超过了保留窗口,就会在没有任何报错的情况下丢失数据,因此“消费延迟相对于保留期限”是我们认为必须配置的告警之一。

日志压缩(log compaction)是另一种策略。Kafka 为每个 key 至少保留最新的一条记录并丢弃更旧的,值为 null 的墓碑记录(tombstone)则表示删除。压缩 topic 适合当前状态类的数据,例如配置或每位客户的最新资料,新的消费者不必重放全部历史就能重建状态。它不适合每一次发生都有意义的事件流。

什么时候 Kafka 是杀鸡用牛刀

Kafka 意味着多一个需要运维的集群,或者多一项需要付费的托管服务,外加一整套团队里每位工程师都必须理解的概念。有多个消费者、需要重放、持续的高数据量,才配得上这些代价。仅仅是某个服务需要跑后台任务,则配不上。

  • 对于只有一类消费者的工作队列,SQS 这样的托管队列或 RabbitMQ 这样的 broker,能以少得多的组件提供逐条消息确认、延迟和重试。
  • 对于单个服务内部的中等数据量,用 SELECT FOR UPDATE SKIP LOCKED 读取一张 PostgreSQL 表,既能与业务数据处于同一事务,又不需要新增基础设施。
  • 对于两个服务之间需要立即得到答复的请求与响应,一次同步调用比一对 topic 更清晰。

正在做类似的事情

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