分布式事务解决方案:我在滴滴踩过的那些坑

动态规划狗
2025-12-13 03:23
阅读 235

作者:滴滴4年后端开发,司机端核心业务老油条,开源源码重度爱好者,现居杭州,正在默默看网易和阿里的机会


上周五晚上十点半,我正盯着屏幕里那行熟悉的 Transaction rolled back because of timeout 日志发呆,左手咖啡已经凉了,右手键盘还沾着泡面油——没错,又是一个典型的“线上事故 + 产品催进度”组合拳。

事情起因很简单:我们司机端有个新需求,用户下单后要同时扣减司机余额、更新订单状态、写入风控流水。三个服务,跨两个数据库,产品经理一句“这个功能下个月上线哈”,结果就把我推进了分布式事务的深水区。

说实话,以前在小公司时,这种场景直接上 BEGIN...COMMIT 就完事了。但到了滴滴这种量级,别说单库事务了,连“微服务”这三个字说出来都得掂量掂掂——你以为你在调用一个接口,其实你在跟整个宇宙博弈

今天这篇不讲八股文,就说说我这半年怎么在 Go 项目里把分布式事务从“能跑”做到“稳如老狗”的实战经验,顺带吐吐槽、自曝几个让我想砸电脑的坑。


起手式:别一上来就搞 Seata

刚接到需求那会儿,我第一反应是:“不是有 Seata 吗?GitHub Star 几万,阿里背书,拿来即用!”

于是吭哧吭哧搭了个 AT 模式 demo,本地跑得飞起。结果一上测试环境,TPS 直接掉到个位数。查了半天才发现:Seata 的 undo log 表锁太重,尤其在高并发扣款场景下,MySQL 的 gap locknext-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 逼的)。

核心思路很简单:

  1. Try 阶段:冻结资源(比如预扣余额)
  2. Confirm 阶段:真正提交
  3. 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=0try_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 IGNOREON 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

优化手段:

  1. 批量提交:多个 TCC 事务共享一个 DB 连接池,减少事务开销
  2. 异步写日志:Try 成功后,日志写入走 channel + worker pool
  3. 索引优化:给 biz_idstatus 加联合索引,加速补偿扫描

优化后效果(测试环境,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 开发者的建议

  1. 别怕造轮子,但要评估 ROI
    Seata 不适合你?那就自己搞。但记住:轮子要有明确边界,别试图做一个通用框架,专注解决当前业务问题。

  2. 日志!日志!日志!
    分布式事务出问题,90% 靠日志定位。我们给每个 TCC 事务生成唯一 trace_id,贯穿 Try/Confirm/Cancel 全流程。

  3. 监控告警必须到位
    我们在 Prometheus 里暴露了 tcc_pending_transactions 指标,超过阈值自动告警。上次双11零点,就靠这个提前发现了下游服务超时。

  4. 压测!压测!压测!
    本地跑通 ≠ 生产能用。一定要模拟网络抖动、服务宕机、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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝