本节目标:掌握导致 @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 入门 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。