TCC 分布式事务在支付场景中的实践
做支付系统的人大概都绕不开一个问题:当一个业务操作需要同时更新多个服务的数据时,怎么保证它们要么一起成功,要么一起失败。在退款场景里,这个问题尤其明显——退款操作本身要记录状态,后置的余额更新也要同步完成,中间任何一环出问题,都会导致数据不一致。
我第一次真正面对这个问题,是在做收退款模块重构的时候。当时退款和余额更新是通过消息队列异步解耦的,理论上最终会一致,但实际上经常出现退款成功但余额没更新、或者余额更新了但退款状态还是"处理中"的情况。运维同事每周要花好几个小时去人工核对和修复。
后来我们引入了 TCC 模式来解决这个问题。
TCC 是什么
TCC 是 Try-Confirm-Cancel 的缩写,核心思路是把一个分布式事务拆成三个阶段:
- Try:预留资源。不真正执行业务,只是检查条件并锁定资源。比如退款场景里,Try 阶段会检查账户余额是否足够、退款单是否合法,并冻结对应的金额。
- Confirm:确认执行。当所有参与者的 Try 阶段都成功后,统一执行 Confirm。这个阶段才是真正扣减余额、更新退款状态。
- Cancel:回滚。如果任何一个参与者的 Try 失败,所有已经 Try 成功的参与者都要执行 Cancel,释放预留的资源。
和传统的 2PC(两阶段提交)相比,TCC 的优势在于它是业务层面的补偿,而不是依赖数据库锁。这意味着它不会长时间占用数据库连接,更适合高并发场景。
实际接入时遇到的问题
看起来很清晰的三阶段,真正落地时会踩不少坑。
空回滚
这是最常见的问题。当一个请求因为网络超时没有到达 Try 阶段,但调用方认为失败了,直接发起 Cancel。这时候 Cancel 收到的是一个从来没有 Try 过的请求。
我们的处理方式是在 Cancel 阶段增加判断:如果发现对应事务 ID 没有 Try 记录,直接返回成功,不执行任何业务逻辑。同时要记录这个状态,防止后续真正的 Try 请求到达时被错误执行。
悬挂
悬挂和空回滚是伴生问题。场景是这样的:Cancel 先到(因为网络延迟),执行了空回滚;然后 Try 才到,这时候如果 Try 继续执行,就会导致资源被永久冻结——因为 Cancel 已经标记为完成了,不会再有后续的 Confirm 或 Cancel 来释放。
解决方法是在 Try 阶段也检查一下:如果发现这个事务 ID 已经被 Cancel 过,就拒绝执行 Try。
幂等
Confirm 和 Cancel 都可能因为网络重试被多次调用。每个接口都必须保证幂等——同样的请求执行多次,结果和执行一次完全一样。我们的做法是用事务 ID 做唯一键,执行前检查是否已经处理过。
和 Saga 的对比
TCC 之外,另一个常见的分布式事务方案是 Saga。两者的核心区别在于:
- TCC 是预先检查、预留资源,失败概率更低,但接入成本高,每个服务都要实现 Try/Confirm/Cancel 三个接口
- Saga 是直接执行,失败后按反向顺序补偿,接入简单,但补偿逻辑可能失败,需要额外处理
在支付这种对资金一致性要求很高的场景,我更倾向 TCC。因为 Try 阶段的预留机制相当于多了一道防线,可以在真正扣款之前就把大部分异常拦截住。Saga 的补偿虽然简单,但在涉及资金的场景里,"先扣了再退回去"的风险比"先冻住了再确认"要大得多。
不过 TCC 的代价也很明显。每个参与的服务都要改造成三个接口,业务逻辑被拆得更细,代码量和测试用例都会增加。如果只是普通的业务操作,Saga 通常是更务实的选择。
总结
TCC 不是银弹,但在支付场景下,它提供了一个可靠的思路:把"做一件事"变成"先占个位、再确认、出错就退"。实际落地时,空回滚、悬挂、幂等这三个问题一定要在设计阶段就考虑清楚,否则上线后的运维成本会很高。
另外,TCC 框架的选择也很重要。我们当时用的是自研的轻量实现,如果现在重新做,可能会考虑 Seata 这类开源方案,社区支持和文档会更完善一些。