重复消费和顺序,哪个更难
同一张订单的三条消息「创建」「支付」「退款」,被两个消费者实例并行处理,库存先收到退款、再收到创建,状态直接乱套——退款先到的时候,库存侧还没有这笔订单的任何记录,退款没地方落;创建随后才到,库存和订单两边的账从此对不上。另一边,同一条支付消息因为消费者超时没回 ack(消息确认,告诉队列这条我处理完了)被重投了一次,积分发了两遍。新人总问这两个问题哪个更难,因为它们的症状都写在报障单第一行。
结论一句话:重复比顺序好防——重复有标准答案,唯一键加状态检查每个消费接口各配一份;顺序没有通用解,只有把并行改串行的代价权衡,取舍做不到位,重复和乱序就会一直漏进来。
两者的发生机制和防线对照:
| 重复消费 | 乱序 | |
|---|---|---|
| 怎么发生 | 重投、重试、ack 没送达但本地事务已提交 | 分区乱序、多实例并行、重试堵住后面的新消息 |
| 防线 | 数据库唯一键(biz_id 加操作类型),或状态 CAS | 按业务键哈希到同一队列,或状态机拒收回退消息 |
| 代价 | 每个消费接口都要做,躲不掉 | 吞吐下降,或者堵队风险 |
| 防不住的后果 | 重复扣款、重复积分 | 库存超卖、状态回跳 |
两套防线能收进同一次判断,靠的是状态机里那句「旧状态拒收新状态」。库存预占表 stock_reserve 的处理用一行条件更新,同时做完重复和乱序两个判断:
UPDATE stock_reserve
SET status = 'HELD', held_at = NOW()
WHERE order_id = 10086 -- 示例单号
AND status = 'INIT'; -- 只有 INIT 才允许进 HELD
受影响行数等于零,要么是重复投递(已经是 HELD),要么是乱序(还没到 INIT 之后的合法步骤)——两种情况消费者都要拒绝处理,把消息丢回重试或死信,等前面的状态落位。防重的唯一键保证同一笔只执行一次,状态 CAS(Compare-And-Set,只在旧状态匹配时才允许更新的原子操作)保证步骤不跳序。
三条注意:
- 建模在先:顺序的第一道防线在建模不在技术,合法迁移表里写清楚哪一步接哪一步,乱序消息天然无路可走,这条比换任何消息中间件都管用。
- 判重落库:防重的落点必须在数据库,内存里的判重集合重启即丢,等于没防。
- 最终一致:重试堵队时别追求实时有序,追求最终一致——RocketMQ 的重试消息进重试队列,后面的新消息照常投递,处理逻辑只要每次都先问状态机「这一步现在还合法吗」,晚到的旧消息自己会找到死信。
系列:深文 什么时候真的需要消息队列 | 相关 支付回调:幂等、重试与对账 | 技术纵深系列索引