1. 从一个凌晨两点的报错说起
Caused by: javax.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call
这个报错,如果你做过后端开发,大概率在某个深夜被它折磨过。我第一次遇到它是在一个数据同步模块里,本地跑得好好的,一上测试环境就炸,日志里翻来覆去就是这一行。当时我的第一反应是"事务不是加了吗",第二反应是"是不是数据库连接池有问题",排查了两个小时才发现,问题出在一个我压根没注意的方法调用链上。
这篇文章不打算给你背教科书,而是想把这个异常彻底拆开讲清楚:它到底在什么条件下触发、为什么同样的代码有时报错有时不报、Spring 的事务代理在背后做了什么、以及我踩过的几个典型坑和对应的排查手法。适合已经写过 JPA/Hibernate 但被事务问题卡住的中级开发者,也适合想搞明白"声明式事务到底怎么生效"的进阶读者。核心关键词就一个:TransactionRequiredException,围绕它展开的所有实战细节,我都会尽量讲透。
先说结论:这个异常的本质是——你调用的持久化操作,执行时当前线程上没有一个处于活动状态的事务。听起来简单,但"为什么没有"这件事,能衍生出至少七八种不同的场景,每一种的修法都不一样。下面逐个拆。
2. 这个异常到底在什么条件下被抛出
2.1 抛出点的源码逻辑
要理解它,得先知道是谁抛的。这个异常来自 JPA 规范,具体实现里通常是 Hibernate 的TransactionRequiredException。触发它的典型位置是EntityManager的写操作:persist、merge、remove、flush,以及某些情况下的查询刷新。
Hibernate 在执行这些操作前,会检查当前EntityManager是否关联了一个"实际事务"。注意这里的措辞——actual transaction,不是"事务上下文",不是"事务同步",而是真正开启的数据库事务。源码层面的判断大致是这样的逻辑:
// 简化后的判断逻辑示意 if (!isTransactionInProgress()) { throw new TransactionRequiredException( "No EntityManager with actual transaction available for current thread" ); }isTransactionInProgress()检查的是当前 Session 是否绑定了一个活跃的Transaction对象。如果只是处于一个 Spring 的@Transactional注解方法里,但事务传播行为被设置成了NOT_SUPPORTED,或者事务因为某种原因被挂起了,那这个方法依然会返回 false。
2.2 和"没有事务"是两回事
很多人会把它和LazyInitializationException搞混,或者觉得"我没加 @Transactional 所以报错很正常"。但实际情况往往更微妙:你加了 @Transactional,它照样报。这才是最让人抓狂的地方。
我整理了一张对照表,把"看起来有事务"和"实际有事务"的区别列清楚:
| 场景 | 表面现象 | 实际事务状态 | 是否报错 |
|---|---|---|---|
| 方法加了 @Transactional,同类内部调用 | 有注解 | 代理未生效,无事务 | 报错 |
| 方法加了 @Transactional,传播行为 REQUIRES_NEW 但外层已挂起 | 有注解 | 新事务未正确开启 | 可能报错 |
| 在 @Transactional 方法里手动 new 了一个线程 | 外层有事务 | 新线程无事务绑定 | 报错 |
| 通过 this 调用而非代理调用 | 有注解 | 代理被绕过 | 报错 |
| 事务已提交后再操作实体 | 曾有事务 | 事务已结束 | 报错 |
| 只读事务里执行写操作 | 有事务 | 事务只读,写被拒 | 报错或静默失败 |
这张表里的每一行,我都真实遇到过至少一次。下面挑几个最有代表性的展开讲。
2.3 为什么"有时报有时不报"
这是新手最困惑的点。同一个save方法,在 A 流程里正常,在 B 流程里就抛异常。原因通常在于调用路径不同。Spring 的事务是基于 AOP 代理实现的,只有通过代理对象进入的方法,注解才会被解析。如果你的调用是从类的内部方法直接this.xxx()过去的,那走的是原始对象,代理根本没参与,事务自然不存在。
还有一种情况是事务传播行为。比如外层方法标了@Transactional(propagation = Propagation.NOT_SUPPORTED),它会挂起当前事务,内层方法即使标了REQUIRED,在某些配置下也可能拿不到活动事务。这类问题不会稳定复现,取决于调用顺序和线程状态,所以特别难查。
3. 事务代理的生效边界:为什么注解有时像没写一样
3.1 Spring AOP 代理的两种实现
要彻底搞懂这个异常,绕不开 Spring 的事务代理机制。Spring 默认用两种方式生成代理:JDK 动态代理(基于接口)和CGLIB 代理(基于子类)。从 Spring Boot 2.x 开始,默认切换到了 CGLIB,因为它在处理无接口类时更方便。
代理的核心作用是:在目标方法执行前后,插入事务开启、提交、回滚的逻辑。所以只有经过代理对象的方法调用,事务才会生效。这就引出了那个经典的自调用陷阱。
@Service public class OrderService { @Transactional public void createOrder(Order order) { // 这里直接 this 调用,代理被绕过 this.updateStock(order); orderRepository.save(order); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateStock(Order order) { // 期望开启新事务,实际不会 stockRepository.decrement(order.getSkuId()); } }上面这段代码,updateStock上的REQUIRES_NEW完全失效,因为它压根没走代理。如果createOrder本身没有事务(比如注解写错了位置),那save就会直接抛TransactionRequiredException。
3.2 自调用问题的三种解法
我试过三种方案,各有取舍:
- 注入自身代理:通过
@Autowired注入OrderService自己,或者用AopContext.currentProxy()。前者简单但有点绕,后者需要开启exposeProxy = true。 - 拆分到独立 Bean:把
updateStock挪到另一个 Service 里。这是最干净的做法,也是我目前最推荐的,职责清晰,事务边界明确。 - 用 TransactionTemplate 手动控制:编程式事务,代码里显式
execute,不依赖代理。适合逻辑复杂、需要精细控制提交时机的场景。
// 方案三:TransactionTemplate 示例 @Autowired private TransactionTemplate transactionTemplate; public void createOrder(Order order) { transactionTemplate.execute(status -> { orderRepository.save(order); stockRepository.decrement(order.getSkuId()); return null; }); }TransactionTemplate的好处是事务边界一目了然,不依赖注解解析,也不会被自调用坑到。缺点是代码稍微啰嗦一点,而且嵌套事务的传播行为需要手动配置。
3.3 代理失效的其他隐蔽情况
除了自调用,还有几种代理失效的场景值得警惕:
- 方法不是 public:Spring 的事务代理默认只拦截 public 方法。protected、private、包级方法上的
@Transactional会被静默忽略。 - 类被 final 修饰:CGLIB 无法代理 final 类,JDK 代理要求接口。final 类上的事务注解同样失效。
- 异常被吞掉:事务回滚依赖异常传播,如果你在方法里 try-catch 了异常且没重新抛出,事务不会回滚,但这不是本异常的直接原因,属于另一个坑。
- 多数据源配置错误:配置了多个
EntityManagerFactory但事务管理器没对应上,导致事务管理器管的是 A 数据源,操作的是 B 数据源的 EntityManager。
提示:判断代理是否生效,最直接的办法是在方法里打印
this.getClass().getName(),如果输出的是类似OrderService$$EnhancerBySpringCGLIB的名字,说明代理生效了;如果是原始类名,那事务注解基本是摆设。
4. 线程与异步场景下的经典翻车现场
4.1 手动 new Thread 的陷阱
事务是绑定在线程上的。Spring 用ThreadLocal存储事务上下文,所以一旦你手动开了新线程,那个线程上什么都没有。看这段代码:
@Transactional public void batchImport(List<Data> dataList) { for (Data data : dataList) { // 每个元素开一个线程处理 new Thread(() -> { dataRepository.save(data); // 这里必然抛异常 }).start(); } }外层方法有事务,但新线程里没有。save执行时,当前线程的ThreadLocal是空的,Hibernate 找不到活动事务,直接抛TransactionRequiredException。
4.2 @Async 的隐蔽性
@Async比手动 new Thread 更隐蔽,因为它看起来"很 Spring"。但本质上它也是把方法丢到另一个线程池执行,事务上下文不会自动传递。
@Async @Transactional public void asyncSave(Data data) { dataRepository.save(data); }这段代码里,@Async和@Transactional同时存在,但执行顺序决定了结果。@Async先起作用,方法被提交到线程池,然后在新线程里,@Transactional才尝试开启事务。理论上这样是能工作的——新线程里开启新事务。但如果你在异步方法里又调用了别的需要事务的方法,或者事务管理器配置有问题,就会翻车。
我遇到过一次更诡异的:@Async方法和调用方在同一个类里,结果@Async也没生效,方法同步执行了,但事务因为自调用也没生效,最后save报错。两个注解一起失效,排查起来相当酸爽。
4.3 线程池复用带来的状态污染
还有一种情况:线程池里的线程被复用了,上一个任务的事务上下文没清理干净。正常情况下 Spring 会在任务结束时清理ThreadLocal,但如果用了自定义线程池且没正确包装,就可能残留脏状态。表现是"偶尔报错,重启就好",非常难定位。
排查这类问题的思路是:在报错的方法入口打印当前线程名和事务状态。
System.out.println("Thread: " + Thread.currentThread().getName()); System.out.println("Tx active: " + TransactionSynchronizationManager.isActualTransactionActive());如果isActualTransactionActive()返回 false,那基本可以确定当前线程没有活动事务,接下来就是顺着调用链往上找,看事务在哪一环断掉了。
5. 一套可复用的排查链路
5.1 从异常堆栈定位第一现场
拿到这个异常,第一步永远是看堆栈。不要只看最上面那行Caused by,要往下翻,找到第一个业务代码的栈帧。那才是真正触发持久化操作的地方。
at com.example.service.DataService.saveData(DataService.java:45) at com.example.controller.DataController.importData(DataController.java:30)看到这个栈,就知道问题出在DataService.saveData这一层。接下来检查三件事:这个方法有没有@Transactional?调用它的importData有没有事务?两者之间的调用是走代理还是自调用?
5.2 用日志确认事务边界
Spring 提供了事务日志开关,加上这两行配置,事务的开启、提交、回滚都会打出来:
logging: level: org.springframework.transaction: DEBUG org.hibernate.engine.transaction: DEBUG日志里会看到类似Creating new transaction with name [...]、Participating in existing transaction、Initiating transaction commit这样的记录。如果某个方法执行时完全没有这些日志,说明事务压根没开。
5.3 常见原因速查表
我把这些年遇到的触发场景整理成一张表,方便对照排查:
| 排查项 | 检查方法 | 典型修法 |
|---|---|---|
| 注解是否在 public 方法上 | 看方法修饰符 | 改为 public 或改用编程式事务 |
| 是否自调用 | 看调用是否通过 this | 拆分 Bean 或注入自身代理 |
| 是否跨线程 | 看是否有 new Thread / @Async | 传递事务上下文或在新线程内开事务 |
| 传播行为是否被挂起 | 看外层方法的 propagation | 调整传播行为为 REQUIRED |
| 事务管理器是否匹配 | 看 EntityManagerFactory 与 TransactionManager 绑定 | 统一数据源配置 |
| 是否只读事务 | 看 @Transactional(readOnly=true) | 去掉 readOnly 或拆分写操作 |
| 事务是否已结束 | 看操作是否在方法返回后执行 | 把操作移入事务方法内 |
5.4 一个真实的排查案例
某次一个定时任务报这个错,代码结构大致是:定时任务方法标了@Transactional,里面调用了一个processBatch方法,processBatch里又通过CompletableFuture.runAsync并行处理数据。问题就出在runAsync上——它默认用 ForkJoinPool,新线程没有事务。
当时的修法是:把事务边界缩小到每个并行任务内部,用TransactionTemplate在每个异步任务里单独开事务。这样既保留了并行能力,又保证了每个任务有自己的事务。
public void processBatch(List<Data> dataList) { List<CompletableFuture<Void>> futures = dataList.stream() .map(data -> CompletableFuture.runAsync(() -> { transactionTemplate.execute(status -> { dataRepository.save(data); return null; }); })) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); }这个改法的关键点是:事务不能跨线程传递,但可以在每个线程内独立开启。想清楚这一点,很多异步场景的事务问题就迎刃而解了。
6. 几个容易忽略的配置细节
6.1 JPA 与 Hibernate 的 flush 模式
EntityManager的 flush 模式会影响写操作的时机。默认是AUTO,会在事务提交前或查询前自动 flush。如果你把 flush 模式改成了COMMIT,那写操作会延迟到事务提交时才真正执行。这时候如果事务本身有问题,报错会出现在提交阶段,堆栈看起来和业务代码没关系,排查难度陡增。
spring: jpa: properties: org.hibernate.flushMode: COMMIT我一般不建议改这个配置,除非有明确的性能需求。默认的AUTO已经能覆盖绝大多数场景。
6.2 只读事务的写操作
@Transactional(readOnly = true)是个好习惯,能优化查询性能。但如果你在这个方法里不小心执行了写操作,Hibernate 在某些版本下会直接抛异常,某些版本下则静默忽略。这种不一致性很坑,所以只读事务的边界要划清楚,别把写操作混进去。
6.3 多数据源下的事务管理器绑定
配置多数据源时,每个EntityManagerFactory需要绑定对应的JpaTransactionManager。如果绑定错了,事务管理器管的是数据源 A,而你操作的EntityManager来自数据源 B,那事务检查就会失败。
@Bean public JpaTransactionManager transactionManager( @Qualifier("primaryEntityManagerFactory") EntityManagerFactory emf) { return new JpaTransactionManager(emf); }这段配置的关键是@Qualifier要对应上。多数据源项目里,我见过太多因为 qualifier 写错导致事务失效的案例。
6.4 测试环境的事务陷阱
写单元测试时,@DataJpaTest默认会给每个测试方法包一层事务,测试结束后回滚。但如果你在测试里手动开了线程,或者用了@Transactional(propagation = NOT_SUPPORTED),事务上下文就断了,save操作会报这个异常。测试代码里遇到它,先检查是不是测试框架的事务和你的操作不在同一个线程上。
7. 我踩过的三个印象最深的坑
7.1 坑一:注解加在接口上
早期我习惯把@Transactional加在 Service 接口的方法上,实现类不写。用 JDK 动态代理时这没问题,但后来项目切换到 CGLIB 代理,接口上的注解就不一定被继承了。表现是部分方法事务生效,部分不生效,取决于代理方式和配置。后来我统一改成注解加在实现类的方法上,再没出过这个问题。
7.2 坑二:事务方法里调用了 native query
有一次在事务方法里执行了一个原生 SQL 的更新操作,用的是entityManager.createNativeQuery(...).executeUpdate()。这个方法本身不检查事务,但执行时如果当前没有活动事务,底层连接可能处于自动提交模式,导致更新立即生效且不受事务控制。虽然不直接抛TransactionRequiredException,但数据一致性问题更严重。教训是:原生 SQL 也要放在事务边界内。
7.3 坑三:Spring Boot 版本升级后的行为变化
从 Spring Boot 2.x 升到 3.x 时,Hibernate 从 5 升到 6,事务检查变得更严格。以前某些"侥幸能跑"的代码,升级后直接抛异常。比如在@Transactional方法返回后,通过EntityManager再操作实体,2.x 下可能因为 session 还没关而成功,3.x 下直接报TransactionRequiredException。升级时这类问题会集中爆发,建议提前在测试环境跑一遍全量用例。
注意:如果你正在做版本升级,重点检查那些"事务方法返回后还在用实体"的代码,以及依赖
OpenSessionInView的懒加载逻辑。这两块是重灾区。
8. 写在最后的一点个人习惯
处理这个异常这么多年,我养成了一个习惯:任何涉及写操作的方法,第一眼先确认事务边界在哪里。不是看有没有注解,而是看这个方法的调用链上,事务是从哪一层开始、到哪一层结束、中间有没有断点。注解只是声明,真正生效的是代理和线程上下文。
另一个习惯是,新项目里我会尽量用TransactionTemplate处理复杂的写逻辑,把事务边界显式写出来。声明式事务适合简单的 CRUD,一旦涉及异步、多数据源、嵌套调用,编程式事务的可控性优势就体现出来了。代码多写几行,换来的是排查问题时少熬几个夜,这笔账怎么算都划算。
如果你现在正被这个异常卡住,建议按第 5 节的排查链路走一遍,重点看线程和代理这两个维度。大部分情况下,问题就藏在这两处。