分布式事务这道坎,我踩过坑也搭过桥
去年九月,我从一家中型电商公司技术总监的位置上裸辞了。不是因为钱少(虽然确实不多),也不是和老板闹掰了——纯粹是想在成都这个节奏慢得让人打哈欠的城市里,试试自己能不能从0到1搞点真正属于自己的东西。每天早上八点出门遛狗、顺便买杯本地咖啡馆的豆子,下午写代码,晚上看开源项目的PR,这种生活,香得很。
但创业之前,我得把脑子里那些“血泪教训”倒干净。今天就想聊聊分布式事务——这个让无数后端兄弟夜不能寐、产品经理一脸懵逼、测试同学反复提bug的“系统级幽灵”。
事情是怎么搞砸的?
故事还得回到前年双11前夕。我们系统要上线一个“组合购”功能:用户下单时,同时扣库存、生成订单、发优惠券、记录营销行为。四个服务,四个数据库,跨网络调用。听起来挺常规?可问题就出在“常规”上。
当时团队里有个新人小张,信誓旦旦说:“我用Spring的@Transactional注解包一下不就完了?”结果上线当晚,库存扣了,订单没生成,优惠券却发出去了。用户拿着白送的券来薅羊毛,运维半夜打电话给我:“老大,数据库快被刷爆了!”
我当时坐在家里阳台上啃兔头,差点把筷子扔了——这哪是分布式事务,这是分布式事故现场!
为什么本地事务在微服务里“失效”?
很多人刚接触微服务时,会天真地以为:只要每个服务内部事务做好,整体就没问题。大错特错。
本地事务只能保证单个数据库的一致性。一旦涉及多个服务、多个数据源,ACID 就崩了。CAP 理论早就告诉我们:在网络分区不可避免的前提下,你只能在一致性和可用性之间做权衡。
而现实更残酷:业务要强一致,老板要高可用,产品要明天上线。
于是,我们不得不面对几种主流方案。接下来这些,都是我带着团队在生产环境里真刀真枪干过的,不是纸上谈兵。
方案一:两阶段提交(2PC)——理想很丰满,现实很骨感
2PC 是教科书里的经典方案:协调者先问所有参与者“能不能提交”,大家都说OK,再发正式提交指令。
理论上完美,实操中……嗯,我只想说:别用。
为什么?性能差到离谱。一次事务至少两次网络往返,数据库锁持有时间翻倍。我们压测时发现,TPS 直接掉到原来的 1/3。更致命的是,如果协调者挂了,参与者可能一直卡在“准备”状态,资源锁死——这就是所谓的“阻塞问题”。
有一次凌晨三点,DBA 跑来敲我门:“老李,MySQL 的 InnoDB 锁表了,几十万行卡住!” 我一看日志,果然是 2PC 协调服务宕机导致的悬挂事务。修了俩小时,第二天全员泡面开会复盘。
结论:除非你在银行核心系统(且不差钱),否则别碰 2PC。它就像那个永远准时但走路比蜗牛还慢的同事——可靠但拖后腿。
方案二:TCC(Try-Confirm-Cancel)——灵活但烧脑
TCC 是我后来主推的方案。它的核心思想是:把业务逻辑拆成三个阶段:
- Try:预留资源(比如冻结库存)
- Confirm:真正执行(扣减冻结的库存)
- Cancel:释放预留(解冻)
听上去很优雅?是的,但它要求你每个业务接口都手动实现三套逻辑。这意味着开发成本翻倍,测试用例翻三倍。
举个真实例子:我们的优惠券服务,原本一个 issueCoupon(userId, couponId) 方法搞定。用了 TCC 后,得写:
// Try: 预占券
public boolean tryReserveCoupon(String userId, String couponId);
// Confirm: 真正发放
public boolean confirmIssue(String txId);
// Cancel: 释放预占
public boolean cancelReserve(String txId);
而且,这三个方法必须满足幂等性!否则重试时可能发十张券出去。有一次因为 Cancel 方法没做幂等,用户退款后券没退回,反而又领了一张——客服电话被打爆。
但好处也很明显:性能几乎无损,因为 Try 阶段不加全局锁,Confirm/Cancel 只是本地操作。我们在大促期间扛住了 5K+ TPS,稳如老狗。
经验之谈:TCC 适合核心链路(如下单、支付),但别滥用。非关键路径(比如发通知、埋点)完全可以用最终一致性。
方案三:基于消息队列的最终一致性——我的“平民英雄”
如果你觉得 TCC 太重,又不想用 2PC 这种古董,那基于消息的最终一致性可能是你的菜。
思路很简单:把“同步调用”改成“异步通知”。比如:
- 订单服务创建订单(本地事务)
- 同时发一条“订单已创建”消息到 Kafka/RocketMQ
- 库存服务消费消息,扣库存
- 如果扣失败,消息重试或进死信队列人工处理
关键点在于:发消息和写 DB 必须在一个本地事务里完成。否则可能 DB 写成功了,消息没发出去,下游就永远不知道。
我们用的是 RocketMQ 的事务消息机制。它通过“半消息 + 回查”保证原子性。配置起来有点绕,但一旦跑通,稳定性极高。
// 伪代码:RocketMQ 事务消息发送
TransactionMQProducer producer = new TransactionMQProducer("order_group");
producer.setTransactionListener(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// 1. 写订单表
orderDao.insert(order);
// 2. 返回 COMMIT_MESSAGE 表示本地事务成功
return LocalTransactionState.COMMIT_MESSAGE;
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 回查:订单是否存在?
return orderDao.exists(msg.getOrderId()) ?
LocalTransactionState.COMMIT_MESSAGE :
LocalTransactionState.ROLLBACK_MESSAGE;
}
});
这套方案上线后,我们砍掉了 70% 的分布式事务场景。剩下那 30%(比如资金相关),才用 TCC。
吐槽一句:产品经理总说“用户要实时看到结果”,但其实大部分场景延迟几秒根本没人 care。别被“强一致”的执念绑架了。
方案对比:一张表说清楚
| 方案 | 一致性级别 | 性能影响 | 开发复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC | 强一致 | ⚠️ 极高 | 中 | 银行、金融核心系统 |
| TCC | 强一致 | ✅ 低 | ⚠️ 高 | 下单、支付等关键链路 |
| 消息最终一致 | 最终一致 | ✅ 极低 | 中 | 通知、积分、日志等非核心链路 |
| Saga | 最终一致 | ✅ 低 | ⚠️ 高(需补偿) | 长流程业务(如旅行预订) |
注:Saga 模式我们没用过,但研究过。它通过“正向操作 + 补偿回滚”实现,适合步骤多、耗时长的业务。不过补偿逻辑写起来比 TCC 还头疼,慎入。
实战中的血泪教训
1. 幂等性不是可选项,是必选项!
无论是 TCC 的 Confirm/Cancel,还是消息消费者的处理逻辑,必须幂等。我们吃过太多亏:网络超时重试导致重复扣款、重复发券、重复发货……
解决方案?给每个操作加唯一业务 ID(比如 orderId + actionType),用数据库唯一索引或 Redis 去重。
2. 监控和告警要跟上
分布式事务失败往往很隐蔽。用户可能只看到“下单失败”,但背后是库存服务超时、消息堆积、补偿任务卡住……
我们后来在 Prometheus 里加了关键指标:
- TCC 事务成功率
- 消息消费延迟
- 补偿任务积压数
一旦异常,企业微信机器人直接@值班人。别等用户投诉才救火。
3. 别追求 100% 自动化
有些极端 case,比如网络分区持续数小时,或者第三方支付回调丢失,人工干预是必要的。我们做了个后台工具,支持运营手动触发“重试事务”或“强制回滚”。
别想着用技术解决所有问题——有时候,一个按钮比一百行代码管用。
最后一点思考
离职前最后一次架构评审会上,我说过一句话:“分布式事务不是技术问题,是业务容忍度问题。”
很多团队一上来就追求强一致,结果把系统搞得又重又慢。其实,先问业务是否真的需要强一致。90% 的场景,最终一致性+人工兜底就够了。
现在我在成都的小工作室里,用 Go 重写一套轻量级 TCC 框架(开源计划中)。不为别的,就为了证明:技术可以优雅,也可以接地气。
如果你也在为分布式事务头疼,不妨先问问自己:用户真的会在意那几秒钟的不一致吗?
反正我家楼下茶馆的大爷,喝完一杯碧潭飘雪才抬头看手机——他可不在乎我的订单是不是“强一致”。
(完)
P.S. 上周五晚上调试补偿任务到两点,今早遛狗时还在想那个死锁问题。创业真香,但头发真少。

评论 0