WEBKT

微服务TCC防悬挂与空回滚:除了Redis锁,还有哪些硬核方案?

33 0 0 0

TCC分布式事务:除了Redis锁,如何优雅处理悬挂和空回滚?

在微服务架构中,TCC(Try-Confirm-Cancel)模式虽然灵活,但“空回滚”和“悬挂”是两个让人头秃的经典问题。很多人的第一反应是用Redis加锁,但Redis在极端情况下(如网络分区、缓存穿透)并不总是最可靠的。

作为一名踩过无数坑的后端老鸟,今天聊聊除了Redis,我们团队在生产环境验证过的几种更稳健的方案。

一、 什么是空回滚和悬挂?(快速回顾)

  • 空回滚 (Null Rollback)Cancel 方法比 Try 先执行。比如网络抖动导致Try请求超时,全局事务触发回滚,但此时业务还没执行Try,Cancel却到了。
  • 悬挂 (Suspension)Try 方法比 Cancel 后执行。Try由于网络拥堵晚到了,此时全局事务已经回滚并结束了,Try再执行就会破坏数据一致性。

二、 为什么Redis锁可能不够?

Redis锁通常依赖 SETNX 设置幂等键。但在高并发下,如果Try执行完但未及时释放锁(例如业务逻辑阻塞),或者Cancel操作因为Redis主从切换导致锁失效,依然会产生悬挂。我们需要更贴近业务底层的约束。

三、 替代方案与实战策略

1. 数据库唯一索引 + 事务记录表(最推荐)

这是目前我认为最稳妥、不依赖外部中间件的方案。

  • 原理:建立一张 tcc_transaction_log 表,记录全局事务ID、分支事务ID、状态等。
  • 防悬挂:在Try执行前,先查表。如果表里已经存在该事务ID且状态为“已回滚/已完成”,说明Cancel已经执行过了,直接拒绝Try操作。
  • 空回滚:执行Cancel时,如果查表发现没有对应的Try记录(说明Try没执行过),则插入一条“空回滚”记录,或者直接返回成功(取决于业务是否允许空操作)。
  • 杀手锏:给这张表的 xid (全局事务ID) 加唯一索引
    • 当Try晚到试图插入记录时,数据库会直接报唯一键冲突,从而从根源上杜绝悬挂。

2. 利用注册中心/配置中心的状态机(如Nacos/Apollo)

如果你的微服务已经用了Nacos做配置管理,可以利用它做状态同步。

  • 流程
    • 全局事务开始时,在Nacos写入一个Key,状态为 GlobalRunning
    • Try阶段:读取Key,必须是 GlobalRunning 才能执行。
    • Cancel阶段:将Key置为 GlobalRollbacking
  • 防悬挂:Try执行前检查Key状态,如果是 GlobalRollbackingGlobalEnded,则拒绝执行。
  • 注意:这需要考虑Nacos的CP/AP特性,通常需要配合本地缓存兜底。

3. 业务参数幂等性设计(TCC的高阶玩法)

这是一种“设计模式”层面的解法,不依赖外部锁。

  • 核心思想:将事务状态内嵌到业务参数中。
  • 场景:比如扣款接口,参数带上 request_id(全局唯一)。
  • 实现
    • Try阶段:尝试冻结资金,并记录 request_id
    • Cancel阶段:解冻资金,不删记录,只改状态。
    • 防悬挂:Try执行时,先查库看是否有这个 request_id。如果有,且状态是“已取消”,说明Cancel已经跑过了,直接抛异常或忽略。
    • 这种方式把TCC的控制权完全下放到了业务层,虽然代码耦合度高点,但最灵活。

四、 总结与避坑指南

  1. 不要完全信任Redis:尤其是在跨机房部署时,Redis的锁超时时间很难配置得完美。
  2. Try阶段尽量短:TCC的Try只是做资源预留(冻结),不要做耗时操作,越快越好,这样能减少悬挂发生的窗口期。
  3. Cancel的幂等性是必须的:无论用哪种方案,Cancel被调用多次(重试机制)是常态,必须保证多次调用结果一致。

一句话总结
如果不想引入复杂组件,数据库唯一索引 + 事务日志表是防悬挂的银弹;如果业务允许,尽量在Try阶段就把全局唯一的业务ID生成并落库,利用数据库的约束力来对抗网络的不确定性。

你在实际项目中遇到过最奇葩的TCC异常场景是什么?欢迎评论区交流。

架构师老李 TCC分布式事务微服务架构防悬挂方案

评论点评