本文是「Java + Python 全栈学习」系列第 15 篇。AOP 是 Spring 的另一半灵魂。这篇文章不堆术语,专治一种病------为什么你加了
@Transactional,事务却没生效?把所有失效场景归到同一个根因,你对 AOP 的理解就从"会用注解"升级到"会排查一切切面问题"。

一、一个让无数人踩坑的现象
业务代码:
java
@Service
public class OrderService {
@Transactional
public void payOrder(Long id) { ... }
// 同类内部调用
public void batchPay(List<Long> ids) {
for (Long id : ids) {
payOrder(id); // @Transactional 静默失效!
}
}
}
payOrder 单独被外部调用时事务好好的;但从 batchPay 内部调它,事务就没了。运行到一半抛异常,前几条订单的扣款已经落库------经典的"事务失效"事故。
更让人崩溃的是失效场景不止这一种。Spring 文档里列了一长串"标注了却没用"的情形:自调用、private 方法、final 方法、非 public 方法、新对象(而非容器 bean)、异常被吞掉、回滚异常类型不对......新人常常逐条背诵,背完还是踩坑。
这篇文章的目标:把这七八种场景归到同一个根因。 当你掌握了根因,任何"切面类"的失效(不止事务,还有 @Async、@Cacheable、@TransactionalEventListener...)都能一眼看穿。
二、AOP 的本质:动态代理包裹的"洋葱"
2.1 横切关注点:一个问题的诞生
假设你要给 50 个方法加日志:
java
public void payOrder(Long id) {
log.info("payOrder begin, id={}", id);
// 业务
log.info("payOrder end");
}
50 个方法里复制粘贴同样的日志行,散弹枪式修改------日志格式一改就是 50 处。这就是"横切关注点":一段逻辑要切到多个业务方法上,但与业务本身无关。
AOP 的思路:把日志写一次,用某种"声明"告诉框架"在这些方法前后插入它"。运行时框架替你织入。声明层(注解/切点)和实现层(通知)解耦,重复劳动消失。
2.2 动态代理:Spring AOP 的物理基础
Spring AOP 的实现是运行时动态代理(非 AspectJ 的编译期织入)。简单说,框架给你的 Bean 套一层"代理壳":
调用方 ──> 代理对象(织入了通知) ──> 原始对象
↑
@Around / @Before / @After 在这里生效
两种代理:
| 类型 | 触发条件 | 实现 | 局限 |
|---|---|---|---|
| JDK 动态代理 | 目标类实现了接口 | Proxy.newProxyInstance |
只能代理接口方法 |
| CGLIB 代理 | 目标类无接口(或强制 cglib) | 生成子类 | final 方法/类不能代理 |
Spring Boot 2.x 起默认 proxyTargetClass=true,强制 CGLIB------所以下面讲的"代理壳"基本都是 CGLIB 子类代理。
2.3 三件套:切点、通知、织入
java
@Aspect
@Component
public class LogAspect {
// 1. 切点:匹配哪些方法(execution 表达式)
@Pointcut("execution(* com.my.service..*(..))")
public void serviceMethod() {}
// 2. 通知:在这些方法的什么位置干啥
@Around("serviceMethod()")
public Object log(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
return pjp.proceed(); // 3. 织入点:执行原始方法
} finally {
log.info("{} cost={}ms", pjp.getSignature(), System.currentTimeMillis() - start);
}
}
}
记住关键时序 :代理壳在调用 proceed() 前后插通知;proceed() 才进入原始对象。通知能力完全依赖"调用走代理壳"这一前提------而下面要讲的所有失效场景,全部是破坏了这一前提。
三、动手:手写 JDK 代理版的最小 AOP
不写代码不会信服。20 行体会"代理壳 + 织入"的物理本质:
java
// 接口
public interface OrderService {
void payOrder(Long id);
}
// 原始实现(被代理对象)
public class OrderServiceImpl implements OrderService {
public void payOrder(Long id) { /* 业务:扣库存+扣款 */ }
}
// 代理工厂:织入日志
OrderService target = new OrderServiceImpl();
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(),
new Class[]{OrderService.class},
(p, method, args) -> {
System.out.println("[before] " + method.getName());
Object ret = method.invoke(target, args); // 进入原始对象
System.out.println("[after] " + method.getName());
return ret;
});
proxy.payOrder(1L); // 输出:[before] payOrder / [after] payOrder
注意一件事:原始对象是 target,代理对象是 proxy 。容器注入给外部的永远是 proxy;但 target 内部的 this 指向它自己,绝不是 proxy。这一个 this 错位,就是全部失效的根。
四、统一根因:调用没走代理
现在回到开头的失效清单,逐个验证它们都是同一个根因的化身:
场景 1:自调用(本文开篇的例子)
java
public void batchPay(List<Long> ids) {
for (Long id : ids) {
payOrder(id); // 等价于 this.payOrder(id)
}
}
this 是原始对象 target,不是代理壳。调用绕过了代理 → 事务通知没机会织入 → 失效 。这就是 @Transactional 自调用失效的物理真相。修法:从容器拿自己的代理(AopContext.currentProxy(),需开启 exposeProxy=true),或者把 payOrder 拆到另一个 Bean。
场景 2:private / final 方法
CGLIB 靠生成子类织入,final 方法不能被覆盖,private 也不能------代理壳根本没有这个方法的"代理版本"。调用即使走代理,也直接跳过通知。
场景 3:非 public 方法(默认配置下)
Spring 事务的默认策略只对 public 方法生效。原因是protected/包级方法在不同代理策略下行为不一致,干脆规定只对 public 生效以保证可预期。修法不是改成 public,而是问自己"这个方法该不该公开"------多数时候答案是该拆分。
场景 4:异常被 catch 吞掉
事务通知靠捕获异常触发回滚。你把异常 catch 住,代理壳看到的方法"正常返回",自然不回滚。修法:catch 后 rethrow,或显式 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
场景 5:回滚异常类型不匹配
默认只回滚 RuntimeException 和 Error。抛 IOException(受检异常)不回滚。这源于 JDBC 的 SQLException 设计(受检异常 + Spring 的默认策略)。修法:@Transactional(rollbackFor = Exception.class) 写成肌肉记忆。
场景 6:调用的是 new 出来的对象,不是容器 bean
代理壳是容器在 Bean 生命周期里生成的(1.4-01 提到的 postProcessAfterInitialization)。new OrderServiceImpl() 出来的对象没经过容器,根本没壳可走。这是"为什么 Service 必须交给容器管理"的根因。
六个场景,一个根因:调用没经过代理壳。 记住这个根因,再遇到 @Async 异步没生效、@Cacheable 没缓存、自定义切面没触发------你只需要问一个问题:这次调用走代理了吗?
五、事务传播行为:同一根因的延伸应用
有了根因,再看事务传播就轻松了。Spring 默认 REQUIRED:
外部方法 A(已开事务 TX1)
└─ 调用内部方法 B(REQUIRED → 加入 TX1)
└─ B 抛异常 → TX1 标记回滚 → A 也回滚
因为 A、B 用的是同一条连接、同一个事务------它们最终回滚到同一个 savepoint。其他传播行为(REQUIRES_NEW 开新事务、NESTED 用 savepoint、SUPPORTS 有就加入没有就裸跑...)控制的是"内部方法是否新开事务",但这一切只在调用走代理时才生效------又是一遍根因验证。
工程含义:自调用不仅让事务失效,连传播行为也失效------你以为"内部方法 REQUIRES_NEW 会开新事务",结果因为 this. 而没走代理,全在原事务里。自调用是 Spring 失效的头号杀手。
六、常见误区清单
- "AOP 性能很差,生产慎用"------动态代理创建代理对象有成本,但调用本身只多了一次方法转发,纳秒级。热点方法的 concern 更多在链路深度,而不是代理本身;
- "所有方法都能用 AOP 增强"------上面六条反例就是答案;
- "AspectJ 和 Spring AOP 是一回事"------Spring AOP 是运行时代理,AspectJ 是编译/加载时织入(更强大也更快,但构建复杂);
- "切点写错只会不生效" ------还会引发性能问题:切点过宽(如
execution(* com..*.*(..))匹配一切)会让 Spring 启动时为海量 Bean 生成代理,启动时间和内存都炸。
七、小结
| 问题 | 答案 |
|---|---|
| Spring AOP 的实现? | 运行时动态代理(JDK 或 CGLIB) |
| 横切关注点的解药? | 通知写一次 + 切点声明匹配范围 |
| 失效的统一根因? | 调用没经过代理壳(this 错位、final/private、非容器 bean...) |
| 自调用为什么失效? | this 指向 target 而非 proxy,绕过代理 |
| 默认回滚异常? | RuntimeException + Error,需自定义时写 rollbackFor |
| 排查思路? | 任何切面失效,先问"调用走代理了吗" |
下一篇深入 SpringMVC 的请求处理流程:DispatcherServlet 的九大组件到底各管什么?为什么说 SpringMVC 没发明新东西,只是把 Servlet 时代的三大苦役自动化了?
本篇思考题: 在你项目里搜一下
@Transactional,找出所有自调用场景(同类内部调用了带@Transactional的方法),评估有没有隐患?(评论区交一份排查报告。)上篇回顾: Java全栈实战 \| 1.4-01 Spring IoC:new 一个对象能有什么错?推到尽头你就理解了控制反转
------ 完 ------