Spring事务异常TransactionRequiredException:从代理机制到线程上下文的深度排查

发布时间:2026/10/12 22:17:42
Spring事务异常TransactionRequiredException:从代理机制到线程上下文的深度排查
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 代理失效的其他隐蔽情况除了自调用还有几种代理失效的场景值得警惕方法不是 publicSpring 的事务代理默认只拦截 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(ListData 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(readOnlytrue)去掉 readOnly 或拆分写操作事务是否已结束看操作是否在方法返回后执行把操作移入事务方法内5.4 一个真实的排查案例某次一个定时任务报这个错代码结构大致是定时任务方法标了Transactional里面调用了一个processBatch方法processBatch里又通过CompletableFuture.runAsync并行处理数据。问题就出在runAsync上——它默认用 ForkJoinPool新线程没有事务。当时的修法是把事务边界缩小到每个并行任务内部用TransactionTemplate在每个异步任务里单独开事务。这样既保留了并行能力又保证了每个任务有自己的事务。public void processBatch(ListData dataList) { ListCompletableFutureVoid 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 节的排查链路走一遍重点看线程和代理这两个维度。大部分情况下问题就藏在这两处。