两个月前,我分享了《从百万并发到零延迟:Golang 玩转 Kafka / RabbitMQ / Redis 高效队列全攻略》,文章重点聚焦于高并发场景下如何稳定使用消息队列本身。
而在上一篇文章《订单重复扣款事故复盘:Go 微服务中事务发件箱模式的实战 (含源码)》中,我则结合一次真实线上事故,深入探讨了数据库与消息队列之间的一致性问题。
文章发布后,收到了许多有价值的反馈,其中几类问题反复被提及:
- 消费者端不是应该做幂等吗?
- 做了幂等,为什么还会出现重复扣款?
- 事务发件箱算不算分布式事务?
- 本地消息表和 Outbox 到底是什么关系?
这些问题共同指向一个核心议题:在微服务架构下,我们究竟应该如何理解“事务一致性”?
今天,我将以专题形式,将这些概念置于同一上下文中进行系统性梳理。我们更多关注设计原理,暂不局限于特定开发语言。
一、先说结论:幂等很重要,但它远远不够
在系统设计讨论中,常听到一句话:“这个问题没关系,消费者做幂等就好了。”
这句话对,但不完整。
更准确的说法是:幂等只能防止“重复请求”,防不住“重复执行业务”。 许多线上事故恰恰由后者引发。前者容易被重视,而后者常被误认为是用户操作问题。实际上,严谨的程序员应更关注后者,这正是互联网“用户思维”的体现,关乎产品成败。
二、消费者幂等:它到底在防什么?
在微服务系统中,消费者幂等是必选项,这一点毋庸置疑,它非常有用。
消费者幂等通常解决以下问题:
- MQ 至少一次投递导致的重复消息
- 网络抖动、超时、重试造成的消息重发
- 消费者实例重启、重复拉取消息
常见手段包括:
- 以
messageId / eventId 作为唯一键
- 使用本地“已处理事件表”进行过滤
- 用数据库唯一索引兜底
- 采用 Redis 防重 Token(如 SETNX 命令)
但这里有一个非常重要的前提条件:同一条业务事件,被重复投递。
三、为什么“做了幂等”,还是会重复扣款?
回顾上一篇文章的事故场景,实际情况是:
- 上游订单服务因网络超时触发重试
- 完整地重新执行了一次创建订单的业务逻辑
- 数据库中生成两个不同的订单
- 向 MQ 发送两条内容合法、语义正确的事件
对于下游服务(消费端)而言:
- 订单 ID 不同
- 事件 ID 不同
- 请求语义完全合法
这不是重复消息,而是两次不同的业务操作。 在这种情况下,消费者幂等从设计上就无法阻止第二次执行。
四、问题的根源:业务在生产者侧被执行了两次
这也是我在事故复盘中反复强调的观点:很多一致性问题,根本不在消费者,而在生产者。
如果同一次用户请求在生产者侧就可能被执行多次,那么:
都只能起到“止损”作用,而非“治本”。在微服务架构中,各独立系统之间既不能完全信任上游,也不能过度依赖下游解决问题。尤其是生产端,若一味将问题抛给下游,问题可能愈发严重,因为下游系统对上游问题不可控,其所有操作仅是被动的防守。文章后面会提到的层层兜底才是正解。
五、那是不是要上“分布式事务”呢?
这是第二类高频问题。先给出一个工程结论:绝大多数互联网微服务场景,并不适合追求强一致的分布式事务。
能提出此问题的朋友,通常具备丰富的项目经验。从工程实践看,分布式事务大致有三条路线。
六、分布式事务的三种工程取舍
在实践项目中,工程取舍源于对各种资源的平衡考量。我们简要分析它们的特点及适用场景。
1️⃣ 强一致事务(2PC / XA)
- 强一致
- 性能与可用性成本极高
- 适用于低并发、数据严谨的场景
在高并发、微服务体系中基本不推荐,即便在大厂互联网领域也较少使用。
2️⃣ 补偿型事务(TCC / Saga)
- TCC 适合“钱”:因其
Try 阶段预留资源,能保证 Try 成功后,后续 Confirm 一定成功。
- Saga 适合“流程”:例如电商下单等多步骤、耗时长且无法长时间锁定资源的流程。若后续步骤失败,则通过消息或补偿接口回滚前序操作。
使用这两种模式时,幂等性是基础。若无幂等,补偿事务可能导致数据被“补偿过头”。
- 业务侵入性强
- 设计、开发与维护成本较高
适合场景:
3️⃣ 最终一致性事务(本地消息表 / Outbox)
最终一致性事务是当前分布式系统,尤其是高并发互联网架构中的主流选择。
-
本地消息表
在业务数据库中增加一张“消息”表,从而在同一个业务事务中完成业务数据和消息表的插入。
-
Outbox 模式(发件箱模式)
Outbox 模式本质上是本地消息表在现代微服务中的“进化版”或标准称谓。其核心差异在于 OutboxEvent 表的设计,但原子操作、异步投递、短暂容错、最终一致的核心理念是相同的。
你在上一篇文章中看到的事务发件箱模式即属此类,此处不再赘述,感兴趣的朋友可回顾前文。
七、本地消息表 ≈ 事务发件箱吗?
这是第三个常被问到的问题。
结论很明确:事务发件箱(Transactional Outbox),本质上就是“本地消息表”这类方案的模式化、规范化表达。
它们解决的是同一个问题:如何保证“业务数据写入”与“事件发布意图”处于同一个本地事务中。
区别更多体现在:
而非设计思想本身,在某种意义上它们确实属于同一类方案。
八、事务发件箱真正解决的是什么?
以订单服务为例,事务发件箱保证:
- 订单一旦成功创建
- “需要发送一条业务事件”这个事实一定被记录
- 不会因网络、MQ短暂不可用而丢失事件
它解决的是:生产者侧的一致性问题。
但它并不试图解决:
这正是“最终一致性”的边界。
九、一个成熟系统的正确姿势:层层兜底
真正稳定的系统,从不依赖某个“银弹方案”,而是建立层层兜底的防御体系。
| 层级 |
解决的问题 |
| 前端 / API |
防重复提交 |
| 生产者 |
防重复执行业务 |
| 本地事务 |
保证原子性 |
| Outbox |
防消息丢失 |
| 消费者 |
防重复消费 |
| 业务约束 |
最终兜底 |
我们不迷恋、不依赖、不拖泥带水,做好自己的本分。每一层只解决自己该解决的问题。
十、写在最后
回顾这些问题,其实并不复杂。许多“幽灵 Bug”并非源于代码能力不足,而是因为:对系统边界、责任划分和一致性模型的理解不够清晰。
当你真正厘清:
- 幂等的适用范围
- 生产者与消费者的职责边界
- 最终一致性方案的取舍逻辑
许多线上事故本可提前避免。
如果你对相关实现细节感兴趣,可以回顾前两篇文章中的代码示例。也欢迎在评论区继续探讨你在幂等设计、事务一致性、微服务架构方面的实践经验。
后续我将继续围绕 Golang 微服务工程实践,系统性地梳理这些易被忽略却非常关键的问题。
本文写于平安夜,圣诞节发布,最后祝大家圣诞节快乐。

图:一个卡通风格的冰淇淋甜筒,寓意节日甜趣与技术探讨并存。
进一步探讨
本文探讨了微服务中事务一致性的核心挑战与分层解决方案。如果你对构建稳定、可靠的分布式系统架构有更多兴趣,可以访问云栈社区的后端与架构板块,这里汇聚了关于高可用、分布式系统设计的深度讨论。同时,社区也设有专门的Go语言板块,供Gopher们交流Goroutine、Channel等核心特性的实战心得。