重复消费和顺序,哪个更难

梦想恒久 0 阅读 技术沉淀

同一张订单的三条消息「创建」「支付」「退款」,被两个消费者实例并行处理,库存先收到退款、再收到创建,状态直接乱套——退款先到的时候,库存侧还没有这笔订单的任何记录,退款没地方落;创建随后才到,库存和订单两边的账从此对不上。另一边,同一条支付消息因为消费者超时没回 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 的重试消息进重试队列,后面的新消息照常投递,处理逻辑只要每次都先问状态机「这一步现在还合法吗」,晚到的旧消息自己会找到死信。

系列:深文 什么时候真的需要消息队列 | 相关 支付回调:幂等、重试与对账 | 技术纵深系列索引