1. 别再只看表面:@Transactional 失效背后的真实原因
做了几年 Java 开发,用 @Transactional 注解管理事务可能是最稀松平常的事。但我见过太多项目里因事务失效引发线上数据不一致的“事故现场”——库存扣了、订单没生成;账户扣款成功、余额表没变化。排查到最后,问题往往不在数据库,也不在 SQL,而是 @Transactional 压根没生效。
我刚带团队时也踩过类似的坑。某个定时任务批量处理对账文件,方法上加了 @Transactional 注解,测试环境数据量小一点问题没有。上线后某个批次处理到一半抛了运行时异常,结果发现前一半已经入库的数据没有回滚,彻底把库存数据搞花了。排查后才发现是同类内部方法调用,代理根本没有拦截。
这个注解到底怎么工作的?失效场景有哪些?传播机制、隔离级别、连接管理又该怎么选?这篇文章不打算停留在“注解真方便”的层面,直接把 Spring 事务从代理原理到实战配置拆一遍,把那些常规文档不会写的细节讲透。先特别强调一句:Spring 的声明式事务基于 AOP 动态代理实现,所有“失效”问题,几乎都能从代理机制上找到根源。
@Transactional 是 Spring 框架提供的一套声明式事务解决方案,核心价值在于将事务开启、提交、回滚这些繁琐操作交给容器去管理,开发者只需在方法或类上标注一个注解。但它的“自动”是有前提的,背后隐藏着代理对象、事务同步管理器、连接绑定等一堆细节。搞懂这些细节,才算真正会用这个注解,而不是被它坑。
2. 手写一个代理示例,弄清方法调用为什么“穿透”代理
2.1 Spring 事务的本质:AOP 动态代理外加一个事务拦截器
要理解 @Transactional 失效原因,先得从底层机制说起。我们平时写的业务 Bean,经过 Spring 容器初始化后,并不是直接返回那个原始对象,而是返回一个经过 AOP 增强的代理对象。Spring 默认使用 JDK 动态代理,也可以配置使用 CGLIB,具体要看目标类是否实现了接口。代理对象跟原始对象有相同的接口或继承关系,但内部包裹了事务拦截器逻辑。
每次调用被 @Transactional 标注的方法时,实际调用链条是“调用方 -> 代理对象 -> 事务拦截器 -> 原始对象方法”。事务拦截器会做这些事:
- 从配置中解析事务属性(传播行为、隔离级别、超时时间、只读标志)
- 从 Spring 容器中获取 PlatformTransactionManager 事务管理器
- 通过事务管理器开启事务、获取数据库连接
- 把连接绑定到当前线程的 ThreadLocal 中
- 执行原始对象的目标方法
- 根据方法执行是否抛异常,决定提交或回滚
- 清理事务上下文,把连接归还连接池
这个“代理”的存在,意味着目标方法被执行前和后,Spring 都插入了额外逻辑。核心问题在于:代理拦截的前提是调用要经过代理对象。如果调用没有经过代理,而是直接调用了原始对象的方法,事务拦截器根本没有机会介入,注解自然失效。
2.2 自己动手模拟代理链路的执行顺序
为了说清这个问题,我用简化代码模拟一下代理拦截的过程。虽然 Spring 的 TransactionInterceptor 肯定复杂得多,但整体链路就是这么回事。
public class TransactionInterceptor { // 模拟代理拦截逻辑 public Object invoke(MethodInvocation invocation) throws Throwable { // 1. 事务开启前:获取事务管理器,开启事务,绑定连接 TransactionStatus status = transactionManager.getTransaction(transactionAttribute); try { // 2. 执行目标方法 Object result = invocation.proceed(); // 3. 方法正常返回:提交事务 transactionManager.commit(status); return result; } catch (Exception e) { // 4. 方法抛异常:按规则回滚事务 transactionManager.rollback(status); throw e; } finally { // 5. 清理事务同步状态 TransactionSynchronizationManager.clear(); } } }从这段链路就能看出几个关键点。目标方法正常结束才能提交;抛了异常才回滚;方法执行期间,事务相关信息通过 ThreadLocal 与当前线程绑定。所以事务要不要回滚,完全取决于你能不能“感知”到异常。感知不到异常,Spring 就只能傻乎乎地提交。
再进一步说,如果调用方拿到的是原始对象而不是代理对象,那这段拦截逻辑根本不会执行,事务相当于不存在。这种情况最常见的触发点就是同类内部方法调用。
@Component public class OrderService { public void createOrder() { // 这里直接调用内部方法,this 是原始对象,不是代理对象 this.insertOrder(); this.insertOrderItem(); } @Transactional public void insertOrder() { // 事务方法 } @Transactional public void insertOrderItem() { // 事务方法 } }createOrder 方法内部,this.insertOrder() 和 this.insertOrderItem() 调用的是 OrderService 原始对象的方法,代理对象根本没有介入,事务自然无效。如果 insertOrder 执行成功、insertOrderItem 执行失败,前者不会回滚,最终造成数据不一致。
这个例子描述的问题实在太常见,网上面试题也是高频考点。但真正落实到项目里,很多人写的代码就是这个形态,然后深夜查数据为啥不对。
2.3 为什么 InvocationTargetException 会干扰你的问题判断
排查事务问题时,很多人被 InvocationTargetException 绕晕。这个异常是反射调用目标方法时的包装异常,目标方法本身抛出的异常被包在它的 target 属性里。
代理对象调用目标方法也是通过反射完成的,所以事务方法内部抛出的异常经过反射层会被包装。Spring 处理回滚时,会层层解包来解析具体异常类型,判断是否匹配回滚规则。如果你自己用反射调用了事务方法,你的 catch 逻辑没解包,直接对比 InvocationTargetException 的类型,肯定对不上预期异常,事务回滚判断也可能出错。
实际排查事务问题时,第一步应该去看目标方法是否被代理,而不是盯着异常栈一层层猜。最简单的方式是打印调用对象类型。
// 容器中注入的类型是代理类 System.out.println(orderService.getClass().getName()); // 输出中会出现 CGLIB 或 JDK 代理相关字样 // 类似 OrderService$$EnhancerBySpringCGLIB如果在容器里这个类的名字带了 EnhancerByCGLIB,说明走代理链路没问题;如果就是普通类名,那一定在配置层面出了岔子,比如没有开启注解驱动事务。
3. 失效场景全排查:同类的坑、异常吞掉、非 public 方法
3.1 同类自调用、私有方法、非事务方法互相调用
事务方法被同类内部其他方法调用时,事务失效,这是压倒性的第一大坑。我整理一下这类问题的几种具体形式,以及各自的解决思路。
场景一:同类内部 this 调用。如上面代码所示,this.method() 调用绕过代理。
解决办法有几个:
- 将事务方法拆分到独立的 Service Bean,通过注入代理对象调用
- 注入自身代理:
@Component public class OrderService { @Autowired private OrderService self; public void createOrder() { // 调用代理自身的 insertOrder,事务能生效 self.insertOrder(); } @Transactional public void insertOrder() { ... } }- 使用 AopContext.currentProxy() 获取当前代理对象:
public void createOrder() { OrderService proxy = (OrderService) AopContext.currentProxy(); proxy.insertOrder(); }使用 AopContext.currentProxy() 需要在配置里设置 exposeProxy = true,否则运行时会直接抛 IllegalStateException。
场景二:方法不是 public。Spring 的注解驱动事务默认只对 public 方法生效,private、protected、默认包可见方法上的 @Transactional 都会失效。原因很简单,Spring 的代理逻辑只对 public 方法进行拦截,其他可见性方法要么被子类覆盖逻辑干扰,要么根本不被代理链调用。项目里把这个坑踩穿的人不少,特别是内部私有方法想“顺便”加个事务,结果事务全程没参与。
场景三:在非事务方法里直接调用另一个类的事务方法,但那个非事务方法也属于同一个 Bean,而且标注事务的方法自己又调了内部方法。这个组合拳容易和“传播行为”叠加产生复杂效果,需要先从结构上理清楚调用链。一般建议事务边界放在入口方法上,而不是撒在多个内部方法上。你可以在一个方法上统一管理事务,内部方法就不要再标注事务,以免传播行为互相干扰。
3.2 异常被 catch 吞掉:事务提交了,你以为回滚了
这是事务失效的第二大场景。代码长这样:
@Transactional public void transfer() { try { accountMapper.reduceBalance(fromId); // 这里抛了业务异常 throw new RuntimeException("余额不足"); } catch (Exception e) { // 异常被吞掉,方法正常返回 log.info("转账失败: {}", e.getMessage()); } }方法体内部把异常 catch 了,没有重新抛出,方法正常返回。Spring 事务拦截器看到方法正常返回,认为一切成功,就把事务提交了。结果是余额已经扣减,但调用方根本不知道出了问题。
这在真实项目中非常隐蔽,因为日志里看起来有报错,你以为事务回滚了,实际数据库已经改动。排查手段也很反直觉:要看日志打印顺序,如果事务提交日志没打印,那可能是异常没有被拦截器感知。
正确做法有两种。要么在 catch 块中手工标记回滚:
@Transactional public void transfer() { try { accountMapper.reduceBalance(fromId); throw new RuntimeException("余额不足"); } catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); log.info("转账失败: {}", e.getMessage()); // 这里可以继续做其他操作,事务最终还是会回滚 } }要么就不要 catch,让异常传到事务拦截器层面,由 Spring 统一处理回滚。大多数情况下,第二种更简洁。如果需要 catch 做一些日志记录,可以 catch 之后再原样抛出,或者使用自定义异常封装后抛出。吞异常是最不可取的。
3.3 非异常导致回滚失效的其他隐蔽案例
还有一个常见问题是 SQL 执行成功但没匹配到任何行。比如更新语句 set 的条件生效了,但对列表为空导致影响行数为 0,方法本身没有任何异常。此时事务虽然参与,但没产生实际变化,这不算事务失效,只算业务逻辑问题。要判断事务是否真的提交了,应该看数据库锁和日志,而不是代码。
另一种隐蔽问题是多线程。Spring 事务是通过 ThreadLocal 把数据库连接绑定到当前线程的。如果你在事务方法内启动了新线程去执行数据库操作,那个新线程拿不到事务上下文绑定的连接,也就无法参与当前事务。如果子线程里操作的是另一个数据源,更是各走各的。这种场景在异步任务、消息处理中很常见,正确的做法是把事务边界移到子线程要执行的方法上,或者使用编程式事务,确保子线程有自己的事务上下文。
还有一点值得记录:动态代理生效范围和方法可见性绑定。如果方法标注了 @Transactional 但不是 public,Spring 不会直接报错,而是静默忽略,这很讨厌。建议团队 Code Review 时多加注意,或者引入架构守护工具扫描非 public 方法上的 @Transactional。
4. 传播行为不是随便选的:REQUIRED 与 REQUIRES_NEW 实战差异
4.1 七种传播行为的概念与关键选择场景
Spring 定义了七种事务传播行为,默认是 REQUIRED。我用一张表把最常用的几种整理出来,重点关注它们的事务关系和行为效果。
| 传播行为 | 当前存在事务时 | 当前无事务时 | 典型场景 |
|---|---|---|---|
| REQUIRED | 加入当前事务 | 新建事务 | 同一事务内多个操作,默认选它 |
| REQUIRES_NEW | 挂起当前事务,新建事务 | 新建事务 | 日志记录、审计、消息发送,必须独立提交 |
| NESTED | 嵌套事务,保存点回滚 | 新建事务 | 某一段逻辑失败不影响主事务整体回滚 |
| SUPPORTS | 加入当前事务 | 非事务方式执行 | 查询操作,有事务就参与,没有也行 |
| NOT_SUPPORTED | 挂起当前事务,非事务执行 | 非事务方式执行 | 某些特殊操作不希望占用事务连接 |
| MANDATORY | 加入当前事务 | 抛异常 | 强制要求必须在一个事务中执行 |
| NEVER | 抛异常 | 非事务方式执行 | 严格禁止事务 |
实际团队里用得最多的就是前三个。如果不理解 REQUIRES_NEW 和 REQUIRED 的区别,可能会出现这样的场景:用户下订单时,往订单流水表插一条记录,结果订单主流程回滚了,流水也跟着回滚,审计日志全没了。
4.2 REQUIRES_NEW 是“停掉一个事务开另一个事务”,不是嵌套
REQUIRES_NEW 的行为是:外层事务被挂起,内层方法开启一个全新事务,内外两个事务彼此隔离。内层事务提交后,即使外层事务最终回滚,内层事务已经提交的数据不会被回滚。
这个特征特别适合某些“无论如何都要记录”的场景,比如消息发送、审计日志、操作记录。但注意一点:REQUIRES_NEW 是需要额外数据库连接资源的。事务挂起期间,外层连接被持有但处于闲置状态,内层再获取一个新连接,高并发下连接池容易出现空闲等待。连接池如果很小,这种方法容易成为瓶颈。
如果确实想实现“部分成功、部分失败不影响主流程”,可以选择 NESTED 结合保存点机制。NESTED 不会挂起外层事务,而是使用数据库 savepoint 特性,在内层方法出错时回滚到保存点。主事务依然持有连接,连接资源消耗比 REQUIRES_NEW 小,但 MySQL 等数据库支持嵌套事务的能力要看具体驱动配置。我在项目中通常优先使用 REQUIRES_NEW 处理日志类独立提交需求,用 NESTED 处理复杂的批量业务分段回滚需求。
4.3 自调用与传播行为的组合陷阱:一个案例拆到底
下面这个案例我去年在某项目排查了整晚,正好把“自调用 + 传播行为”组合起来说明。
@Service public class OrderService { @Autowired private OrderMapper orderMapper; public void createOrderWithLog() { // 同类调用,事务不会在这里统一 this.saveOrder(); this.saveOrderLog(); } @Transactional(propagation = Propagation.REQUIRED) public void saveOrder() { orderMapper.insertOrder(...); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveOrderLog() { orderMapper.insertOrderLog(...); } }执行 createOrderWithLog 时,this.saveOrder() 和 this.saveOrderLog() 都是直接调用内部对象方法,两个方法上的 @Transactional 全部失效,saveOrderLog 的 REQUIRES_NEW 根本不会新建独立事务。表面上看着是两个独立事务,实际是零事务,因为它们都没走代理。
排查这类问题最典型的线索就是:执行失败时,数据库数据已经写入了。因为异常被同类内部调用“截断”,代理层没有任何拦截。正确的结构应当是:
@Service public class OrderService { @Autowired private OrderService self; @Autowired private OrderLogService orderLogService; public void createOrderWithLog() { // 通过代理调用自身,主方法事务生效 self.saveOrder(); // 通过独立 Bean 调用,REQUIRES_NEW 生效 orderLogService.saveOrderLog(); } }把 saveOrderLog 拆到 OrderLogService 中,并且用 OrderLogService 的代理对象去调用,REQUIRES_NEW 才会真正意义上开启新事务。自调用加传播行为的组合,是最容易让经验丰富的开发者都掉坑的地方。
5. 隔离级别、只读事务与连接绑定:一次说清配置误区
5.1 隔离级别不是越高越好,关键是匹配业务场景
Spring 事务注解通过 isolation 属性指定隔离级别,默认使用数据库默认隔离级别(MySQL 为 REPEATABLE_READ)。很多新手一上来直接设置 SERIALIZABLE,觉得“最高级最安全”,但代价是性能和并发能力断崖式下降。
各隔离级别解决的核心问题不同,应对的选择应该是从业务需求出发:
- READ_UNCOMMITTED:能读到未提交数据,脏读风险大,几乎不会用在生产环境
- READ_COMMITTED:解决脏读,但不可重复读问题仍然存在,适用对一致性要求不极致的查询类场景
- REPEATABLE_READ:解决脏读和不可重复读,MySQL 默认,配合 MVCC 提供快照读
- SERIALIZABLE:完全串行化,写并发能力极差,只用于极少数对数据一致性要求极端、写并发低的场景
实际开发中,如果两个并发事务要同时操作同一行库存数据,用 READ_COMMITTED 加数据库悲观锁(select ... for update),通常比 REPEATABLE_READ 更可控。REPEATABLE_READ 在 MySQL 里通过快照读实现,两个事务各自读到自己的快照,反而可能让并发更新改为最后提交覆盖结果,带来脏写风险。我见过有人把隔离级别升到 SERIALIZABLE 来解决并发扣库存问题,结果压测直接打满数据库连接,业务超时一大片,这不是事务注解的锅,是对隔离级别理解有偏差。
5.2 readOnly = true 到底做了什么,别被名字误导
readOnly 属性设置成 true 时,Spring 会做两件事:
- 向数据库提示这是只读事务,有些数据库会以此做优化,但 MySQL 的 InnoDB 引擎并不会因此跳过加锁
- 有些场景下,Spring 会关闭某些持久层框架的脏检查、flush 机制,提升一点性能
但要注意:readOnly = true 并不能阻止你在事务方法里执行 insert、update、delete。它只是建议性提示,不是硬性校验。Spring 默认并不会拦截写操作。如果你的项目里想通过 readOnly 做强制校验,那是完全错误的用法。
我平时会把 readOnly 用在以下两类方法上:
- 查询单条或多条数据的 Service 方法
- 大批量只读统计方法,配合超时时间控制
写操作的方法一律不设 readOnly,避免和事务拦截器的行为产生模糊地带。如果你的方法只读,但内部还调了查询后写入 Redis 这类操作,也最好别标 readOnly,因为 Redis 操作不走数据库事务,但 Spring 的只读提示没有太大意义。稳妥起见,只对纯数据库查询才设置。
5.3 事务超时时间和连接绑定:ThreadLocal 的另一个细节
事务超时时间(timeout)单位是秒,默认 -1 表示不超时。如果设置超时,一旦事务执行超过阈值,Spring 会尝试回滚事务,但具体行为取决于数据库驱动和底层连接状态,并不保证一定会回滚。这里很容易出现“超时了但锁没释放”的棘手问题。
数据库事务是通过连接绑定的,连接又是从连接池借来的。Spring 把连接保存到 TransactionSynchronizationManager 的 ThreadLocal 中。每个线程里的操作都使用同一个线程绑定的连接,确保整个事务操作的是同一个连接,而不是从连接池里每次拿新连接。这就是事务一致性的基础。
如果方法内部直接使用 new Thread() 或线程池去执行数据库操作,子线程里的数据库访问不在同一个 ThreadLocal 上下文里,自然不会继承父线程的事务连接。这种场景下的操作要么各自独立提交,要么直接抛错。有些团队因为一个异步任务在事务里更新了两张表,子线程更新另一张表,最后数据乱套,就是这个原因。
6. 什么时候必须放弃声明式事务,改用编程式事务
6.1 声明式事务的灵活性边界
@Transactional 是“开箱即用”的代表,但有个天然缺点:方法粒度控制。事务边界是从方法进入开始,方法结束提交或回滚。遇到下面这些场景,声明式事务反而不好用:
- 同一个方法里想在不同阶段设置不同的回滚条件
- 某一段逻辑失败单独回滚,另一段逻辑失败也单独回滚,但两者之间还有业务依赖
- 需要动态决定事务是否开启、是否参与当前事务
- 需要在一个循环里为多次操作各自开启独立事务
- 需要精细控制事务的挂起与恢复
这时编程式事务更香。Spring 提供了 TransactionTemplate,直接手动控制事务的提交和回滚。
6.2 TransactionTemplate 的正确打开方式
TransactionTemplate 的使用非常简单,但有的人会用成了“把声明式注解搬进了代码”:
@Autowired private TransactionTemplate transactionTemplate; public void batchProcess(List<Long> ids) { for (Long id : ids) { transactionTemplate.execute(status -> { try { // 每一条数据一个事务,某条失败不影响整体 mapper.update(id); return Boolean.TRUE; } catch (Exception e) { // 手动回滚当前事务 status.setRollbackOnly(); log.error("处理失败: {}", id, e); return Boolean.FALSE; } }); } }这个模式适合批处理任务。如果每个数据项的处理都希望独立提交,无论后续数据项成功失败,都不会相互影响,这种结构非常稳定。注意 execute 方法返回后的状态:当回调抛出异常时,TransactionTemplate 会自动回滚;当回调正常返回且没有调用 setRollbackOnly,它会自动提交。所以 catch 块里一定要把业务异常往外抛或设置 rollbackOnly,否则只 catch 不处理,照样可能提交。
另一种复杂场景:手动开启事务、执行操作、根据条件决定提交或回滚:
@Autowired private PlatformTransactionManager transactionManager; public void complexBiz() { DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); TransactionStatus status = transactionManager.getTransaction(definition); try { // 业务操作1 // 业务操作2 boolean needRollback = checkSomething(); if (needRollback) { transactionManager.rollback(status); return; } transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; } }这种写法适合在同一个方法里实现多段事务控制,声明式注解做不到这么细腻。但它有个需要注意的地方:代码里 commit 和 rollback 必须保证成功执行,如果不小心写漏了,事务会一直挂着,连接无法释放。使用 TransactionTemplate 可以自动管理,比手写 PlatformTransactionManager 的 commit/rollback 更安全。
6.3 事务方法里调用远程接口的“长期锁”问题
声明式事务一个隐蔽的大坑:事务方法里调用了远程接口或 RPC,但远程接口耗时很长,事务一直没提交,数据库连接一直占着。如果再涉及锁,其他事务只能干等。
我处理过的一个问题:订单支付回调方法里,@Transactional 方法中调用了第三方 HTTP 接口,原意是想在第三方确认后更新订单状态。结果第三方接口超时 10 秒,整个事务就挂了 10 秒。高峰期并发一上来,数据库连接直接被占满,业务崩溃,还把问题定位到“连接池大小不够”上,误导了排查方向。
正确做法是:事务方法里尽量不调用远程接口,把远程调用放到事务外面。先拿数据、再调远程、再开事务去更新,或者反过来,先更新本地事务,再调远程,失败后人工介入。远程调用参与事务,只能增加锁等待时间和不可控性。如果一定要在事务内调远程,也请把超时时间设置得短一些,并评估连接池容量是否能扛住并发等待。
7. 最终自查清单:快速定位不生效或者回滚异常
这里贡献一份我实际用过的排障清单,按顺序检查,大多数问题五分钟之内能定位。
- 确认 Spring 配置里启用了 @EnableTransactionManagement(或 XML 的 tx:annotation-driven)。没有开启注解驱动,任何 @Transactional 都不会生效。
- 确认事务管理器是否配置正确,数据源是否对得上。多数据源场景下,不同数据源需要对应不同的事务管理器。
- 确认目标方法是被代理调用的,不是内部 this 调用。打印类名或判断增强逻辑。
- 确认方法是 public 可见性,否则 Spring 不会拦截。
- 确认异常没有被 catch 吞掉,且异常类型匹配回滚规则。默认只有 RuntimeException 和 Error 触发回滚,checked exception 需要显式指定 rollbackFor。
- 确认传播行为设置符合预期,不会因为 REQUIRES_NEW 或 NESTED 改变事务边界。
- 确认方法内部没有新开线程访问数据库,或者确认子线程操作确实应该独立事务。
- 确认没有配置 readOnly=true 的方法执行了写操作且期望回滚,以及是否出现锁等待。
还有一条很实用的经验:在方法入口和出口打印事务状态信息,用TransactionSynchronizationManager.isActualTransactionActive()判断当前是否存在真实事务,比用日志猜要容易得多。
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); System.out.println("当前是否存在真实事务: " + txActive);这篇文章基于我实际踩坑后的总结,把 Spring @Transactional 的完整链路梳理了一遍。我个人特别想再强调一遍:注解本身并不复杂,复杂的是代理链路、异常传播和事务资源绑定这几条隐线。理解这几条隐线,遇到多少事务问题都不会慌。如果之后还有时间,我会再整理一篇基于 Spring Boot 3 + 多数据源的项目里,不同事务管理器如何协同、如何通过事务同步事件做事务后的回调处理。