Seata TCC分布式事务实战:从原理到生产环境避坑指南

📅 2026/8/12 11:44:02
Seata TCC分布式事务实战:从原理到生产环境避坑指南
1. 从“分布式事务”这个老难题说起如果你做过几年后端开发尤其是在微服务架构下折腾过数据一致性那“分布式事务”这个词对你来说大概率不是什么美好的回忆。订单创建了库存没扣积分增加了优惠券没核销这种“一半成功一半失败”的烂摊子处理起来比写业务代码本身还头疼。我最早接触分布式事务解决方案时试过基于消息队列的最终一致性也折腾过两阶段提交2PC各有各的麻烦。消息队列方案对业务侵入大补偿逻辑复杂而传统的2PC对数据库和中间件要求高性能瓶颈明显在云原生和微服务拆得越来越细的今天显得有点力不从心。正是在这种背景下像Seata这样的分布式事务框架开始流行起来。它把那些复杂的协调、回滚逻辑封装起来给我们提供了一套相对统一的编程模型。Seata支持好几种模式比如AT自动补偿、TCCTry-Confirm-Cancel、Saga长事务等。今天我们不聊别的就聚焦在TCC模式上。为什么是TCC因为在我看来它是业务侵入性、性能和数据强一致性之间一个非常不错的平衡点。它不像AT模式那样严重依赖数据库的undo log对某些不支持undo log的数据库或中间件不友好也不像Saga模式那样一阶段就提交可能产生脏读。TCC要求我们开发者自己定义三个明确的阶段Try、Confirm、Cancel虽然多写了一些代码但换来了对事务边界的绝对掌控和对各种异构数据源比如Redis、ES、MQ的支持能力。网上关于Seata TCC的教程不少但很多要么是官网示例的简单复刻要么只讲通了Demo一遇到生产环境的复杂场景——比如嵌套调用、并发冲突、空回滚、幂等控制——就语焉不详。这篇文章我就结合自己最近在一个电商履约系统中落地Seata TCC 1.4.2的真实经历把从环境搭建、代码编写、到各种坑的排查和填平过程给你完整地捋一遍。我们的目标是让你看完之后不仅能跑通一个TCC示例更能理解其内在机制具备在实际项目中设计和驾驭TCC事务的能力。2. 环境与项目骨架避开第一个坑在开始写一行业务代码之前先把环境搭对能省掉后面80%的莫名其妙的问题。我这次用的是Seata 1.4.2版本部署方式选择了Docker Compose这比直接下载jar包运行要方便和干净得多。2.1 部署Seata Server (TC)TC是Transaction Coordinator事务协调器是Seata的大脑必须最先启动。很多人卡在第一步就是因为没搞清楚配置。首先你需要一个docker-compose.yml文件。网上很多教程给的配置还是老版本的直接抄过来可能连服务都起不来。下面是我验证过可用的1.4.2版本配置version: 3.8 services: seata-server: image: seataio/seata-server:1.4.2 container_name: seata-server ports: - 8091:8091 # 事务协调器默认端口 - 7091:7091 # 控制台端口1.4.2开始自带控制台 environment: - SEATA_IP你的服务器IP # 重要如果是云服务器填内网IP本地测试填127.0.0.1 - SEATA_PORT8091 - STORE_MODEfile # 事务日志存储模式单机测试用file最简单。生产环境请用db或redis - SERVER_NODE1 # 节点编号单机就是1 volumes: - ./seata-config:/seata-server/resources networks: - seata-net networks: seata-net: driver: bridge这里有几个关键点SEATA_IP这是最大的坑。这个IP是TC服务对外暴露的地址Client你的业务服务要靠这个地址来连接TC。如果你在本地用Docker跑Client是宿主机上的Spring Boot应用那么这里应该填宿主机的IP127.0.0.1或本机局域网IP而不是容器内部的IP。如果填错了Client会连不上TC报“no available server to connect”错误。存储模式STORE_MODEfile表示将事务日志全局事务会话、分支事务记录等存在本地文件。这对于开发测试完全没问题重启容器数据就没了。生产环境绝对不要用file模式因为无法支持集群和高可用。生产环境应该用db模式并配置好对应的数据库连接信息。控制台从1.4.2开始Seata Server自带了一个简单的控制台访问http://你的IP:7091即可默认账号密码是seata/seata。可以在上面查看全局事务列表和状态对于调试非常有用。启动命令很简单docker-compose up -d。用docker logs -f seata-server查看日志看到 “Server started...” 的字样就说明启动成功了。2.2 准备业务项目TM/RMTC搭好了接下来是事务参与者也就是我们的业务服务。我们需要创建一个简单的多模块Spring Boot项目来模拟分布式场景。假设我们有一个“订单服务”和一个“库存服务”。项目依赖是关键。在父pom中引入Spring Cloud和Spring Cloud Alibaba的依赖管理。注意版本兼容性我用的组合是Spring Boot: 2.3.12.RELEASESpring Cloud: Hoxton.SR12Spring Cloud Alibaba: 2.2.7.RELEASE在订单服务和库存服务的pom中都需要引入以下核心依赖!-- Seata 客户端 -- dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.4.2/version /dependency !-- Spring Cloud Alibaba Nacos 作为配置和注册中心也可用Eureka等 -- dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-nacos-discovery/artifactId /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-nacos-config/artifactId /dependency配置文件application.yml是另一个容易出错的地方。订单服务作为事务发起者TM的配置示例spring: application: name: order-service cloud: nacos: discovery: server-addr: localhost:8848 # Nacos地址 config: server-addr: localhost:8848 file-extension: yaml seata: application-id: ${spring.application.name} tx-service-group: my_test_tx_group # 事务组需要和TC配置对应 enable-auto-data-source-proxy: false # TCC模式不需要数据源代理设为false config: type: nacos # 从Nacos读取TC配置 nacos: server-addr: ${spring.cloud.nacos.config.server-addr} group: SEATA_GROUP >dependency groupIdio.seata/groupId artifactIdseata-rm-tcc/artifactId version1.4.2/version /dependency然后定义接口。这个接口必须被业务实现类实现并且方法要遵循特定规则LocalTCC // 声明这是一个本地TCC接口 public interface InventoryTccAction { /** * Try 方法 * param businessKey 业务唯一键用于标识本次操作 * param productId 商品ID * param count 扣减数量 * return 是否成功 */ TwoPhaseBusinessAction(name inventoryTccAction, commitMethod commit, rollbackMethod cancel) boolean prepare(BusinessActionContext actionContext, BusinessActionContextParameter(paramName productId) String productId, BusinessActionContextParameter(paramName count) Integer count); /** * Confirm 方法 * param actionContext 业务上下文框架自动注入包含prepare方法的参数 * return 是否成功 */ boolean commit(BusinessActionContext actionContext); /** * Cancel 方法 * param actionContext 业务上下文 * return 是否成功 */ boolean cancel(BusinessActionContext actionContext); }关键注解解析LocalTCC: 标识这是一个TCC接口可以是Spring Bean。TwoPhaseBusinessAction: 核心注解标注在Try方法上。name: TCC事务的名称全局唯一用于标识这个业务动作。commitMethod: Confirm方法的方法名。rollbackMethod: Cancel方法的方法名。BusinessActionContextParameter: 标注在Try方法的参数上框架会将这些参数值保存到BusinessActionContext中并自动传递给后续的Confirm/Cancel方法。这是TCC模式能正确回滚的关键因为Cancel方法需要知道Try阶段冻结的是哪个商品、多少数量。3.2 实现TCC接口接下来是实现类。这里的设计直接关系到事务的可靠性。Service public class InventoryTccActionImpl implements InventoryTccAction { Autowired private InventoryMapper inventoryMapper; Override Transactional(rollbackFor Exception.class) public boolean prepare(BusinessActionContext actionContext, String productId, Integer count) { // 1. 检查库存是否充足 Inventory inventory inventoryMapper.selectById(productId); if (inventory null || inventory.getAvailable() count) { throw new RuntimeException(库存不足); } // 2. 执行预留操作冻结库存 int frozen inventory.getFrozen() null ? 0 : inventory.getFrozen(); int newFrozen frozen count; int newAvailable inventory.getAvailable() - count; int updateCount inventoryMapper.updateFrozenAndAvailable(productId, newFrozen, newAvailable); if (updateCount ! 1) { throw new RuntimeException(冻结库存失败可能并发冲突); } // 3. 记录预留日志可选但推荐用于幂等和排查 // 可以将 xid, branchId, productId, count, status(try) 插入一张单独的日志表 log.info(TCC Try 成功。xid{}, productId{}, count{}, actionContext.getXid(), productId, count); return true; } Override Transactional(rollbackFor Exception.class) public boolean commit(BusinessActionContext actionContext) { String xid actionContext.getXid(); String productId (String) actionContext.getActionContext(productId); Integer count (Integer) actionContext.getActionContext(count); log.info(TCC Confirm 开始。xid{}, productId{}, count{}, xid, productId, count); // 1. 幂等性检查查询日志表如果该xid和branchId已经执行过confirm则直接返回true // if (tccLogService.isConfirmed(xid, branchId)) { return true; } // 2. 执行真正的扣减将冻结的库存清零总库存减少 int updateCount inventoryMapper.confirmDeduct(productId, count); if (updateCount ! 1) { // 这里可能因为Try阶段数据被篡改等原因失败需要告警和人工介入 log.error(TCC Confirm 失败xid{}, xid); // 通常Confirm不允许失败失败后应不断重试直到成功。框架会重试。 throw new RuntimeException(Confirm扣减库存失败); } // 3. 更新日志表状态为 commit // tccLogService.updateStatus(xid, branchId, commit); log.info(TCC Confirm 成功。xid{}, xid); return true; } Override Transactional(rollbackFor Exception.class) public boolean cancel(BusinessActionContext actionContext) { String xid actionContext.getXid(); String productId (String) actionContext.getActionContext(productId); Integer count (Integer) actionContext.getActionContext(count); log.info(TCC Cancel 开始。xid{}, productId{}, count{}, xid, productId, count); // 1. 幂等性检查查询日志表如果该xid和branchId已经执行过cancel则直接返回true // if (tccLogService.isCancelled(xid, branchId)) { return true; } // 2. 执行取消操作释放冻结的库存 int updateCount inventoryMapper.cancelDeduct(productId, count); if (updateCount ! 1) { // Cancel失败也需要重试。但这里有个“空回滚”问题需要处理见下文。 log.error(TCC Cancel 失败xid{}, xid); throw new RuntimeException(Cancel释放库存失败); } // 3. 更新日志表状态为 cancel // tccLogService.updateStatus(xid, branchId, cancel); log.info(TCC Cancel 成功。xid{}, xid); return true; } }实现要点与坑点数据库操作Try阶段是冻结Confirm是扣减冻结值Cancel是释放冻结值。这三个操作必须是幂等的。这意味着即使网络超时导致TC重复调用执行多次也不会对数据状态产生错误影响。上面的代码通过“幂等性检查”和“基于状态的更新”如update table set frozen frozen - #{count} where product_id #{id} and frozen #{count}来保证。业务上下文BusinessActionContext是框架在Try阶段帮你保存的参数包在Confirm/Cancel阶段自动注入。务必用BusinessActionContextParameter注解标记所有Cancel阶段需要的参数。本地事务每个TCC方法prepare, commit, cancel都标注了Transactional。这是因为每个阶段本身可能包含多个数据库操作需要保证其原子性。Seata的全局事务是由这些本地事务组合而成的。日志表强烈建议建立一张独立的TCC事务日志表。记录xid全局事务ID、branchId分支事务ID、业务键、状态try/commit/cancel、创建时间。它的作用巨大幂等控制在Confirm/Cancel前先查日志避免重复执行。问题排查当出现异常时可以通过日志还原事务执行链路。防悬挂配合“空回滚”检查。4. 发起与管控全局事务TM的角色库存服务的TCC接口准备好了现在需要在订单服务事务管理器TM中发起并管理这个全局事务。在订单服务的业务方法上使用GlobalTransactional注解即可开启一个Seata全局事务。Service public class OrderServiceImpl implements OrderService { Autowired private InventoryTccAction inventoryTccAction; // 通过Feign或Dubbo引入的远程TCC接口代理 Autowired private OrderMapper orderMapper; Override GlobalTransactional(name createOrder, timeoutMills 60000, rollbackFor Exception.class) public Order createOrder(OrderDTO orderDTO) { // 1. 创建订单本地事务 Order order new Order(); // ... 设置订单属性 orderMapper.insert(order); // 2. 调用库存服务执行TCC Try阶段 BusinessActionContext context new BusinessActionContext(); context.setActionName(inventoryTccAction); // 这里实际是通过RPC框架如Feign调用了 inventoryTccAction.prepare(context, ...) boolean tryResult inventoryTccAction.prepare(context, orderDTO.getProductId(), orderDTO.getCount()); if (!tryResult) { throw new RuntimeException(库存预留失败); } // 注意Try阶段只是预留资源订单创建和库存预留都未最终提交。 // 3. 这里可以继续调用其他TCC服务比如扣减积分、优惠券... // 如果所有Try都成功GlobalTransactional 注解的方法正常结束Seata会自动触发所有参与者的Confirm阶段。 // 如果此处抛出异常Seata会自动触发所有参与者的Cancel阶段。 return order; } }TM端的关键点GlobalTransactional这是TM的标识。它会在方法开始时向TC申请一个全局事务IDXID并将其通过线程上下文传播到所有远程调用中。超时控制timeoutMills很重要。它定义了整个全局事务的超时时间。如果超过这个时间事务还未完成所有分支未到达终态TC会主动发起回滚。需要根据业务链路的复杂度合理设置。异常回滚默认情况下只有RuntimeException和Error会触发回滚。如果你希望受检异常也回滚需要使用rollbackFor属性指定。RPC调用调用inventoryTccAction.prepare看起来是本地调用但实际上它应该是一个远程服务的代理。你需要通过Feign或Dubbo将InventoryTccAction接口暴露为远程服务并在订单服务中通过FeignClient等方式引入。Seata的TCC组件会自动拦截这个调用将其注册为一个分支事务到TC。5. 生产环境避坑指南空回滚、幂等与悬挂如果只是跑通Demo上面的知识就够了。但一旦上生产你会遇到几个经典的“坑”。不处理好它们分布式事务的可靠性无从谈起。5.1 空回滚Empty Rollback问题描述一个分支事务的Try方法因网络超时等原因根本没有执行但TM却发起了全局回滚导致TC调用了该分支的Cancel方法。后果Cancel方法执行了“释放冻结资源”的操作但Try根本没冻结过任何资源。这可能导致数据不一致比如库存被错误地增加了。解决方案在Cancel方法中必须进行空回滚判断。Override public boolean cancel(BusinessActionContext actionContext) { String xid actionContext.getXid(); Long branchId actionContext.getBranchId(); // 1. 查询事务日志表判断Try是否执行过 TccLog tccLog tccLogService.getByXidAndBranchId(xid, branchId); if (tccLog null) { // Try没执行过是空回滚 log.warn(收到空回滚请求xid{}, branchId{}. 插入一条取消记录防止悬挂。, xid, branchId); // 插入一条状态为‘cancel’的记录目的是为了“占位”防止后续的悬挂问题 tccLogService.insertCancelLog(xid, branchId, productId, count); return true; // 空回滚直接返回成功 } // 2. 如果已经执行过Cancel幂等返回 if (cancel.equals(tccLog.getStatus())) { return true; } // 3. 正常执行Cancel逻辑... }核心就是Cancel时检查Try是否已执行。没执行过就是空回滚记录一条Cancel日志后直接返回不执行业务逻辑。5.2 幂等控制Idempotence问题描述由于网络抖动或TC重试机制Confirm或Cancel接口可能会被重复调用。后果如果不做幂等重复的Confirm会导致资源被多次扣减重复的Cancel会导致资源被多次释放。解决方案利用事务日志表实现幂等。在Try成功后插入一条状态为try的记录。在Confirm/Cancel执行前先查日志。如果状态已经是commit/cancel则直接返回成功。执行完Confirm/Cancel业务逻辑后更新日志状态为commit/cancel。所有数据库更新操作尽量使用带状态的更新语句例如-- Confirm: 扣减冻结值同时确保冻结值足够 UPDATE inventory SET frozen frozen - #{count}, total total - #{count} WHERE product_id #{productId} AND frozen #{count}; -- Cancel: 释放冻结值同时确保冻结值足够 UPDATE inventory SET frozen frozen - #{count}, available available #{count} WHERE product_id #{productId} AND frozen #{count};这样即使重复执行只要条件不满足影响行数就是0不会破坏数据。5.3 悬挂Hanging问题描述空回滚发生后之前被阻塞的Try请求终于到达了服务端并执行成功。后果Try在Cancel之后执行导致资源被冻结但再也没有Confirm或Cancel来释放它了因为全局事务已结束造成资源永久锁定。解决方案在Try方法中进行悬挂判断。Override public boolean prepare(BusinessActionContext actionContext, String productId, Integer count) { String xid actionContext.getXid(); Long branchId actionContext.getBranchId(); // 悬挂检查在执行业务前先查日志 TccLog tccLog tccLogService.getByXidAndBranchId(xid, branchId); if (tccLog ! null cancel.equals(tccLog.getStatus())) { // 发现已经有一条Cancel日志说明发生了空回滚且Cancel先于Try到达 log.warn(检测到悬挂Try方法被拒绝执行。xid{}, branchId{}, xid, branchId); return true; // 或者抛出一个特定的异常让TM感知 } // ... 正常的Try业务逻辑 // 执行业务后插入状态为‘try’的日志 tccLogService.insertTryLog(xid, branchId, productId, count); return true; }核心是Try执行前检查是否已有对应xid和branchId的Cancel记录。如果有说明是悬挂应拒绝执行Try业务逻辑。空回滚、幂等、悬挂这三个问题是TCC模式在生产环境落地必须解决的“三座大山”。解决它们的核心工具就是那张TCC事务日志表。通过它记录状态所有操作都变成“先查后改”的模式从而保证最终一致性。6. 进阶话题嵌套调用、并发与监控解决了基本可靠性问题我们再看一些更复杂的场景。6.1 嵌套的TCC调用有时候一个TCC服务内部需要调用另一个TCC服务。比如“创建订单”事务中调用“库存服务”的TCC接口而“库存服务”的Try操作里又需要调用“仓储服务”的TCC接口来锁定库位。Seata是支持这种嵌套的。关键在于XID的传递。只要确保RPC框架如Dubbo、Feign的过滤器正确地将当前线程上下文中的XID传递到下游服务Seata就能自动识别并管理这个嵌套分支事务。它会形成一个调用树TC会确保所有分支包括嵌套的一起提交或回滚。在代码上不需要特殊处理只需确保每个服务都正确引入了Seata客户端并配置了事务组。6.2 高并发下的资源竞争在TCC的Try阶段我们经常要“冻结”资源。在高并发场景下对同一条记录比如同一商品库存的冻结操作可能发生冲突。解决方案数据库悲观锁在Try方法的SQL中使用SELECT ... FOR UPDATE先锁定记录再执行更新。但这会严重影响性能。乐观锁在库存表中增加一个版本号字段。Try操作时使用UPDATE ... SET frozen frozen #{count}, version version 1 WHERE id #{id} AND version #{oldVersion}。如果更新失败影响行数为0说明发生了并发冲突可以重试或直接返回失败。分布式锁在进入Try业务逻辑前用Redis或Zookeeper对业务键如lock:inventory:{productId}加锁。这是最常用的方式但要注意锁的粒度、超时时间和释放的可靠性。我个人更倾向于“乐观锁 有限重试”或“细粒度分布式锁”的组合。在库存冻结场景可以在应用层用Redis分布式锁控制同一商品ID的并发然后在数据库层用乐观锁做最终保障。6.3 监控与排查当TCC事务出现问题时排查链路比普通调用复杂。你需要关注几个点Seata TC控制台访问http://tc-server:7091查看全局事务列表。你可以看到每个全局事务的XID、状态、开始时间、耗时以及其下所有分支事务的状态。这是最直观的定位工具。业务日志在TCC接口的三个方法中务必打印包含XID和BranchId的日志。这是串联整个事务链路的唯一标识。通过ELK或类似的日志聚合系统用XID可以轻松搜出该事务在所有微服务中的执行痕迹。事务日志表你业务库里的那张TCC日志表是最终的证据。通过它可以清楚地看到每个分支事务走到了哪一步try/commit/cancel以及执行时间。如果发现一个事务长时间处于“try”状态那很可能发生了悬挂如果大量“cancel”日志没有对应的“try”日志那可能是空回滚频发需要检查网络或超时配置。Metrics监控Seata客户端会暴露一些Metrics需要额外配置比如全局事务提交/回滚次数、分支事务注册次数、各种模式的耗时等。可以接入Prometheus和Grafana对分布式事务的健康度进行监控和告警。7. 总结与个人体会走完这一整套流程从环境搭建、代码编写到填坑进阶你应该对Seata TCC模式有了比较立体的认识。它不是银弹需要你付出额外的设计设计TCC接口和资源预留逻辑和编码实现三阶段方法成本。但换来的是对复杂业务场景更强的掌控力以及对异构数据源的支持。我个人在项目中的体会是设计阶段多花时间在写代码前一定要和业务方、架构师一起把每个分布式事务的边界、每个参与服务的Try/Confirm/Cancel具体操作画清楚。特别是资源预留的粒度是锁整条记录还是锁部分字段这直接影响并发性能。日志表是生命线那张小小的TCC事务日志表在开发和排查阶段的价值远超你的想象。一定要建而且要设计好查询索引xid, branch_id, status, create_time。超时时间要合理全局事务超时时间timeoutMills不能设得太短否则长链路业务容易超时回滚也不能设得太长否则出问题时资源锁定时间过长。需要根据压测和线上监控来调整。做好降级和熔断分布式事务框架本身也可能成为故障点。如果TC集群不可用你的业务应该有降级方案比如切到基于消息的最终一致性或者记录异常工单人工处理。在调用TCC服务时也要配置好Feign/Dubbo的超时和熔断避免一个分支事务的阻塞拖垮整个全局事务。最后TCC模式的思想——先试探后确认——其实是一种非常普适的解决分布式问题的思路。即使未来你不使用Seata这种通过业务日志和状态机来实现最终一致性的模式在设计和处理复杂业务逻辑时依然会给你带来启发。