分布式事务避坑:别靠 @Transactional 硬扛跨服务场景
这次我们直接聊一个面试高频、生产环境更容易踩的坑:分布式事务。
很多同学在单体应用里用@Transactional用得顺手,一拆微服务,订单服务扣库存、调积分、减优惠券,发现数据库操作分散在好几个服务里,还是照旧在方法上加@Transactional。结果呢?订单创建成功了,库存没扣掉,或者库存扣了,订单回滚了,两边数据对不上。这不是个例,是跨服务场景下最常见的分布式事务误区。
@Transactional解决的是本地事务,它管不了别的服务里的数据库。跨服务调用的数据一致性,必须依赖专门的分布式事务方案。这篇文章会把问题根源、方案选型、代码示例、性能代价和排查思路一次性讲清楚。
1. 分布式事务核心能力速览
先给一张速览表,把分布式事务相关的方案和关键点一次性看明白。
| 能力项 | 说明 |
|---|---|
| 本地事务 | Spring@Transactional,依赖单库事务,无法跨服务保证一致性 |
| 跨服务事务问题 | 多个服务各自操作数据库,一损不一定俱损,数据容易不一致 |
| 强一致方案 | 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 / 2PC
XA 是 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 Result<Void> 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 和事务消息的对比测试。
第三,补上幂等、重试和对账。这套东西比事务框架本身更重要,很多生产数据不一致问题不是事务没生效,而是幂等没做到位。
这篇文章提到的代码示例都是可运行思路的最小版本,具体落地时按项目实际结构调整包名、表结构和中间件配置。建议收藏备用,等到真要改造跨服务事务时,翻出来对照排查。