RabbitMQ 重投为什么会把库存扣两次?幂等消费的完整解法

消息可靠到达,不等于业务只执行一次。
摘要| 我沿 Microi吾码AI 当前 RabbitMQ 链路,从接口引擎的 V8.MQ.SendMsg 追到租户物理队列、事务发布、消费者信封校验、业务处理和 ACK。结论很直接:RabbitMQ 可以帮助消息不轻易丢,但业务只生效一次,必须由稳定 EventId、消费者原子幂等和正确 ACK 顺序共同保证。本文绑定当前源码行号、SHA-256 与 4/4 聚焦测试。
✦ ① 队列很可靠,为什么库存还是扣了两次
很多 MQ 教程把故事停在“消息已经持久化”。但在真实订单链路里,最危险的时刻恰恰发生在消息到达之后:消费者已经把库存减掉,进程却在 ACK 抵达 broker 之前掉线。RabbitMQ 不知道业务事务已经完成,只能把消息再次交给消费者。
这不是 RabbitMQ 坏了,而是 at-least-once 交付的正常代价。系统要追求的不是绝不重投,而是同一业务事件无论到达多少次,最终只生效一次。

每一段都有自己的成功语义,不能用一个“发送成功”覆盖整条链。
先给结论|| Broker 可靠性解决消息运输;EventId、inbox 和业务事务解决重复执行。
✦ ② 吾码里的真实入口不是手写 RabbitMQ 客户端
在 Microi吾码AI 中,业务通常从接口引擎调用 `V8.MQ.SendMsg`。消费者同样可以是接口引擎:在 `diy_queue_receive` 里绑定逻辑队列名和接口引擎 Key,消息到达后平台把标准信封放进 `V8.Param.Message`。这样,队列连接、租户隔离与消费生命周期留给平台,订单、库存、第三方同步等逻辑继续由低代码接口引擎编排。
var eventId = V8.Param.eventId || V8.Method.NewUlid();
var send = await V8.MQ.SendMsg({
QueueName: 'order_process',
EventId: eventId,
Message: { OrderId: V8.Param.orderId, Action: 'reserve_stock' }
});
if (send.Code !== 1) return send;
return { Code: 1, Data: { EventId: eventId } };
最重要的一行|| 调用方因超时重试时必须复用同一个 EventId;重新生成 Id,相当于主动绕过幂等。
✦ ③ 发布成功,到底证明了什么
当前发布实现会规范 EventId,把逻辑队列转成租户物理队列,声明 durable 队列和 persistent 消息,并把 MessageId 设成 EventId。发布过程进入 RabbitMQ 事务,等待 `BasicPublishAsync` 后再等待 `TxCommitAsync`。这让 broker 接受消息成为一个明确边界。
但这个边界只证明消息进入 broker。它没有证明消费者已经运行,没有证明数据库提交,更没有证明第三方 ERP 收到了请求。产品文案最好写“任务已受理”,而不是“订单已同步完成”。
- 已证明:EventId、租户和物理队列已经规范化。
- 已证明:持久消息完成本次 RabbitMQ 事务提交。
- 未证明:消费者、业务数据库和第三方系统已经成功。
SendMsg Code=1
= 当前租户队列已完成本次 broker 提交
≠ 消费者执行成功
≠ 外部系统完成
≠ 业务只执行一次
✦ ④ ACK 顺序决定你会不会提前确认
当前消费者先反序列化并校验标准信封;非法信封直接拒绝且不重入业务。只有接口引擎处理返回成功,才发送 ACK 并清除重试状态。失败时,根据 `FailToReject` 进入有界重投或直接拒绝。这个顺序是正确底座:业务提交必须发生在 ACK 之前。

ACK 晚到会重投;ACK 早到则可能永久丢掉尚未完成的业务。
不可消灭的窗口|| 业务提交后、ACK 前掉线,消息一定可能重来;所以消费者必须天然接受重复。
✦ ⑤ 一个能落地的 inbox 幂等模式
最稳妥的做法,是给消费记录表建立 `(OsClient, QueueName, EventId)` 唯一约束。消费者进入业务事务后先插入 inbox;唯一键冲突说明这个事件已经处理过,直接返回成功,让平台 ACK,但绝不再次扣库存。首次插入成功时,inbox 与库存修改在同一事务提交。
var envelope = V8.Param.Message;
var eventId = envelope.EventId;
var existing = V8.FormEngine.GetFormData('mq_inbox', {
_Where: [['EventId', '=', eventId]],
_SelectFields: ['Id', 'Status']
});
if (existing.Code === 1 && existing.Data.Status === 'done')
return { Code: 1, Msg: 'duplicate ignored' };
// 生产实现应由唯一约束/条件更新保证原子占位,
// 再在同一事务边界内完成库存副作用与 done 状态。
上面代码展示的是接口引擎结构,真正抗并发必须依赖数据库唯一约束或原子条件更新,不能只做“先查再写”。两个消费者同时查不到记录时,普通查询没有任何互斥能力。
✦ ⑥ 多租户 MQ 不能只在队列名前拼字符串
当前源码把逻辑队列规范为 `microi.{lowerOsClient}.{queueName}`。如果输入已经带着别的 `microi.` 租户前缀,会直接拒绝;队列字符也有白名单。这不是命名美化,而是防止一个租户的接口引擎把消息投进另一个租户的消费链。
EventId 去重键也必须带租户和队列维度。只用一个全局 EventId,可能让两个租户碰巧相同的业务号互相吞消息;只按订单号去重,又可能让不同动作互相覆盖。
- 队列维度:服务端生成 `microi.{tenant}.{queue}`,拒绝外租户前缀。
- 幂等维度:`OsClient + QueueName + EventId` 共同定位一个业务事件。
- 连接维度:租户 RabbitMQ 配置与账号边界不能退化成共享兜底。
推荐幂等键|| OsClient + QueueName + EventId;业务动作不同就使用不同事件身份。
✦ ⑦ 这次真正跑了什么验证

当前本地聚焦测试 4/4 通过,失败为 0。
我在当前已编译的 Microi.Tests 程序集上执行 RabbitMQ 契约相关聚焦测试:4 项、4 项通过、0 项失败。覆盖队列租户规范、跨租户拒绝,以及 HTTP、后台任务与 RabbitMQ 的追踪传播源。完整 TRX、命令、执行时间和 SHA-256 保存在内部证据清单。
这组测试证明当前代码契约与本地测试一致;它没有连接生产 RabbitMQ 集群,也没有做 broker 断电、网络分区、海量积压或线上吞吐测试。
✦ ⑧ 结尾:让消息可以重来,让结果不会重做

先把正确性做成合同,再调消费者并发和吞吐。
一套可上线的 RabbitMQ 消费链,至少要回答五个问题:事件身份是否稳定,幂等占位是否原子,副作用与消费记录是否同事务,失败是重投、拒绝还是死信,EventId 能否串起发布与消费日志。任何一个答不上来,所谓“可靠消息”都可能只是把重复执行藏得更深。
Microi吾码AI 已经把租户队列、持久消息、事务发布、信封校验和 ACK 顺序搭成了平台底座。业务开发真正需要补上的,是与自己订单、库存、付款、通知语义一致的幂等合同。允许消息重投,但拒绝业务重做,这才是 MQ 的完成定义。
本文验证范围|| 当前源码 SHA、行号与 2026-08-23 的 4/4 本地聚焦测试成立;未把它冒充生产故障注入或吞吐压测。
更多推荐




所有评论(0)