@Transactional 为什么管不住跨服务?订单与库存分布式事务方案解析

发布时间:2026/9/2 2:12:21
@Transactional 为什么管不住跨服务?订单与库存分布式事务方案解析 开始之前先问一个线上问题订单服务调用库存服务扣减库存订单表里加了Transactional库存服务本地也加了Transactional。结果订单回滚了库存却扣掉了。数据不一致了这个锅谁来背这个场景在面试里出现频率相当高但很多人答不到点子上。原因不是不懂 CAP 和 BASE而是把Transactional当成了分布式事务的万能钥匙。Transactional解决的是本地事务也就是同一个事务管理器、同一个数据库连接、同一个服务进程内的一组操作一旦调用链跨了服务、跨了数据库它就管不住了。本文就用订单与库存这个经典跨服务场景把“为什么Transactional不能硬扛分布式事务”讲清楚再给出面试和落地时真正能用的分布式事务解决方案。全文会围绕三个问题展开本地事务和分布式事务的边界到底在哪订单与库存跨服务场景下数据不一致是怎么发生的以及从方案选型、接口设计、幂等重试、性能排查几个角度怎么正确落地分布式事务。1. 核心认知Transactional 到底在什么范围内生效1.1 本地事务的工作原理Transactional在 Spring 中基于 AOP 实现。一个方法加上注解后Spring 会为该类生成代理对象在进入方法前开启事务方法执行结束后根据是否抛出运行时异常决定 commit 或 rollback。理解它的关键点是事务开启时Spring 的事务管理器会从DataSource中获取一个数据库连接并把这个连接绑定到当前线程。后续这个线程内执行的 SQL 操作都通过同一个连接执行最终在同一个数据库事务里提交或回滚。Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { // 1. 插入订单表 orderMapper.insert(dto); // 2. 更新订单明细 orderItemMapper.insert(dto.getItems()); } }上面这段代码中订单主表和订单明细表在同一个数据库中orderMapper.insert和orderItemMapper.insert使用同一个数据库连接任何一个步骤抛异常整个事务都能回滚。1.2 为什么跨服务之后 Transactional 就失效了现在把场景改成订单服务和库存服务是两个独立部署的应用数据库也是两个独立的库。订单服务中加了Transactional内部通过 HTTP 或 RPC 调用库存服务扣减库存。Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { // 1. 插入订单表本地事务连接A orderMapper.insert(dto); // 2. 远程调用库存服务扣减库存不在本地事务控制范围内 inventoryClient.deductStock(dto.getSkuId(), dto.getQuantity()); // 3. 模拟后续业务失败 if (dto.getFlag()) { throw new RuntimeException(订单后续处理失败); } } }问题就在第 2 步库存服务收到请求后在自己的事务里执行库存扣减提交的是库存库的连接。订单服务的Transactional只能控制订单库的连接。当第 3 步抛出异常时订单服务执行回滚但库存服务的事务已经提交了。最终的结果就是订单表中没有新增订单但库存表被扣减了。Transactional对跨服务的远程调用完全没有约束力因为两者根本不在同一个事务上下文里。1.3 分布式事务一致性的核心矛盾分布式事务之所以难是因为它要解决的核心矛盾是多个独立的资源数据库、消息队列、缓存、第三方接口之间无法通过单库事务的 ACID 来保证原子性。数据库本地事务依赖的是锁、undo log、redo log这些机制由单个数据库保证。跨服务之后没有统一的锁管理器也没有统一的日志系统所以只能通过“协调各方”的方式达到最终一致性。理解这一点是面试回答的基础。面试官问分布式事务并不是真的想听你背诵两阶段提交的流程而是想确认你是否理解一致性边界在哪里。数据不一致发生在哪个环节。回滚失败后如何补偿。最终一致性和强一致性的取舍。2. 分布式事务方案速览下面是面试和工程实践中常用的分布式事务方案。没有银弹不同方案解决不同强度的一致性需求。方案核心原理一致性强度侵入性适用场景2PC两阶段提交准备阶段 提交阶段协调者统一决策强一致高数据库需支持 XA单数据库中间件、跨库规模较小的场景TCCTry、Confirm、Cancel业务层面补偿最终一致接近强一致高需要实现三个方法资金类、账户类、需要实时感知结果的场景本地消息表业务操作和消息写入同库事务异步消费最终一致中需要维护消息表订单状态通知、积分、日志等异步场景事务消息RocketMQ半消息 回查消息和业务同事务提交最终一致中依赖消息中间件异步解耦、订单创建后发事件Saga每个本地事务都带补偿事务正向执行 逆向补偿最终一致高需要编排或协同长流程、跨多服务的业务流程Seata AT 模式对业务无侵入通过全局锁 undo_log 做反向补偿最终一致低引入 Seata 依赖和配置快速落地、改造量小的场景表格要记但不能只记表格。面试时更合理的表达是根据业务一致性要求选型。比如资金类和库存扣减通常不能接受异步延迟适合 TCC 或 Seata而订单创建后的短信通知、积分赠送、数据同步适合事务消息或本地消息表。3. 典型场景拆解订单与库存的跨服务事务3.1 业务调用链订单与库存是分布式事务最常见的面试案例。完整调用链一般是用户下单 - 订单服务创建订单状态待支付/已创建 - 调用库存服务扣减库存 - 支付服务发起支付 - 支付成功回调 - 订单服务更新订单状态为已支付如果下单时就扣减库存订单创建失败或支付超时后库存的恢复逻辑就成了关键问题。很多团队早期方案是“先扣库存后创建订单”用Transactional包住整个逻辑结果就是库存数据各种对不上。3.2 用代码演示“假回滚”下面构造一个最小复现场景。两个服务order-service和inventory-service都使用 MySQL不接任何分布式事务组件。order-service的订单服务Service public class OrderService { private final OrderMapper orderMapper; private final InventoryClient inventoryClient; public OrderService(OrderMapper orderMapper, InventoryClient inventoryClient) { this.orderMapper orderMapper; this.inventoryClient inventoryClient; } Transactional public Long createOrder(Long skuId, Integer quantity) { Order order new Order(); order.setSkuId(skuId); order.setQuantity(quantity); order.setStatus(0); orderMapper.insert(order); // 远程调用库存服务 inventoryClient.deduct(skuId, quantity); // 模拟后续处理失败 if (quantity 10) { throw new RuntimeException(订单数量异常触发回滚); } return order.getId(); } }inventory-service的库存扣减接口Service public class InventoryService { private final InventoryMapper inventoryMapper; Transactional public void deduct(Long skuId, Integer quantity) { int count inventoryMapper.deduct(skuId, quantity); if (count 0) { throw new RuntimeException(库存不足); } } }order-service中通过 Feign 客户端调用FeignClient(name inventory-service, url ${inventory.service.url}) public interface InventoryClient { PostMapping(/inventory/deduct) void deduct(RequestParam(skuId) Long skuId, RequestParam(quantity) Integer quantity); }这个流程的执行顺序是订单服务插入订单数据持有订单库连接。通过 Feign 调用库存服务扣减库存。库存服务在自己的事务里提交库存扣减。订单服务抛出异常订单数据回滚。最终库存减了订单没了。这就是分布式事务中典型的“部分成功、部分失败”状态。3.3 判断数据不一致的标准怎么快速确认问题用 SQL 查两边数据。-- 订单库 SELECT * FROM orders WHERE sku_id 1001 ORDER BY id DESC LIMIT 3; -- 库存库 SELECT sku_id, stock FROM inventory WHERE sku_id 1001;操作步骤调用接口传入skuId1001、quantity11触发订单回滚。查看订单表没有新增订单。查看库存表stock已经被扣减了 11。满足第 2 步和第 3 步同时发生就说明出现了跨服务数据不一致。这个验证过程不需要依赖任何分布式事务组件只要两个独立服务加两个独立表即可复现。4. 正确做法按业务一致性要求选型4.1 方案一Seata AT 模式改动最小Seata AT 模式对业务代码侵入最小。它的核心角色是事务协调器 TC、事务管理器 TM、资源管理器 RM。业务代码中只需要在全局事务入口加GlobalTransactional框架会自动通过 undo_log 表记录前后镜像在全局回滚时反向补偿。Service public class OrderService { GlobalTransactional(name create-order-tx, timeoutMills 30000) public Long createOrder(Long skuId, Integer quantity) { orderMapper.insert(order); inventoryClient.deduct(skuId, quantity); return order.getId(); } }Seata 的 AT 模式不是银弹。它依然存在全局锁竞争、undo_log 表膨胀、网络异常导致分支事务状态不确定等问题。如果你的场景是金融级强一致AT 模式不一定是最好的选择。4.2 方案二TCC 模式适合强一致要求TCC 把每个事务操作拆成 Try、Confirm、Cancel 三个方法。订单服务负责编排库存服务需要实现预留库存和确认扣减两个动作。public interface InventoryTccService { TwoPhaseBusinessAction(name deductTcc, commitMethod confirm, rollbackMethod cancel) void deduct(BusinessActionContext context, BusinessActionContextParameter(paramName skuId) Long skuId, BusinessActionContextParameter(paramName quantity) Integer quantity); void confirm(BusinessActionContext context); void cancel(BusinessActionContext context); }库存扣减的 TCC 实现思路Try锁定库存把库存从“可用”变为“锁定”不直接扣减。Confirm把锁定库存真正扣减。Cancel释放锁定库存。TCC 的优点是能够避免长事务和数据库锁持有时间过长但会显著增加开发量且要求业务系统天然具备可预留资源、可确认、可补偿的状态。订单减库存如果已经有支付超时关单逻辑用 TCC 还需要在 Cancel 里处理“已发货不能退库存”这种边缘情况。4.3 方案三事务消息 最终一致性适合异步场景如果业务允许异步订单创建后通过 RocketMQ 事务消息触发库存扣减是更轻量的选择。流程是订单服务在本地事务中创建订单同时发送半消息。消息队列 Broker 收到半消息后不会立即投递给消费者。订单服务本地事务提交后确认发送本地事务回滚则删除半消息。Broker 通过回查机制确认订单状态最终保证消息一定投递。库存服务消费消息扣减库存扣减成功后更新消息状态。这个方案的关键是库存服务消费消息时要做幂等处理。消息队列提供的是“至少一次”投递不是“恰好一次”所以消费端必须依靠唯一业务键去重。Component public class StockDeductConsumer { Autowired private InventoryService inventoryService; RabbitListener(queues stock.deduct.queue) public void onMessage(StockDeductMessage message) { // 幂等判断检查流水表是否已处理过该消息ID if (inventoryService.isProcessed(message.getMessageId())) { return; } inventoryService.deductWithIdempotent(message); } }5. 接口 API 与调用链设计幂等、重试和补偿分布式事务里接口设计比事务注解更重要。因为你无法保证网络和进程永远正常所以每个跨服务接口都要考虑重复调用会不会出问题、失败后怎么重试、最终如何对账。5.1 接口设计建议跨服务的写接口必须接收并校验幂等键。{ requestId: uuid-xxx-123, orderId: 20250101001, skuId: 1001, quantity: 2, operator: user-001 }库存服务收到请求后先查询库存流水表是否已存在requestId。存在则直接返回成功结果不存在则执行扣减并写入流水。这样即使订单服务超时重试库存也只会扣一次。5.2 重试和补偿订单服务调用库存服务超时不能简单地把异常吞掉也不能无限重试。正确做法是第一次调用超时记录日志返回“处理中”状态。后台任务扫描“待扣库存”的订单按间隔重试。重试次数超过阈值标记人工处理或自动退款/关单。UPDATE orders SET status STOCK_DEDUCT_FAILED, retry_count retry_count 1, next_retry_time DATE_ADD(NOW(), INTERVAL 10 SECOND) WHERE order_id #{orderId} AND status CREATED AND retry_count 5;6. 环境准备与演示工程结构如果你想把上面的例子跑起来建议按下面的方式组织一个最小工程。这里不固定版本以你本机可用的 Spring Boot 和数据库版本为准。建议工程目录distributed-tx-demo ├── order-service │ ├── src/main/java/com/demo/order │ ├── src/main/resources/application.yml │ └── pom.xml ├── inventory-service │ ├── src/main/java/com/demo/inventory │ ├── src/main/resources/application.yml │ └── pom.xml └── sql ├── order.sql └── inventory.sql两个服务各自独立的数据库表结构可以这样设计。订单库CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(64) NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, request_id VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL );库存库CREATE TABLE inventory ( id BIGINT AUTO_INCREMENT PRIMARY KEY, sku_id BIGINT NOT NULL UNIQUE, stock INT NOT NULL ); CREATE TABLE stock_deduct_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, request_id VARCHAR(64) NOT NULL UNIQUE, order_no VARCHAR(64) NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL, create_time DATETIME NOT NULL );stock_deduct_log表的存在就是为了支持幂等。没有这张表消息重试和接口重试都会导致库存数据错误。启动前需要检查两个服务的端口是否冲突建议 order-service 使用 8081inventory-service 使用 8082。每个服务的数据源配置是否指向正确的库。OpenFeign 调用的 URL 是否指向 inventory-service 的实际地址。两个服务的application.yml配置要点server: port: 8082 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/inventory_db?useUnicodetruecharacterEncodingutf8 username: root password: your_password # 订单服务中的 OpenFeign URL inventory: service: url: http://127.0.0.1:80827. 功能测试与效果验证演示工程跑起来之后按下面的步骤验证分布式事务问题。7.1 正常路径验证测试项输入预期结果创建订单并扣库存skuId1001, quantity1订单表新增一条记录库存表扣减 1重复调用requestId 相同库存只扣减一次返回成功正常路径验证通过后才说明两个服务之间的调用链是通的。7.2 异常回滚验证测试项输入预期结果扣库存成功后订单抛异常skuId1001, quantity11订单表无新增记录库存表扣减 11库存服务抛异常skuId1002, quantity9999订单回滚库存不变执行时重点观察订单服务和库存服务的控制台日志。两个库的数据变化。是否有长事务或锁等待。7.3 判断成功标准判断一个分布式事务方案是否有效标准不是“代码不报错”而是任何一步失败最终数据都能回到一致状态。重复请求不会产生副作用。系统在断网、数据库宕机等极端情况下能通过日志和兜底任务修复。8. 资源占用与性能影响分布式事务方案对性能的影响主要体现在连接池、数据库锁、全局锁和消息积压上。8.1 连接池占用使用Transactional时事务一旦开启数据库连接会被占用到事务结束。如果远程调用耗时从 50ms 变成 2000ms数据库连接被占用的时间也随之拉长。高并发下连接池容易被耗尽。观察指标HikariPool的活跃连接数。连接池等待获取连接的线程数。请求的 TP99 耗时。8.2 数据库锁等待先扣库存再创建订单库存行会被长期锁住。如果下单量集中在少数几个 SKU很容易出现锁等待甚至死锁。使用 TCC 的 Try 阶段也会锁库存但确认和取消的动作释放锁更快。观察方式-- MySQL 查看当前锁等待 SELECT * FROM information_schema.innodb_trx; SELECT * FROM information_schema.innodb_lock_waits;8.3 Seata AT 模式的性能瓶颈Seata AT 模式在全局事务提交前会持有全局锁其他事务修改同一行数据时需要等待。高并发写同一个库存 SKU 时吞吐量会比普通本地事务明显下降。缓解方式把热点 SKU 的库存拆分为多个库存桶减少行锁冲突。控制全局事务的粒度不要把一个大型流程全部包进GlobalTransactional。合理设置全局事务超时时间。9. 常见问题与排查方法问题现象可能原因排查方式解决方案订单回滚了库存没有回滚远程调用在本地事务之外独立提交对比订单表和库存流水表数据引入 TCC、Seata 或事务消息库存被重复扣减重试请求没有幂等处理查库存流水表是否存在重复 requestId写入库存流水时加唯一索引先查后写接口超时后不确定库存是否扣减成功远程调用结果丢失或超时查库存流水表确认 requestId 是否处理使用状态机 后台重试而不是同步重试事务长时间不结束连接池耗尽远程调用耗时过长查看连接池活跃连接数和调用链链路耗时不在本地事务内做远程调用或缩短超时时间消息重复消费消费端未做幂等查看消费日志对比消息 ID消费前查流水表消费后写幂等记录死锁多个服务以不同顺序更新同一行库存查看 innodb_lock_waits统一资源加锁顺序热点库存分桶全局事务回滚失败分支事务网络异常或 TC 宕机查看 Seata 事务日志检查分支注册状态配置重试机制增加异常告警对账不平缺少对账任务定时任务对比订单与库存流水增加每日对账和人工处理通道10. 面试答题建议与最佳实践10.1 面试怎么答面试官问“分布式事务怎么解决”时不建议一上来就背方案。更稳的思路是先明确场景是跨服务调用还是跨数据库调用。说明本地事务和分布式事务的边界Transactional只能管本地数据库事务。按一致性要求选型强一致选 TCC 或 Seata最终一致选事务消息。指出事务消息方案的关键半消息、回查、幂等消费、失败重试。补充落地细节为什么要有流水表、怎么做幂等、怎么对账。如果能结合一个真实踩坑经历比背一万字理论更有效。比如直接说“我以前用 Transactional Feign 调库存订单回滚后库存没回滚后来在库存接口加了 requestId 幂等 状态机重试才解决”这句话基本可以锁定面试官的兴趣。10.2 工程落地的最佳实践不要把远程调用放进本地事务里。即使本地事务能回滚远程服务也不会跟着回滚。所有跨服务写接口都要支持幂等。用 requestId 或业务唯一键做流水表加唯一索引。同步调用的超时时间要短建议默认 3 到 5 秒超时后进入重试队列。每个跨服务调用的失败路径都要有明确的补偿动作不能只 catch 后打印日志。核心业务要有对账任务定时扫描不一致数据进入告警和人工处理流程。不要追求所有场景强一致。积分、短信、通知、日志这类场景最终一致性完全足够。热点库存数据可以分桶避免所有请求都锁同一行库存。引入 Seata、TCC 等框架前先做压测观察全局锁和连接池消耗。异常日志要包含 requestId、orderNo、skuId方便排查链路。11. 总结面试和落地真正该掌握的分布式事务要点Transactional在单服务、单数据库内是真有用的工具但跨服务场景下不能硬扛。它管不到别的服务的事务提交因此订单服务回滚时库存服务已经提交的扣减不会被自动撤销。从面试角度你需要掌握的是本地事务和分布式事务的边界、几种方案的取舍、幂等设计、状态机重试、对账补偿。从工程角度你需要做的是别在本地事务里做远程调用给跨服务接口加幂等给异步消费加流水表给核心数据加对账任务。如果你准备拿着本文去解决线上问题建议按顺序做三件事先确认当前调用链里有没有“远程调用包在本地事务里”的写法再给库存、支付等关键写接口补充 requestId 幂等最后评估是否需要引入 Seata 或 TCC。先把前两步做完大部分数据不一致问题已经能消失第三步属于更高强度的一致性保障等业务体量和并发上去了再逐步推进。本文的 demo 结构、SQL 和接口设计可以直接作为团队内部演练的起点。下次再有人告诉你“跨服务事务加 Transactional 就行”把库存多扣的数据截图发给他比讲十遍理论都管用。