Spring 事务为什么会失效:从 @Transactional 代理机制到线上问题排查
Spring 事务为什么会失效:从 @Transactional 代理机制到线上问题排查
在 Spring 项目中,我们通常只需要在业务方法上添加一个 @Transactional 注解,就可以获得事务能力:
1 |
|
看起来非常简单,但在真实项目中,经常会出现下面这些问题:
- 方法上明明添加了
@Transactional,数据却没有回滚; - 内层方法抛出异常,外层方法捕获后,事务仍然提交失败;
- 同样的代码在一个 Service 中生效,换到另一个类里却失效;
- 使用
@Async后,部分数据库操作没有跟随主事务回滚; - 多数据源环境中,只有一个数据库发生了回滚;
- 使用
REQUIRES_NEW后,日志提交了,订单却回滚了; - 明明已经捕获异常,最终却抛出了
UnexpectedRollbackException。
这些现象不一定都是“事务失效”。有些是代理没有生效,有些是回滚规则、传播行为或线程切换带来的正常结果,还有一些则是事务边界设计不合理。
理解 Spring 声明式事务,需要先抓住三个关键点:
@Transactional是事务元数据,真正的事务逻辑由 Spring AOP 代理执行;- 事务边界由实际的方法调用链决定,而不只是由注解所在的位置决定;
- 异常类型、线程、传播行为和事务管理器共同影响最终的提交与回滚。
本文将从一个简单案例开始,逐步分析 Spring 事务的工作机制、常见问题以及线上排查方法。
一、从一个事务未回滚的问题开始
假设创建订单时需要保存订单并扣减库存:
1 |
|
方法执行到:
1 | int result = 1 / 0; |
时会抛出 ArithmeticException。它属于运行时异常,按照 Spring 的默认回滚规则,当前事务应当回滚,因此订单不会保存、库存也不会扣减。
如果数据没有回滚,应该依次确认:
OrderService是否由 Spring 容器管理;- 调用者拿到的是不是 Spring 代理对象;
createOrder()是否通过代理对象调用;- 异常是否真正离开了事务方法;
- 数据库和数据表是否支持事务;
- 当前数据库操作是否由对应的事务管理器管理。
要理解这些检查项,首先需要弄清楚 @Transactional 到底做了什么。
二、@Transactional 到底做了什么
@Transactional 本身不会直接开启事务。它是一份事务元数据,告诉 Spring:
当这个方法通过 Spring 代理对象执行时,需要在方法调用前后织入事务逻辑。
一个典型调用链如下:
1 | Controller |
可以将事务代理简化为下面的伪代码:
1 | public Object invoke(MethodInvocation invocation) throws Throwable { |
真实实现还需要处理:
- 事务传播行为;
- 隔离级别;
- 超时时间;
- 只读标记;
- 回滚规则;
- 事务挂起与恢复;
- 多种事务管理器;
- 嵌套事务与保存点。
但核心思想没有变化:
Spring 必须先拦截方法调用,才能在方法执行前后开启、提交或回滚事务。
因此,排查声明式事务时,第一个问题始终是:
这次方法调用有没有经过 Spring 代理对象?
三、代理与线程:理解事务的两个基础
3.1 JDK 动态代理与类代理
Spring AOP 常见的代理方式有两种:
- JDK 动态代理:基于接口创建代理;
- 类代理:基于目标类创建子类代理,底层通常使用 CGLIB。
假设存在接口:
1 | public interface OrderService { |
对应实现类:
1 |
|
使用 JDK 动态代理时,调用过程可以表示为:
1 | 代理对象.createOrder() |
类代理则可以粗略理解为创建了一个目标类的子类,并在可重写的方法外层加入拦截逻辑。因此,private、final 和 static 方法不能像普通实例方法一样被类代理增强。
需要注意方法可见性的版本差异:
- 对于接口代理,事务方法必须是接口中声明的
public方法; - 从 Spring Framework 6.0 开始,类代理默认也可以处理
protected和包可见方法; private、static方法仍不能通过常规 Spring AOP 代理获得事务增强;final方法不能被类代理重写,因此也无法由类代理拦截。
为了兼容不同代理方式并让事务边界一眼可见,业务代码中仍建议优先把事务放在 Service 层的 public 实例方法上。
无论使用哪种代理方式,都要遵守同一个关键规则:
在默认代理模式下,只有从代理对象外部进入的方法调用才会被拦截。
3.2 事务上下文为什么通常绑定在线程上
对于常见的命令式事务,Spring 通过 TransactionSynchronizationManager 将事务资源绑定到当前线程。绑定的信息可能包括:
- JDBC Connection;
- Hibernate Session 或 JPA EntityManager;
- 当前事务是否激活;
- 当前事务是否只读;
- 事务同步回调;
- 事务名称和隔离级别。
因此,同一个线程中的多个数据库操作可以共享同一个事务:
1 | 线程 A |
一旦切换到新线程,原线程的事务上下文通常不会自动传递:
1 | 线程 A:订单事务 |
这正是 @Async、线程池、CompletableFuture 和手动创建线程经常改变事务行为的原因。
四、最常见的事务失效与误判场景
4.1 同一个类中的方法自调用
这是最常见、也最容易被忽略的问题。
1 |
|
外部执行:
1 | orderService.createOrder(order); |
实际调用过程是:
1 | 外部调用 |
createOrder() 本身没有事务。它在类内部调用 saveOrder() 时,本质上执行的是 this.saveOrder(),没有再次经过代理对象,因此 saveOrder() 上的事务注解不会被拦截器处理。
如果外层方法本身有事务:
1 |
|
那么 saveOrder() 中的数据库操作仍会运行在外层事务中,但 saveOrder() 自己声明的传播行为、隔离级别、超时和回滚规则不会单独生效。
例如,下面的 REQUIRES_NEW 不会因为自调用而创建新事务:
1 |
|
4.2 对象不是 Spring Bean
手动创建的对象不会被 Spring 包装成事务代理:
1 | OrderService orderService = new OrderService(orderRepository); |
即使 createOrder() 上有 @Transactional,Spring 也没有机会拦截这次调用。
正确做法是让 Spring 管理对象,并通过依赖注入获取它:
1 |
|
除了显式的 new,还要留意以下情况:
- 工具类自行创建 Service;
- 第三方框架创建了对象,但没有交给 Spring;
- 在 Bean 完成代理前的初始化阶段调用事务方法;
- 测试代码直接实例化目标类,而不是从 Spring 上下文获取 Bean。
4.3 方法不能被当前代理方式拦截
下面这些写法不适合作为常规声明式事务边界:
1 |
|
1 |
|
1 |
|
问题分别在于:
private方法不能从代理外部调用,也不能被子类重写;final方法不能被类代理重写;static方法属于类本身,不是代理对象的实例方法调用。
对于 protected 或包可见方法,需要结合 Spring 版本和代理类型判断。除非项目明确依赖类代理及对应版本能力,否则优先使用 public 方法。
4.4 异常被 try-catch 吃掉
1 |
|
事务拦截器通常根据方法离开时的结果决定提交还是回滚。上面的异常已经在方法内部被捕获,拦截器看到的是“方法正常返回”,因此会尝试提交事务。
最直接的做法是记录日志后继续抛出异常:
1 |
|
业务异常一般可以继承 RuntimeException:
1 | public class OrderCreateException extends RuntimeException { |
如果业务要求捕获异常并返回特定结果,也可以手动标记回滚:
1 |
|
这种写法会让当前事务最终回滚,但事务控制已经侵入业务代码,而且调用链更难推断。能通过明确的异常语义表达失败时,通常仍应优先抛出业务异常。
4.5 抛出受检异常但没有配置回滚规则
Spring 的默认回滚规则是:
RuntimeException及其子类触发回滚;Error及其子类触发回滚;- 受检异常默认不触发回滚。
例如:
1 |
|
如果没有其他全局或局部回滚规则,IOException 默认不会触发回滚。
可以精确指定需要回滚的异常:
1 |
|
也可以为统一的受检业务异常配置回滚:
1 |
|
不建议在所有方法上机械地设置 rollbackFor = Throwable.class。应该根据项目的异常体系明确哪些异常代表业务失败,哪些异常允许事务提交。
版本提示:Spring Framework 6.2 及以上版本允许通过 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) 全局调整默认回滚策略。排查旧项目或多模块项目时,不要只根据异常继承关系下结论,还要检查全局事务配置。
4.6 使用 @Async、线程池或新线程
1 |
|
异步方法:
1 |
|
通常情况下:
- 订单保存运行在线程 A 的事务中;
- 消息保存运行在线程 B 中;
- 线程 B 不会自动加入线程 A 的事务;
- 线程 A 回滚时,线程 B 已提交的数据不会自动回滚。
即使异步方法也添加了事务:
1 |
|
它开启或加入的也是异步线程自己的事务,不是调用方原来的事务。
如果必须保证数据库数据和消息的一致性,可以考虑:
- 本地消息表;
- Outbox Pattern;
- 消息队列提供的事务消息;
- 事务提交后的事件处理;
- 带重试和幂等控制的可靠投递机制。
不要期望通过线程上下文复制来实现真正的跨线程本地事务。
4.7 数据库操作没有真正加入事务
即使 Spring 正确建立了事务,数据库层也可能无法按预期回滚。常见原因包括:
- 数据表或存储引擎不支持事务;
- SQL 使用了另一个未受管理的数据库连接;
- 错误地开启了自动提交;
- 执行了会导致数据库隐式提交的 DDL;
- 动态数据源在事务开启后才切换;
- 实际操作的数据源不属于当前事务管理器。
以 MySQL 为例,可以检查表的定义:
1 | SHOW CREATE TABLE orders; |
或查看表状态:
1 | SHOW TABLE STATUS LIKE 'orders'; |
4.8 多数据源使用了错误的事务管理器
假设系统中有两个数据源:
1 | orderDataSource |
分别对应:
1 | orderTransactionManager |
下面的方法只明确选择了订单事务管理器:
1 |
|
orderTransactionManager 通常只能管理订单数据源。日志数据库操作可能使用另一条连接并独立提交,因此不会跟随订单事务一起回滚。
可以在各自的业务边界明确选择事务管理器:
1 |
|
但要注意:两个本地事务管理器不会天然组成一个原子事务。如果业务要求两个数据库同时提交或回滚,需要进一步评估:
- XA/JTA;
- Seata 等分布式事务方案;
- 最终一致性;
- 本地消息表;
- 业务补偿机制。
五、如何解决自调用问题
5.1 拆分到另一个 Service
这是通常最清晰的解决方式:
1 |
|
事务方法放到另一个 Bean:
1 |
|
调用链变为:
1 | OrderService |
它的优点是事务边界清晰、职责明确、容易测试,也不依赖额外的 AOP 技巧。
5.2 使用 TransactionTemplate
如果事务边界不适合拆分成新的 Service,可以使用编程式事务:
1 |
|
TransactionTemplate 不依赖内部方法调用是否经过代理,适合:
- 一个方法包含多个事务阶段;
- 批处理需要逐条提交;
- 需要显式控制回滚;
- 事务边界无法自然拆分到其他 Bean。
5.3 注入自身代理
也可以注入当前 Service 的代理对象:
1 |
|
这种方式能够让调用经过代理,但也会带来循环依赖、阅读成本和职责不清等问题,不适合作为默认方案。
5.4 使用 AopContext.currentProxy()
开启代理暴露后,可以获取当前代理对象:
1 |
1 | public void createOrder(Order order) { |
这种方式会让业务代码与 Spring AOP 强耦合,并且只能在有效的代理调用链中使用。除非有明确理由,否则不建议在普通业务代码中大量使用。
解决方案的推荐顺序通常是:
1 | 拆分清晰的业务 Bean |
六、事务传播行为的实际效果
传播行为回答的是:
当一个事务方法调用另一个事务方法时,内层方法应当加入现有事务,还是创建新的事务边界?
最常见的三种传播行为是 REQUIRED、REQUIRES_NEW 和 NESTED。
6.1 REQUIRED:有事务就加入,没有就创建
REQUIRED 是默认传播行为:
1 |
|
它的规则是:
1 | 外层有事务:加入外层事务 |
当订单与库存操作位于同一个物理事务中时,任何一步触发回滚,整体都会回滚:
1 | 订单事务 |
内层方法虽然拥有自己的逻辑事务范围,但加入外层事务后,仍共享同一个物理事务。内层将事务标记为 rollback-only,会影响外层最终能否提交。
6.2 REQUIRES_NEW:始终创建独立事务
1 |
|
它的规则是:
1 | 外层有事务:挂起外层事务,创建独立事务 |
例如:
1 |
|
可能得到:
1 | 日志事务:已经提交 |
这适合必须独立保存的信息,例如审计日志、失败记录或重试任务。但它通常需要额外的数据库连接:外层事务仍占用自己的资源,内层事务还要获取新的连接。连接池容量不足时,可能发生连接池耗尽甚至相互等待。
6.3 NESTED:基于保存点的嵌套事务
NESTED 通常使用 JDBC 保存点实现:
1 |
|
可以将它理解为:
1 | 外层物理事务 |
内层失败时可以回滚到保存点,外层事务仍有机会继续;但如果外层事务最终回滚,内层已经执行的内容也会一起回滚。
| 对比项 | REQUIRES_NEW |
NESTED |
|---|---|---|
| 物理事务 | 独立事务 | 通常与外层共用一个事务 |
| 数据库连接 | 通常使用新连接 | 通常复用外层连接 |
| 实现方式 | 挂起外层并开启新事务 | 数据库保存点 |
| 外层回滚后内层结果 | 可以保留 | 一起回滚 |
| 内层回滚对外层的影响 | 通常不直接影响 | 可回滚到保存点后继续 |
| 基础设施要求 | 支持事务挂起与独立资源 | 支持 JDBC 保存点 |
NESTED 主要适用于 JDBC 资源事务,并依赖数据库、驱动和事务管理器的保存点能力。使用 JPA 或其他事务管理器时,不能仅凭注解假设它一定可用。
七、为什么会出现 UnexpectedRollbackException
考虑下面的订单服务:
1 |
|
库存服务如下:
1 |
|
两个方法都使用默认的 REQUIRED,因此加入同一个物理事务:
1 | 1. OrderService 开启事务 |
关键在于:
捕获 Java 异常,并不会清除已经设置的
rollback-only状态。
外层代码认为方法已经恢复正常,但物理事务已经不允许提交。Spring 抛出 UnexpectedRollbackException,是为了避免调用方误以为数据已经成功提交。
7.1 如何处理
解决方式取决于业务语义。
如果库存失败就应该让订单整体失败,最简单的做法是不要吞掉异常:
1 |
|
如果库存失败属于预期业务分支,可以避免通过异常控制流程,改为返回明确结果:
1 |
|
也可以根据真实业务语义选择独立事务或配置 noRollbackFor,但不能只为了消除异常而关闭回滚。订单成功、库存失败往往意味着更严重的数据一致性问题。
八、线上如何判断事务是否生效
排查事务问题时,不要只盯着注解。应该沿着“代理、事务状态、异常、线程、数据源”逐层确认。
8.1 开启 Spring 事务日志
可以临时调整日志等级:
1 | logging: |
根据项目实际使用的事务管理器保留对应配置。重点观察:
- 是否创建了新事务;
- 是否加入已有事务;
- 是否挂起和恢复了外层事务;
- 是否提交或回滚;
- 哪个异常触发了回滚;
- 实际识别到的是哪个事务方法。
生产环境的 TRACE 日志量可能很大,应当临时开启、定向采集并及时恢复。
8.2 检查注入对象是否为代理
在调用方检查 Spring 注入的对象:
1 | import org.springframework.aop.support.AopUtils; |
不要简单地在目标方法内部用 AopUtils.isAopProxy(this) 下结论。方法内部的 this 通常代表目标对象语义,结果容易造成误判。
8.3 检查当前事务是否激活
可以在问题代码附近临时记录:
1 | import org.springframework.transaction.support.TransactionSynchronizationManager; |
如果预期的事务方法中输出 transactionActive=false,常见原因包括:
- 方法没有经过代理;
- 当前对象不是 Spring Bean;
- 事务基础设施没有启用或注解没有被识别;
- 代码已经切换到新线程;
- 使用了错误的事务管理器。
8.4 检查事务是否已被标记为回滚
在有效的 Spring 事务调用中,可以临时检查:
1 | boolean rollbackOnly = TransactionAspectSupport |
如果业务异常已经被捕获,但 rollbackOnly=true,那么当前事务最终仍然只能回滚。
这段代码只能在事务拦截器管理的调用上下文中使用,否则可能抛出“找不到事务状态”的异常。
8.5 检查异常是否离开事务方法
重点确认:
- 异常是否在方法内部被捕获;
- 捕获后是否只记录日志,没有重新抛出;
- 是否使用返回值表示失败;
- 是否抛出了默认不回滚的受检异常;
- 是否配置了全局或局部回滚规则;
- 异常是否发生在另一个线程;
- 异常是否发生在事务提交之后。
8.6 检查是否发生线程切换
重点搜索:
1 |
1 | new Thread(...) |
1 | CompletableFuture.runAsync(...) |
1 | executorService.submit(...) |
1 | threadPoolTaskExecutor.execute(...) |
一旦发生线程切换,就要重新分析事务边界,不能默认新线程会继承原线程事务。
8.7 检查调用链中的自调用
搜索同一个类中的:
1 | this.saveOrder(); |
以及省略 this 的内部调用:
1 | saveOrder(); |
如果被调用方法依赖自己的 REQUIRES_NEW、NESTED、rollbackFor、isolation 或 timeout 配置,这些配置可能因为自调用而没有机会被事务拦截器读取。
8.8 检查数据源与事务管理器
多数据源项目需要确认:
- Repository、Mapper 或 EntityManager 实际使用哪个 DataSource;
- 当前
@Transactional选择了哪个 TransactionManager; - 是否存在动态数据源切换;
- 数据源路由是否在事务开启之前完成;
- SQL 是否使用了当前事务绑定的连接。
动态数据源尤其容易出现一个问题:事务开启并绑定连接后,再切换路由标识通常已经太晚。
8.9 从数据库侧验证
继续确认:
- SQL 是否真正执行;
- SQL 使用了哪个连接;
- 当前连接是否关闭自动提交;
- 表是否支持事务;
- 是否存在触发器或存储过程;
- 是否执行了隐式提交操作;
- 是否有其他线程随后修改了相同数据;
- 查询结果是否来自缓存。
有时看到“数据没有回滚”,实际情况可能是部分操作走了另一个数据源、数据库触发器写入了其他表,或者远程服务已经完成了本地事务无法撤销的操作。
九、声明式事务还是编程式事务
Spring 常用的事务使用方式包括:
- 声明式事务:
@Transactional; - 编程式事务:
TransactionTemplate。
9.1 声明式事务
1 |
|
优点:
- 使用简单;
- 代码侵入性低;
- 方法边界与事务边界一致时可读性好;
- 适合典型的 Service 方法。
缺点:
- 依赖 Spring AOP 代理;
- 容易受到自调用影响;
- 不适合在一个方法中灵活划分多个事务阶段;
- 复杂的异常和传播配置不容易从调用点看出来。
9.2 编程式事务
1 | public void batchImport(List<Order> orders) { |
在这个例子中,每个订单都运行在独立的 TransactionTemplate 执行范围中。只要外层没有另一个事务改变传播语义,某个订单失败就不会让整个批次一起回滚。
编程式事务的优点是边界明确、控制灵活,适合逐条提交、分段提交和复杂事务编排;缺点是事务代码会侵入业务代码,使用过多会降低可读性。
简单的选择原则是:
1 | 事务边界与 Service 方法边界一致 |
十、一个更合理的订单事务设计
假设订单创建流程包括:
1 | 创建订单 |
这些步骤不应该因为写在同一个方法里,就被机械地塞进同一个数据库事务。首先要分析每一步的一致性要求。
10.1 核心数据使用同一个事务
如果订单、库存和优惠券必须强一致,可以放在同一个本地事务中:
1 |
|
前提是这些 Repository 使用同一个事务管理器能够管理的资源。
10.2 失败日志使用独立事务
失败日志需要在主事务回滚后仍然保留,可以使用独立 Bean 和 REQUIRES_NEW:
1 |
|
在事务方法外层捕获主流程异常:
1 | public void createOrderSafely(CreateOrderCommand command) { |
这里需要保证:
createOrderSafely()调用的是orderApplicationService代理对象;saveFailureLog()调用的是orderLogService代理对象;- 日志事务失败时,不覆盖原始业务异常;
- 连接池能够承受独立事务带来的额外连接需求。
10.3 消息不要直接绑定在普通本地事务中
下面的代码存在不一致窗口:
1 |
|
消息可能已经发送,但数据库事务随后回滚。
较轻量的方式是发布应用事件,并在事务提交后处理:
1 | applicationEventPublisher.publishEvent( |
1 |
|
但 AFTER_COMMIT 只能保证监听逻辑在事务提交后执行,不能保证消息一定发送成功。进程退出、网络故障等问题仍可能导致消息丢失。
一致性要求较高时,更可靠的设计通常是 Outbox:
1 | 同一个业务事务 |
十一、其他常见误区
11.1 readOnly = true 不等于禁止写入
1 |
|
readOnly = true 主要用于表达意图,并允许事务管理器、数据库驱动或 ORM 进行相应优化。不同技术栈的处理方式不同,它不一定像数据库权限一样严格阻止写操作,因此不能把它当作安全控制机制。
11.2 事务范围不是越大越好
1 |
|
长事务可能导致:
- 数据库连接长期占用;
- 锁持有时间过长;
- 并发性能下降;
- 死锁概率增加;
- 事务超时;
- 回滚成本增加。
更合理的做法通常是先在事务外完成不需要数据库锁保护的远程调用和计算,再把必须原子执行的数据库操作放入尽可能短的事务中。
不过,也不能为了缩短事务而随意把业务拆开。事务范围最终应由一致性边界决定,而不是只由性能决定。
11.3 数据库事务无法回滚远程副作用
普通本地数据库事务不能自动撤销:
- HTTP/RPC 调用;
- 文件写入;
- Redis 普通命令;
- 消息发送;
- 第三方支付请求;
- 短信或邮件发送。
例如:
1 |
|
订单记录可以回滚,但第三方支付不会自动撤销。这类流程需要结合幂等、状态机、补偿操作、可靠事件或最终一致性方案设计。
11.4 不要在事务中进行长时间等待
1 |
|
如果前面的 SQL 已经持有数据库锁,整个等待期间锁都可能无法释放。类似地,应避免在事务中等待用户输入、调用不受控的慢接口、上传大文件或执行长时间重试。
十二、线上排查流程与检查清单
发生事务问题时,可以按照下面的顺序排查:
1 | 1. 确认对象是否由 Spring 管理 |
最终要回答三个关键问题:
1 | 当前代码有没有事务? |
代理与调用
- 当前类是否由 Spring 容器管理;
- 调用方拿到的是否是 Spring Bean;
- 事务方法是否通过代理对象调用;
- 是否存在同一个类中的方法自调用;
- 是否手动
new了 Service; - 方法是否能被当前代理方式拦截;
- 是否在
private、final或static方法上使用事务。
异常与回滚
- 异常是否被
try-catch捕获; - 捕获后是否重新抛出;
- 当前异常是否属于默认回滚异常;
- 是否存在全局回滚策略;
- 是否需要配置
rollbackFor; - 是否错误配置了
noRollbackFor; - 当前事务是否已经是
rollback-only; - 是否可能出现
UnexpectedRollbackException。
传播行为
- 内外层方法的传播行为是否符合业务语义;
-
REQUIRES_NEW是否真的经过代理调用; -
NESTED是否被当前事务管理器支持; - 独立事务是否可能导致部分提交;
- 连接池是否能支撑
REQUIRES_NEW的额外连接。
线程与异步
- 是否使用了
@Async; - 是否使用了线程池;
- 是否使用了
CompletableFuture; - 是否手动创建了线程;
- 是否错误地认为事务会自动跨线程传播。
数据库与数据源
- 数据表是否支持事务;
- Repository 是否使用正确的数据源;
- 是否选择了正确的事务管理器;
- 是否存在动态数据源切换;
- 是否操作了多个本地数据库;
- 是否存在数据库隐式提交;
- SQL 是否使用了当前事务绑定的连接。
事务设计
- 事务范围是否过大;
- 是否在事务中调用慢接口;
- 是否在事务中发送普通消息;
- 是否在事务中执行文件或远程操作;
- 是否更适合使用
TransactionTemplate; - 是否需要 Outbox 或最终一致性方案;
- 是否设置了合理的事务超时时间。
十三、总结
Spring 声明式事务看起来只是一个 @Transactional 注解,但它的实际行为依赖于多个条件:
1 | Spring Bean |
其中最重要的原则是:
@Transactional不是在方法内部自动生效的,它依赖 Spring 事务基础设施在方法调用外部建立事务边界。
遇到事务问题时,不要只检查注解有没有添加,而应该沿着实际调用链分析:
1 | 谁调用了这个方法? |
理解代理、线程、异常、传播行为和资源边界之后,大多数所谓的“事务失效”都可以被准确解释和快速定位。