分布式事务避坑别靠 Transactional 硬扛跨服务场景这次我们直接聊一个面试高频、生产环境更容易踩的坑分布式事务。很多同学在单体应用里用Transactional用得顺手一拆微服务订单服务扣库存、调积分、减优惠券发现数据库操作分散在好几个服务里还是照旧在方法上加Transactional。结果呢订单创建成功了库存没扣掉或者库存扣了订单回滚了两边数据对不上。这不是个例是跨服务场景下最常见的分布式事务误区。Transactional解决的是本地事务它管不了别的服务里的数据库。跨服务调用的数据一致性必须依赖专门的分布式事务方案。这篇文章会把问题根源、方案选型、代码示例、性能代价和排查思路一次性讲清楚。1. 分布式事务核心能力速览先给一张速览表把分布式事务相关的方案和关键点一次性看明白。能力项说明本地事务SpringTransactional依赖单库事务无法跨服务保证一致性跨服务事务问题多个服务各自操作数据库一损不一定俱损数据容易不一致强一致方案XA/2PC、Seata AT 模式适合短事务、高一致性要求场景最终一致性方案TCC、Saga、本地消息表、事务消息适合长事务、高并发场景经典场景订单服务 库存服务 积分服务订单与库存分布式事务最典型推荐思路能避免分布式事务就避免无法避免时用 Seata 或事务消息主要代价性能损耗、代码复杂度增加、幂等和重试机制必须补齐这张表就是本文的路线图。下面逐个拆。2. Transactional 的边界它只能管本地事务先说清楚Transactional到底做了什么。Transactional是 Spring 提供的声明式事务注解底层通过 AOP 拦截方法在方法执行前开启数据库事务正常执行完提交抛出异常就回滚。关键点在于它绑定的是当前线程同一个数据源连接上的事务。代码上看它长这样Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { // 1. 写入订单表 orderMapper.insert(dto.toOrder()); // 2. 扣减库存这里调用了库存服务 inventoryClient.deduct(dto.getSkuId(), dto.getQty()); // 3. 如果库存服务失败订单能回滚吗 } }上面这段代码的问题非常典型。orderMapper.insert走的是订单库inventoryClient.deduct是 HTTP 或 RPC 调用走的是库存服务自己的数据库。Transactional只包裹了当前服务的数据源事务所以订单插入成功后调用库存服务失败抛出异常订单事务回滚订单不会落库。更隐蔽的是如果库存服务调用成功但库存服务自己的事务提交之后订单服务后续逻辑发生异常订单事务回滚了库存已经扣了没有办法自动回滚。第二个场景才是跨服务事务最大的坑。Transactional感知不到库存服务内部发生的事务提交也无法让库存服务跟着一起回滚。最终结果就是订单没了库存却扣了。这里再补充一个容易忽略的细节Transactional在同一个类内部方法自调用时不会生效在private方法上不生效在非 Spring 管理的对象上不生效。这些限制让本地事务本身已经有很多陷阱更不用说把事务边界延伸到 RPC 调用之外。3. 跨服务场景的分布式事务问题模型把问题抽象一下。理论上分布式事务追求的目标是 ACID但 CAP 定理决定了在分布式系统中分区容错性无法避免强一致性和可用性不能同时完美满足。所以实际方案都是做取舍要么牺牲可用性保证强一致2PC要么牺牲实时一致性保证最终一致消息、Saga。订单与库存分布式事务是最经典的例子。业务流程是用户下单订单服务创建订单写入订单表。订单服务调用库存服务扣减库存。库存服务扣减成功后返回结果。订单服务确认订单状态为“已创建”。问题在于第 2 步到第 4 步之间任何异常都会导致数据不一致。比如订单写入成功库存扣减失败此时订单还在但没有库存可用。库存扣减成功但返回响应超时订单服务无法判断库存是否真的扣了重试会重复扣减。库存扣减成功订单服务后续更新状态失败订单和库存状态不匹配。这个模型在面试中经常被拿来考查日常开发中也特别容易处理不当。很多人以为是“加上事务就能保证一致性”实际上完全不是。4. 分布式事务常见方案与选型对比分布式事务方案不是只有一种选型要看业务对一致性的要求、事务时长、并发量和团队维护成本。4.1 强一致方案XA / 2PCXA 是 X/Open 组织定义的分布式事务规范2PC 是两阶段提交协议。数据库层面通过全局事务管理器协调多个数据库的提交和回滚。优点是一致性最强数据库原生支持。缺点是性能差事务期间资源锁持有时间较长第二阶段如果协调者宕机整个事务阻塞。适合并发量不大、对一致性要求极高的金融类短事务场景。实际落地时很多团队不会直接用原生 XA而是用 Seata 的 AT 模式来接近这个效果。4.2 Seata AT 模式Seata 是国产开源分布式事务框架AT 模式是其主打模式。核心思路是业务 SQL 正常执行Seata 自动生成 undo_log事务提交时做两阶段确认出现异常时用 undo_log 反向补偿。AT 模式对业务代码侵入小基本保持原来的开发方式只在全局事务入口加GlobalTransactional。性能比 XA 好但脏写处理有额外开销事务隔离级别默认是读未提交对隔离级别要求高的场景要谨慎。基本用法如下Service public class OrderService { GlobalTransactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { // 1. 订单服务写订单表 orderMapper.insert(dto.toOrder()); // 2. 调用库存服务扣减库存 inventoryClient.deduct(dto.getSkuId(), dto.getQty()); // 3. 调用积分服务增加积分 pointsClient.add(dto.getUserId(), dto.getAmount()); } }Seata 部署需要运行 TC事务协调器服务客户端接入需要配置 registry.conf 和 file.conf引入seata-spring-boot-starter。4.3 TCC 模式TCC 是 Try-Confirm-Cancel 三段式。业务方自己实现三个方法Try预留资源比如冻结库存。Confirm确认执行把冻结库存真正扣掉。Cancel取消执行把冻结库存释放回可用库存。TCC 的优点是对业务资源做了细粒度控制性能比 XA 好适合跨服务的资源预留场景。缺点是开发量大每个参与方都要实现三套逻辑对幂等和空回滚要求非常高。public interface InventoryTccService { TwoPhaseBusinessAction(name deductTcc, commitMethod confirm, rollbackMethod cancel) void deduct(BusinessActionContext context, BusinessActionContextParameter(paramName skuId) String skuId, BusinessActionContextParameter(paramName qty) Integer qty); boolean confirm(BusinessActionContext context); boolean cancel(BusinessActionContext context); }TCC 框架一般用 Seata TCC 或者阿里开源的 TCC-Transaction。注意TCC 的 Confirm 和 Cancel 方法必须幂等否则网络超时重试时会产生重复操作。4.4 Saga 模式Saga 是把长事务拆成多个本地事务每个本地事务完成后发布事件下一个事务通过事件触发。一旦某一环失败通过反向补偿事务依次回滚。Saga 适合业务流程长、事务链复杂的场景。它的核心是补偿设计没有全局锁性能最好但一致性是最终一致性中间状态对外可见。如果业务不允许中间状态可见Saga 就不合适。实现方式有两种事件编排式和命令协调式。事件编排式用消息队列驱动服务间解耦最好命令协调式由一个中心协调器统一调度。4.5 本地消息表本地消息表是经典方案。核心思想是业务操作和消息写入放在同一个本地事务里然后通过消息表异步通知下游服务。流程是订单服务开启本地事务写订单表 写一条“待发送扣库存消息”到本地消息表。本地事务提交。消息投递组件定时扫描消息表把消息发给库存服务。库存服务消费消息扣减库存。库存服务处理成功后回调确认订单服务更新消息状态为“已完成”。消息投递失败定时重试。这个方案的优点是业务实现相对简单消息可靠性有保证。缺点是需要额外建消息表并且要自己处理消息幂等和重试消息表本身会造成数据库额外压力。4.6 RocketMQ 事务消息RocketMQ 把本地消息表方案做成了基础设施。生产者先发送半消息再执行本地事务如果本地事务成功提交确认消息消费者只能消费确认后的消息。RocketMQ 的定时回查机制会自动检查长时间未确认的半消息询问生产者本地事务最终结果。这种方式对上层业务屏蔽了消息表细节使用体验比本地消息表自然很多。代码结构大致是// 发送事务消息示例按实际项目接口调整 TransactionMQProducer producer new TransactionMQProducer(order-group); producer.setNamesrvAddr(127.0.0.1:9876); producer.setTransactionListener(new TransactionListener() { Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { // 执行订单本地事务 return LocalTransactionState.COMMIT_MESSAGE; } Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // 回查本地事务状态 return LocalTransactionState.COMMIT_MESSAGE; } }); producer.start();用事务消息有一个前提限制依赖 RocketMQ 组件线下了这个方案就不可用。5. 方案选型参考表方案一致性性能代码侵入开发成本适用场景Transactional仅本地高低低单库本地事务XA / 2PC强一致低低中短事务金融核心Seata AT最终一致中低中跨服务事务改造成本可控TCC最终一致中高高资源预留类业务Saga最终一致高中高长事务链路本地消息表最终一致高中中不引入额外中间件RocketMQ 事务消息最终一致高中中已使用 RocketMQ 的场景从面试和实战两个角度看最值得深挖的是 Seata AT 和 RocketMQ 事务消息。前者代表通用分布式事务框架后者代表消息最终一致性方案的工程化落地。6. 订单与库存分布式事务代码示例以最常见的“订单服务调库存服务”为例写一份可运行的思路模板。6.1 库存服务接口库存服务暴露扣减接口内部加乐观锁防止超卖RestController RequestMapping(/inventory) public class InventoryController { Resource private InventoryService inventoryService; PostMapping(/deduct) public ResultVoid deduct(RequestBody DeductRequest request) { boolean success inventoryService.deduct(request.getSkuId(), request.getQty()); return success ? Result.ok() : Result.fail(库存不足); } }库存扣减 SQL 使用条件更新UPDATE inventory SET stock stock - #{qty} WHERE sku_id #{skuId} AND stock #{qty}6.2 订单服务 Seata 接入订单服务引入 Seata 后全局事务入口标记GlobalTransactional库存服务不需要额外改动事务边界Seata 会通过拦截器和 undo_log 实现跨服务回滚。Service public class OrderServiceImpl implements OrderService { Resource private OrderMapper orderMapper; Resource private InventoryFeignClient inventoryClient; Override GlobalTransactional(rollbackFor Exception.class) public void createOrder(OrderCreateRequest request) { // 1. 本地写订单 orderMapper.insert(buildOrder(request)); // 2. 远程扣减库存若库存服务异常全局事务回滚 inventoryClient.deduct(request.getSkuId(), request.getQuantity()); // 3. 后续业务逻辑 orderMapper.updateStatus(request.getOrderId(), OrderStatus.CREATED); } }这里要特别注意Seata AT 模式下远程调用必须在全局事务上下文传播时间内完成。默认通过 Seata 的RootContext传递事务 ID走 Feign 拦截器自动传递。如果远程调用使用线程池异步执行事务上下文会被隔断Seata 的全局事务无法覆盖异步线程里的操作。6.3 订单服务事务消息示例如果使用 RocketMQ 事务消息订单与库存的流程调整订单写库和发送半消息在同一本地事务库存服务通过消费消息扣减。Service public class OrderServiceImpl implements OrderService { Resource private OrderMapper orderMapper; Resource private TransactionMQProducer producer; Override public void createOrder(OrderCreateRequest request) { Message message new Message(order-topic, deduct-stock, buildMessageBody(request).getBytes(StandardCharsets.UTF_8)); // 传入本地业务参数executeLocalTransaction 中使用 TransactionSendResult result producer.sendMessageInTransaction(message, request); } }实际上TransactionListener里通过本地事务执行订单写入并且回查机制保证事务不丢逻辑大体是producer.setTransactionListener(new TransactionListener() { Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { OrderCreateRequest request (OrderCreateRequest) arg; try { orderMapper.insert(buildOrder(request)); return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { String orderId msg.getKeys(); Order order orderMapper.selectById(orderId); return order ! null ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } });注意executeLocalTransaction返回COMMIT_MESSAGE只代表半消息被确认库存扣减在消费端异步执行它本身也有失败可能。所以消费端必须做幂等处理和失败重试。7. 分布式事务的幂等、重试、对账问题分布式事务方案只能解决事务协调不能替代业务层的幂等设计。无论用哪种方案都要面对“重复请求”和“部分失败”的问题。先说幂等。库存扣减接口如果用上面的条件更新 SQL天然具备一定幂等性stock qty条件不满足时返回失败。但如果你用的是UPDATE inventory SET stock stock - 1这种无条件的语句重复请求就会重复扣减。更通用的是引入业务唯一键。比如订单号作为幂等键库存服务记录消费流水表每次扣减前先查询流水是否存在-- 库存扣减流水表 CREATE TABLE inventory_deduct_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(64) NOT NULL, sku_id VARCHAR(64) NOT NULL, qty INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, UNIQUE KEY uk_order_sku (order_id, sku_id) );先插入流水成功再扣库存重复请求插入流水时会被唯一索引拦截。再说重试。跨服务调用超时是常态不能因为一次超时就直接判定失败。合理的方式是调用方记录待确认任务定时查询被调用方最终状态。库存服务也应该提供查询接口让订单服务知道某笔扣减到底成没成功。最后是对账。即使已经上了分布式事务方案仍然要保留对账机制。最简单的做法是定时任务扫描订单表和库存扣减流水找出状态不匹配的数据触发人工或自动修复。对账是最后一道防线不能省。8. 性能代价与常用观察手段分布式事务不是免费的使用前要清楚它的性能特征。Seata AT 模式中每个参与的事务分支都要写 undo_log全局事务提交时需要回归自己的事务 ID。资源锁定时间比纯本地事务更长尤其在多服务参与时全局锁范围扩大并发冲突概率上升。观察指标主要看全局事务提交耗时、分支事务注册耗时、undo_log 清理是否积压。TCC 模式的性能集中在业务代码上Try 阶段做了资源预留Confirm 和 Cancel 执行速度直接决定事务总耗时。性能瓶颈通常出现在 Confirm 阶段调用链路过长、锁等待过久。消息方案的代价在最终一致性延迟。订单写库成功后库存扣减可能秒级甚至更晚才完成。需要监控消息积压、消费失败次数和回查次数。常用的观察手段有三类日志观察开启 Seata 客户端日志或消息消费日志查看全局事务 ID 和分支事务提交记录。指标监控采集事务执行耗时、成功率和失败率接入 Prometheus Grafana 这类监控体系。数据库监控查看 undo_log 表增长情况、消息表积压量、幂等流水表重复情况。如果发现事务耗时上升优先检查参与方数量、资源锁等待和网络延迟。参与方越多二阶段协调成本越高建议拆分事务粒度。9. 常见问题与排查方法问题现象可能原因排查方式解决方案Transactional 方法自调用事务不生效同类内部调用未走代理检查调用是否通过 this 调用注入自身代理或拆类远程调用失败但本库事务已提交事务边界未覆盖远程服务查看调用链日志改用 Seata 或 TCC/消息方案库存重复扣减未做幂等查看库存扣减流水表加唯一索引加幂等表Seata 全局事务回滚不完全TC 不可用或分支事务注册失败查看 TC 日志和客户端日志检查 Seata 服务状态事务消息半消息一直无确认回查逻辑有异常查看消息回查日志检查本地事务状态查询逻辑消息消费失败不断重试消费端逻辑异常查看消费日志和死信队列修复消费逻辑并处理死信跨服务事务超时参与方多或锁等待观察全局事务耗时拆分事务链或换成异步最终一致数据库连接池耗尽事务持有连接时间过长检查活跃连接数和慢 SQL缩短事务范围减少远程调用10. 最佳实践与使用建议先说一条核心原则能不用分布式事务就不要用。很多跨服务场景可以通过表结构设计和流程序列化来避免分布式事务。比如订单和库存如果可以将扣减库存改由订单表状态驱动异步完成就不需要在主流程里同步扣库存。如果一定要用建议按下面这套工程方法落地。第一事务边界要小。GlobalTransactional只包必要业务操作远程调用超过 3 到 4 个服务时事务成功率显著下降不如拆成两条独立流程配合对账。第二幂等和重试是标配。任何分布式事务方案都不能省掉幂等设计。用唯一业务键、唯一索引、消费状态表三层保证。第三保留人工对账入口。自动化做不到 100% 时至少要有查询工具和定时扫描任务能在数据漂移后及时发现并修正。第四监控要前置。上线前就把 Seata 或消息中间件的监控接好事务耗时、回滚率、消息积压这几个指标必须看得到。第五涉及用户资金、优惠券、积分等敏感业务时必须确认操作有合法业务依据并在数据权限上做隔离不能因为事务方案不完善导致用户资产损失。11. 总结与下一步分布式事务这个问题的核心不是选哪个框架而是先认清事务边界。Transactional是本地事务工具天然不具备跨服务协调能力。把多个服务的数据库操作放进同一个事务里只会得到错误的安全感。下一步建议按顺序做三件事第一梳理你项目里所有跨服务调用看哪些真正需要强一致哪些可以容忍最终一致。能做到这一步很多问题会直接消失。第二对必须保证一致性的场景选定方案先做最小验证。比如用 Seata AT 跑通订单和库存两个服务再做 TCC 和事务消息的对比测试。第三补上幂等、重试和对账。这套东西比事务框架本身更重要很多生产数据不一致问题不是事务没生效而是幂等没做到位。这篇文章提到的代码示例都是可运行思路的最小版本具体落地时按项目实际结构调整包名、表结构和中间件配置。建议收藏备用等到真要改造跨服务事务时翻出来对照排查。