
本文分享使用 Kafka 的一些经典模式。有时你感觉 Kafka 好难搞,可能不是因为 Kafka 本身复杂,而是主题、消费者组、状态、重试和 Schema 演进这些基础模式没有设计好。
核心要点
- Kafka topic 应该围绕业务事件命名,而不是围绕服务命名;否则很容易把事件流做成“绕远路的 RPC”。
- consumer group 代表一个独立的业务消费目的,同一事件可以被库存、邮件、分析等多个逻辑工作单元独立消费。
- 事件驱动不等于无状态串联事件,关键流程仍然需要由有状态服务判断是否发出下一个事件。
- 发件箱模式、退避重试、DLQ 和 Schema Registry 是 Kafka 生产可用性的基础设施,不是可选装饰。
什么时候会觉得 Kafka 难用?
Kafka 适合承载事件驱动架构,但它不会自动替你完成领域建模、失败恢复和兼容性治理。很多团队踩坑的根因不是“不会写生产者和消费者”,而是把 topic 当服务队列、把消费者组当实例编号、把失败消息当作可以无限重放的普通消息。
让我们从基础开始:
1. 每个事件类型一个主题
反模式:
orders-service-topic
shipping-service-topic
analytics-service-topic
每个服务都有自己的主题?不不不,你要这么搞,那就不是事件驱动设计了,这种设计只能看做是多了些步骤的远程过程调用(RPC)。
正模式:
order.created
order.shipped
order.cancelled
主题应该表示一个什么什么事件,多个服务可以对同一事件做出反应,这样你的架构才能保持松耦合。
2. 消费者组 = 一个逻辑工作单元
消费者组很简单:一个组 = 一个合理的业务目的。
但很多团队都把这一点搞砸了。他们为每个实例创建一个消费者组,更糟糕的是,还会为不同的任务重复使用同一个组。
正模式:
- inventory-updater 仓库更新器
- email-sender 邮件发送器
- analytics-processor 分析处理器
这些中的每一个都是独立的组。它们都使用 order.created 并独立完成自己的工作。
这将所有内容解耦。它还具有出色的扩展性——Kafka 会在同一组内的消费者之间对分区进行负载均衡。
3. 避免无状态的链式事件
假设你收到一个 order.created 的消息,然后你甚至没有检查任何内容就立即发布一个 order.verified 的消息。这很危险。不要把 Kafka 变成一个愚蠢的消息传递者。
正模式:事件 + 状态。
不要为了模拟工作流程而将五个事件串联在一起,而是让事件触发一个拥有逻辑和状态的服务,并且仅在必要时发出下一个事件。
这样可以避免意外的无限循环、幽灵事件和令人困惑的重放错误。
4. 使用发件箱模式
有没有试过在插入数据库后向 Kafka 发送消息,结果 Kafka 调用失败?现在你的数据库已更新……但 Kafka 从未收到该消息。恭喜你——你刚刚制造了一个不一致性!
发件箱模式来解决这个问题
将事件作为同一事务的一部分存储在你的数据库中,如下所示:
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (event_type, payload) VALUES ('order.created', '{...}');
COMMIT;
然后运行一个后台任务,读取 outbox 表并将事件发送到 Kafka。
原子!可靠!可重放!
5. 采用退避重试
人们认为“Kafka 让一切都可重放”,所以他们滥用它。他们只是不假思索地重新消费失败的事件。
但如果错误是由于下游系统宕机导致的呢?(比如A服务消费Kafka消息时要调用B服务做一些逻辑,此时B服务挂了,进而导致最终未能成功消费)
使用带有指数退避的重试队列,或者使用像Kafka Streams / Debezium这样内置错误处理功能的框架。
或者手动创建如下主题存放消费失败的消息,延迟重试:
- order.created.retry.1m
- order.created.retry.10m
- order.created.dlq
巴辉特提醒:这种 retry 队列的模式很有意思,之前我也未曾关注过。通常来讲,建议一个 topic + 一个 consumer group 作为颗粒度创建一套 retry 队列,因为事件可能有多个 consumer group 来消费。这个知识有点绕,大家可以通过 GPT 了解更多细节。
举例,比如 100 条事件里只有 1 条消费失败,如果持续重试这一条,持续失败,就会影响后面的事件消费。
当然,如果你们的业务逻辑就必须得严格要求顺序,那另当别论,case by case 来看哈。
6. Schema 要全局统一
如果您的事件看起来像这样:
{
"id": 123,
"user": "john",
"total": 100
}
这样没问题……直到有人添加了一个新字段:
{
"id": 123,
"user": "john",
"total": 100,
"vip": true
}
现在,你的使用者程序出现故障。或者更糟的是—— 悄无声息地出现异常行为。
巴辉特提醒:这个意思是说,consumer 不知道事件格式发生变化,可能会引起故障,需要一个机制,让所有人知道消息格式变了,而且消息格式需要兼容性。
使用 Avro/Protobuf + Schema Registry,你会得到:
- 向前/向后兼容性
- 严格类型标注
- 演进支持
当团队壮大时,你会感谢自己的这种做法。
一张简单的 Kafka 架构图
+------------------+
| Order Service |
|------------------|
| Emits: order.created |
+--------+---------+
|
v
+--------+---------+
| Kafka |
|-------------------|
| Topics: |
| - order.created |
| - order.retry |
| - order.dlq |
+--------+----------+
|
+-----------+------------+
| | |
v v v
+-----------+ +--------------+ +------------------+
|Inventory | |Email Service | |Analytics Service |
|Updater | |(Consumer) | |(Consumer) |
+-----------+ +--------------+ +------------------+
每个服务都是一个独立的消费者组,对相同的事件做出反应。发件箱模式(未显示)在生产者端实现。
Kafka 设计检查表
| 设计点 | 推荐做法 | 常见风险 |
|---|---|---|
| Topic 命名 | 使用 order.created 这类事件名 |
用服务名建 topic,耦合生产者和消费者 |
| 消费者组 | 一个 consumer group 对应一个业务目的 | 每个实例一个组,或者多个任务复用同一组 |
| 流程推进 | 由有状态服务判断是否发出后续事件 | 无条件链式发布,制造循环和重放混乱 |
| 写库与发消息 | 使用 outbox 表保证同一事务内记录事件 | 数据库已提交但 Kafka 发送失败 |
| 失败处理 | 退避重试、重试 topic、DLQ 分层处理 | 失败消息原地反复阻塞后续消费 |
| Schema 演进 | Avro/Protobuf + Schema Registry | 字段变化导致消费者故障或静默异常 |
常见问题
Kafka topic 应该按服务建,还是按事件建?
优先按事件建。按服务建 topic 会让消费者感知生产服务边界,系统很容易退化成异步 RPC;按事件建 topic,多个消费者可以围绕同一个业务事实独立响应。
Kafka 消费失败后直接重放可以吗?
不建议无脑重放。下游不可用、数据格式不兼容、业务校验失败都可能导致持续失败。更稳妥的方式是使用退避重试队列和 DLQ,把短暂故障、长时间故障和需要人工处理的消息分开。
为什么 Schema Registry 很重要?
事件一旦被多个团队消费,字段新增、删除和类型变化都会影响消费者。Schema Registry 的价值在于把兼容性检查前置,避免格式演进变成线上故障。
总结
Kafka 并不难。你跳过了那些让事情变得易于处理的模式,结果反而把事情弄复杂了。
你越早遵循这些原则,Kafka就越早成为一个健壮、可扩展的事件驱动系统的支柱,而不是凌晨3点令人头疼的调试难题。
原文:https://codingplainenglish.medium.com/kafka-is-hard-because-you-keep-ignoring-these-patterns-588b2ebac3c0