
双十一压测那天我盯着监控面板上订单服务那根陡峭的红色折线整个人是懵的。下单接口的耗时从平时的120ms直接飙到900ms数据库连接池被打满库存表出现大量锁等待。查到最后罪魁祸首不是SQL也不是缓存而是我们为了搞定订单、库存、账户三服务之间的数据一致性在AT模式下引入的全局锁。那次之后我把Seata的TCC模式完整啃了一遍花了两个周末把核心交易链路全部重构。后来线上再也没出现过因为分布式事务导致的性能毛刺。这篇文章就把我落地Seata TCC的完整思路、代码细节和踩坑记录分享出来主要面向已经会用Seata AT模式、但还没真正搞懂TCC怎么在实际业务里落地的小伙伴。1. 为什么放着开箱即用的AT不选非要用TCC模式先说结论AT模式不是不好它只是不适合所有的场景尤其是电商下单这种短事务、高并发、强竞争的核心链路。1.1 我的分布式事务是怎么一步步变成“必须解决”的问题的微服务改造之前下单就是一个数据库事务里搞定的事insert order、update stock、update account三条SQL要么全成功要么全回滚压根不用操心一致性问题。拆了服务之后订单属于订单库库存属于库存库余额属于账户库。同一个用户操作横跨三个独立的数据库原来那种“一个事务包一切”的玩法直接失效。最开始我图省事在业务代码里手动写补偿库存扣减失败就调库存服务的反向接口还回去。结果一个月内出了三次数据不一致——要么补偿接口没执行成功要么补偿执行了两次要么主流程还没走完就来了个并发请求把库存改乱了。那段时间每次对账都对得头皮发麻。后来接入Seata AT模式问题倒是解决了但数据库压力明显上来了。AT模式在业务SQL执行完之后还要额外解析SQL、生成undolog、获取和释放全局锁。下单链路里库存行是热点数据所有请求都卡在“等Seata全局锁释放”这一步。于是就有了开头压测翻车的那一幕。1.2 Seata四种模式到底差在哪在讲TCC之前先把Seata的四种模式摆在一起看能帮你更清楚TCC的位置。以下是它们的核心差异模式一致性方向侵入性性能特征适用典型场景AT自动补偿基于undo log低基本无感知有SQL解析和全局锁开销通用业务但热点数据高并发时吃力TCC业务显式补偿Try/Confirm/Cancel高每个操作要拆三段由业务SQL决定无额外解析开销核心交易链路、热点资源SAGA正向操作反向操作编排中高需要设计反向操作长事务串行/并行执行长流程、多节点业务流程XA数据库原生2PC低对数据库锁占用极大极少使用兼容性要求极高的场景一句话描述我的选型过程AT模式帮我省掉了写补偿逻辑的麻烦但它把麻烦转移给了数据库的锁TCC把补偿逻辑还给业务但换来了更灵活的资源控制。1.3 AT模式的隐藏成本远不止多写几张表AT模式隐藏成本的本质在于它通过全局锁保证并发事务对同一资源的串行化修改而热点业务恰恰是最需要并发量的。以秒杀场景为例1000个请求同时锁定同一款商品的库存行库存的全局锁被队首事务攥在手里后面的请求全都得原地等待锁释放。更棘手的是全局锁的持有时间横跨整个全局事务的提交窗口只要某个分支事务网络抖动全局锁就多占几十毫秒整个链路就跟着变慢。还有一点很多人容易忽略AT模式默认的隔离级别是读未提交也就是在全局事务提交之前其他事务已经能读到中间态数据。如果业务里出现了一个全局事务尚未提交但另一个事务又去修改同一行数据的情况就会出现脏写风险。虽然Seata通过全局锁做了限制但锁的等待机制本身就抬高了接口RT。所以我当时压测看到900ms的RT其实不是某一台实例挂了而是全局锁的等待塞车——所有下单请求都在排队等同一把锁。1.4 “真香”的底气把资源状态机交给业务TCC的核心设计哲学是让每个资源服务自己对资源状态负责而不是依赖一个通用框架帮你猜测语义。举个例子库存扣减这个动作在业务上并不是“直接减库存”这么简单它包含两层含义用户下单时需要先把一部分库存锁定保证他后续能买到用户最终取消订单或者超时未支付这笔被锁定的库存要释放。AT模式眼里这只是一个update stock set count count - 1它不关心你是否需要“锁定”和“释放”两个独立步骤。而TCC模式下Try阶段做库存冻结Confirm阶段做库存扣减Cancel阶段做库存解冻整个资源状态的变化完全在业务掌控之中。这才叫真正“懂业务”的事务方案。2. TCC三个阶段的业务语义以及比“三段流程”更重要的三个魔鬼细节理论上一句话就能说清TCCTry阶段预留资源Confirm阶段确认提交Cancel阶段回滚释放。但真正落地时运行过程中问得最多的其实是一个更直白的问题程序到底一步一步在跑什么还有一个让很多人头疼的问题是Cancel被调用了但Try根本没执行过怎么办2.1 一个下单场景的Try/Confirm/Cancel到底在做什么我用我在生产环境落地的一个库存冻结场景来拆解。假设用户下单购买一件商品涉及订单服务、库存服务和账户服务。Try阶段资源预留对订单服务来说这里会创建一条“待确认”状态的订单记录状态为PENDING而不是直接变成CREATED对库存服务来说是从可用库存列扣减对冻结库存列增加不会改变库存总量对账户服务来说是把用户可用余额转移到冻结余额同样账户总值不变。// 库存服务的Try逻辑伪代码 public boolean tryDeductStock(Long goodsId, Integer count, String txId) { // 只有当available_stock足够时才把count从available转到frozen int rows stockMapper.freezeStock(goodsId, count); // freezeStock内部执行 // update stock set frozen_stock frozen_stock #{count}, // available_stock available_stock - #{count} // where goods_id #{goodsId} and available_stock #{count} return rows 0; }这套逻辑意味着Try阶段所有操作都是可撤销的。有人可能会问为什么不能直接减库存在Cancel里再加回来考虑到并发如果A事务直接count-1B事务也count-1都成功但A后续失败要回滚此时A把库存加回去就会把B的扣减也覆盖掉这在分布式事务中是无法接受的。严格做好“可用、冻结”分离回滚才能精确无误。Confirm阶段确认提交所有分支的Try都成功之后TC会通知每个分支执行Confirm。Confirm才是真正完成业务操作的阶段订单状态从PENDING改为PAID库存把冻结库存真正扣掉也就是frozen_stock - count总量减少账户把用户冻结余额清除同时给商家账户增加收入。public boolean confirmDeductStock(Long goodsId, Integer count, String txId) { // update stock set frozen_stock frozen_stock - #{count} // where goods_id #{goodsId} return true; }这里的细节是Confirm阶段一般不允许失败。如果它失败了Seata会不断重试。所以Confirm里不要做“检查余额是否足够”之类的操作这种检查应该在Try阶段完成。Confirm只需把Try预留下的资源真正落实。Cancel阶段回滚释放只要任意一个分支的Try失败或者唯一一个全局事务发起方在Confirm前宕机TC就会向所有已完成Try的分支发出Cancel命令。Cancel做的是Try阶段操作的反向订单状态变为CANCELED库存把冻结库存释放回可用库存账户把冻结余额解冻回可用余额。public boolean cancelDeductStock(Long goodsId, Integer count, String txId) { // update stock set frozen_stock frozen_stock - #{count}, // available_stock available_stock #{count} // where goods_id #{goodsId} return true; }2.2 空回滚问题Try都没执行Cancel却来了这是TCC里最容易踩的坑没有之一。场景是这样的Try请求由于网络拥堵迟迟没有到达库存服务但TC那边已经等到了超时直接发出Cancel。此时库存服务收到Cancel但库存表里根本没有对应的冻结记录如果Cancel方法里直接执行frozen_stock - count甚至可能把别人的冻结库存减掉。解决思路是在执行业务操作的表中记录“分支事务ID”及执行状态。在Cancel方法里一定要先检查是否存在“Try已经成功执行”的记录如果查不到就说明Try压根没到Cancel只能直接返回成功不能做任何扣减。TwoPhaseBusinessAction(name stockTccAction, commitMethod confirmDeductStock, rollbackMethod cancelDeductStock) public boolean tryDeductStock(BusinessActionContext context, BusinessActionContextParameter(paramName goodsId) Long goodsId, BusinessActionContextParameter(paramName count) Integer count) { // 如果该txId已经执行过Try直接幂等返回 if (freezeRecordMapper.exists(context.getXid())) { return true; } int rows stockMapper.freezeStock(goodsId, count); if (rows 0) { // 记录txId对应的冻结流水状态为try_done freezeRecordMapper.insert(context.getXid(), goodsId, count); return true; } return false; }2.3 幂等控制和悬挂问题TCC绕不开的另外两个坎重复Confirm/Cancel的幂等控制。Seata的AT模式有全局事务表去重但TCC模式里同一个分支的Confirm或Cancel可能在网络抖动后重试。如果Confirm和Cancel不是幂等的就会出现库存被扣两次、冻结余额被解冻两次的问题。所以每个操作都要用事务状态记录去重public boolean confirmDeductStock(BusinessActionContext context) { if (confirmed.equals(freezeRecordMapper.getStatus(context.getXid()))) { return true; } // 真正扣减冻结库存... freezeRecordMapper.updateStatus(context.getXid(), confirmed); return true; }悬挂问题。悬挂是指分支事务的Try还没执行Cancel就已经先执行完了等Try再迟到时发现事务已经被回滚了Try就不应该继续执行。解决办法还是依赖状态在Try方法执行之前先查一下当前全局事务ID是否已经存在对应的回滚记录如果已经回滚则尝试直接返回成功忽略本次执行否则会让一个已经被取消的事务“复活”。2.4 从设计意图上理解TCC是业务语义驱动的一致性方案深入理解之后你会发现TCC本质上是用业务状态转移来替代数据库锁。它给了你最大限度的控制权同时也把开发复杂度转移给了你。代价是需要侵入每个资源服务的接口设计每个业务操作都要拆成三段。所以在架构评审时一定得想清楚哪些服务值得上TCC哪些服务用AT甚至本地消息表就够了。3. Seata TCC实战落地一个订单扣库存场景的完整过程理论说清楚之后我们把代码完整过一遍。整体链路是订单服务发起全局事务同时调用库存服务和账户服务的TCC接口。3.1 环境准备部署Seata Server这一步不复杂但容易踩坑。我用的Seata版本是1.6.1不建议直接用最新版某些配置格式会变。重点说一下配置从GitHub Release页面下载seata-server-1.6.1.tar.gz解压后修改conf/application.yml1.6.x版本里的注册中心和配置中心。我用的registry.type: file因为本地没有Nacos如果你们已经有Nacos可以用nacos初始化全局事务相关的三张表global_table、branch_table、lock_table。Seata发行包里有script/server/db/mysql.sql直接执行启动服务sh bin/seata-server.sh -p 8091 -h 127.0.0.1。注意注册中心建议先选file模式跑通再切nacos。一开始就直接上Nacos的话如果Nacos配置中心拉不到配置服务是起不来的排查问题时很容易以为是Seata本身的问题。3.2 在业务服务里引入依赖并注册TCC接口订单服务、库存服务、账户服务都需要引入Seata客户端依赖。下面是关键依赖dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId version2.2.5.RELEASE/version !-- 传递依赖已经包含了seata-all用1.6.1版本 -- /dependencyTCC接口的写法是整个落地的核心我们需要用LocalTCC标注这是一个TCC接口用TwoPhaseBusinessAction指定Try方法对应的Confirm方法和Cancel方法。参数传递用BusinessActionContextParameter这样Seata才能把上下文参数在三个方法之间传递。LocalTCC public interface StockTccAction { TwoPhaseBusinessAction(name stockTccAction, commitMethod confirmDeductStock, rollbackMethod cancelDeductStock) boolean tryDeductStock(BusinessActionContextParameter(paramName goodsId) Long goodsId, BusinessActionContextParameter(paramName count) Integer count); }这里有个非常关键的点BusinessActionContext是Seata的上下文对象里面放着全局事务XID和通过BusinessActionContextParameter传入的参数。Confirm方法和Cancel方法可以直接从BusinessActionContext里把Try阶段的参数捞出来不用另外再传一遍。3.3 实现类Try、Confirm、Cancel完整代码库存服务的实现类长这样Service public class StockTccActionImpl implements StockTccAction { Resource private StockMapper stockMapper; Resource private FreezeRecordMapper freezeRecordMapper; Override public boolean tryDeductStock(Long goodsId, Integer count) { String xid RootContext.getXID(); // 防止悬挂如果当前事务已经回滚过直接忽略Try if (freezeRecordMapper.existRollback(xid)) { return true; } if (freezeRecordMapper.exists(xid)) { return true; } int rows stockMapper.freezeStock(goodsId, count); if (rows 0) { freezeRecordMapper.insert(xid, goodsId, count, try_done); return true; } return false; } Override public boolean confirmDeductStock(BusinessActionContext context) { String xid context.getXid(); String status freezeRecordMapper.getStatus(xid); if (confirmed.equals(status)) { return true; } // 扣减冻结库存 stockMapper.confirmFreezeStock((Long) context.getActionContext(goodsId), (Integer) context.getActionContext(count)); freezeRecordMapper.updateStatus(xid, confirmed); return true; } Override public boolean cancelDeductStock(BusinessActionContext context) { String xid context.getXid(); // 空回滚如果Try都没执行过直接返回成功否则回滚会出错 if (!freezeRecordMapper.exists(xid)) { return true; } if (canceled.equals(freezeRecordMapper.getStatus(xid))) { return true; } // 解冻库存冻结库存减掉可用库存加回来 stockMapper.unfreezeStock((Long) context.getActionContext(goodsId), (Integer) context.getActionContext(count)); freezeRecordMapper.updateStatus(xid, canceled); return true; } }这段代码基本把空回滚、幂等、悬挂三个问题都覆盖了。特别说明一下freezeStock的SQL一定要写成“带条件”的更新条件就是可用库存足够否则在高并发下会出现超卖UPDATE stock SET frozen_stock frozen_stock #{count}, available_stock available_stock - #{count} WHERE goods_id #{goodsId} AND available_stock #{count}3.4 事务发起方用GlobalTransactional串起整个链路在订单服务下单接口上直接加GlobalTransactional注解即可这是开启事务的方式GlobalTransactional(name createOrderTx, timeoutMills 30000) public void createOrder(OrderCreateRequest request) { // 1. 创建本地订单状态为PENDING orderMapper.insertPendingOrder(request); // 2. 调用库存服务TCC冻结库存 stockTccAction.tryDeductStock(request.getGoodsId(), request.getCount()); // 3. 调用账户服务TCC冻结余额 accountTccAction.tryFreezeBalance(request.getUserId(), request.getAmount()); // 4. 如果都成功了Seata会自动调用各分支的Confirm // 如果任何一个失败Seata会调用所有已成功Try分支的Cancel }很多文章都忽略一个细节GlobalTransactional只是发起全局事务真正决定提交还是回滚的是各分支TCC接口的Try返回值。如果stockTccAction.tryDeductStock()返回falseSeata会直接抛出BranchTransactionException全局事务进入回滚流程。所以Try方法里一定要做内部校验不能把校验逻辑留在Confirm。3.5 跑通后的验证方法代码写完之后怎么确认TCC真的生效了推荐一个土办法在下单接口里人为制造库存服务Try失败比如把库存改成0观察订单表订单必须被标记为CANCELED观察库存表冻结库存必须回滚到0可用库存不变观察账户表冻结余额必须回滚到0可用余额不变。如果这三张表的数据都对得上那基本说明TCC链路是通的。如果发现余额没回滚就去查Seata的branch_table看分支事务状态是不是PhaseTwo_Canceled。4. 实测中的坑空回滚、重复Confirm、超时悬挂和日志排查定位说句实话光看官方文档你是学不到这些的很多问题只有跑到线上才会出现。这一章我说几个真实踩过的坑。4.1 坑一日志里全是“Branch transaction failed”一看全是空回滚第一次跑通代码后我人为抛出异常测试回滚结果发现Cancel阶段一直报错。查日志后发现每个Cancel进来的时候freezeRecordMapper.exists(xid)查询结果都是false。原因很简单Try执行成功了但Confirm或者Cancel发生的时候Seata分支事务注册和事务状态记录并不是原子操作。Try方法里业务成功之后我紧接着插入冻结流水按道理状态已经写入但服务重启或者接口超时重试时Try根本没执行Cancel就进来了。解决方案如上面代码所示Cancel里必须先查是否有Try记录没有就返回成功。这就是空回滚处理。这个判断逻辑虽然简单但漏掉它基本一测一个准。4.2 坑二Confirm被连续执行了两次现象是接口偶发性报重复扣减的错。定位到最后才发现Seata的Confirm在异常情况下会重试而我们的Confirm没有做幂等设计。第一次Confirm成功之后TC没有收到成功响应于是再次发送Confirm。第二次执行时frozen_stock已经为0了再把冻结库存减一遍看似是把别人的数据扣了实际显示为负数。后来我在Confirm和Cancel方法里都加了状态字段判断确保每个事务ID只处理一次。注意这里的幂等判断要基于分支事务ID不能基于业务主键因为同一笔业务订单可能因为超时重试产生多个分支事务ID。4.3 坑三本地事务悬挂导致数据“莫名其妙”恢复这是最玄学的一个坑现象是全局事务已经执行了Cancel但是过了一会儿原本应该恢复的冻结库存又被“Try”操作改了一遍库存数据出现异常增加。原因是Try请求因为网络阻塞在Cancel执行完很久之后才到达服务端。如果Try不检查状态就直接执行资源预留那么一个已经回滚的事务就像从土里爬出来一样再次影响资源。解决办法就是上面写的Try执行之前先查询是否存在Cancel记录如果存在则直接忽略本次Try不执行任何SQL。为了排查这类问题强烈建议在服务日志里把XID打全。每个分支事务方法入口第一行就打印RootContext.getXID()和当前方法名。线上排查时按XID聚合日志整个全局事务的生命周期一眼就能看明白。4.4 Seata日志和服务端表怎么配合定位问题在Seata Server端日志文件在logs/seata.log里。当出现问题时优先看这几种关键字Register branch success分支注册成功Branch commit/Branch rollback分支提交或回滚的请求Branch transaction failed分支处理失败下方会跟异常栈Global rollback全局回滚事务触发。服务端的三张表也很有用global_table里能看到全局事务当前状态branch_table里能看到每个分支的status_code0为Begin1为Commit2为Rollbacklock_table里能看到锁的持有情况。我通常的做法是出问题先查global_table确认全局事务状态再查branch_table找到失败的分支最后回到那个服务的日志看具体异常。这个链路基本能解决90%的TCC排查问题。5. 什么样的场景下TCC“真香”什么样的场景我劝你放弃说实话TCC不是银弹我甚至见过有团队把简单的用户注册流程也改造成TCC结果只是徒增代码复杂度。这一章聊聊选型边界也算是给“真香”加个说明。5.1 TCC适合什么场景我总结下来的标准有三条跨服务写操作多且这些服务之间强依赖不允许出现“订单创建成功但库存没扣”这种状态热点资源竞争激烈用AT模式的全局锁会严重拖垮性能比如秒杀、库存扣减、账户余额扣款业务本身可以自然地拆成预留、确认、释放三个阶段比如库存冻结/扣减/解冻、余额冻结/扣款/解冻。拿我们线上系统举例。最初的交易链路用了AT模式压测时扛不住后来换成TCC之后库存服务接口RT从900ms降到150ms以下可用性高了不少。所谓“真香”本质上是在性能开销可控的前提下拿回了业务的确定性。5.2 哪些场景不建议用TCC单库单服务内的强事务本地事务就够用了没必要引入TCC反而会让简单逻辑变得复杂。简单CRUD接口比如查询修改个人资料这种如果硬套TCC就得为每个操作写两段补偿方法纯属给自己加戏。团队对TCC不熟悉没有完善的监控和告警体系TCC调试难度比AT高出了问题如果不看日志很难快速定位是哪个分支失败了可能排查很久。另外提醒一句TCC方案里如果Confirm阶段大量失败说明你的Try接口设计得不对劲应该把更多校验前移到Try阶段而不是等Confirm在做了一次查询之后才报错。5.3 我的实际选型习惯我做技术方案时通常会画一张决策表场景推荐方案理由单库多表更新Transactional本地事务最简单可靠跨服务偶尔调用可以容忍短时间最终一致本地消息表或MQ事务异步解耦吞吐高跨服务强一致但并发量不是特别高Seata AT开发效率高跨服务强一致且热点资源并发竞争激烈Seata TCC性能最好资源控制最细长流程、多服务编排Seata SAGA 或状态机更适合长事务不会长期占资源TCC真正的适用面没有一些人想象的那么宽但它非常可靠适合业务核心链路中的关键动作能扛住一般的业务高峰。5.4 项目落地时的团队协作建议最后分享一个组织层面的经验如果团队要上TCC不要一上来就把所有服务全改选一条从“订单到库存”的核心链路先跑通积累经验后再铺开。同时务必把所有TCC接口的Try/Confirm/Cancel方法名、参数、事务状态定义写清楚放在团队Wiki里。这不是文档负担而是TCC模式天然需要更强的接口契约约束没有明确的命名和参数约定项目后期会非常难维护。我自己最开始踩了那么多坑有一个根本原因想一口气把所有服务都改成TCC结果改动太大光排查问题就花了两周。后来换了个思路只改交易链路其他服务按兵不动反而顺利很多。技术重构这事步子太大确实容易扯着。如果你正准备把Seata TCC引入到自己项目里建议你先拿订单库存这两个服务的最小闭环练手把Try/Confirm/Cancel和空回滚、幂等、悬挂这几个问题全部跑顺之后再往账户服务、支付服务扩展。这套东西看着门槛高但只要前两个服务跑通了后面基本就是复制粘贴加微调。