分布式事务解决方案:我在滴滴踩过的那些坑
作者:滴滴4年后端开发,司机端核心业务老油条,开源源码重度爱好者,现居杭州,正在默默看网易和阿里的机会
上周五晚上十点半,我正盯着屏幕里那行熟悉的 Transaction rolled back because of timeout 日志发呆,左手咖啡已经凉了,右手键盘还沾着泡面油——没错,又是一个典型的“线上事故 + 产品催进度”组合拳。
事情起因很简单:我们司机端有个新需求,用户下单后要同时扣减司机余额、更新订单状态、写入风控流水。三个服务,跨两个数据库,产品经理一句“这个功能下个月上线哈”,结果就把我推进了分布式事务的深水区。
说实话,以前在小公司时,这种场景直接上 BEGIN...COMMIT 就完事了。但到了滴滴这种量级,别说单库事务了,连“微服务”这三个字说出来都得掂量掂掂——你以为你在调用一个接口,其实你在跟整个宇宙博弈。
今天这篇不讲八股文,就说说我这半年怎么在 Go 项目里把分布式事务从“能跑”做到“稳如老狗”的实战经验,顺带吐吐槽、自曝几个让我想砸电脑的坑。
起手式:别一上来就搞 Seata
刚接到需求那会儿,我第一反应是:“不是有 Seata 吗?GitHub Star 几万,阿里背书,拿来即用!”
于是吭哧吭哧搭了个 AT 模式 demo,本地跑得飞起。结果一上测试环境,TPS 直接掉到个位数。查了半天才发现:Seata 的 undo log 表锁太重,尤其在高并发扣款场景下,MySQL 的 gap lock 和 next-key lock 直接把性能干趴了。
更尴尬的是,我们司机端用的是 Go 写的微服务,而 Seata 官方对 Go 的支持……怎么说呢,社区版基本靠志愿者维护,文档比我的发际线还稀疏。有一次升级版本,@GlobalTransactional 注解突然不生效,翻了三天源码才发现是 gRPC 协议头兼容性问题。
血泪教训:别迷信“大厂方案”。适合你的,才是最好的。
方案选型:最终一致性 vs 强一致性?
我们内部开过一次技术评审会,CTO 火箭哥(真名保密)直接拍板:“司机余额变动必须强一致,其他可以最终一致。”
所以拆解下来:
- 扣余额 + 更新订单状态 → 必须原子成功或失败
- 写风控流水 → 可以异步补偿
这就决定了我们不能全盘用消息队列(比如 RocketMQ 事务消息),因为风控流水丢了顶多告警,但余额扣了订单没更新?那司机师傅怕是要打电话到客服骂娘了。
于是我把目光投向了 TCC(Try-Confirm-Cancel)模式。虽然实现成本高,但可控性强,而且天然适配 Go 的 context + error 处理哲学。
🤔 为什么选 TCC 而不是 Saga?
Saga 的补偿逻辑是“反向操作”,但“扣余额”的反向是“加回去”——万一中间有其他业务也改了余额,补偿就可能超发。TCC 的 Cancel 是基于 Try 阶段冻结的资源,更安全。
实战:用 Go 手撸一个轻量 TCC 框架
既然现有方案水土不服,那就自己造轮子(其实是被 deadline 逼的)。
核心思路很简单:
- Try 阶段:冻结资源(比如预扣余额)
- Confirm 阶段:真正提交
- Cancel 阶段:释放冻结资源
但在 Go 里,难点在于 如何保证 Confirm/Cancel 一定能执行,尤其是服务宕机、网络分区时。
关键设计:事务日志 + 定时补偿
我搞了个 tcc_transaction 表:
CREATE TABLE tcc_transaction (
id BIGINT PRIMARY KEY,
biz_id VARCHAR(64) NOT NULL, -- 业务唯一ID,比如 order_id
status TINYINT NOT NULL, -- 0: trying, 1: confirmed, 2: cancelled
try_time DATETIME,
confirm_time DATETIME,
cancel_time DATETIME,
retry_count INT DEFAULT 0,
max_retry INT DEFAULT 5,
created_at DATETIME,
updated_at DATETIME
);
每次发起 TCC 事务,先插入一条 status=0 的记录,然后依次调用各参与方的 Try 接口。
如果所有 Try 成功,就更新状态为 Confirmed,并触发 Confirm;只要有一个 Try 失败,立刻进入 Cancel 流程。
但问题来了:Confirm 或 Cancel 如果失败怎么办?
答案:异步重试 + 幂等。
我在 Go 里用 cron 库起了个后台任务,每 30 秒扫描 status=0 且 try_time 超过 1 分钟的记录(说明卡住了),自动触发 Cancel。同理,Confirm 也有重试机制。
// 示例:Confirm 重试逻辑(简化版)
func (s *TCCService) RetryConfirm(ctx context.Context) {
txns, _ := s.repo.GetPendingConfirmTxns(time.Now().Add(-1*time.Minute))
for _, txn := range txns {
if err := s.confirmOne(ctx, txn); err != nil {
// 记录失败,增加 retry_count
s.repo.IncrementRetry(txn.ID)
if txn.RetryCount > txn.MaxRetry {
// 告警!人工介入
alert.Send(fmt.Sprintf("TCC confirm failed after %d retries", txn.MaxRetry))
}
}
}
}
💡 幂等是生命线!
所有 Confirm/Cancel 接口必须基于biz_id做幂等。我们在 DB 层用INSERT IGNORE或ON DUPLICATE KEY UPDATE,避免重复操作。
坑点实录:这些细节差点让我背锅
坑 1:时间窗口导致“幽灵事务”
有一次压测,发现偶尔会出现“余额扣了但订单没更新”。查日志发现:Try 成功后,服务重启,Confirm 没执行,但定时任务还没到扫描时间。
解决方案:Try 阶段必须同步落库,且 Confirm/CANCEL 触发要尽可能快。我们后来把事务日志的写入放在 Try 之前,确保“有日志才有操作”。
坑 2:Go Context 超时传导失效
Go 的 context 很好用,但在 TCC 跨服务调用时,父 context 超时不会自动 cancel 子 goroutine。有一次 Confirm 阶段某个下游服务 hang 住,导致整个事务卡死。
后来我们在每个 RPC 调用都显式传递 context.WithTimeout(parentCtx, 2*time.Second),并在 Cancel 时主动 close 连接。
坑 3:算法?其实是个状态机!
很多人以为分布式事务是“算法题”,其实它更像一个有限状态机(FSM)。
我们的事务状态流转如下:
[Start]
↓
Trying → All Try Success? → Yes → Confirming → Confirmed
↓ No ↑
Cancelled ← Canceling ←─────┘
一旦进入 Confirming 或 Canceling,就不能再切换。这个状态机我用 Go 的 switch-case + 事务日志状态硬编码实现,简单粗暴但可靠。
🙃 别被“两阶段提交”、“Paxos”这些词吓到。在业务系统里,99% 的场景只需要一个靠谱的状态机 + 重试机制。
性能优化:从 50 QPS 到 3000+
初期版本上线后,压测只有 50 QPS,DB CPU 打满。分析发现瓶颈在 事务日志表的频繁 UPDATE。
优化手段:
- 批量提交:多个 TCC 事务共享一个 DB 连接池,减少事务开销
- 异步写日志:Try 成功后,日志写入走 channel + worker pool
- 索引优化:给
biz_id和status加联合索引,加速补偿扫描
优化后效果(测试环境,4C8G):
| 指标 | 优化前 | 优化后 |
|---|---|---|
| TPS | 50 | 3200 |
| P99 延迟 | 850ms | 45ms |
| DB CPU | 95% | 35% |
为什么不用消息队列做最终一致?
有同学问:“你们风控流水为啥不用 RocketMQ 事务消息?”
答:用了,但仅限于非核心链路。
我们现在的架构是:
- 核心链路(余额+订单)→ TCC 强一致
- 非核心链路(风控、通知)→ RocketMQ 事务消息 + 幂等消费
这样既保证了资金安全,又避免了 TCC 的复杂度扩散到所有服务。
Go 里用 github.com/apache/rocketmq-client-go 接入很方便,关键是要处理好 半消息回查 和 消费者幂等。
// RocketMQ 事务监听器示例
producer.SetTransactionListener(&primitive.TransactionListener{
ExecuteLocalTransaction: func(msg *primitive.Message) primitive.LocalTransactionState {
// 1. 写本地事务(比如插入风控流水)
// 2. 返回 Commit 或 Rollback
return primitive.CommitMessageState
},
CheckLocalTransaction: func(msg *primitive.MessageExt) primitive.LocalTransactionState {
// 回查本地事务状态
return getLocalTxState(msg.TransactionId)
},
})
给 Go 开发者的建议
别怕造轮子,但要评估 ROI
Seata 不适合你?那就自己搞。但记住:轮子要有明确边界,别试图做一个通用框架,专注解决当前业务问题。日志!日志!日志!
分布式事务出问题,90% 靠日志定位。我们给每个 TCC 事务生成唯一 trace_id,贯穿 Try/Confirm/Cancel 全流程。监控告警必须到位
我们在 Prometheus 里暴露了tcc_pending_transactions指标,超过阈值自动告警。上次双11零点,就靠这个提前发现了下游服务超时。压测!压测!压测!
本地跑通 ≠ 生产能用。一定要模拟网络抖动、服务宕机、DB 主从切换等场景。
最后说两句
写这篇文章时,我刚搞定一个 TCC 死锁 Bug——两个事务互相等待对方释放余额冻结,典型的“哲学家就餐问题”。修完后看了眼时间,凌晨两点,窗外杭州下着雨。
有时候觉得,做后端就像在黑暗中搭积木,你永远不知道哪一块会突然塌掉。但正是这些坑,才让我们从“API 调用工程师”变成真正的系统构建者。
分布式事务没有银弹,但只要你愿意沉下去看源码、抠细节、扛住压力,总能找到一条属于你的“最佳实践”之路。
对了,如果你也在杭州,对 Go + 高并发感兴趣,欢迎私信聊聊(阿里网易滴滴都行,反正我现在是“骑驴找马”状态 😅)。
附:关键配置参考(Go + MySQL)
# tcc config
tcc:
retry_interval: 30s
max_retry: 5
confirm_timeout: 2s
cancel_timeout: 2s
# db connection pool
db:
max_open_conns: 100
max_idle_conns: 20
conn_max_lifetime: 30m
代码已脱敏,核心逻辑可复用。完整实现涉及公司内部中间件,就不开源了,见谅。

评论 0