分布式事务解决方案:最佳实践
上周五晚上 10 点半,我瘫在深圳南山科技园的工位上,盯着屏幕上疯狂刷屏的 Deadlock found when trying to get lock 报错,心里一万头草泥马奔腾而过。这已经是我本周第三次被线上告警叫醒了。说好的早睡早起、健康生活呢?果然,程序员的 flag 都是用来打脸的。
我是京东某核心交易系统的后端开发,五年老油条了,经历过好几届 618 和双 11 的“洗礼”。我们系统每天处理数千万级订单,涉及库存、优惠券、积分、物流等多个微服务。去年双 11 前夕,业务方突然要求上线一个“跨服务组合优惠”功能——买 A 商品送 B 积分,同时扣减 C 优惠券。听起来人畜无害,但背后是三个独立数据库的写操作。产品经理轻飘飘一句“保证一致性就行”,差点让我当场表演一个原地去世。
问题来了:分布式事务到底怎么搞?
单体时代,一个 BEGIN ... COMMIT 就搞定的事,现在拆成七八个服务,每个服务还可能用不同数据库(MySQL、Mongo、TiDB 乱炖),传统的本地事务直接歇菜。这时候你就得面对分布式事务这个“大魔王”。
市面上常见的方案有这么几种:
- 2PC/3PC:理论很美,性能很烂,锁持有时间长,在高并发场景下简直就是自虐。
- TCC(Try-Confirm-Cancel):灵活,但代码侵入性强,每个业务都要手写 Try/Confirm/Cancel 三套逻辑,维护成本爆炸。
- Saga 模式:通过补偿事务回滚,适合长流程,但不保证隔离性,中间状态可能被其他事务看到,容易产生脏读。
- 基于消息队列的最终一致性:异步解耦,性能好,但实现复杂,要处理消息重复、丢失等问题。
- Seata / DTM 等开源框架:封装了上述模式,开箱即用,但学习曲线陡峭,且对 Go 生态的支持参差不齐。
我们团队评估了一圈,最终决定采用 基于可靠消息的最终一致性 + 补偿机制,并用 Go 实现了一套轻量级的事务协调器。为什么选它?简单:性能扛得住、代码改动小、运维能睡着觉。
踩坑实录:别信文档,信日志!
一开始我们天真地以为用 Kafka 发个消息就完事了。结果上线第一天,测试同学就发现:用户支付成功了,但积分没到账。查日志发现,消息压根没发出去——因为发消息和本地事务不是原子的!
典型的“先写 DB 再发消息”陷阱。如果 DB 写成功了,但发消息时网络抖动失败,数据就丢了。反过来,“先发消息再写 DB”更危险:消息发了,DB 写失败,下游服务白忙活。
解决办法?本地消息表(Local Message Table)。思路很简单:
- 在业务 DB 中建一张
outbox表 - 业务操作和插入
outbox记录放在同一个本地事务里 - 后台有个轮询任务,不断扫描
outbox,把消息发到 MQ - 发送成功后标记为已发送,避免重复
这样,只要本地事务成功,消息就一定不会丢。虽然多了张表,但换来的是强可靠性。
// 示例:下单时同时插入订单和消息记录
func CreateOrder(ctx context.Context, order Order) error {
return db.WithTx(ctx, func(tx *sql.Tx) error {
// 1. 创建订单
if err := tx.ExecContext(ctx, "INSERT INTO orders ...", ...); err != nil {
return err
}
// 2. 插入本地消息表
msg := OutboxMessage{
ID: uuid.New(),
Topic: "order.created",
Payload: json.Marshal(order),
Status: "pending",
CreatedAt: time.Now(),
}
_, err := tx.ExecContext(ctx,
"INSERT INTO outbox (id, topic, payload, status, created_at) VALUES (?, ?, ?, ?, ?)",
msg.ID, msg.Topic, msg.Payload, msg.Status, msg.CreatedAt)
return err
})
}
后台轮询服务用 Go 写,goroutine + channel 轻松搞定。我们每秒扫 500 条,延迟控制在 200ms 内,双 11 期间稳如老狗。
// 消息投递 Worker
func (s *Sender) Start() {
ticker := time.NewTicker(100 * time.Millisecond)
for range ticker.C {
msgs, _ := s.repo.FetchPendingMessages(500)
for _, msg := range msgs {
go s.deliver(msg) // 并发投递
}
}
}
func (s *Sender) deliver(msg OutboxMessage) {
if err := s.mq.Publish(msg.Topic, msg.Payload); err != nil {
// 重试 or 告警
log.Errorf("Failed to send message %s: %v", msg.ID, err)
return
}
s.repo.MarkAsSent(msg.ID) // 更新状态
}
吐槽时间:运维同学看到我们又加了个轮询服务,差点拿咖啡泼我。但我说:“哥,这比半夜被 PagerDuty 叫醒强吧?” 他默默点了点头。
补偿机制:Plan B 不是摆设
最终一致性最大的问题是:中间状态不可见。比如用户支付后,积分还没到账,他刷新页面看到“积分未增加”,立马投诉客服。
所以我们加了一层 补偿 Job:定时扫描“异常状态”的订单(比如支付成功但积分未到账),主动触发补偿逻辑。
这里用到了一个简单的 状态机算法:
订单状态: [CREATED] -> [PAID] -> [POINTS_ADDED] -> [COUPON_DEDUCTED]
如果发现某个订单卡在 PAID 超过 5 分钟,就调用积分服务的 AddPointsCompensate(orderID) 接口。补偿接口必须是 幂等 的!我们约定所有补偿接口都带 request_id,服务端用 Redis 记录已处理的 ID。
func AddPointsCompensate(ctx context.Context, req CompensateRequest) error {
if s.redis.SetNX(ctx, "compensate:"+req.RequestID, "1", 24*time.Hour).Val() == false {
return nil // 已处理,直接返回
}
return s.pointsService.Add(req.UserID, req.Points)
}
别小看这个 SetNX,去年双 11 就靠它挡住了因重试导致的积分重复发放事故。当时测试同学跑来问:“你们是不是给用户发了 10 倍积分?” 我看了一眼监控,淡定回他:“没有,幂等保命。”
性能与可维护性:Go 的优势在哪?
我们全栈 Go(除了 DB),选择 Go 实现事务协调器有几个原因:
- 并发模型天然契合:goroutine 轻量,处理高并发消息投递毫无压力。
- 编译型语言,部署简单:一个二进制文件扔到容器里就跑,运维同学狂喜。
- 清晰的错误处理:虽然
if err != nil写到手抽筋,但至少不会像 Java 那样堆栈溢出看半天。
特别提一下 代码可读性。我们团队有个不成文规定:任何超过 50 行的函数必须拆。所以你看上面的 CreateOrder,逻辑清晰,注释到位,新来的实习生都能看懂。反观隔壁组用某 JVM 语言写的 TCC,Confirm 逻辑嵌套七层,作者自己都看不懂了,每次改需求都像拆炸弹。
效果如何?数据说话
上线三个月,经历了 618 大促,效果如下:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 事务成功率 | 98.2% | 99.99% |
| 平均延迟 | 1.2s | 320ms |
| 运维告警次数/周 | 15+ | <2 |
最爽的是,今年双 11 当天,我居然能在晚上 9 点准时下班!虽然回家路上还在刷手机看监控,但至少不用守在公司等凌晨的流量洪峰了。
一点心得:别追求完美,追求可用
很多新人一上来就想搞 Seata、搞 XA,觉得“学术正确”就是最好的。但在真实的互联网业务中,简单、稳定、可运维才是王道。我们这套方案虽然“土”,但扛住了真实流量,代码也容易理解。领导问起来,我也能三句话讲清楚原理。
另外,分布式事务不是银弹。能用业务设计规避的,尽量规避。比如把强一致性需求转化为“最终一致 + 用户提示”,用户体验反而更好。毕竟,用户真的在乎那 200ms 的延迟吗?他们只在乎“钱没丢、东西能到”。
最后,分享一句我们架构师的名言:“所有分布式问题,都是因为你想得太复杂。”
好了,今天就水到这里。我去泡杯枸杞茶,准备迎接明天早 8 点的站会——产品经理说又要加个“跨店满减叠加积分翻倍”功能……救命!
(完)
P.S. 如果你也在深圳搞 Go 后端,欢迎约咖啡聊聊分布式那些事儿。不过别找我要代码,我们 GitLab 权限管得比腾讯还严 😅

评论 0