Spring Boot 事务失效排查实战:8 个高频场景、传播行为与稳定写法

事务失效大多不是注解忘写,而是代理没走到、异常被吞掉、传播行为选错,或者把跨线程、跨服务动作误算进了一个本地事务。

结论先说:Spring 事务失效,十有八九不是 @Transactional 没写,而是代理没走到、异常没有触发回滚、传播行为和线程边界理解错了。排查顺序如果对,很多线上问题 10 分钟内就能定性;排查顺序如果错,越查越像“Spring 有 bug”。

很多团队把事务当成“加一个注解就行”的基础设施,等到线上出现“接口报错但数据已经落库”“方法明明标了事务却没有回滚”“异步链路只回滚了一半”时,才发现自己理解的其实只是事务表面。

这篇文章不打算停留在概念解释,而是按真实排障路径来讲:事务到底靠什么生效、哪些写法最容易失效、传播行为该怎么选、哪些副作用动作根本不应该放进同一个本地事务里,以及最终怎样写出更稳的 Spring Boot 事务代码。

一、先用一张表把“事务失效”快速归类

如果你碰到的是下面这些现象,通常已经可以把排查范围快速收敛:

现象 最常见原因 第一步检查什么
方法抛异常,但数据还是提交了 异常被吞掉,或抛的是受检异常但没配置回滚规则 看是否 catch 后直接返回,或是否用了 rollbackFor
外部调用有事务,类内部互相调用没有事务 同类自调用绕过了 Spring 代理 看调用是不是 this.xxx() 或同类直接方法调用
异步方法里的数据库操作没有一起回滚 事务上下文没有跨线程传播 看是否用了 @Async、线程池或手动新开线程
两个库或数据库加消息只成功了一部分 本地事务边界被误当成了分布式事务 看是否涉及多数据源、远程服务、MQ、文件存储
有些方法标了事务但完全没生效 方法没经过 Spring 管理对象,或代理条件不满足 看 Bean 是否由 Spring 创建、方法是否为稳定可代理入口

很多人一看到“事务失效”就先怀疑数据库、驱动或框架版本,其实大多数问题都发生在应用层调用路径上。先确认调用有没有真正进入事务代理,比先看数据库参数更有判别力。

二、先把原理讲透:Spring 事务不是注解魔法,而是代理 + 连接绑定

@Transactional 本身不会直接开启事务,真正起作用的是 Spring 在方法调用前后织入的事务拦截逻辑。常见流程可以理解成这样:

  1. 调用进入被 Spring 管理的代理对象,而不是普通 Java 对象。
  2. 事务拦截器根据注解配置,决定是否开启事务,以及使用哪个事务管理器。
  3. 当前线程绑定数据库连接或持久化上下文,后续数据库操作加入这个事务边界。
  4. 方法正常返回时提交事务,抛出满足回滚规则的异常时回滚事务。
  5. 清理线程上下文,把连接归还给连接池。

这条链路里任何一个前提不成立,事务都可能表现为“像是没生效”。最关键的两个前提是:

  • 调用必须经过 Spring 代理。 不经过代理,事务拦截器压根没有出手机会。
  • 事务上下文默认只在当前线程内有效。 一旦切线程,除非你自己设计补偿或编排逻辑,否则不要默认它还在同一个事务里。

理解了这两个前提,后面 80% 的事务问题都能解释得通。

三、8 个最常见的事务失效场景,很多项目至少踩过 3 个

1. Bean 根本不是 Spring 管理的对象

最直接也最容易忽略的场景,就是业务类并不是由容器注入出来的,而是你自己 new 出来的。只要对象不是代理对象,@Transactional 就只剩装饰作用。

典型错误包括:在工具类或普通类里手动创建 Service、在测试代码里直接 new 实现类、在框架回调里绕过容器获取对象。排查时先确认调用链上拿到的究竟是不是容器里的 Bean。

2. 同类自调用绕过代理

这是最经典也最容易被误判的场景。一个类里的方法 A 调用同类方法 B,如果 B 上有 @Transactional,并不等于事务一定生效。因为这次调用通常不会经过代理,而是对象内部的直接方法调用。

同类自调用导致事务不生效的典型写法
@Service
public class CouponService {

    public void batchSend(List<Long> userIds) {
        for (Long userId : userIds) {
            this.sendOne(userId);
        }
    }

    @Transactional(rollbackFor = Exception.class)
    public void sendOne(Long userId) {
        // 插入优惠券记录、更新库存、写发放日志
    }
}

这段代码的问题不在于注解,而在于 this.sendOne() 没经过代理。更稳的做法有三个:

  • 把事务方法拆到另一个 Service,让外部 Bean 调用它。
  • 把批处理逻辑和单次事务逻辑拆成两个职责清晰的应用服务。
  • 如果团队明确接受复杂度,也可以显式拿代理再调用,但这通常不是最简洁方案。

3. 方法入口不稳定,代理根本拦不住

事务最稳的入口永远是被外部调用的 public Service 方法。很多团队把事务加在私有方法、静态方法,或者带有代理限制的方法上,然后期待框架自动处理,这种期待通常不成立。

工程实践里最简单的原则就是:事务边界放在 public 应用服务方法上,不要依赖“某些情况下也许能代理到”的边缘行为。这样既降低版本差异带来的不确定性,也让代码审阅时更容易判断。

4. 异常被 catch 住了,事务感知不到失败

事务回滚依赖的是“方法最终以异常退出”,如果你在方法内部把异常吃掉,外层拦截器看到的就是一次正常返回,于是自然会提交事务。

异常被吞掉时,事务通常会正常提交
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
    try {
        orderRepository.save(command.toEntity());
        stockService.reserve(command.getSkuId(), command.getQuantity());
    } catch (Exception exception) {
        log.error("createOrder failed", exception);
        return;
    }
}

这类代码最危险的地方在于:日志里明明有 error,开发者直觉上会以为事务已经回滚,实际上数据已经提交。更稳的处理方式是二选一:

  • 要么记录日志后继续抛出异常,让事务自行回滚。
  • 要么明确调用回滚标记,并且保证调用方能感知失败。

5. 抛的是受检异常,但你以为默认会回滚

Spring 默认对 RuntimeExceptionError 回滚,对普通受检异常并不会自动回滚。很多业务代码里抛的是自定义 Exception、IO 异常或业务受检异常,如果没显式配置,最后很容易出现“接口失败但事务没回滚”。

因此,团队里如果存在大量受检异常,更稳妥的习惯是明确写出:

对受检异常显式声明回滚规则
@Transactional(rollbackFor = Exception.class)
public void pay(PayCommand command) throws Exception {
    // ...
}

这不是说所有场景都必须无脑加 rollbackFor = Exception.class,而是你要知道默认规则是什么,再决定是否扩大回滚范围。

6. 传播行为选错了,事务“看起来正常”,结果却不是你想要的

很多问题不属于事务失效,而属于事务按配置正常工作,但配置本身错了。最常见的误区有两个:

  • 希望内层方法失败不影响外层,却仍然使用 REQUIRED,结果整个调用链一起回滚。
  • 希望外层失败能带着内层一起回滚,却在内层用了 REQUIRES_NEW,结果内层事务已经提前提交。

这类问题最容易在“主订单 + 日志记录”“核心写库 + 审计落表”“支付主流程 + 优惠券发放”这种混合场景里出现。传播行为一旦选错,数据库表现通常和注解看起来的语义完全相反。

7. 用了 @Async、线程池或手动开线程,却还以为在同一个事务里

事务上下文默认是线程绑定的。也就是说,主线程里开启的本地事务,不会自动流到新线程。下面这些写法都要默认视为事务边界已经断开:

  • 在事务方法里调用 @Async 方法。
  • 在事务方法里提交线程池任务。
  • 手动 new Thread()CompletableFuture.supplyAsync()

如果你需要“数据库提交成功后再异步处理”,正确思路通常不是强行让异步任务加入事务,而是把异步动作设计成提交后执行。例如发 MQ、推送消息、刷新搜索索引、发短信邮件,都更适合放到事务提交之后。

8. 把远程调用、多数据源或消息发送误当成本地事务的一部分

本地事务只能保证当前事务管理器控制范围内的资源一致。你在一个 Spring Boot 方法里同时更新 MySQL、调用另一个服务、发送 MQ、写 ES,再加一个 Redis 删除动作,并不等于这些动作天然是原子性的。

尤其是下面两种场景最容易出事故:

  • 多数据源: 方法上有事务,但实际只管住了默认数据源,另一个数据源并不在同一个事务管理器里。
  • 本地事务 + 远程副作用: 数据库回滚了,但短信已发、消息已投、下游接口已调用成功。

一旦出现这种跨资源编排,正确问题就不再是“为什么事务没生效”,而是“这本来就不该指望一个本地事务解决”。

四、传播行为别背定义,要结合业务目标来选

面试里大家都能背出 REQUIREDREQUIRES_NEWNESTED,但真正写业务时,关键不是背名字,而是想清楚“我到底希望哪一层失败时带谁一起回滚”。

传播行为 典型语义 更适合什么场景 常见误区
REQUIRED 有就加入,没有就新建 一个完整业务流程里的主链路数据库操作 默认它能自动隔离所有副作用动作
REQUIRES_NEW 挂起外层事务,开启独立事务 审计日志、补偿记录等希望单独提交的动作 误以为外层回滚时它也会回滚
NESTED 基于保存点的嵌套事务语义 明确需要部分回滚控制且底层支持保存点 把它当成“等价于 REQUIRES_NEW”
SUPPORTS 有事务就加入,没有就非事务执行 纯查询或弱依赖事务上下文的逻辑 用于必须原子提交的写操作
NOT_SUPPORTED 显式以非事务方式运行 不希望长事务包裹的非关键查询或外部调用 放入必须和主链路一起提交的数据库更新

实战里最稳的策略通常不是“到处加事务”,而是先把业务动作分层:哪些必须同生共死,哪些可以提交后异步做,哪些失败了只要记录补偿即可。传播行为只是这个拆分结果的表达,而不是业务边界的替代品。

五、一个更稳的事务边界写法:数据库操作放事务里,副作用放提交后

如果你把发消息、调库存服务、推送通知、写搜索索引全部塞进一个事务方法里,代码看起来“一站式”,但稳定性通常更差。更靠谱的方式是:事务里只做本地数据库必须原子完成的动作,提交成功后再做外部副作用。

更稳的事务边界示例
@Service
public class OrderApplicationService {

    private final OrderRepository orderRepository;
    private final StockRepository stockRepository;
    private final ApplicationEventPublisher eventPublisher;

    public OrderApplicationService(
            OrderRepository orderRepository,
            StockRepository stockRepository,
            ApplicationEventPublisher eventPublisher) {
        this.orderRepository = orderRepository;
        this.stockRepository = stockRepository;
        this.eventPublisher = eventPublisher;
    }

    @Transactional(rollbackFor = Exception.class)
    public Long createOrder(CreateOrderCommand command) {
        Order order = orderRepository.save(Order.create(command));
        stockRepository.reserve(command.getSkuId(), command.getQuantity());
        eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
        return order.getId();
    }
}

@Component
public class OrderCreatedHandler {

    private final MessagePublisher messagePublisher;

    public OrderCreatedHandler(MessagePublisher messagePublisher) {
        this.messagePublisher = messagePublisher;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(OrderCreatedEvent event) {
        messagePublisher.publishOrderCreated(event.orderId());
    }
}

这类写法有几个直接好处:

  • 数据库回滚时,不会提前把消息、副作用动作发出去。
  • 事务方法职责清晰,排查失败原因时边界非常明确。
  • 后续要改成 outbox、MQ、重试或补偿机制时,演进成本更低。

六、线上排查事务问题,建议按这个顺序来

真实线上排障最怕“同时怀疑所有地方”。下面这条顺序更省时间:

  1. 先确认调用是否经过 Spring 管理的代理 Bean。
  2. 再确认事务方法是否是明确的 public 外部入口,是否存在同类自调用。
  3. 检查异常是否被吞掉,或是否属于默认不回滚的异常类型。
  4. 确认传播行为是否真的符合业务目标,而不是“默认写上去”。
  5. 确认有没有切线程、异步执行、线程池任务或远程调用。
  6. 如果涉及多数据源,检查实际使用的是哪个事务管理器。
  7. 最后再看数据库侧事务隔离级别、锁等待、超时和连接池配置。

这个顺序的核心是:先查应用层调用路径,再查资源层配置。因为前者更常见,也更便宜。

七、几个高频误解,最好尽早改掉

1. “加了事务,远程调用失败也会一起回滚”

不会。本地事务只能管住当前受控资源。你调第三方接口成功了,再抛异常回滚数据库,第三方不会跟着回滚。

2. “一个大事务最安全”

不一定。事务越大,锁持有越久,连接占用越长,回滚成本越高,失败面越大。很多业务真正需要的是清晰边界,而不是更长的事务。

3. “只要数据库支持事务,Spring 事务就一定可靠”

数据库支持事务只是底层前提,Spring 事务能否按预期工作,更大程度取决于调用路径、异常规则、线程模型和资源边界。

八、最后给一个简单但有效的落地原则

如果你不想把事务问题搞得过于抽象,可以记住下面这组实践原则:

  • 事务边界优先放在 public 应用服务方法,不要分散在内部辅助方法上。
  • 只把必须原子完成的本地数据库操作放进事务里。
  • 远程调用、发消息、推送通知、刷新索引优先放到提交后。
  • 对异常规则要显式,不要假设所有异常默认都回滚。
  • 只要涉及异步、线程池、多数据源或多服务,就不要再用“本地事务思维”硬套。

事务这件事,说到底不是注解技巧,而是边界设计。真正稳的系统,通常不是事务写得最“复杂”的系统,而是边界最清楚、失败方式最可预期的系统。

本文总结

  • 先判断事务有没有真正通过 Spring 代理生效,再排查异常类型、传播行为和线程边界,效率会高很多。
  • 多数“事务失效”其实不是数据库不支持事务,而是自调用、异常吞掉、@Async 切线程或事务管理器选错。
  • 更稳的事务写法是把事务边界收敛在单个应用服务方法内,远程调用、发消息和副作用动作放到提交后。
GYSTACK 文章文末广告 硅云云服务器活动 适合个人项目、轻量建站和出海业务部署。
后浪云移动端信息流广告 后浪云主机服务 适合长期部署、独立站和海外机房需求。