开始之前先问一个线上问题:订单服务调用库存服务扣减库存,订单表里加了@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 | 单数据库中间件、跨库规模较小的场景 |
| TCC | Try、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;操作步骤:
- 调用接口,传入
skuId=1001、quantity=11,触发订单回滚。 - 查看订单表,没有新增订单。
- 查看库存表,
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 使用 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:80827. 功能测试与效果验证
演示工程跑起来之后,按下面的步骤验证分布式事务问题。
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 面试怎么答
面试官问“分布式事务怎么解决”时,不建议一上来就背方案。更稳的思路是:
- 先明确场景:是跨服务调用,还是跨数据库调用。
- 说明本地事务和分布式事务的边界:
@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 就行”,把库存多扣的数据截图发给他,比讲十遍理论都管用。