TCC事务在微服务场景下怎么实现
摘要:# 微服务里,TCC事务这玩意儿到底咋整? 前两天和一位做电商平台的朋友喝酒,聊起他们最近一次大促的“翻车”现场:用户下单成功,积分也扣了,但优惠券死活没核销掉。后台一看,三个微服务,俩成功了,一个挂了,数据直接对不上。他灌了口啤酒,叹气道:“分布式事务…
微服务里,TCC事务这玩意儿到底咋整?
前两天和一位做电商平台的朋友喝酒,聊起他们最近一次大促的“翻车”现场:用户下单成功,积分也扣了,但优惠券死活没核销掉。后台一看,三个微服务,俩成功了,一个挂了,数据直接对不上。他灌了口啤酒,叹气道:“分布式事务这坑,真是谁踩谁知道。”
这场景你应该不陌生吧?只要你的系统拆成了微服务,数据一致性就是个绕不过去的坎。今天咱不聊那些大而全的解决方案,就掰开揉碎了说说 TCC事务 —— 这名字听着挺技术,说白了,就是一种“先占坑,后确认”的聪明办法。
一、TCC到底是个啥?先忘掉教科书
别被“Try-Confirm-Cancel”这仨词吓住。咱们用人话翻译一下:
- Try(尝试):不是真干,而是“占座”。比如扣库存,不是直接减100,而是先冻结100。下单时先预扣款,不是真划走你的钱。这一步,资源先锁住,但操作可回退。
- Confirm(确认):大家都“占座”成功了?好,那现在动真格的。把冻结的库存真减掉,预扣的款正式划账。
- Cancel(取消):但凡有一个服务“占座”失败,比如优惠券系统崩了,那就喊一声“撤!”。把冻结的库存释放,预扣的款退回。
说白了,TCC就是把一个原本“一刀切”的原子操作,拆成两个阶段:先做足准备(Try),再统一提交(Confirm)或回滚(Cancel)。这思路,跟咱们生活中“订婚-结婚”或者“租房定金”的逻辑简直一模一样——先有个缓冲,避免一头扎进去回不了头。
很多文章会把TCC和2PC(两阶段提交)放一块儿讲,但这里有个关键区别(也是TCC更实用的地方):2PC依赖数据库底层的事务协议,而TCC是业务代码层面自己实现的。这意味着,你可以跨不同的数据库、甚至不同的技术栈(比如一个用MySQL,一个用MongoDB)来保证一致性。灵活性一下就上来了。
二、光说不练假把式,看个真实“栗子”
咱们就拿最经典的“下单扣库存”场景开刀。假设你有三个服务:订单服务(Order)、库存服务(Stock)、账户服务(Account)。
传统做法(问题所在):用户一点支付,订单服务调库存服务减库存,再调账户服务扣款。万一扣款时网络抖一下,库存已经减了,钱却没扣成,得,数据乱了。
TCC怎么做? 我们分阶段看:
第一阶段:Try(尝试冻结资源)
- 订单服务:生成一个“待确认”的订单记录,状态是
TRYING。 - 库存服务:收到“冻结库存”请求。别直接
stock = stock - 1,而是专门搞个frozen_stock字段,执行frozen_stock = frozen_stock + 1。总可用库存就是stock - frozen_stock。 - 账户服务:收到“冻结余额”请求。同样,别直接扣钱,而是在用户账户下,将订单金额从“可用余额”挪到“冻结余额”里。
(插句大实话:很多团队栽跟头,就栽在Try阶段图省事,直接动了核心资源。到时候Cancel都 Cancel 不干净,留下一堆脏数据。)
第二阶段:成功就Confirm,失败就Cancel
- 如果都Try成功了:事务协调者(可以是独立的组件,也可以由订单服务兼任)发起Confirm。
- 订单服务:把订单状态从
TRYING改成CONFIRMED(已确认)。 - 库存服务:把刚才冻结的那部分库存正式扣掉:
stock = stock - 1,同时frozen_stock = frozen_stock - 1。 - 账户服务:把“冻结余额”里的钱正式划走,清空冻结记录。
- 订单服务:把订单状态从
- 如果任何一个Try失败(比如账户余额不足):协调者发起Cancel。
- 订单服务:把订单状态改成
CANCELLED(已取消)。 - 库存服务:释放冻结:
frozen_stock = frozen_stock - 1。 - 账户服务:把“冻结余额”里的钱,加回“可用余额”。
- 订单服务:把订单状态改成
看到没?整个过程中,真正的资源扣减(Confirm)只在最后一步发生。之前都是在玩“预备动作”,随时能撤。
三、别急着鼓掌,坑都给你标出来了
TCC听起来很美,但真想把它在微服务里跑顺了,有几个坎儿你必须心里有数:
1. 空回滚(网络一抽风就遇上) 场景:Try请求发出去,因为网络超时没收到响应,协调者直接判它失败,发起Cancel。但可能实际上,人家的Try操作后来默默执行成功了。这时候再收到Cancel,不就多回滚了一次吗?
- 怎么办:给每个分布式事务一个全局唯一的ID(比如XID)。每个参与的服务,在执行Try时,把这个XID和操作记录落库。收到Cancel时,先查一下库,如果这个XID没执行过Try,那这个Cancel就是“空”的,直接返回成功就行,别真去动业务数据。
2. 防悬挂(比空回滚更烦人) 场景:和上面相反。Try请求因为网络拥堵,跑得比Cancel还慢。结果Cancel先执行完了(因为没找到Try记录,做了空回滚)。然后那个迟到的Try才慢悠悠地执行成功……得,资源被冻结,再也没人Confirm或Cancel它了,就“悬挂”在那儿了。
- 怎么办:还是靠那个全局XID。在执行Try之前,先检查一下,有没有相同XID的Cancel记录已经存在了。如果有,说明Cancel先到了,那这个Try就不能执行,直接拒绝。
3. 幂等性(说一万遍也不嫌多) 网络这玩意儿,可能重复发消息。Confirm或Cancel接口可能被调用多次。你的代码必须保证:用同样的参数调无数次,效果和调一次一样。不然钱可能被扣多次,库存可能被多减几次。
- 怎么办:最简单的,还是利用事务状态和XID。在服务本地记录每个XID的最终状态(
CONFIRMED或CANCELLED)。接口被调用时,先查状态,如果已经是终态,直接返回成功,别做任何操作。
4. 业务改造成本高(这是最疼的) TCC要求你每个参与的业务操作,都必须拆出Try、Confirm、Cancel三个接口。这可不是加个注解就能搞定的事,是实打实地要修改业务逻辑和数据库表结构(比如加冻结字段)。很多老系统,改造成本能让你倒吸一口凉气。 (个人偏见时间:我觉得这是TCC最大的缺点。它把分布式事务的复杂性,几乎全部转嫁给了业务研发。有时候看着产品经理催得急,真想问他:“这功能,值得我们把半个系统重写一遍吗?”)
四、所以,到底啥时候该用TCC?
聊了这么多,咱也别把TCC当银弹。它适合的场景其实挺明确的:
- 对一致性要求高:钱、库存、票务这些,玩不起“最终一致”。
- 业务逻辑能清晰拆分:你的业务能自然地划分出“预留资源”和“确认消费”两个步骤。
- 性能有一定要求:相比2PC在Try阶段就锁死资源,TCC的锁粒度更细(业务层面),持有时间更短(理论上),并发性能更好一些。
- 技术栈异构:你的微服务用了五花八门的数据库,底层搞不了统一的事务协议。
那什么时候别硬上呢?如果你的业务本身就是“日志型”、“可补偿”(比如发个通知,没发出去重试就行),或者对实时一致性要求没那么苛刻,用个消息队列+最终一致性的方案,可能更简单、更香。
写在最后
说实话,在微服务里搞事务,就像在钢丝上跳舞,没有绝对完美的方案。TCC提供了一种“业务可控”的强一致性思路,但代价是复杂度和开发量。
我自己的经验是,上任何分布式事务方案之前,先问问自己:是不是服务拆得太细了?这个业务场景真的需要这么强的一致性吗? 有时候,调整一下业务边界或流程,比硬怼一个技术方案更管用。
技术选型,永远是权衡的艺术。希望这篇带着点个人碎碎念的解读,能帮你把TCC看得更透一些。至少下次遇到类似问题,你能知道坑在哪儿,值不值得踩。
行了,关于TCC,今天就聊这么多。如果你在实践中有更痛的领悟,或者更好的“野路子”,欢迎来聊聊——毕竟,实战里的土办法,往往比教科书里的标准答案更有用。

