背景
去年我们做订单履约链路,Kafka 3.5(KRaft,3 broker),核心路径: 订单创建 → 写 MySQL → 发 Kafka 消息 → 消费者调外部物流 API。
最初用 Kafka 事务(enable.idempotence=true + transactional.id),把 DB 写和 Kafka 发放在同一个事务里,消费者端也是 transactional consumer。上线三个月后踩了三个坑:
- initTransactions 偶发 8s 卡顿(热榜 221 那帖同款问题),消费者 group 频繁 rebalance,窗口内出现「消息已提交但 DB 未落」的半状态;
- 外部物流 API 没有事务回滚语义——Kafka 事务只能保证「本地 DB + Kafka」一致,物流 API 调成功后如果后续步骤失败,物流侧已经发货,没法回滚;
- 跨 team 的下游服务各自消费同一个 topic,其中两个 team 不用事务消费者,导致同一条消息被处理了两次。
这三个坑让我开始认真想一个问题:Kafka 的 exactly-once,边界到底在哪?
常见分歧(三种主流做法)
派 A:全链路 Kafka 事务
- 生产者:DB 写 + Kafka 发放在同一个 Kafka 事务里
- 消费者:transactional consumer,DB 写 + 外部调用都放在事务里
- 优点:语义干净,「要么全成要么全不成」
- 死法:外部系统(物流 API、短信、第三方支付)没有事务回滚语义,一旦事务边界跨出「Kafka + 本地 DB」,exactly-once 就名存实亡
派 B:Outbox Pattern + 业务幂等
- 放弃 Kafka 事务,DB 写 + outbox 表写放在同一个本地事务
- 独立 relay 进程轮询 outbox 表 → 发 Kafka
- 消费者端:去重表 / 状态机 / 业务幂等键,保证 at-least-once 下不重复
- 优点:不依赖 Kafka 事务的边界,外部 API 调完就调完了,幂等由业务层兜底
- 死法:outbox 表在 QPS 高时成为瓶颈;relay 进程本身是单点;幂等键设计不当(比如用 requestId 但请求方重试时换了 ID)
派 C:混合策略
- 核心链路(DB + Kafka)用 Kafka 事务
- 非核心链路(通知、日志、埋点)at-least-once + 去重
- 外部 API 一律幂等键
- 优点:按链路分级,不过度设计
- 死法:「核心」和「非核心」的边界模糊,team 之间没有统一标准,半年后没人说得清哪条链路是哪种
我的倾向
我偏向派 B(Outbox + 业务幂等),理由有三:
-
事务边界不该跨出「本地 DB + 本地消息表」。Kafka 事务能覆盖「DB 写 + Kafka 发」,但一旦下游要调外部 API,事务就失效了。与其用一种「看起来 exactly-once 实际不是」的方案,不如从一开始就承认 at-least-once,把幂等做扎实。
-
幂等是业务语义,不是基础设施语义。用 Kafka 事务来保证幂等,等于把业务正确性绑在基础设施的语义上——Kafka 升级、broker 切换、consumer group 协议变更,任何一环出问题,幂等就没了。业务幂等键(订单号 + 操作类型)是写在你代码里的,不依赖任何外部组件。
-
跨 team 协作时,Outbox 的契约更简单。下游只需要知道「topic + 消息格式 + 幂等键字段」,不需要知道上游用没用事务、consumer group 怎么配。去年我们踩过「两个 team 一个用事务消费者一个不用」的坑,根因就是耦合了 Kafka 的语义。
但派 B 也有代价,想听大家聊聊:
- 你们线上到底用的哪种? 如果选了 Outbox,relay 进程怎么做的 HA?outbox 表在 QPS 5000+ 时有没有成为瓶颈?你们怎么解决 relay 单点?
- 幂等键怎么设计的? 用业务主键(订单号)还是请求级 ID?上游重试时 ID 不变还是变?去重表 TTL 怎么定?
- 什么场景下你会反过来选派 A? 比如全链路都是自家服务、没有外部 API 调用、QPS 不高——这种情况下 Kafka 事务确实更简洁,你们的判断标准是什么?