去年双十一前夕,凌晨三点,订单支付全线崩溃。团队排查了四十多分钟,最终定位到——分布式事务。那时候用的还是老一套的2PC,像一根筋的卫兵,死守着资源不放。吞吐量一上来,数据库连接池爆了,然后就……完了。事后我们开始死磕TCC,当时心里就一个念头:再不能这么脆了。
一、TCC这玩意儿,怎么就能不阻塞?
传统2PC,你懂得——就是那种‘大家准备好,我要提交啦’的同步等待。协调者问一圈,全部锁定,等最慢的那个回应。阻塞,死锁风险,性能一坨。TCC不一样。它把一次全局事务拆成三个独立的小操作:Try、Confirm、Cancel。Try阶段只是‘软预留’资源,不真正执行。比如订单系统里,库存扣减?别真扣,先冻结那个数量。用户余额?先冻结金额。这些都是业务层自己设计的状态字段,像update inventory set frozen_qty = frozen_qty + 5 where sku_id = 123 and available_qty >= 5。这一步做完,立刻释放连接。协调者再调用Confirm,各服务执行真正的扣减:把冻结量转成实际扣减。如果某一步失败,就调用Cancel,把冻结量还原回去。整个过程中,资源没有强锁,异步化的,并发一下就上来了。
但你要是觉得就这么简单?天真了。Try阶段的设计学问大着呢。比如有些操作根本没法预留,像发短信、调外部API。这种怎么办?业内有几种套路:要么在Try阶段做‘空提交’,记录一条日志说‘我准备好了’,然后Confirm时真正发送;要么干脆把这类操作挪到事务之外,用本地消息表+最终一致性兜底。说实话,TCC最美的地方就在于它把业务决策权还给了开发者,你可以精确控制哪些资源需要预占,哪些可以直接试探,而不是被底层框架一把梭。

二、别光吹,上数据——压测对比见真章
我们拿同一套订单业务逻辑,分别用2PC和TCC实现,在32核64G的服务器上,用JMeter压了半小时。数据库是MySQL,连接池200。结果——2PC在300并发时就频繁超时,TPM(每秒事务数)卡在80左右,数据库连接池大量处于等待状态;换上TCC,并发拉到800,TPM稳定在650以上,响应时间中位数从2PC的3200ms骤降到210ms。为什么差这么多?核心就是阻塞时间。2PC平均一个事务占着连接和锁长达1.2秒,TCC的Try阶段平均20ms完事。这极短的临界区,让并发能力产生质的飞跃。
你可能会问:“那TCC就没代价吗?”当然有。我们观察到,TCC的业务代码量增加了约40%。每个接口都要实现Try/Confirm/Cancel三套逻辑,还得处理异常重试。所以这不是免费午餐。但相比2PC动不动就让系统瘫痪的灾难,多写点代码算什么?这就是工程权衡。再看具体响应时间分布,2PC的99线飙到8秒,TCC的99线才900ms;错误率上,2PC在并发超过400后直接冒出大量连接超时和死锁错误,TCC在800并发时错误率仍低于0.1%。这不只是好看的数字,这是线上用户能感到的丝滑。

三、三个坑,踩了才知道多疼
如果只是写个Demo,TCC看起来优雅得像首诗。一上生产,各种妖魔鬼怪就出来了。下面这三个坑,是我们用真金白银(线上故障)换来的经验。
坑1:空回滚——Cancel来得比Try还快
场景是这样的:全局事务发起后,Try请求因为网络抖动超时了,协调者以为失败,立刻发起Cancel。此时Cancel请求先到达业务服务,而那个慢半拍的Try还在路上。Cancel执行时发现没有任何冻结资源,如果不做判断,可能会把别人的正常数据回滚掉——比如直接把库存数量加了回去。实际上根本没有冻结过。这就是空回滚。记得有次,我们因为没处理空回滚,导致一个商品库存莫名其妙多了100件,财务对账差点报警。解决方法?在Cancel操作里,必须确认对应的Try是否执行过。怎么做?建一张‘事务记录表’,Try阶段插入一条记录(包含事务xid和分支id),Cancel时查表,如果没有记录,直接返回成功,什么都不做。这个设计点看似简单,但很多实现都忘了。
坑2:悬挂——Try在Cancel之后才到
接上面,Cancel先到并执行完了,然后那个迷路的Try包终于到达,服务端一看,没有事务记录(因为Cancel可能已经把记录清掉了或者没留痕),于是执行了Try,冻结了资源。可是这个事务早就全局结束了!这些被冻结的资源就永远挂着,成了悬挂资源,业务数据被污染。要避免悬挂,在Cancel操作执行完后,需要标记事务已取消。比如更新事务记录状态为‘已取消’。当Try姗姗来迟,一查状态是已取消,直接拒绝,返回失败。这需要框架层面支持,或者在业务代码里用分布式锁或幂等表来防。我们当时用Seata的TCC模式,框架有防悬挂机制,但配置不当还是会踩坑,尤其是自研组件时。
坑3:幂等——别小看重试
网络不可靠,Confirm或Cancel可能被重试多次。如果接口不幂等,就会出现重复扣款、重复释放资源等严重后果。所以,每个Confirm和Cancel操作都必须基于业务单号做幂等控制。常见的做法是在事务记录表里存操作状态:Try插入记录时状态为‘尝试中’,Confirm成功后更新为‘已确认’,Cancel成功后更新为‘已取消’。每次执行前检查状态,已完成的直接返回成功。这样重试多少次都安全。另外,数据库层面可以用唯一索引来兜底,防止并发写入。我们还吃过一个暗坑:幂等表的数据量快速增长,需要及时归档,否则影响操作性能。要定期清理已终态的事务记录。这个维护机制容易被忽视,导致后期慢查询拖垮整个服务。

写代码的时候,我们常常追求极致的优雅。但分布式系统里,优雅往往敌不过现实。TCC不是银弹,它是一把需要精心打磨的刀。你得自己卷起袖子处理空回滚、悬挂和幂等——这些细节决定了生产环境的稳定性。可一旦驾驭好,那种用业务逻辑的细腻去换取系统吞吐量的快感,啧啧,会上瘾。不要怕多写那40%的代码,它们才是护城河。