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后才到,必须靠状态判断拦截,否则就会变成状态错乱——这就是第二个坑,行业黑话叫“悬挂”。
悬挂指的是:空回滚已经执行完了,迟到的Try才到达,如果Try不去检查是否已经有取消标记,就会平白无故冻住一笔库存,而且永远没人去Confirm或Cancel,那笔库存就“悬挂”在那了,后台对账怎么都对不上。解决方式:Try执行前查一次是否存在取消流水,存在直接拒绝或忽略,记住这个检查要跟Try的写操作放在同一个本地事务里,查-改分离会有并发缝隙。
第三个坑是幂等。Confirm和Cancel都可能因为网络问题被重复调用,即使事务框架帮你重试,业务方法本身也必须幂等。我在代码里的套路是:给冻结流水表建唯一约束,唯一键就是订单ID加业务类型。用INSERT ... ON DUPLICATE KEY UPDATE或先insert后update,让重复的Confirm/Cancel只更新状态、不重复计算数量。关于这块,网上讨论很多,这里不展开,但上面处理方式已经是我在项目里跑过半年、经过故障演练验证的方案,可以直接抄。
2.3 为什么TCC的代码侵入性那么强
TCC最大的痛点是:它逼着你把每个业务操作拆成三份,还要在接口文档里逐个维护Try/Confirm/Cancel的幂等与状态流转。一个下单链路往往涉及库存、账户、积分、优惠券四五个服务,意味着每个服务都要写三套方法加配套流水表,这工作量不比业务代码本身小。而且这些方法还必须是本地事务的,如果Confirm阶段联动多个数据库表,本身又变成一个分布式事务——那就套娃了。
正因如此,我现在接项目时会先问一个灵魂问题:团队有没有人力维护这么多流水表和状态机?没有的话,别硬上TCC。TCC适合那种“操作少而精、并发极高、资金敏感”的场景,比如钱包支付、秒杀扣库存;不适合那种“流程长、分支多、重试容易”的场景,后者更适合Saga,下面讲。
3. Saga不是“没有回滚”,它是“异世界回滚”
3.1 Saga怎么定义:一串本地事务加上一串补偿
Saga的概念源于1987年那篇论文《Sagas》,核心思想特别简洁:把一个长事务切成N个有序的短事务T1、T2……Tn,每个Ti都只在自己的数据库里提交,同时定义一个反向的补偿事务Ci。一旦某个Ti失败,就退出补偿,执行C(i-1)……C1,把已经提交的前置操作给“恢复回去”。
注意,这里已经是“已提交”的状态,所以补偿是逻辑层面的撤销,而不是数据库层面的回滚。举例:订单服务先创建订单(提交),库存服务扣库存(提交),如果积分服务加积分失败,Saga就会反过来调库存服务的补偿接口把库存加回去,调订单服务的补偿接口把订单调成已取消。用户看到的,是一个订单曾经短暂存在过,然后又被废弃了。
这跟TCC有本质区别:TCC的操作在最终事务完成前,外人是不“看不见”的(冻结中的库存不可卖、冻住的余额不可用),它更像一个憋大招的过程,憋完一起提交;而Saga则是在一条流水线上实时地做着每个步骤,下一个步骤失败时,前面已经流走的零件就靠倒着往回送。用大白话说,TCC是“先锁后做”,Saga是“边做边能改”。
3.2 编排式Saga和协同式Saga:两条完全不同的实现路线
Saga落地时,有两种经典的驱动方式,选错了后面排错会很痛苦。
第一种是协同式(Choreography),也叫事件驱动式。整个流程没有中心节点,每个服务完成自己的本地事务后,发一个事件,触发下一个服务。比如订单服务发布“订单已创建”,库存服务监听这个事件,扣库存后再发“库存已扣减”,账户服务接着监听……一旦某步失败,又通过事件链反向补偿。这种模式的好处是服务间解耦、无需额外的协调器,缺点也很要命:流程分散,出问题很难追踪,事件满天飞,想搞清楚当前到底进行到哪一步,只能去各个消息中间件里掘地三尺。我个人的经验,除非团队对事件风暴特别熟,否则慎用协同式。
第二种是编排式(Orchestration)。由一个中央编排器(比如Seata Saga可以理解为一种状态机引擎)显式定义整个流程:先调A服务,再调B服务,B失败则调C服务(补偿A)。编排器集中管理状态,一张流程图就能看全。现在工业界大部分落地都走这个方向,因为它可观测、可重试、可设计复杂的补偿路由。下图不画马,自己脑补可能更好:编排器像一台自动售货机的控制器,你投币,它按顺序吐饮料,卡住了它就按反顺序把已吐出来的给收回。
我用Seata的Saga模式时,走的就是编排式,用JSON定义状态机描述每个步骤的输入、输出和补偿节点,可视化工具直接渲染成图,运维和QA都能看懂。这一点后面专门讲实操。
3.3 Saga的三种恢复策略:向后恢复与向前恢复的取舍
Saga失败后如何恢复,理论上只有两大流派:向后恢复(Backward Recovery)和向前恢复(Forward Recovery)。
向后恢复就是上面说的:从失败点起,把已成功的事务逐个补偿回去,最终整个Saga处于“什么都没发生过”的业务等价状态。支付订单流程里库存扣了、但支付超时取消,就需要把库存回补。这是我日常用得最多的策略,因为多数业务失败是业务层面的,比如余额不足、库存售罄,不值得重试,直接撤销前面步骤更合理。
向前恢复则是另一个方向:失败后不撤销,而是不断重试Ti直到成功。它适用于那些临时性故障,比如网络抖动、数据库连接池打满,重试几次大概率能过。很多Saga框架会支持两种策略混合使用,我在订单履约系统里就是这样配的:依赖外部系统的步骤(比如短信通知、发票接口)用重试,核心扣减步骤用补偿。
Saga的补偿事务本身也要满足幂等,这一点跟TCC一样,否则补偿重放会造成“退货退回两次”这种事故。
3.4 Saga缺了什么:隔离性
Saga最常被架构师诟病的就是没有隔离性。TCC至少通过资源预留把中间状态藏起来了,而Saga的各个中间提交状态对外是可见的。想象一个场景:Saga正在执行,T1创建了订单且状态为“待支付”,T2还没完成扣库存面试。此时用户查订单,看到的是“已提交”状态;如果另一个服务正在做促销限购统计,它可能把这个还没真正完成的订单也算进去了。更极端的是,T1提交之后、T2尚未执行之前,如果这个事务在中间被用户取消了,怎么处理?库里又有一笔订单要标记取消,但T1补偿还没开始。这实际已经超出了Saga框架本身的职责,需要业务自己去兜底。
解决思路有几条,我实际验证过两条相对靠谱的:
一是把这种中间状态在业务上定义成合法状态。既然Saga允许中间态可见,那就干脆给它一个正常的状态语义,比如“下单中”,前端展示时加个loading,统计分析时把该状态字段过滤掉。
二是引入会话级别的隔离,给每个Saga分配一个全局事务ID,涉及查询的客户端必须显式携带这个ID,数据服务查询时,如果发现数据属于某个未完结的Saga,可以做标记或限制读取。这个方案实现复杂,非高并发严要求场景不用强上。
4. TCC vs Saga选型:不搞出一张对比表怎么决策
4.1 六个维度逐项拉出来比
| 维度 | TCC | Saga |
|---|---|---|
| 一致性类型 | 近似强一致(提交前冻结不可见) | 最终一致(中间态可见) |
| 隔离性 | 通过资源预留提供业务级隔离 | 无自带隔离,需业务层自行处理 |
| 实现侵入性 | 高,每个操作拆成Try/Confirm/Cancel三套 | 中低,业务方法保持单一,补偿单独写 |
| 事务跨度 | 适合短事务、高频、少分支 | 适合长事务、多分支、跨系统 |
| 资源效率 | 资源被预留,可能造成占用浪费,需要锁粒度控制 | 资源随用随释放,无长期预留 |
| 排查难度 | 问题出在状态不流转,排查集中在流水表 | 问题出在中间态被看到、补偿错乱,需看全局状态机 |
表格一行列出来大家就明白了,TCC在意的是“保证这个事最终成了还是最终没成”,Saga在意的是“失败了能不能理顺”。所以如果你的核心场景是“钱、库存、券”这种不能错的资源操作,TCC天然更有底气;如果你的核心场景是“用户点了个单,后面一串服务逐步完成”这种人工介入也不怕的长流程,Saga更灵活、成本更低。
4.2 典型业务怎么选:三个实操案例
第一个案例,支付扣款加积分。账户扣款和积分发放是两个独立库的服务。扣款不能少,积分可以后补,但一旦扣款成功、积分事务挂了,不能给用户留下“钱扣了积分没到”的永久错误。这场景我倾向TCC,因为扣款这事必须准,用Try阶段锁余额,Confirm真正划扣,Cancel释放,能保证资金强一致。积分那步即便失败,也有一个Confirmation状态可查,方便人工介入。
第二个案例,电商订单创建链路,涉及订单、仓储、派单、消息通知。一个订单要创建,库存服务预留库存,物流服务创建运单,通知服务发短信。这个链路长且大量依赖外部系统,TCC把库存一冻结好几分钟,库存压力极大;而Saga很适合,订单创建后各服务逐步处理,失败时补偿,用户中间看到“处理中”完全合理。我在仓储项目里用Saga重写过一遍之后,日常告警数直接降了一个量级,因为再不用手写一堆Confirm套Cancel。
第三个案例,库存秒杀。高并发、资源有限、必须防止超卖。这种场景千万别用Saga,因为Saga的中间态可见性会让两个人同时看到最后一单库存都能下订单,真正的扣减到确认时才会发现库存没了。此时必须TCC的冻结模式,或者更轻量的本地消息表加定时对账方案。
4.3 反模式:什么时候两派都不该上
还有一类场景,TCC和Saga都不适用,比如纯查询、纯报表,或者业务允许短暂丢数据再修复的。如果一个链路里所有操作都只是写同一个数据库的表,那用本地事务就行,硬拆成分布式事务纯属给运维上强度。另外,若依赖的下游完全不支持补偿(比如外部对接方没有退款接口),Saga补偿也走不通,你还不如用本地消息表加大还报,先把业务跑通。
5. Seata落地:主线事务从配置到调试的全过程
5.1 Seata的TCC实现与实操配置
Seata是目前国内最普及的分布式事务框架,我主要用它做TCC的落地。一个标准TCC事务在Seata里的落地分为两步:定义事务参与者,启动全局事务。
参与者定义推荐用接口方式,写一个接口,三个方法,其中Try和Confirm/Cancel都必须在同一个事务分支里能拿到全局事务ID,保证幂等:
java复制@LocalTCC
public interface StockAction {
@TwoPhaseBusinessAction(name = "deductStock", commitMethod = "confirmDeduct", rollbackMethod = "cancelDeduct")
boolean tryDeductStock(BusinessActionContext ctx, Long skuId, Integer qty);
boolean confirmDeduct(BusinessActionContext ctx);
boolean cancelDeduct(BusinessActionContext ctx);
}
在业务入口处开启全局事务:
java复制@GlobalTransactional(timeoutMills = 30000, name = "create-order-tx")
public void createOrder(OrderDTO cmd) {
stockAction.tryDeductStock(cmd.getSkuId(), cmd.getQty());
accountAction.tryFreezeBalance(cmd.getAccountId(), cmd.getPayAmount());
// 全部成功后,Seata自动发起Confirm,失败则按逆序Cancel
}
Seata的TCC分支事务是异步提交的,所以写流水表时一定注意时间边界,确认取消消息到达时,分支可能刚提交,也可能还在途。我记得有一次压测时遇到大量取消调用先于Try响应到达的情况,后来发现是因为业务处理慢导致事务超时,Seata提前发出了Cancel。这个场景下,Try方法如果没做悬挂检查,就会默默冻住库存。所以前面的Try要检查流水表的状态不是CANCELLED,这个逻辑别省。
另外,Seata里一个特别容易被忽略的点是事务分组配置。tx-service-group必须和registry.conf对应一致,否则GlobalTransaction分支根本找不到TC,运行时报“Could not found global transaction xid”错。这个错误不是代码问题,是配置没同步。
5.2 Seata Saga的状态机描述与运行机制
Seata的Saga模式,工业界落地主要使用状态机引擎。它允许你通过JSON描述流程,引擎负责驱动、推进、补偿。整体上,编排器执行节点:SagaStateMachine、ServiceTask、CompensateState。
这里直接给一个简化版的JSON轮廓,大家感受一下:
json复制{
"Name": "createOrderSaga",
"StartState": "CreateOrder",
"States": {
"CreateOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"CompensateType": "CompensateService",
"CompensateServiceName": "orderCompensateService",
"Next": "DeductStock"
},
"DeductStock": {
"Type": "ServiceTask",
"ServiceName": "inventoryService",
"CompensateType": "CompensateService",
"CompensateServiceName": "inventoryCompensateService",
"Next": "AddPoints"
},
"AddPoints": {
"Type": "ServiceTask",
"ServiceName": "pointsService",
"End": true
}
}
}
在这个状态机里,AddPoints失败时,引擎按照堆栈顺序调DeductStock的补偿,再调CreateOrder的补偿,全程不需要你写一行“扛分支”代码。这套模型的好处是补偿逻辑跟主逻辑解耦,补偿流程可以单独测试,状态机推进情况有Seata Dashboard日志可看。
5.3 实战中高频踩坑记录
坑一:补偿事务与主事务并发。 Saga的一个步骤刚执行完,补偿就进来了。如果你的补偿查询条件跟主事务查询条件一样,很可能刚好查到主事务半提交状态的数据。我的方案是状态机里给ServiceTask节点配置一个MaxRetryTimes和RetryBackoffSeconds,一旦补偿执行遇到数据不存在,就触发重试。同时业务补偿接口里先“幂等check”,存在才动数据。
坑二:Saga里跳过某个服务后状态不一致。 状态机支持跳节点,但我让下游服务跳过某步骤时,补偿节点也自动跟着跳过。实际排查过一例,因为跳节点配置没配补偿,导致某个Saga事务虽然状态机显示“回滚完成”,但一个库存补偿始终没执行。所以做状态机设计时,节点和补偿节点必须成对配置。
坑三:事务超时时间设置太短。 Saga天然是跨长时间的事务,如果外部接口响应慢,默认超时之后状态机可能直接进入补偿流程,但其实主流程还在跑。本地测试时要把超时调长,建议依据业务P99时间乘以3作为初始值,再逐步收紧。
6. 线上排查与故障复盘:分布式事务的体检清单
6.1 排查分布式事务问题的三板斧
所谓框架只是工具,出了问题还得靠方法。我在生产环境排查这类问题时,固定按三个方向来。
第一,全局事务日志链路。所有参与方必须能按全局事务ID拉通日志,否则每个服务看自己的日志就是盲人摸象。我在项目里统一采用了:入口处生成全局事务ID,传递到各个参与方上下文,日志框架的MDC自动带上。
第二,分支事务状态表。无论是TCC的冻结流水还是Saga的状态机实例,都得有一张表能查询“当前这个全局事务走到的节点、状态、重试次数、异常堆栈”。实操时我在每个服务落了一张branch_tx_log表,字段包括tx_id、branch_type、action_name、status、error_msg、try_time、execute_time。哪个环节卡住,一查便知。
第三,对数对账。分布式事务做得再完美,线上也可能因为人为改库、接口逻辑调整出偏差。我每周会跑一次对账任务,核对各服务的流水与总账是否满足业务勾稽关系,比如“库存期初=可用+冻结+已售”“积分总表与积分流水汇总一致”,一旦出现差,立刻捞数据查。这套兜底机制比任何框架都稳。
6.2 监控指标别只盯着成功率和超时
很多团队上线分布式事务后,只盯着“事务成功率”。但更该看的是这三个指标:
一是悬挂率。也就是出现空回滚标记后又有Try到达的数量。用SQL能统计出来,如果这个数大于零,说明网络或事务超时设计有问题,需要优化超时时间和分支并发度。
二是补偿执行次数和成功比例。补偿是系统自我保护的最后一道关卡,如果补偿大量失败,说明补偿逻辑或下游系统有bug,需要重点排查。
三是活动分支数与资源占用。TCC模式下如果活动分支长时间不收敛,大概率有悬挂或死锁,它直接表现为冻结库存居高不下、账户冻结金额越攒越多。
这些指标可以在现有监控平台上自定义,图上直接看趋势,可以提前预警,不用等用户投诉系统才反应过来。
6.3 一次真实的线上故障复盘
有次我们的Saga做券活动发券链路,从订单一产生就开始发券。某天因为下游发券服务误改了逻辑,导致某个步骤反复抛异常。Saga状态机自动启用了向前恢复,一直重试。流程看似“还在走”,但那条全局事务卡在一个节点超过两小时,用户的券一直没发出来。排查链路时发现,状态机里有重试参数,默认无限重试且不告警,直到兜底对账把它捞了出来才暴露。
那次之后我给团队立的规矩是:一是所有Saga节点的重试次数必须有上限,超过上限转入“待人工处理”,同时告警;二是任何全局事务执行超过设定阈值,自动进入慢事务池,负责人每天Review;三是故障演练必须人为制造某个服务长时间宕机,看补偿链路是否真的按设计走完。不做演练,你永远不知道你的补偿代码只在文档里“通”过。
回到开头那个凌晨三点的扣款事故,最终定位的根因其实就是典型的TCC悬挂加空回滚没处理好,导致库存服务冻了一部分数据,账目怎么都对不上。改完之后,团队顺手把一套对账任务做成了自动化日报,再遇到类似问题,都能在十几分钟内定位到具体分支而不是全网捞日志。分布式事务这东西,框架只是上半场,对过程的观测和意外分支的兜底才是下半场。选型时看业务,落地时看细节,运行后看监控,三步都走到位,才敢说你的分布式事务体系是稳的。
