返回博客
返回主页

2025-08-15 · 分布式

TCC 分布式事务在支付场景中的实践

做支付系统的人大概都绕不开一个问题:当一个业务操作需要同时更新多个服务的数据时,怎么保证它们要么一起成功,要么一起失败。在退款场景里,这个问题尤其明显——退款操作本身要记录状态,后置的余额更新也要同步完成,中间任何一环出问题,都会导致数据不一致。

我第一次真正面对这个问题,是在做收退款模块重构的时候。当时退款和余额更新是通过消息队列异步解耦的,理论上最终会一致,但实际上经常出现退款成功但余额没更新、或者余额更新了但退款状态还是"处理中"的情况。运维同事每周要花好几个小时去人工核对和修复。

后来我们引入了 TCC 模式来解决这个问题。

TCC 是什么

TCC 是 Try-Confirm-Cancel 的缩写,核心思路是把一个分布式事务拆成三个阶段:

  1. Try:预留资源。不真正执行业务,只是检查条件并锁定资源。比如退款场景里,Try 阶段会检查账户余额是否足够、退款单是否合法,并冻结对应的金额。
  2. Confirm:确认执行。当所有参与者的 Try 阶段都成功后,统一执行 Confirm。这个阶段才是真正扣减余额、更新退款状态。
  3. 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 阶段的预留机制相当于多了一道防线,可以在真正扣款之前就把大部分异常拦截住。Saga 的补偿虽然简单,但在涉及资金的场景里,"先扣了再退回去"的风险比"先冻住了再确认"要大得多。

不过 TCC 的代价也很明显。每个参与的服务都要改造成三个接口,业务逻辑被拆得更细,代码量和测试用例都会增加。如果只是普通的业务操作,Saga 通常是更务实的选择。

总结

TCC 不是银弹,但在支付场景下,它提供了一个可靠的思路:把"做一件事"变成"先占个位、再确认、出错就退"。实际落地时,空回滚、悬挂、幂等这三个问题一定要在设计阶段就考虑清楚,否则上线后的运维成本会很高。

另外,TCC 框架的选择也很重要。我们当时用的是自研的轻量实现,如果现在重新做,可能会考虑 Seata 这类开源方案,社区支持和文档会更完善一些。