尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

分布式事务实战:TCC模式原理、Seata实现与避坑指南

分布式事务实战:TCC模式原理、Seata实现与避坑指南 1. 从一次“幽灵库存”说起为什么我们需要TCC去年我们团队重构一个核心的电商下单链路把原本的单体应用拆成了订单、库存、优惠券、支付四个独立的微服务。拆分后系统吞吐量上去了但一个诡异的问题开始频繁出现用户下单成功但库存却没扣减或者库存扣了订单却没生成。客服后台隔三差五就能看到这种“幽灵订单”或“幽灵库存”的投诉。排查下来问题根源很典型本地事务的ACID特性在跨服务的分布式环境下失效了。在单体架构里我们用数据库事务一个BEGIN TRANSACTION到COMMIT就能保证订单表插入和库存表更新要么都成功要么都失败。但微服务化后订单服务和库存服务各有自己的数据库这个“要么都成功要么都失败”的原子性承诺数据库自身已经无法提供了。这就是分布式事务问题。当时我们调研了多种方案最终在业务一致性要求极高的核心交易链路中选择了TCCTry-Confirm-Cancel模式。它不是银弹但在需要强一致性的场景下提供了最清晰、最可控的解决思路。今天我就结合那次实战踩坑和后续的优化把TCC从原理到落地的细节掰开揉碎了讲清楚。2. TCC的核心思想把“事务”拆成你能控制的三个阶段理解TCC首先要跳出数据库事务那种“黑盒”的思维。数据库事务是数据库引擎帮你做的你只关心结果。而TCC是一种业务层面的补偿型事务方案它要求开发者主动参与事务过程把一个分布式事务拆解成三个由业务代码定义的阶段Try尝试这个阶段是整个事务的“资源预留”环节。它的核心目标是检查并锁定必要的业务资源为后续的最终操作做准备。注意这里不是直接执行业务操作而是做一个“预操作”。例如在电商场景下订单服务Try阶段不是直接创建订单而是生成一个状态为“待确认”的订单记录并预留用户账户的这笔资金比如冻结优惠券。库存服务Try阶段不是直接扣减库存而是检查库存是否充足如果充足则预扣冻结相应数量的库存available_stock total_stock - frozen_stock确保这部分库存不会被其他订单占用。核心特征Try操作必须满足幂等性无论调用多少次效果一样和空补偿即使Try没执行Cancel也能正确执行。这个阶段的所有操作共同构成了一个“中间状态”这个状态对外部可能是不可见的或者标记为“处理中”。Confirm确认当所有参与者的Try阶段都成功执行后事务协调者通常是TCC框架会发起Confirm指令。这个阶段才是真正执行业务操作将Try阶段预留的资源进行实际消耗。由于Try阶段已经做好了所有检查和预留所以Confirm操作必须保证成功。它通常很快只涉及状态更新。订单服务将“待确认”的订单状态更新为“已确认”。库存服务将预扣的库存从frozen_stock中减去并减少total_stock。核心特征Confirm操作也必须满足幂等性。因为网络可能超时协调者可能会重试调用。Cancel取消如果任何一个参与者的Try阶段失败或者事务协调者决定回滚整个事务比如业务规则校验失败则会进入Cancel阶段。这个阶段的任务是释放Try阶段预留的资源回滚到事务开始前的状态。订单服务将“待确认”的订单状态更新为“已取消”并释放冻结的资金或优惠券。库存服务将预扣的库存frozen_stock释放回可用库存。核心特征Cancel操作同样需要幂等性和空补偿。注意TCC的成功极度依赖业务代码对这三个阶段逻辑的精准实现。框架如Seata只负责调度和重试业务资源的状态管理冻结、确认、释放必须由开发者自己编码控制。这是TCC与XA等“黑盒”方案最根本的区别。2.1 TCC vs. 其他主流分布式事务方案为了更清楚TCC的定位我们把它和另外几种常见方案放在一起对比方案核心思想一致性强度性能影响业务侵入性典型场景2PC/XA数据库层两阶段提交由资源管理器RM和事务管理器TM配合。强一致差。同步阻塞持有数据库锁时间长。低。对业务代码几乎无侵入。传统单体应用跨库、内部系统集成。TCC业务层两阶段提交通过Try/Confirm/Cancel三个业务逻辑实现。最终一致Confirm后强一致中。Try阶段锁定业务资源非DB锁Confirm/Cancel快。非常高。需改造业务逻辑实现三个接口。对一致性要求高、可容忍短暂中间状态的业务如支付、交易。本地消息表基于本地事务和消息队列通过轮询重试保证最终一致。最终一致好。异步化无阻塞。中。需建消息表业务中耦合消息处理。跨系统数据同步、非核心业务解耦如扣库存后发短信。最大努力通知调用方尽最大努力通知接收方接收方需提供幂等接口。不保证最终一定成功。弱一致好。完全异步。中。需实现通知与回调接口。对一致性要求不高的场景如结果通知、审计日志同步。SAGA长事务拆分为多个本地事务每个事务有对应的补偿事务正向执行反向补偿。最终一致好。无全局锁。高。需为每个子事务设计补偿操作。业务流程长、跨多个系统的场景如旅行订票订酒店、机票、租车。从表格可以看出TCC在强一致性需求和性能之间取得了一个较好的平衡。它避免了XA协议长时间的数据库锁占用锁在Try阶段以“业务资源冻结”的形式存在粒度更细又通过明确的Confirm阶段达成了比“最终一致”更及时的一致性状态。它的代价就是高昂的业务侵入性——你需要为每个参与分布式事务的业务方法都设计并实现Try、Confirm、Cancel三个操作。3. 落地实战基于Seata实现一个TCC订单创建流程理论说再多不如一行代码。我们以经典的“创建订单并扣减库存”场景看看如何用主流的TCC框架Seata来实现。这里假设你已经搭建好Seata ServerTC-事务协调者并在订单服务和库存服务中引入了Seata客户端依赖。3.1 第一步设计业务资源与状态这是最关键的一步需要在设计数据表时就为TCC预留操作空间。库存表 (t_inventory) 设计CREATE TABLE t_inventory ( id bigint(20) NOT NULL, product_id varchar(32) NOT NULL COMMENT 商品ID, total_stock int(11) NOT NULL DEFAULT 0 COMMENT 总库存, frozen_stock int(11) NOT NULL DEFAULT 0 COMMENT 冻结库存Try阶段预扣, available_stock int(11) GENERATED ALWAYS AS (total_stock - frozen_stock) VIRTUAL COMMENT 可用库存虚拟列, PRIMARY KEY (id), UNIQUE KEY uk_product_id (product_id) ) ENGINEInnoDB COMMENT库存表;核心是引入了frozen_stock字段。可用库存通过计算列total_stock - frozen_stock动态得出这样在Try阶段我们只需增加frozen_stock而不动total_stock就实现了“预扣”。订单表 (t_order) 设计CREATE TABLE t_order ( id bigint(20) NOT NULL, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint(20) NOT NULL, product_id varchar(32) NOT NULL, count int(11) NOT NULL COMMENT 购买数量, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0-待确认(Try)1-已确认(Confirm)2-已取消(Cancel), total_amount decimal(10,2) NOT NULL COMMENT 订单金额, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB COMMENT订单表;这里我们用一个status字段来标识订单在TCC流程中所处的阶段。3.2 第二步编写TCC服务接口与实现在Seata中一个TCC服务需要定义一个接口并用LocalTCC1.5.0版本也可用TwoPhaseBusinessAction注解标注其Try方法用BusinessActionContextParameter标注需要传递的参数。库存服务TCC接口 (InventoryTccService):LocalTCC public interface InventoryTccService { /** * Try阶段预扣库存 * param businessActionContext 事务上下文用于传递参数 * param productId 商品ID * param count 预扣数量 * return 是否预扣成功 */ TwoPhaseBusinessAction(name inventoryTccAction, commitMethod commit, rollbackMethod rollback) boolean prepareDeductInventory(BusinessActionContext businessActionContext, BusinessActionContextParameter(paramName productId) String productId, BusinessActionContextParameter(paramName count) Integer count); /** * Confirm阶段确认扣减库存 */ boolean commit(BusinessActionContext businessActionContext); /** * Cancel阶段释放预扣库存 */ boolean rollback(BusinessActionContext businessActionContext); }库存服务TCC实现 (InventoryTccServiceImpl):Service Slf4j public class InventoryTccServiceImpl implements InventoryTccService { Autowired private InventoryMapper inventoryMapper; Override Transactional(rollbackFor Exception.class) public boolean prepareDeductInventory(BusinessActionContext context, String productId, Integer count) { log.info( 库存服务 Try 开始 xid: {}, productId: {}, count: {}, context.getXid(), productId, count); // 1. 检查可用库存是否充足 (total_stock - frozen_stock) Inventory inventory inventoryMapper.selectByProductIdForUpdate(productId); // 加行锁防止并发 if (inventory.getAvailableStock() count) { throw new RuntimeException(库存不足预扣失败); } // 2. 执行预扣增加冻结库存 int rows inventoryMapper.freezeStock(productId, count); if (rows ! 1) { throw new RuntimeException(预扣库存失败); } log.info( 库存服务 Try 成功冻结库存: {}, count); return true; } Override Transactional(rollbackFor Exception.class) public boolean commit(BusinessActionContext context) { String productId (String) context.getActionContext(productId); Integer count (Integer) context.getActionContext(count); log.info( 库存服务 Confirm 开始 xid: {}, productId: {}, count: {}, context.getXid(), productId, count); // 确认扣减将冻结库存转为真实扣减 int rows inventoryMapper.confirmDeduct(productId, count); // UPDATE t_inventory SET total_stock total_stock - ?, frozen_stock frozen_stock - ? WHERE product_id ? if (rows ! 1) { // Confirm必须成功这里失败需要告警并人工干预 log.error(库存Confirm失败需人工处理xid: {}, productId: {}, context.getXid(), productId); // 通常会有告警机制通知运维人员 throw new RuntimeException(库存Confirm失败); } log.info( 库存服务 Confirm 成功实际扣减库存: {}, count); return true; } Override Transactional(rollbackFor Exception.class) public boolean rollback(BusinessActionContext context) { String productId (String) context.getActionContext(productId); Integer count (Integer) context.getActionContext(count); log.info( 库存服务 Cancel 开始 xid: {}, productId: {}, count: {}, context.getXid(), productId, count); // 释放冻结的库存 int rows inventoryMapper.unfreezeStock(productId, count); // UPDATE t_inventory SET frozen_stock frozen_stock - ? WHERE product_id ? // 注意这里即使rows为0空补偿也返回true保证幂等 log.info( 库存服务 Cancel 完成释放冻结库存: {}, count); return true; } }对应的Mapper SQL片段!-- 冻结库存 -- update idfreezeStock UPDATE t_inventory SET frozen_stock frozen_stock #{count} WHERE product_id #{productId} AND (total_stock - frozen_stock) #{count} !-- 乐观锁方式检查可用库存 -- /update !-- 确认扣减 -- update idconfirmDeduct UPDATE t_inventory SET total_stock total_stock - #{count}, frozen_stock frozen_stock - #{count} WHERE product_id #{productId} AND frozen_stock #{count} !-- 防止超额确认 -- /update !-- 释放冻结 -- update idunfreezeStock UPDATE t_inventory SET frozen_stock frozen_stock - #{count} WHERE product_id #{productId} AND frozen_stock #{count} !-- 空补偿处理如果冻结量不足说明可能没执行过Try直接返回成功 -- /update订单服务的TCC接口与实现逻辑类似Try阶段插入状态为0待确认的订单Confirm阶段将状态更新为1已确认Cancel阶段将状态更新为2已取消。这里篇幅所限不再赘述。3.3 第三步在全局事务发起方编排TCC调用通常下单操作由订单服务发起它作为全局事务的发起者TM。Service Slf4j public class OrderServiceImpl implements OrderService { Autowired private OrderTccService orderTccService; // 订单自身的TCC服务 Autowired private InventoryTccService inventoryTccService; // 通过Feign等调用库存TCC服务 GlobalTransactional // Seata注解开启一个全局分布式事务 Override public OrderDTO createOrder(OrderCreateRequest request) { log.info( 开始全局事务创建订单); // 1. 前置业务校验非事务内 // ... // 2. 执行TCC Try阶段事务内 // 2.1 订单服务Try生成待确认订单 BusinessActionContext orderActionContext new BusinessActionContext(); orderActionContext.setActionContext(order, request); boolean orderTryResult orderTccService.prepareCreateOrder(orderActionContext, request); if (!orderTryResult) { throw new RuntimeException(订单预创建失败); } // 2.2 库存服务Try预扣库存 (通过RPC) BusinessActionContext inventoryActionContext new BusinessActionContext(); inventoryActionContext.setActionContext(productId, request.getProductId()); inventoryActionContext.setActionContext(count, request.getCount()); boolean inventoryTryResult inventoryTccService.prepareDeductInventory(inventoryActionContext, request.getProductId(), request.getCount()); if (!inventoryTryResult) { throw new RuntimeException(库存预扣失败); } // 3. 如果所有Try成功Seata框架会自动调用各服务的Confirm方法 // 如果任何Try失败或此处抛出异常Seata框架会自动调用各服务的Cancel方法 log.info( 全局事务Try阶段全部成功等待Confirm); // 通常这里可以返回一个中间状态的订单ID return new OrderDTO().setStatus(PROCESSING); } }当createOrder方法被GlobalTransactional注解标注后Seata会在方法开始时向TC注册全局事务并生成全局唯一的XID。随后对orderTccService.prepareCreateOrder和inventoryTccService.prepareDeductInventory的调用会被Seata拦截并作为分支事务注册到TC。当方法执行完毕未发生异常Seata TC会驱动所有参与者执行Confirm若发生异常则驱动执行Cancel。4. 避坑指南TCC实践中那些“坑”与最佳实践TCC的概念并不复杂但真正在生产环境稳定运行需要处理好以下几个核心问题。4.1 空回滚、防悬挂与幂等性TCC接口的三大基石这是实现一个健壮的TCC接口必须解决的三个问题Seata等框架提供了基础支持但业务代码必须配合。空回滚Empty Rollback指在没有调用Try方法的情况下调用了Cancel方法。为什么会出现网络超时或拥堵时TM可能认为Try失败了实际可能还在路上于是触发全局回滚调用Cancel。此时Cancel需要能正确识别出这是一个“空”操作并直接返回成功而不是去释放一个不存在的资源。解决方案在Try阶段在业务数据库中插入一条分支事务记录或更新一个状态字段记录全局事务XID和分支事务状态如status1。在Cancel阶段先查询这条记录。如果记录不存在说明是空回滚直接返回成功。Seata的TwoPhaseBusinessAction注解会自动管理一个tcc_fence_log表来帮助处理这个问题。防悬挂Anti-Suspend指Cancel比Try先执行。为什么会出现和空回滚原因类似但顺序反了。Try请求因网络拥堵延迟到达Cancel请求先到并执行了空回滚。随后延迟的Try请求到达并执行成功导致资源被永久冻结悬挂。解决方案同样是利用分支事务记录。在Cancel执行空回滚时除了直接返回成功还要在记录中标记“已回滚”如status2。当延迟的Try请求到达时先检查记录。如果发现该XID下已有“已回滚”的记录则拒绝执行Try直接返回失败。Seata的TCC Fence模式正是为此设计。幂等性IdempotenceTry、Confirm、Cancel三个接口都可能因为网络重试而被重复调用。接口必须保证多次调用的结果与一次调用相同。解决方案通过业务状态机和唯一事务标识来实现。以上面的库存操作为例Try幂等基于product_id和XID判断是否已执行过预扣是则直接返回成功。Confirm幂等检查库存记录如果frozen_stock已被确认扣减即total_stock已减则直接返回成功。Cancel幂等检查库存记录如果frozen_stock已被释放或根本不存在则直接返回成功。实操技巧在数据库更新语句的WHERE条件中加入状态判断是保证幂等的常用且高效手段如上面SQL中的AND frozen_stock #{count}。4.2 事务日志与故障恢复TC挂了怎么办Seata的TC事务协调者负责维护全局事务和分支事务的状态。如果TC服务器宕机内存中的事务状态会丢失。因此TC必须将事务日志持久化到数据库如MySQL。这样在TC重启后可以从数据库恢复未完成的事务状态并继续推进重试Confirm或Cancel。在部署时务必配置好TC的store.modedb及相关数据源。对于业务服务RM同样需要保证分支事务操作的持久化。这就是为什么Try、Confirm、Cancel方法通常都需要加上Transactional确保资源状态变更和分支事务记录如果自己管理在一个本地事务中提交。4.3 超时与重试策略的设计分布式环境下网络超时是常态。TCC框架如Seata内置了重试机制但需要合理配置。Try阶段超时应设置一个相对较短的超时时间。如果Try长时间不响应TM应尽快超时并触发全局回滚调用Cancel避免资源长时间冻结。Confirm/Cancel阶段超时这两个阶段必须成功因此需要设置更长的超时时间和更积极的重试策略。Seata TC会按照配置的间隔和次数不断重试直到成功。对于始终无法Confirm/Cancel的“悬挂”事务需要监控告警并准备人工干预的通道如运营后台提供强制Confirm/Cancel功能。业务层面的补偿除了框架重试对于重要的业务可以额外增加一个异步任务定期扫描长时间处于“中间状态”如订单状态为“待确认”的数据尝试推动其完成或告警。4.4 资源预留时间的考量Try阶段执行的“资源预留”是有时效的。例如冻结库存不能无限期冻结否则会影响商品销售。通常需要设置一个预留超时时间。可以在Try操作时在数据库中记录一个expire_time。由一个后台作业扫描即将过期的预留记录自动触发Cancel操作释放资源。这要求Cancel接口能够被系统内部定时任务调用进一步强调了Cancel幂等性的重要性。5. 进阶思考TCC的适用边界与常见误解经过上面的剖析你应该能感觉到TCC是一种重业务的方案。在决定采用它之前必须明确它的适用场景和成本。TCC最适合的场景对一致性要求严格的核心业务如金融交易、支付、订单创建等无法接受“最终一致”可能带来的短暂数据矛盾。执行时间较短的业务TCC要求资源锁定时间Try到Confirm/Cancel不宜过长否则影响系统并发和用户体验。业务模型易于拆分为Try/Confirm/Cancel这是前提。有些复杂业务补偿逻辑Cancel可能比正向逻辑还复杂甚至无法定义就不适合用TCC。关于TCC的常见误解误解一TCC性能一定比XA好。不一定。如果Try阶段业务逻辑非常重或者涉及复杂的资源检查其性能开销可能很大。TCC的优势在于将锁从数据库层面提升到了业务层面允许更灵活的锁粒度设计并且Confirm/Cancel通常很快。但如果设计不当性能可能更差。误解二用了TCC框架就高枕无忧。框架只解决了事务调度和重试的问题业务接口的幂等性、空补偿、防悬挂必须由开发者自己保证。这是TCC模式能否正确工作的根本。误解三TCC可以替代所有分布式事务方案。显然不是。对于实时性要求不高、允许最终一致的场景如记录用户操作日志、发送通知短信使用本地消息表或最大努力通知方案在复杂度和性能上更具优势。对于流程特别长涉及数十个服务的业务SAGA模式可能更合适因为它每个步骤都是独立的本地事务没有全局锁。我个人在实际项目中的体会是TCC就像一把手术刀精准而有力但使用门槛高。在引入前一定要和业务、产品同学充分沟通确认强一致性的必要性。然后在代码实现上必须对每一个TCC接口进行严格的单元测试和集成测试特别是针对网络超时、重复调用等异常场景的测试。最后完善的监控和告警机制必不可少你需要能实时看到有多少全局事务处于二阶段有多少重试失败以便及时介入处理。分布式事务没有完美的方案TCC是在业务可控性和一致性强度之间一个非常经典的折中选择理解其精髓方能驾驭其复杂性。
返回列表