【背景】 去年我们做订单履约链路,Kafka 3.5(KRaft,3 broker),核心路径: 订单创建 → 写 MySQL → 发 Kafka 消息(key = 订单号)→ 消费者调外部物流 API → 回写物流单号。
业务方两条硬要求:
- 同一订单的消息必须按序消费(创建 → 支付 → 发货);
- 同一消息不能重复执行(重复调物流 API = 重复发货 = 真金白银的损失)。
我们最初的方案是「Redis SETNX(msgId) 做幂等 + 同 key 路由到同一 partition 保证顺序」,上线三周翻了一次车:
一次 broker 滚动重启触发 consumer group rebalance,offset 回退了 87 条。重放时:
- msgId 幂等命中 → 87 条全部被跳过,其中 12 条是「支付」事件(用户已付款但物流没发货,客诉直接打过来);
- 顺序也破了:这 87 条里有 3 个订单的「创建 → 支付」被拆到两次重放,消费者先处理了「支付」再处理「创建」,业务状态机直接乱了。
我们最后改成「业务键幂等(订单号 + 事件类型做唯一键,落 MySQL)+ 单 partition 单消费者 + 消费失败有限次(5 次)后进 DLQ + DLQ 每日人工巡检」,才把这两个洞都堵上。
【常见分歧 / 几种做法】
- msgId 幂等(Redis SETNX / DB 唯一索引存 msgId):实现最简单,但 rebalance 回退窗口内,已消费的 msgId 直接跳过 → 丢消息 + 顺序破。它拦的是「重复投递」,不是「重复执行」。
- 业务键幂等(订单号 + 事件类型落 DB):能拦住真正的重复执行,但拦不住「同 key 不同事件」的乱序;DB 写入放大是硬成本(每条消息多一次 INSERT + 一次 SELECT)。
- 分布式锁(Redis/DB,粒度 = key):锁内保证顺序,但锁释放后下一条还没进来时,另一条不同 key 的消息可能在同一 partition 里乱序;Redis 锁默认 30s 超时 + 长任务(调外部 API 5s+)的组合很容易打爆。
- 单 partition 单消费者 + 顺序消费:Kafka partition 内顺序是 broker 给的,单消费者不会 rebalance,是「顺序的硬保证」。代价是吞吐被 partition 数锁死,partition 不能无限扩(broker 元数据压力 + 磁盘 IO 放大)。
- Kafka 事务(enable.idempotence + transactional)+ 消费者 end_transaction 后提交 offset:理论上最优雅,但 producer 侧事务 + consumer 侧事务的组合,rebalance 时 offset 提交时机和事务提交的原子性,最后还是靠业务自己保证。
【我的倾向与理由】 我倾向「业务键幂等 + 单 partition 单消费者 + 消费失败 5 次后进 DLQ + DLQ 每日人工巡检」。理由:
- msgId 幂等是「假幂等」:rebalance 回退是真实存在的窗口(不是理论问题),msgId 幂等在这个窗口里等于没有;
- 业务键幂等才是「真幂等」:它拦的是「重复执行」,不管消息被投递几次、从哪个 offset 重放;
- 单 partition 单消费者是「顺序的硬保证」:consumer group rebalance 会破顺序,单消费者不会;
- DLQ + 人工巡检是「兜底的最后一道」:5 次重试失败说明是系统性故障(下游挂了 / 数据异常),自动重试只会放大故障,不如停下来让人看。
代价也很实在:DB 写入放大、吞吐受 partition 数限制、DLQ 巡检有人力成本。
【想听大家在什么场景下怎么选】
- 你们线上是「msgId 幂等」还是「业务键幂等」?rebalance 回退窗口有没有真实数据(回退多少条、丢多少、客诉几单)?
- 单 partition 单消费者你们扛得住多少 QPS?partition 数怎么定的,有没有因为 partition 扩不动而被迫接受「顺序不保证」的场景?
- DLQ 你们多久清一次?清的时候业务方怎么确认「这条消息到底该不该重放」——有没有遇到过「重放会炸、不重放会丢」的两难?
- 有没有人用 Kafka 事务(producer + consumer 双事务)扛住过真实流量?rebalance 时的 offset 提交时机你们怎么处理的?
0赞
0收藏
0楼层