分布式事务解决方案:那些年我和订单系统相爱相杀的日子
离职前两周,我还在跟一个分布式事务的烂摊子死磕。现在坐在咖啡馆里敲下这篇文章时,手边是半杯凉掉的美式,脑子里却全是去年双11那天凌晨三点运维兄弟在 Slack 里@我的消息:“哥,订单和库存对不上了,差了 327 单……”
我是老张,刚从前东家——一家中型电商平台的技术总监岗位上“光荣退休”,准备自己搞点事情。在那个组待了快两年,从单体架构一路陪跑到微服务 + K8s 上云,也算见证了系统从“能跑就行”到“高可用、可追溯、可观测”的蜕变。今天想聊的,就是这段旅程中最让我头秃的问题之一:分布式事务。
起因:微服务拆分后的“账算不清”
事情得从我们把订单服务拆出来那会儿说起。最初整个电商系统是个大单体,MySQL 一库搞定一切,事务靠 BEGIN ... COMMIT 就稳如老狗。但随着业务膨胀,团队也裂变成订单、库存、优惠券、支付四个小组,系统自然就拆成了微服务。
问题来了:用户下单时,要同时操作订单库(创建订单)、库存服务(扣减库存)、优惠券服务(核销优惠券)。这三个操作要么全成功,要么全失败。但在分布式环境下,你调 A 成功了,调 B 网络抖了一下超时了,调 C 返回“优惠券已过期”——这时候你咋 rollback?MySQL 的本地事务可跨不了服务边界。
产品经理当时拍着胸脯说:“技术同学,用户体验不能打折啊,用户付了钱结果没生成订单,这不得被投诉死?”
我说:“行,那你给我三天时间重构。”
他说:“明天上线。”
我:……
于是,分布式事务,成了我躲不掉的宿命。
方案选型:不是所有“最终一致”都叫靠谱
市面上主流方案无非几种:2PC、TCC、Saga、消息队列+本地事务表。作为重度 ChatGPT 用户(没错,我写代码一半靠它补全,另一半靠它安慰),我先让 Claude 给我列了个对比表:
| 方案 | 一致性级别 | 复杂度 | 性能影响 | 适用场景 |
|---|---|---|---|---|
| 2PC(XA) | 强一致 | 高 | 大(阻塞) | 金融核心系统 |
| TCC | 最终一致 | 极高 | 中 | 高并发交易系统 |
| Saga | 最终一致 | 中 | 低 | 长流程业务 |
| 本地消息表 + MQ | 最终一致 | 低 | 低 | 通用场景 |
我们不是银行,扛不住 2PC 的性能损耗;TCC 要写 Try/Confirm/Cancel 三套逻辑,团队里新人多,怕写出 bug 反而更难维护;Saga 适合订单-发货-物流这种长链路,但我们下单就三步,有点杀鸡用牛刀。
最后拍板:本地消息表 + RocketMQ 的事务消息。理由很现实:Go 生态支持好,团队熟悉,且能用“补偿机制”兜底。
实战:用 Go 把“不确定”变成“可恢复”
我们的技术栈是 Go + Gin + GORM + MySQL + RocketMQ。核心思路很简单:
- 下单时,在订单服务的本地事务里,同时写入订单记录 和 一条“待发送”的消息到
outbox表; - 后台有个 goroutine 定时扫
outbox,把消息发给 MQ; - 库存/优惠券服务消费消息,执行本地操作;
- 如果某一步失败,通过重试或人工干预补偿。
听起来很美好,但魔鬼在细节。
坑一:消息重复消费
MQ 为了保证“至少一次投递”,会重试。这就意味着库存服务可能收到两条“扣减 SKU-1001 ×1”的消息。如果没做幂等,用户买一件商品,库存扣两次——直接负数。
解法:每个消息带唯一 ID(比如 order_id + action_type),库存服务建个 dedup_key 唯一索引:
type DedupRecord struct {
Key string `gorm:"uniqueIndex"`
CreatedAt time.Time
}
消费前先插入这条记录,失败就说明处理过了,直接 return。
坑二:本地事务和消息发送的原子性
最开始我天真地以为:先写订单,再发 MQ 消息。结果某次 MySQL commit 成功,但发 MQ 时网络闪断——消息丢了,库存没扣,用户付了钱但没锁库存,超卖了。
正确姿势:必须把“写订单”和“写 outbox 消息”放在同一个本地事务里!
tx := db.Begin()
defer func() {
if err != nil { tx.Rollback() } else { tx.Commit() }
}()
// 1. 创建订单
if err = tx.Create(&order).Error; err != nil { return err }
// 2. 写 outbox 消息(状态为 pending)
msg := OutboxMessage{
ID: uuid.New().String(),
Topic: "inventory_decrement",
Payload: marshal(payload),
Status: "pending",
}
if err = tx.Create(&msg).Error; err != nil { return err }
然后由独立的 OutboxProcessor 扫表发送,发送成功再 update status 为 sent。这样即使进程 crash,重启后也能继续处理。
坑三:补偿太慢,用户等不及
有一次用户反馈:“我下单成功了,但库存还是满的,是不是系统 bug?”
查日志发现:MQ 消费延迟了 5 分钟——因为库存服务那边 GC 停顿了。
优化:引入“主动查询 + 超时补偿”。前端下单后,前端轮询订单状态;如果 3 秒内库存未扣减,就调用一个 /compensate/inventory 接口手动触发重试。虽然不优雅,但用户体验保住了。
算法?别笑,真的用上了
说到算法,很多人觉得分布式事务就是搭架子,跟算法无关。但我们在做“消息去重”和“异常检测”时,还真用上了些小技巧。
比如,为了快速判断某条消息是否已处理,我们没用数据库查(太慢),而是用 Bloom Filter 缓存在 Redis 里:
bf := bloom.NewWithEstimates(1000000, 0.01) // 100万条,误判率1%
key := fmt.Sprintf("dedup:%s", msgID)
if bf.Test([]byte(key)) {
return // 已处理
}
bf.Add([]byte(key))
redis.Set(ctx, key, "1", 24*time.Hour) // 持久化兜底
另外,为了监控“事务断裂”(比如订单创建了但消息 never sent),我们写了个定时任务,用 滑动窗口算法 检测异常比例:
- 每分钟统计:
outbox_pending_count / total_orders - 如果连续 3 分钟 > 0.5%,告警
这比单纯看 error log 有效多了——毕竟,沉默的失败最可怕。
效果与反思:线上稳定了,但我辞职了
这套方案上线后,双11期间处理了 120w+ 订单,事务成功率 99.98%。剩下的 0.02% 是用户取消订单或支付超时,属于业务正常流失。
运维兄弟终于不再半夜 call 我,测试小姐姐也夸“这次回归用例少写了 200 条”。但说实话,我心里清楚:没有完美的方案,只有合适的妥协。
本地消息表方案虽然简单,但引入了额外的表、后台任务、补偿逻辑,系统复杂度其实上升了。如果重来一次,我可能会试试 Seata 的 AT 模式(虽然 Go 支持还不成熟),或者直接上 Event Sourcing + CQRS ——当然,那又是另一个故事了。
给后来者的几点开发心得
- 不要迷信“强一致”:大多数电商场景,最终一致 + 快速补偿,比强一致更实用。用户能接受“稍等一下”,但不能接受“一直卡住”。
- 日志和 Trace 是救命稻草:务必给每个事务分配全局 ID(比如
X-Request-ID),贯穿所有服务。不然出问题你连在哪断的都不知道。 - 自动化补偿比人工快:设计时就想好“怎么回滚”,而不是等出事了再救火。我们后来加了个自动补偿机器人,凌晨三点自动修复 80% 的断裂事务。
- Go 的 Goroutine 不是万能的:别在 HTTP handler 里直接开 goroutine 发消息——进程 crash 就丢了。用可靠队列或持久化 outbox。
- 和产品好好沟通:明确告诉他们“分布式事务有成本”,别让他们以为这是技术理所当然该搞定的事。
现在回头看,那段和分布式事务死磕的日子,虽然痛苦,但也让我真正理解了“可靠性”不是配置几个参数就能搞定的,而是设计、代码、监控、运维的组合拳。
创业之后,我打算把这套方案封装成一个轻量级 Go 库,名字都想好了:go-outbox。如果你也在踩类似的坑,欢迎来 GitHub 找我聊——或者请我喝杯咖啡,聊聊你怎么被产品经理逼疯的。
毕竟,程序员的世界里,bug 会修复,需求会延期,但兄弟的情谊(和吐槽)永远在线。

评论 0