《Spring Boot 入门》14.3 事务失效的常见场景

事务注解写了却不生效,是入门阶段最费解的一类问题。本节把自调用绕过代理、方法非 public、异常被 try-catch 吞掉、rollbackFor 未配置、跨线程传播丢失、数据库引擎不支持等场景逐个拆开,给出原因与解法,并用一张速查表和日志排查法收尾。

本节目标:掌握导致 @Transactional 不生效的八类常见原因,能对着症状定位问题,并用日志与断点确认事务是否真的开启、提交或回滚。
适用版本:Spring Boot 4.1.x(Java 21)

14.3 事务失效的常见场景

14.1 讲过,@Transactional 靠 AOP 代理生效;14.2 讲了多个事务怎么组合。这一节处理最实际的问题:注解明明写了,为什么数据还是不一致? 下面把八类场景逐个拆开,每类都给出「为什么失效」与「怎么修」。全部示例仍围绕图书借阅流程。

14.3.1 自调用失效:绕过了代理

这是最经典、最容易踩的一类。看下面的写法:

package com.example.library.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class BorrowService {

    public void borrow(Long readerId, Long bookId) {
        // 同类内部直接调用本类方法
        doBorrow(readerId, bookId);
    }

    @Transactional(rollbackFor = Exception.class)
    public void doBorrow(Long readerId, Long bookId) {
        bookRepository.decreaseStock(bookId);
        recordRepository.save(new BorrowRecord(readerId, bookId));
        if (true) {
            throw new IllegalStateException("模拟失败");
        }
    }
}

调用 borrow() 后,doBorrow() 抛异常,但库存扣减没有回滚。原因是代理机制:

  • 你从容器拿到的 BorrowService 是代理对象,它包着真实的 BorrowService;
  • 外部调用 borrow() 时,进入的是代理,但 borrow() 自己没有 @Transactional,代理不做任何事务处理,直接调用真实对象;
  • 真实对象执行到 doBorrow(...) 时,这是对象内部的方法调用(this.doBorrow()),this 指向真实对象而非代理,因此事务逻辑根本没被触发。

用一句话概括:只有通过代理发起的调用,@Transactional 才生效。

Spring 的代理有两种实现:

代理方式前提特点
JDK 动态代理目标类实现了接口基于接口生成代理,注入时须用接口类型
CGLIB目标类无接口生成目标类的子类,覆盖方法

Spring Boot 默认 spring.aop.proxy-target-class=true,一律使用 CGLIB,因此即使没有接口也能代理。但无论哪种代理,都拦不住 this 的内部调用——这是自调用失效的根源。

解法一:拆到另一个 Bean(推荐)

把事务方法放到独立的 Bean 里,通过注入调用,就走到了代理:

@Service
public class BorrowTxService {

    @Transactional(rollbackFor = Exception.class)
    public void doBorrow(Long readerId, Long bookId) {
        bookRepository.decreaseStock(bookId);
        recordRepository.save(new BorrowRecord(readerId, bookId));
    }
}

@Service
public class BorrowService {

    private final BorrowTxService borrowTxService;

    public BorrowService(BorrowTxService borrowTxService) {
        this.borrowTxService = borrowTxService;
    }

    public void borrow(Long readerId, Long bookId) {
        borrowTxService.doBorrow(readerId, bookId);   // 通过代理调用,事务生效
    }
}

这是最清晰、最推荐的做法:职责也更分明。

解法二:注入自身

让 Bean 持有自己的代理,通过它来调用:

@Service
public class BorrowService {

    @Autowired
    @Lazy
    private BorrowService self;   // 注入的是代理,不是 this

    public void borrow(Long readerId, Long bookId) {
        self.doBorrow(readerId, bookId);   // 走代理,事务生效
    }

    @Transactional(rollbackFor = Exception.class)
    public void doBorrow(Long readerId, Long bookId) {
        // ...
    }
}

@Lazy 用来打破「自己注入自己」时的循环依赖。也可以用构造器注入配合 ObjectProvider<BorrowService>。这种做法能用,但可读性差,且容易让人困惑。

解法三:AopContext.currentProxy()

从 AOP 上下文里取出当前代理对象:

@Service
public class BorrowService {

    public void borrow(Long readerId, Long bookId) {
        ((BorrowService) AopContext.currentProxy()).doBorrow(readerId, bookId);
    }

    @Transactional(rollbackFor = Exception.class)
    public void doBorrow(Long readerId, Long bookId) {
        // ...
    }
}

前提是必须开启 exposeProxy,即在配置类上加上 @EnableAspectJAutoProxy(exposeProxy = true)。这种做法侵入性强、要求开启额外配置,只建议在无法拆 Bean 的遗留代码里使用。三种解法的取舍很明确:能拆 Bean 就拆 Bean,其余两种是权宜之计。

14.3.2 方法不是 public

Spring 的事务代理只对 public 方法生效。原因还是 CGLIB:它通过继承并覆盖方法来插入事务逻辑,而 private、protected、包可见的方法无法被覆盖(private 甚至不可见)。

@Service
public class BorrowService {

    @Transactional(rollbackFor = Exception.class)
    void doBorrow(Long readerId, Long bookId) {   // 包可见 —— 事务不生效
        // ...
    }
}

上例中 doBorrow 没有 public,注解会被静默忽略(不同版本可能打印一条警告,但不会报错)。修正很简单:把方法改成 public。这也是为什么事务方法一般放在 Service 层、且都声明为 public。

14.3.3 异常被 try-catch 吞掉

事务回滚的触发条件是异常从被代理的方法里抛出去。如果方法内部把异常捕获后不重新抛出,代理就看不到异常,会当作正常返回并提交事务:

@Transactional(rollbackFor = Exception.class)
public void borrow(Long readerId, Long bookId) {
    try {
        bookRepository.decreaseStock(bookId);
        recordRepository.save(new BorrowRecord(readerId, bookId));
        throw new IllegalStateException("模拟失败");
    } catch (Exception ex) {
        log.error("借阅失败", ex);   // 只记日志,异常没抛出去
    }
    // 代理认为方法正常结束 → 提交事务
}

结果:异常被吞,事务提交,库存扣减留在库里。这是最隐蔽的一类失效,因为代码「看起来」处理了异常。

修正方式二选一:

  • 重新抛出异常:catch (Exception ex) { log.error(...); throw ex; },让代理感知失败;
  • 主动标记回滚:TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();。

14.3.4 rollbackFor 未配置:受检异常不回滚

这是 14.1.6 讲过的坑,放在失效清单里再强调一次:默认只回滚 RuntimeException 与 Error,受检异常不会回滚。

@Transactional   // 没有 rollbackFor
public void borrow(Long readerId, Long bookId) throws IOException {
    bookRepository.decreaseStock(bookId);
    if (!remoteChecker.check(readerId)) {
        throw new IOException("remote check failed");   // 受检异常 → 不回滚
    }
}

IOException 抛出后,事务被提交,库存扣减留存。修正:统一写 @Transactional(rollbackFor = Exception.class)。

14.3.5 事务方法内开新线程

事务上下文绑定在当前线程上(通过 ThreadLocal 保存)。方法内手动 new Thread(...) 或提交到线程池执行数据库操作,新线程里没有事务上下文,那些操作会在自己的连接上自动提交:

@Transactional(rollbackFor = Exception.class)
public void borrow(Long readerId, Long bookId) {
    bookRepository.decreaseStock(bookId);

    new Thread(() -> {
        // 新线程:不在事务里,失败也不会跟着回滚
        recordRepository.save(new BorrowRecord(readerId, bookId));
    }).start();

    throw new IllegalStateException("模拟失败");   // 只回滚主线程的库存扣减
}

结果:库存扣减回滚了,但流水记录已经在新线程里独立提交——又是不一致。

正确做法是把并发操作移出事务方法:要么在主事务里同步完成,要么在事务提交之后再异步执行(例如用 TransactionSynchronizationManager.registerSynchronization(...) 在 afterCommit 里发起异步任务)。Spring Boot 4.1 增强了 @Async 的上下文传播,但数据库事务本身不会跨线程传播,这一点不会因版本而改变。

14.3.6 加在 private / final / static 方法上

CGLIB 通过继承并覆盖实现代理,因此以下三种方法上的 @Transactional 一定无效:

修饰符为什么失效
private子类无法访问,不能被覆盖
final不能被覆盖
static属于类而非实例,代理机制不介入
@Transactional
private void doBorrow(...) { }        // 无效

@Transactional
public final void doBorrow(...) { }   // 无效

@Transactional
public static void doBorrow(...) { }  // 无效

规则很简单:事务方法必须是可被覆盖的实例方法,也就是 public(非 final)。

14.3.7 数据库引擎不支持事务

这一条与 Spring 无关,但同样会导致「注解写了没效果」。以 MySQL 为例,MyISAM 存储引擎不支持事务,DDL 语句也会隐式提交。若表被建成了 MyISAM:

CREATE TABLE borrow_record (
    id BIGINT PRIMARY KEY,
    reader_id BIGINT,
    book_id BIGINT
) ENGINE = MyISAM;

那么在这个表上的写操作永远不会回滚,无论 @Transactional 怎么写。修正方式是把引擎改为 InnoDB:

ALTER TABLE borrow_record ENGINE = InnoDB;

现代 MySQL(8.x)默认引擎就是 InnoDB,但迁移来的老库、或手工建表时指定了引擎,都可能留下这个坑。排查时可以用 SHOW TABLE STATUS 或 SHOW CREATE TABLE 确认引擎。

14.3.8 @Transactional 与 @Async 同用

@Async 会把方法交给另一个线程执行。若同时标了 @Transactional,要分清「事务边界落在哪个线程」:

@Async
@Transactional(rollbackFor = Exception.class)
public void borrowAsync(Long readerId, Long bookId) {
    // 在异步线程里开启自己的事务
}
  • 当外部异步调用 borrowAsync 时,方法体在异步线程里执行,事务也在异步线程里开启、提交或回滚——调用方的事务不会传播进来,两者互不影响;
  • 如果调用方本身处于事务中,它的事务与异步方法的事务是两个独立事务,异步方法的失败不会导致调用方回滚,反之亦然。

这类写法容易造成「以为绑在一起、其实各管各的」的误判。实践中建议:不要把 @Async 与 @Transactional 叠在同一个方法上,需要异步 + 事务时,把事务方法放在被 @Async 调用的独立 Bean 里,语义更清楚。

14.3.9 症状 → 原因 → 解法 速查表

症状可能原因解法
注解写了,异常后数据仍被改同类内部自调用,绕过代理拆到另一个 Bean;或注入自身;或 AopContext
同上,且方法是包可见 / private事务只对 public 方法生效改为 public
抛异常了却没回滚异常被 try-catch 吞掉重新抛出,或 setRollbackOnly()
抛了受检异常却没回滚默认只回滚运行时异常rollbackFor = Exception.class
主流程回滚了,异步写的数据还在新线程没有事务上下文事务提交后再异步;不要跨线程写
final / static 方法上不生效代理无法覆盖改为普通 public 实例方法
换了个库/表就不回滚存储引擎不支持事务(MyISAM)改用 InnoDB
日志/审计记录跟着回滚消失传播行为是默认的 REQUIRED需要留存时用 REQUIRES_NEW

14.3.10 如何确认事务真的生效

光看代码容易误判,最可靠的方式是打开事务日志。在配置里把事务相关包调到 DEBUG / TRACE:

logging:
  level:
    org.springframework.transaction: DEBUG
    org.springframework.transaction.interceptor: TRACE

再次触发借阅流程,日志里会出现事务的完整生命周期:

2026-10-04T10:20:13.882+08:00 DEBUG 60312 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.example.library.service.BorrowService.borrow]
2026-10-04T10:20:13.901+08:00 DEBUG 60312 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager     : Creating new transaction with name [com.example.library.service.BorrowService.borrow]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2026-10-04T10:20:13.955+08:00 DEBUG 60312 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager     : Initiating transaction rollback

判读要点:

  • 出现 Creating new transaction 或 Participating in existing transaction,说明事务确实开启了;
  • 结尾若是 Initiating transaction commit,说明走了提交;
  • 结尾若是 Initiating transaction rollback,说明走了回滚;
  • 如果这些行一条都没有,基本可以断定事务没生效——优先怀疑自调用、方法非 public、或 Bean 根本没被代理。

除了日志,还可以在事务方法首尾打断点,观察 TransactionSynchronizationManager.isActualTransactionActive() 的返回值,或用 IDE 的调用栈看入口是代理类还是真实类。把这套方法固化下来,排查「事务失效」会比对着代码猜快得多。

小结

  • @Transactional 靠 AOP 代理生效,只有通过代理发起的调用才有效;同类内部 this 调用会绕过代理,是最常见的失效原因,优先用「拆到另一个 Bean」解决。
  • 事务方法必须是可被覆盖的 public 实例方法;private / final / static 上的注解会被静默忽略。
  • 异常必须从被代理的方法抛出才会触发回滚;被 try-catch 吞掉、或抛出受检异常而未配 rollbackFor,都会导致「异常了却提交」。
  • 事务上下文绑定线程,跨线程写数据不受事务管理;@Async 与 @Transactional 叠用要分清事务边界。
  • 数据库层面也可能失效:MyISAM 等引擎不支持事务。
  • 排查手段是把 org.springframework.transaction 调到 DEBUG,看是否出现 Creating new transaction 与 commit / rollback 行。

阅读导航:上一节:14.2 传播行为与隔离级别 · 下一节:15.1 Flyway 入门 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计