☰
@Transactional 为什么管不住跨服务?订单与库存分布式事务方案解析
2026/9/26 4:59:04 网站建设 项目流程

开始之前先问一个线上问题:订单服务调用库存服务扣减库存,订单表里加了@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); }

这个流程的执行顺序是:

  1. 订单服务插入订单数据,持有订单库连接。
  2. 通过 Feign 调用库存服务扣减库存。
  3. 库存服务在自己的事务里提交库存扣减。
  4. 订单服务抛出异常,订单数据回滚。
  5. 最终库存减了,订单没了。

这就是分布式事务中典型的“部分成功、部分失败”状态。

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;

操作步骤:

  1. 调用接口,传入skuId=1001、quantity=11,触发订单回滚。
  2. 查看订单表,没有新增订单。
  3. 查看库存表,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 事务消息触发库存扣减,是更轻量的选择。

流程是:

  1. 订单服务在本地事务中创建订单,同时发送半消息。
  2. 消息队列 Broker 收到半消息后,不会立即投递给消费者。
  3. 订单服务本地事务提交后,确认发送;本地事务回滚,则删除半消息。
  4. Broker 通过回查机制确认订单状态,最终保证消息一定投递。
  5. 库存服务消费消息扣减库存,扣减成功后更新消息状态。

这个方案的关键是库存服务消费消息时要做幂等处理。消息队列提供的是“至少一次”投递,不是“恰好一次”,所以消费端必须依靠唯一业务键去重。

@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 使用 8081,inventory-service 使用 8082。
  • 每个服务的数据源配置是否指向正确的库。
  • OpenFeign 调用的 URL 是否指向 inventory-service 的实际地址。

两个服务的application.yml配置要点:

server: port: 8082 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/inventory_db?useUnicode=true&characterEncoding=utf8 username: root password: your_password # 订单服务中的 OpenFeign URL inventory: service: url: http://127.0.0.1:8082

7. 功能测试与效果验证

演示工程跑起来之后,按下面的步骤验证分布式事务问题。

7.1 正常路径验证

测试项输入预期结果
创建订单并扣库存skuId=1001, quantity=1订单表新增一条记录,库存表扣减 1
重复调用requestId 相同库存只扣减一次,返回成功

正常路径验证通过后,才说明两个服务之间的调用链是通的。

7.2 异常回滚验证

测试项输入预期结果
扣库存成功后订单抛异常skuId=1001, quantity=11订单表无新增记录,库存表扣减 11
库存服务抛异常skuId=1002, quantity=9999订单回滚,库存不变

执行时重点观察:

  • 订单服务和库存服务的控制台日志。
  • 两个库的数据变化。
  • 是否有长事务或锁等待。

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 面试怎么答

面试官问“分布式事务怎么解决”时,不建议一上来就背方案。更稳的思路是:

  1. 先明确场景:是跨服务调用,还是跨数据库调用。
  2. 说明本地事务和分布式事务的边界:@Transactional只能管本地数据库事务。
  3. 按一致性要求选型:强一致选 TCC 或 Seata;最终一致选事务消息。
  4. 指出事务消息方案的关键:半消息、回查、幂等消费、失败重试。
  5. 补充落地细节:为什么要有流水表、怎么做幂等、怎么对账。

如果能结合一个真实踩坑经历,比背一万字理论更有效。比如直接说“我以前用 @Transactional + Feign 调库存,订单回滚后库存没回滚,后来在库存接口加了 requestId 幂等 + 状态机重试才解决”,这句话基本可以锁定面试官的兴趣。

10.2 工程落地的最佳实践

  • 不要把远程调用放进本地事务里。即使本地事务能回滚,远程服务也不会跟着回滚。
  • 所有跨服务写接口都要支持幂等。用 requestId 或业务唯一键做流水表,加唯一索引。
  • 同步调用的超时时间要短,建议默认 3 到 5 秒,超时后进入重试队列。
  • 每个跨服务调用的失败路径都要有明确的补偿动作,不能只 catch 后打印日志。
  • 核心业务要有对账任务,定时扫描不一致数据,进入告警和人工处理流程。
  • 不要追求所有场景强一致。积分、短信、通知、日志这类场景,最终一致性完全足够。
  • 热点库存数据可以分桶,避免所有请求都锁同一行库存。
  • 引入 Seata、TCC 等框架前,先做压测,观察全局锁和连接池消耗。
  • 异常日志要包含 requestId、orderNo、skuId,方便排查链路。

11. 总结:面试和落地真正该掌握的分布式事务要点

@Transactional在单服务、单数据库内是真有用的工具,但跨服务场景下不能硬扛。它管不到别的服务的事务提交,因此订单服务回滚时,库存服务已经提交的扣减不会被自动撤销。

从面试角度,你需要掌握的是:本地事务和分布式事务的边界、几种方案的取舍、幂等设计、状态机重试、对账补偿。从工程角度,你需要做的是:别在本地事务里做远程调用,给跨服务接口加幂等,给异步消费加流水表,给核心数据加对账任务。

如果你准备拿着本文去解决线上问题,建议按顺序做三件事:先确认当前调用链里有没有“远程调用包在本地事务里”的写法;再给库存、支付等关键写接口补充 requestId 幂等;最后评估是否需要引入 Seata 或 TCC。先把前两步做完,大部分数据不一致问题已经能消失;第三步属于更高强度的一致性保障,等业务体量和并发上去了再逐步推进。

本文的 demo 结构、SQL 和接口设计可以直接作为团队内部演练的起点。下次再有人告诉你“跨服务事务加 @Transactional 就行”,把库存多扣的数据截图发给他,比讲十遍理论都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询