分布式事务解决方案:最佳实践(一个滴滴后端老油条的血泪复盘)
早上八点,我坐在工位上,一边灌着昨天剩下的冰美式,一边盯着监控面板上那条诡异的曲线——司机端订单状态和计费系统对不上。这不是第一次了,但这次偏偏卡在双11前夜,产品那边已经发来第8条“亲,在吗?”的消息。我叹了口气,心想:这破分布式事务问题,再不彻底搞明白,我就该去送外卖了。
我是滴滴的后端开发,干了快四年,主要负责司机端的核心业务链路——从接单、开始服务、结束行程到最终结算。听起来高大上,其实大部分时间都在和各种“数据不一致”斗智斗勇。今天这篇文,不是来背书教科书理论,而是想聊聊我们团队在过去一年里,如何在真实业务场景中踩坑、填坑,最后摸出一套还算靠谱的分布式事务方案。
事情是怎么崩的?
去年9月,我们上线了一个新功能:司机信用分系统。逻辑很简单:司机完成订单 → 系统自动评分 → 更新信用分 → 触发奖励发放(比如现金券)。看似 straightforward,但上线第三天,就有司机反馈“明明完成了5单,信用分没涨,券也没到账”。
排查日志发现:订单服务成功写库,但信用服务调用超时,事务回滚了;而计费服务那边却已经扣了乘客的钱。更尴尬的是,前端同学用 JavaScript 调用我们的 API 时,看到 HTTP 200 就以为万事大吉,结果用户界面显示“奖励已发放”,后台实际啥也没干。
典型的分布式事务一致性问题。而且因为我们用的是微服务架构(Python + Flask + MySQL),每个服务都有自己的数据库,跨服务的 ACID 直接失效。产品经理当时看着我,眼神里写满了“你不是说后端很稳吗?”
我们试过的“土办法”(以及它们怎么翻车的)
1. 最朴素的 try-catch + 重试
一开始,我们天真地认为:“加个重试不就完了?”于是写了段 Python 伪代码:
try:
order_service.complete(order_id)
credit_service.update_score(driver_id)
reward_service.issue_coupon(driver_id)
except Exception as e:
# 记录日志,人工介入
logger.error(f"Order {order_id} failed: {e}")
结果?线上三天,每天凌晨两点运维报警拉满。因为 credit_service 偶尔抖动(网络波动 or DB 锁等待),导致部分流程中断,但订单状态已经更新,无法自动补偿。测试同学还吐槽:“你们这个‘人工介入’是不是指让我手动改数据库?”
2. 消息队列“假装可靠”
后来我们引入 Kafka,把信用更新和奖励发放做成异步消息:
order_service.complete(order_id)
kafka.send("credit_update", {"driver_id": driver_id, "order_id": order_id})
想法很美好:只要消息发出去,后续服务慢慢消费就行。但现实是——消息可能重复、可能丢失、可能乱序。有一次 Kafka 集群升级,积压了两小时消息,司机信用分延迟更新,客服电话被打爆。更别说前端同学还得处理“奖励状态未知”的 UI 状态,JavaScript 里一堆 loading 和 retry 按钮,用户体验稀烂。
真正能打的方案:Saga + 补偿事务
被逼到墙角后,我们决定上 Saga 模式。核心思想很简单:把一个长事务拆成多个本地事务,每个步骤都有对应的“撤销操作”。
比如我们的流程变成:
- 创建订单(本地事务)
- 扣减司机可用额度(本地事务)→ 失败则补偿:释放额度
- 更新信用分(本地事务)→ 失败则补偿:回滚分数
- 发放奖励(本地事务)→ 失败则补偿:回收券(或标记作废)
关键在于,每个服务必须提供 idempotent 的补偿接口。我们在 Python 服务里统一加了装饰器:
@idempotent(key="order_id")
def compensate_credit_update(order_id):
# 根据 order_id 查询原始状态,执行逆操作
original_score = db.get_original_score(order_id)
db.set_score(driver_id, original_score)
同时,我们引入了一个轻量级的 事务协调器(Orchestrator),用状态机驱动整个流程。它不参与业务逻辑,只负责按顺序调用服务,并在失败时触发补偿。
小插曲:上周五晚上,我们上线 Saga 方案,结果因为补偿接口没做幂等,一个司机被扣了三次信用分。还好我们有全链路 trace,半小时定位问题。运维大哥一边重启服务一边嘀咕:“你们后端是不是觉得‘补偿’就是‘随便补补’?”
为什么不用 TCC 或 2PC?
肯定有读者要问:为啥不直接上 TCC(Try-Confirm-Cancel)或者传统 2PC?
答案很现实:性能和复杂度。
TCC 要求每个服务实现 Try/Confirm/Cancel 三套逻辑,对我们这种快速迭代的业务来说,开发成本太高。而且司机端高峰期 QPS 超过 5k,2PC 的同步阻塞会直接拖垮数据库。我们试过 Seata,结果在压力测试中 MySQL 连接池直接爆了。
相比之下,Saga 虽然不能保证强一致性(中间状态可能可见),但在我们场景下完全可接受——司机不会因为信用分晚更新5分钟就卸载 App。最终一致性 + 用户友好的前端提示,才是务实之选。
说到前端,我们和前端团队达成共识:所有涉及多服务操作的页面,都加一个“处理中”状态,并允许用户手动触发“同步状态”按钮。JavaScript 里用 Promise + retry 机制兜底:
async function syncRewardStatus(orderId) {
try {
const res = await fetch(`/api/reward/status?order_id=${orderId}`);
if (res.status === "processing") {
setTimeout(() => syncRewardStatus(orderId), 2000); // 轮询
}
} catch (e) {
showRetryButton(); // 给用户一个手动重试的机会
}
}
区块链?别闹了,但思路可以借鉴
你可能会疑惑:标题里提了区块链,结果通篇没见影子?
其实我们内部技术分享会上,有个实习生提议:“不如用区块链存证,保证事务不可篡改!” 全组笑出声——这就好比为了煮泡面去买个核电站。
但冷静想想,区块链的 “状态变更可追溯 + 不可逆” 思想,对我们设计补偿日志很有启发。我们现在把每个 Saga 步骤的状态变更(包括补偿操作)都写入一个 audit_log 表,字段包括:
| 字段 | 说明 |
|---|---|
| transaction_id | 全局事务ID |
| step | 当前步骤(如 "update_credit") |
| status | success / failed / compensated |
| payload | 原始请求参数(JSON) |
| compensation_payload | 补偿操作参数 |
这样即使出了问题,也能快速还原现场。运维同学甚至写了个脚本,自动分析日志并生成补偿建议——终于不用半夜打电话问我“这个订单到底该补还是该删”了。
效果与心得
上线 Saga + 补偿事务三个月后,我们的数据不一致率从 0.7% 降到 0.02%,且 99% 的异常能在 5 分钟内自动恢复。更重要的是,产品经理再也不敢随口说“这个逻辑很简单,一天能做完吧?”
几点血泪总结:
- 不要追求强一致性:在大多数互联网场景下,最终一致性 + 良好的用户体验 > 理论上的完美。
- 补偿比回滚更实用:与其花力气做分布式回滚,不如设计好可逆操作。
- 日志即资产:详细的事务日志是排查问题的生命线。
- 前端也是防线:别把所有压力都甩给后端,前端的状态管理能极大缓解用户焦虑。
最后,如果你也在被分布式事务折磨,别慌。记住:没有银弹,只有权衡。我们不是在写论文,而是在修一条能让千万司机安心跑单的路。路上有坑很正常,关键是——带上铁锹,别光抱怨。
哦对了,今天又是8点开工。咖啡喝完了,得去续一杯。不然下午开需求评审会,怕自己忍不住对产品说:“你这个需求,需要一个分布式事务,建议先学三年再提。”

评论 0