java 技术随笔

@Transactional 事务详解:传播行为、隔离级别与 9 种失效场景

“我加了 @Transactional 为什么没生效?”——这是 Spring 开发群里出现频率最高的问题之一。事务失效往往不是玄学,而是有规律可循:要么代理没生效,要么异常没触发回滚规则。本文系统梳理 @Transactional 的传播行为、隔离级别,以及 9 种经典失效场景,让你一次排查到位。

一、@Transactional 常用属性

@Transactional(
    propagation  = Propagation.REQUIRED,   // 传播行为,默认 REQUIRED
    isolation    = Isolation.DEFAULT,      // 隔离级别,默认跟随数据库
    timeout      = 30,                     // 超时秒数,超时自动回滚
    readOnly     = false,                  // 只读优化
    rollbackFor  = Exception.class         // 指定哪些异常触发回滚(关键!)
)
public void doBiz() { ... }

Spring 事务本质是基于 AOP 动态代理:调用带事务注解的方法时,实际调用的是代理对象,由事务拦截器在方法前开启事务(connection.setAutoCommit(false))、方法成功后 commit、异常后 rollback。理解了代理,后面的大多数失效场景就迎刃而解。

二、7 种传播行为(面试高频)

传播行为含义
REQUIRED(默认)有事务就加入,没有就新建一个
SUPPORTS有事务就加入,没有就以非事务方式执行
MANDATORY必须在一个事务中执行,否则抛异常
REQUIRES_NEW无论外部有没有事务,都新开一个并挂起外部事务
NOT_SUPPORTED以非事务方式执行,有事务则挂起
NEVER必须以非事务方式执行,有事务则抛异常
NESTED嵌套事务(依赖数据库保存点 Savepoint,可部分回滚)

最常用的两个区别是:REQUIRES_NEW 是“完全独立的事务”,外部回滚不影响它,它回滚也不影响外部NESTED 是“子事务”,内部回滚只回滚自己那一段(通过保存点),外部失败仍会导致整体回滚。典型应用:记录日志这种“就算主流程失败也要写成功”的操作,用 REQUIRES_NEW;批量导入中“跳过单条坏数据继续导入”用 NESTED。

三、隔离级别与并发问题

隔离级别脏读不可重复读幻读
READ_UNCOMMITTED(读未提交)可能可能可能
READ_COMMITTED(读已提交,Oracle 默认)不会可能可能
REPEATABLE_READ(可重复读,MySQL 默认)不会不会可能(InnoDB 靠间隙锁基本避免)
SERIALIZABLE(串行化)不会不会不会(性能最差)
  • 脏读:读到别的事务未提交的数据;
  • 不可重复读:同一条记录,两次读结果不同(别的事务修改了它);
  • 幻读:同一个范围查询,两次读到的行数不同(别的事务插入了新行)。MySQL InnoDB 默认 RR 级别下,通过 MVCC 保证快照读不幻读,配合间隙锁(Next-Key Lock)让当前读也不产生幻读。

四、事务失效的 9 大经典场景

1. 方法不是 public。 Spring 的代理默认基于 JDK 动态代理/CGLIB,事务拦截器只对 public 方法生效(源码里 TransactionInterceptor 判断目标方法,非 public 直接放行不建事务)。private、protected、包内方法都会“失效”。

2. 同类内部方法调用(自调用,最经典)。 this.methodB() 直接调用的是原对象的方法,根本没走代理,事务注解形同虚设。

@Service
public class OrderService {
    public void outer() {
        this.inner(); // ❌ 自调用:绕过代理,inner 的事务不生效
    }
    @Transactional
    public void inner() { ... }
}

解决办法:把 inner 拆到另一个 Bean(注入代理对象再调用);或注入自身代理;或在 Spring 4.3+ 从 ApplicationContext 获取代理后调用。

3. 事务方法被 final / static 修饰。 CGLIB 通过继承生成子类代理,final 方法无法被覆写、static 方法不属于实例,都不会进入事务切面。

4. 异常被 catch 吞掉了。 事务拦截器只在异常抛出方法外时才感知到并回滚;你把异常 catch 住“消化”掉,Spring 认为方法正常返回,自然 commit。

@Transactional
public void save() {
    try {
        insert();
    } catch (Exception e) {
        log.error("出错了", e);
        // ❌ 异常被吞,事务照常提交
    }
}

正确做法:catch 后重新抛出;或手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()

5. 抛出的是受检异常,又没指定 rollbackFor。 Spring 默认只在 RuntimeExceptionError 时回滚,受检异常(如 IOException)默认不回滚。自定义的业务异常如果继承 Exception 而非 RuntimeException,就会“失效”。解决办法:@Transactional(rollbackFor = Exception.class)

6. 事务没被 Spring 管理(new 出来的对象)。 在一个普通类里 new OrderService(),对象根本不是容器里的 Bean,没有代理,@Transactional 完全不生效。必须通过 Spring 容器注入使用。

7. 跨线程调用。 Spring 事务绑定在当前线程的 ThreadLocal 连接上,子线程里拿不到父线程的事务。用线程池/异步(@Async)执行事务方法时,事务只在该子线程内开启,无法与主线程共用一个事务。

8. 数据库引擎不支持事务。 MySQL 的 MyISAM 引擎根本不支持事务,建表时务必使用 InnoDB。

9. 方法上事务但配置了 NOT_SUPPORTED / 传播被覆盖。 外层如果是 PROPAGATION_NOT_SUPPORTED,内部即便声明 REQUIRED 也会以非事务方式执行;同理,一个类方法之间的传播行为由外层入口决定,容易产生“以为有事务其实没有”的错觉。

五、自调用与 REQUIRES_NEW 的经典坑

@Service
public class PayService {
    @Autowired private PayLogService logService; // 注入另一个 Bean

    @Transactional
    public void pay() {
        try {
            doDeduct();
        } catch (Exception e) {
            // 希望“支付失败但日志要记录成功”,需要新事务
            logService.recordLog(LogType.FAIL); // ✅ 跨 Bean 调用,REQUIRES_NEW 才生效
        }
    }
}

如果 recordLog 和 doDeduct 写在同一个类里直接 this 调用,即使 recordLog 标了 @Transactional(REQUIRES_NEW) 也不会新开事务——因为根本没走代理。这也是“日志没写进去”“部分回滚不符合预期”一类问题的根因。

六、排查事务失效的万能套路

  1. 先确认方法是不是 public、是不是被 Spring 管理的 Bean 调进来的;
  2. 检查是否同类自调用 / 被 final 修饰 / 走了 this;
  3. 看异常类型:是 RuntimeException 吗?rollbackFor 配了吗?异常真的抛出方法了吗?
  4. 看类上是不是显式配了 @EnableTransactionManagement(Spring Boot 默认开启,XML/普通 Spring 需要显式开启);
  5. 最后用日志确认:开启 logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG,观察 “Creating new transaction” / “Rolling back” 到底有没有发生。

一句话总结:事务要走代理、异常要出方法、回滚规则要对上。对着这三点检查,90% 的“事务失效”都能在一分钟内定位。

标签
Spring事务@Transactional失效场景