1. 从一次凌晨三点的事故说起:分布式事务到底在解决什么问题
凌晨三点,手机震得床头柜嗡嗡响。告警群里一条消息弹出来:订单服务下单成功,库存服务回滚成功,账户服务扣款成功,然后——账对不上了。下单会员明明付了钱,积分服务却没能加上,订单也显示支付失败,仓库那边库存倒是扣了,用户转头投诉到客服:“我钱都付了你们为什么说没买到”。那是我第一次在TCC和Saga之间来回改了三版方案才弄明白:分布式事务,不是“用哪个框架”的问题,而是“你的业务能不能承受这种中间状态”的问题。
如果你也正在做订单、库存、账户、积分这类跨服务的拆库系统,迟早会碰到同样的事:一个业务流程要跨多个服务、写多个数据库,每个服务自己的本地事务能保证ACID,但拼在一起就没人替你保证一致性了。TCC和Saga就是解决这个问题的两大主流流派。本文要讲的,是我实际在两个项目里分别落地TCC和Saga后总结出来的选型逻辑、核心原理和踩坑经验,适合正在做服务拆分的后端开发、架构师,以及被分布式事务折磨得想跑路的同学参考。
先说一个根本性的结论:TCC和Saga没有绝对的优劣,它们解决问题的思路和代价完全不同。选错不是“跑不起来”,而是上线之后在高并发或故障场景下给你埋雷。很多人把Seata一集成、注解一标、事务一跑就完事了,等到压测或者故障演练时才暴露出一堆诡异现象:少扣了钱、多发了货、空回滚、悬挂、幂等失效。这些问题的根源,往往在选型阶段就注定了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. TCC的真相:Try、Confirm、Cancel三兄弟不只是“接口长这样”
2.1 三阶段模型的核心思想是“资源预留”
TCC全称Try-Confirm-Cancel,这三个阶段名字容易记,但很多人理解的TCC是:Try做业务操作,Confirm确认,Cancel回滚。这个理解太粗糙,直接导致实现时把Try写成了“直接扣库存”,把Cancel写成了“扣错了再加回来”。真正的TCC,Try必须是“预留资源”,不是“完成操作”。
我用电商下单扣库存来拆解。如果你做的是库存服务,参与TCC事务,三个方法应该长这样:
java复制// Try:冻结库存,而不是直接扣
public boolean tryDeductStock(Long orderId, Long skuId, Integer qty) {
// 1. 检查库存是否充足(此时不用锁全表,只查可用量)
// 2. 库存总量不变,把 available_qty 减少 qty,把 frozen_qty 增加 qty
// 3. 记录一条冻结流水,状态为 FROZEN,以备后续Confirm/Cancel时幂等
return freezeRows > 0;
}
// Confirm:确认扣减,冻结变实扣
public boolean confirmDeductStock(Long orderId) {
// 把冻结合并到已扣减,frozen_qty 减少 qty,sold_qty 增加 qty
// 冻结流水状态改为 CONFIRMED
// 这里理论上不能失败,失败要重试直到成功
}
// Cancel:释放冻结
public boolean cancelDeductStock(Long orderId) {
// 把冻结还回去,available_qty 加回 qty,frozen_qty 减少 qty
// 冻结流水状态改为 CANCELLED
}
注意Try里没有把库存扣死,而是挪进“冻结区”,这个设计的关键收益是:在事务提交前的整个过程中,其他不参与本事务的订单仍然能正常卖这个SKU——只是少了一部分可卖量。这就是为什么TCC能提供逼近强一致的隔离性,它牺牲了一点点实时可卖数,换来了“并发下不出负数”的安全保障。
资金账户同理:扣款参与TCC时,Try阶段做“余额冻结”,Confirm阶段把冻结金额变成实际支出,Cancel阶段归还冻结。很多财务系统要求“资金流水可追溯”,以上设计天然生成了一条冻结流水,审计也能用,一石二鸟。
2.2 空回滚、悬挂、幂等:TCC的老三坑
如果只把几个接口写完,测试环境数据量小跑一跑,基本发现不了问题。一上生产、一有网络抖动和超时,三个经典难题立刻冒出来。
第一个是空回滚。Try因为网络超时迟迟没到达参与者,事务协调器等不到Try的响应,直接发了Cancel。此时参与者收到Cancel,但Try压根没执行过,如果Cancel逻辑直接“扣回冻结”,它就会把不存在的冻结扣成负数。我的处理办法是在Cancel里查冻结流水表:如果流水不存在,说明Try没执行,直接返回成功,同时写入一条空回滚标记流水。这里有个细节,空回滚标记写完之后,如果Try后才到,必须靠状态判断拦截,否则就会变成状态错乱——这就是第
