@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。
验证方法
三个自测手段,跑通说明你真懂了。
- 看代理类型:在启动后打印
AopUtils.isAopProxy(bean)和AopUtils.isCglibProxy(bean),确认目标类走的是哪种代理。有接口时是 JDK 代理,this调用必然失效。 - 开事务日志:
logging.level.org.springframework.transaction=DEBUG,正常一次调用能看到Creating new transaction和Initiating transaction commit/rollback。自调用那次,日志里没有这两行——这就是最直接的证据。 - 异常回滚验证:把
create()改成抛IOException,观察数据是否落库。落库了,说明默认回滚规则没覆盖受检异常,需要补rollbackFor。
面试里常被追问:「@Transactional 加在 private 方法上会怎样?」答案是代理覆写不到 private 方法,注解无效,且不报错。同理,final 方法、同类自调用、异常被 catch 吞掉,都是静默失效。
面试速答
- 事务靠代理织入,
this调用、private/final 方法、异常被吞都失效,且不报错。 - 默认只回滚
RuntimeException和Error,受检异常要rollbackFor。 REQUIRES_NEW依赖代理入口,自调用时形同虚设。- 无接口走 CGLIB 子类代理,
final类无法代理。
核心收获:注解只是声明,事务真正生效靠的是调用穿过代理。下一步,把项目里所有 this.xxx() 的自调用揪出来,逐个改成注入代理或拆 Bean。