代理模式下只有“穿过代理的外部方法调用”会被拦截。private 方法既不在接口里,也无法被 CGLIB 子类覆盖,代理根本拦不到它——
@Transactional于是变成一段没人消费的元数据:编译通过、启动正常、运行无事务,全程零报错。
Issue & Env(案发现场与环境)
运行环境
- Spring Boot 2.x / 3.x,Spring Framework 5.x / 6.x
- 默认代理模式:
spring.aop.proxy-target-class=true(CGLIB 目标类代理) - 事务管理:
@Transactional声明式事务
业务场景
为了让 public 方法保持“只做参数校验和编排”的整洁结构,开发者把真正的写库逻辑抽成 private 方法,顺手把 @Transactional 标在它上面,以为事务边界跟着方法走。实际结果是:public 入口照常执行,private 方法里的插入也照常成功,但没有任何事务被开启。
为什么容易踩
- 注解本身合法:
@Transactional可以标在任意方法上,编译器不拦。 - 启动不报错:代理照样建得起来,不会像
final类那样抛AopConfigException。 - 现象和“自调用失效”完全一致:都是 DEBUG 日志里没有
Creating new transaction,容易被归到同一个原因上。
StackTrace(堆栈日志)
本故障没有异常堆栈,唯一的证据是“事务日志缺失”。打开事务拦截器的 DEBUG 级别:
logging.level.org.springframework.transaction.interceptor=DEBUG
正常生效的事务方法,无论成功还是失败,都会留下进出痕迹:
2026-09-27 10:12:03 DEBUG o.s.t.i.TransactionInterceptor : Getting transaction for [OrderService.createOrder]
2026-09-27 10:12:03 DEBUG o.s.t.i.TransactionInterceptor : Completing transaction for [OrderService.createOrder]
而 private 方法上标注解时,整条调用链一句都没有:
2026-09-27 10:12:03 INFO c.e.OrderService : 开始创建订单
2026-09-27 10:12:03 DEBUG c.e.OrderMapper : ==> Preparing: INSERT INTO orders ...
2026-09-27 10:12:03 INFO c.e.OrderService : 订单创建完成
// 没有 Getting transaction,也没有 Completing transaction
关键判据:有 SQL 执行、有正常返回,就是没有事务拦截器的进出日志。这说明方法被调用了,但没经过代理。
Reproduce(复现代码)
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
public void createOrder(Order order) {
// 外部调用进来的是代理,但这一步是普通 Java 方法调用,没有二次穿越代理
doCreate(order);
}
// 复现点:private + @Transactional,注解不会被任何代理消费
@Transactional
private void doCreate(Order order) {
orderMapper.insert(order);
throw new RuntimeException("模拟后续失败");
}
}
调用 createOrder 后:异常如期抛出,但 orders 表里依然留下了已提交的记录——因为从头到尾就没有事务。
Root Cause(根因分析)
1. 代理模式只认“穿过代理的外部调用”
Spring 官方文档在代理模式一节里的表述是:只有外部方法调用经过代理时才会被拦截。这句话有两个约束:
拦截发生的位置:proxy.method() → MethodInterceptor → target.method()
不被拦截的情况:
1. 同类内部调用(this.method())—— 绕过了代理,见另一个分支
2. 代理无法覆盖的方法 —— 可见性不满足,就是本文的情况
2. private 方法为什么一定拦不到
两种代理机制的覆盖范围决定了这条硬边界:
CGLIB(目标类代理,Boot 默认):
原理是继承目标类、override 方法插入拦截器。
private 方法不是虚方法,子类无法 override —— 没有插入点。
JDK 动态代理(接口代理):
原理是实现接口、在 InvocationHandler 里转发。
private 方法既不在接口里,也无法被转发 —— 没有入口。
两者都失效,因此 @Transactional 标在 private 方法上是纯粹的无操作。
3. 一个必须澄清的版本差异
网上大量资料说“非 public 方法一律不生效”,这个说法对 Spring 6.0 以前的版本成立,现在已经不准确:
| 方法可见性 | CGLIB 目标类代理 | JDK 接口代理 |
|---|---|---|
public |
生效 | 生效(且必须在接口中声明) |
protected / 包级 |
Spring 6.0 起生效 | 不生效(接口代理要求 public) |
private |
不生效 | 不生效 |
也就是说,Spring 6.0 起 protected 和包级方法在 CGLIB 下已经可以织入事务;真正“一定拦不到”的只有 private。若团队希望代理行为在不同代理类型下保持一致(回到 5.3 之前的语义),可以显式开启 publicMethodsOnly:
@Bean
TransactionAttributeSource transactionAttributeSource() {
return new AnnotationTransactionAttributeSource(true);
}
4. 快速定位手段
# 列出所有标注了 @Transactional 但可见性非 public 的方法
grep -rn -B3 "@Transactional" src/main/java --include="*Service*.java" | grep -E "private|protected"
The Fix(解决方案)
方案 A(推荐):把事务边界上移到 public 方法
让 public 方法承担事务边界,private 方法退回纯逻辑。这也符合“事务边界应当是明确的业务入口”的建模习惯:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) {
validate(order);
doCreate(order); // private,纯逻辑,不承载事务
}
private void doCreate(Order order) {
orderMapper.insert(order);
}
}
方案 B:private 逻辑确实需要独立事务时,抽到独立 Bean
如果这段逻辑需要独立事务(例如失败也不回滚、或走独立提交),把它挪到另一个 Bean 的 public 方法上,用传播行为表达语义,而不是靠可见性硬凑:
@Service
public class OrderTxService {
@Autowired
private OrderMapper orderMapper;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createInNewTransaction(Order order) {
orderMapper.insert(order);
}
}
注意 REQUIRES_NEW 的语义是独立提交,外层回滚救不了它,用之前先确认业务上确实允许单独留存。
方案 C:确实需要织入非 public 方法时,切 AspectJ 模式
代理模式下可见性边界无法突破,但 AspectJ 编织模式不依赖代理——它直接改写字节码,可以覆盖任意可见性的方法,也能顺带解决自调用问题。代价是要引入 aspectjweaver 并配置编译期或加载期织入,复杂度明显上升:
@Configuration
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class TransactionConfig {
}
绝大多数业务项目不必走到这一步,方案 A 已经足够。
总结与避坑要点
- private 方法上的
@Transactional不报错也不生效,是代理机制决定的硬边界,不是配置问题。 protected/ 包级方法在 Spring 6.0 起的 CGLIB 代理下可以生效,别再照搬“非 public 一律失效”的旧结论。- 事务边界优先放在 public 的业务入口方法上,private 方法只保留纯逻辑。
- 排查时认准一个信号:SQL 执行了、方法返回了、但事务拦截器没有进出日志,就该去查可见性与自调用这两条代理边界。做得更细一点,把
grep非 public 注解方法的检查加进 Code Review 清单,比出事后再查便宜得多。
