← 返回博客
2026-10-27 08:00:01

@Transactional 为什么会静默失效

@Transactional 为什么会静默失效

一个下单接口,@Transactional 加在 OrderService.create() 上,方法里先插订单再扣库存。压测时偶发订单有了、库存没扣。日志没报错,事务也没回滚。你翻遍代码,注解明明写着。

这篇就从这个现场出发,把声明式事务的代理链路拆开,讲清它什么时候生效、什么时候悄悄失效。

手把手实操:复现一次失效

先造一个最小复现。OrderService 里 create() 调 this.deduct(),两个方法都标 @Transactional:

@Service
public class OrderService {
    @Autowired JdbcTemplate jdbc;

    @Transactional
    public void create(Long id) {
        jdbc.update("insert into t_order(id) values(?)", id);
        this.deduct(id);          // 自调用
        throw new RuntimeException("模拟失败");
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deduct(Long id) {
        jdbc.update("update t_stock set n = n - 1 where id = ?", id);
    }
}

跑一遍,你会发现订单插入回滚了,但 deduct 里的更新没有独立提交——它压根没走代理,REQUIRES_NEW 完全没起作用,跟着外层一起回滚。这就是自调用失效。

改法有两种。一是把 deduct 挪到另一个 Bean,注入进来调用;二是注入自身代理:

@Autowired @Lazy OrderService self;
// 调用处改成 self.deduct(id);

@Lazy 是为了打破循环依赖,不加可能因自引用报错(具体行为随 Spring 版本略有差异,以你所用版本为准)。关键点:事务是代理加上去的,this 不是代理。踩坑最多的地方就是这里,很多人以为注解写在方法上就万事大吉。

看源码及解析:代理怎么把事务织进去

@EnableTransactionManagement 默认 proxyTargetClass=false,即优先 JDK 动态代理;目标类没有接口时退到 CGLIB。CGLIB 生成子类覆写方法,所以 final 方法和 final 类没法被代理——这是失效场景二。

真正干活的是 TransactionInterceptor.invoke(),它继承自 TransactionAspectSupport,核心链路:

// TransactionAspectSupport#invokeWithinTransaction
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
Object retVal;
try {
    retVal = invocation.proceedWithInvocation();
} catch (Throwable ex) {
    completeTransactionAfterThrowing(txInfo, ex);
    throw ex;
} finally {
    cleanupTransactionInfo(txInfo);
}
commitTransactionAfterReturning(txInfo);

三个细节值得挖。

一、异常回滚规则。 completeTransactionAfterThrowing 里判断 txInfo.transactionAttribute.rollbackOn(ex),默认实现 DefaultTransactionAttribute.rollbackOn 只对 RuntimeException 和 Error 返回 true。所以抛受检异常(比如 IOException)默认不回滚。要改就写 @Transactional(rollbackFor = Exception.class)。这是失效场景三,也是最隐蔽的一个——代码抛了异常,事务却提交了。

二、传播行为的落点。 REQUIRES_NEW 在 AbstractPlatformTransactionManager.handleExistingTransaction 里走 suspend() 挂起当前事务、新开一个,外层回滚不影响内层。但前提是调用经过了代理,自调用时拦截器根本没进来,REQUIRES_NEW 就是一句空话。

三、只读与超时。 @Transactional(readOnly=true) 只是给底层一个提示,MySQL 下会走只读事务优化,但不是强制拦截写操作,别指望它当保险。

为什么这么设计?Spring 选了「代理 + 拦截器」而不是字节码增强(AspectJ 编译期织入),换来的是零侵入、无需改构建,代价就是只有穿过代理的调用才有事务。这是权衡,不是 bug。

验证方法

三个自测手段,跑通说明你真懂了。

  1. 看代理类型:在启动后打印 AopUtils.isAopProxy(bean) 和 AopUtils.isCglibProxy(bean),确认目标类走的是哪种代理。有接口时是 JDK 代理,this 调用必然失效。
  2. 开事务日志:logging.level.org.springframework.transaction=DEBUG,正常一次调用能看到 Creating new transaction 和 Initiating transaction commit/rollback。自调用那次,日志里没有这两行——这就是最直接的证据。
  3. 异常回滚验证:把 create() 改成抛 IOException,观察数据是否落库。落库了,说明默认回滚规则没覆盖受检异常,需要补 rollbackFor。

面试里常被追问:「@Transactional 加在 private 方法上会怎样?」答案是代理覆写不到 private 方法,注解无效,且不报错。同理,final 方法、同类自调用、异常被 catch 吞掉,都是静默失效。

面试速答

核心收获:注解只是声明,事务真正生效靠的是调用穿过代理。下一步,把项目里所有 this.xxx() 的自调用揪出来,逐个改成注入代理或拆 Bean。