主题 10Spring AOP 与事务
上一章结尾留了一个坑:Bean 被 BeanPostProcessor 包装成代理之后,
注入到别人手里的可能是裸对象。这一章就讲那个层——AOP 代理,
以及它最出名的应用:@Transactional。
这道题的可怕之处在于:它不报错。方法加了注解、代码逻辑完全正确、
编译通过、启动成功,就是不回滚。这一章用 MySQL 8.0 真库跑了 8 组实验,
把「加注解之后到底哪几种情况不生效」变成一行行行数变化——
行数=1 就是没回滚,行数=0 才是真回滚。
10.1为什么 AOP 和事务必须一起讲
面试里这两道题从来不是分开的。问 AOP 是为了问代理,问代理是为了问事务——
@Transactional 就是一个方法拦截器,除此之外什么都不是。
| 你听到的问题 | 它其实在问 | 说错的代价 |
|---|---|---|
| 「AOP 的底层是什么」 | JDK 动态代理 / CGLIB 子类 | 只会说「面向切面编程」,说不出代理类的名字 |
| 「@Transactional 为什么失效」 | 没走代理 | 背得出「同类调用」,说不出为什么绕过了 |
| 「什么异常会回滚」 | 默认只有 RuntimeException | 答「抛异常就回滚」→ 受检异常直接漏 |
| 「传播行为有哪些」 | REQUIRED / REQUIRES_NEW / NESTED 的差别 | 背得出七个名字,说不出哪个会连累外层 |
| 「事务会不会影响性能」 | 一次 begin+commit 的真实成本 | 答「有一点影响」,说不出数量级 |
| 「readOnly 有什么用」 | 它到底拦不拦写操作 | 答「提示数据库只读」→ 实测是真拦 |
aop_lab,表 account(id,name,balance)。
源码单文件 TxDemo.java,java TxDemo self 只跑一组,all 跑全部八组。每组实验开头都会
drop table 重建,所以每个判据都是干净的:
「行数=1」= 没回滚,「行数=0」= 回滚了。启动时 HikariCP 的 SLF4J 提示已省略。
10.2AOP 三块积木,和代理的两个真身
AOP 的名词听着玄,落到代码只有三样东西:在哪切(切点)、切了做什么(通知)、谁来执行(代理)。 前两个是配置,第三个才是原理。Spring 只有两种代理,实测各是什么类:
=== case1 代理的三种形态与开销 ===
JDK 代理类 : $Proxy22
是接口实现: true,是原类: false
CGLIB 代理类 : TxDemo$GreetImpl$$SpringCGLIB$$0
是原类子类: true
[拦截器执行] hi
调 final 方法返回: final 方法本体(上一行没有拦截日志 = final 没被拦)
直接调用 x5000000: 40.5 ms(8.1 ns/次)
代理调用 x5000000: 246.0 ms(49.2 ns/次)
代理额外开销: 41.1 ns/次,约 6.1 倍
| 对比项 | JDK 动态代理 | CGLIB |
|---|---|---|
| 代理类名字 | $Proxy22(jdk.proxy2 包) | TxDemo$GreetImpl$$SpringCGLIB$$0 |
| 与被代理类的关系 | 兄弟:同实现一个接口,instanceof 原类 为 false | 父子:instanceof 原类 为 true |
| 前提 | 目标类必须实现接口 | 类不能是 final,方法不能是 final / private |
| 怎么选 | 有接口时默认;proxyTargetClass=true 可强制 CGLIB | 没接口时自动用 |
| 每次调用开销 | 直接 6–8 ns → 代理 49–52 ns,多约 42 ns,慢 6–8 倍(三次跑,JIT 后波动) | |
final 方法为什么拦不到?CGLIB 靠生成子类并重写方法,final 方法不能被重写,
于是调用时执行的是父类原方法,拦截器根本没机会进场。实测时 Spring 甚至直接打了一条警告:
警告: Public final method [public final java.lang.String TxDemo$GreetImpl.fixed()]
cannot get proxied via CGLIB, consider removing the final marker or using
interface-based JDK proxies.
10.3自调用:事务为什么没生效
这是「@Transactional 失效」的第一名。看实测,同一个类里两个入口:
public void createDirect() {
insert(); // ← 自调用:this.insert(),走的是裸对象
}
public void createViaProxy() {
ctx.getBean(OrderService.class).insertViaProxy(); // ← 从容器拿代理再调
}
@Transactional
public void insert() { ... } // 加注解也没用,因为压根没经过代理
=== case2 自调用让 @Transactional 失效 ===
注入了 OrderService,它的真实类型: $Proxy19
外层方法(无事务)开始,内部 this.insert()
this.insert() 执行,事务是否活跃: false
抛出: RuntimeException
[自调用(this.insert)之后] 行数=1 余额合计=100 ← 数据留下了
[清空表,准备对照] 行数=0 余额合计=0
外层方法(无事务)开始,内部 getBean().insert()
代理.insert() 执行,事务是否活跃: true
抛出: RuntimeException
[经代理调用之后] 行数=0 余额合计=0 ← 真回滚了
两行 事务是否活跃 就是全部答案:false 的那次,SQL 自动提交了,根本没开事务。
注意第一行日志——注入进来的对象是 $Proxy19,但代理内部执行方法时用的是 target,
target 里的 this 指向自己,不再经过代理,事务拦截器自然没被调用。
| 解法 | 写法 | 代价 |
|---|---|---|
| 拆类(首选) | 把事务方法挪到另一个 Bean,注入后调用 | 多一个类,但依赖关系最清晰 |
| 注入自己 | @Lazy @Autowired 自身类型 self; 然后 self.insert() | 有循环依赖风险,需要 @Lazy |
| 拿容器取代理 | ctx.getBean(XxxService.class).insert() | 业务代码耦合容器,不推荐 |
AopContext.currentProxy() | 需要 exposeProxy=true | 要改全局配置,隐式依赖 |
10.4什么异常才回滚
「抛异常就回滚」是错的。默认规则只有一条:只有 RuntimeException 和 Error 回滚。
受检异常(Exception 的直接子类且非运行时)不回滚。五种写法一次测完:
=== case3 什么异常才会回滚 ===
抛出: IllegalStateException
[RuntimeException 未捕获] 行数=0 余额合计=0
抛出: Exception
[Exception 未捕获(默认不回滚)] 行数=1 余额合计=100
抛出: Exception
[Exception + rollbackFor] 行数=0 余额合计=0
方法内部 catch 住了异常,没有往外抛
[catch 吞掉异常] 行数=1 余额合计=100
抛出: RuntimeException
[catch 后重新抛出] 行数=0 余额合计=0
| 写法 | 行数 | 结论 |
|---|---|---|
抛 IllegalStateException(运行时) | 0 | 回滚。默认规则命中 |
抛 Exception(受检) | 1 | 不回滚。提交了 |
@Transactional(rollbackFor = Exception.class) | 0 | 回滚。显式扩大范围 |
方法里 try-catch 吞掉,不往外抛 | 1 | 不回滚。拦截器看不到异常,认为一切正常 |
catch 后 throw e 重新抛出 | 0 | 回滚。异常最终还是出了方法边界 |
try { ... } catch (Exception e) { log.error(...); },
前面已经写进库的数据就永远留下了。要吞异常又不想留下脏数据,
必须手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),
或者把这段逻辑放进 REQUIRES_NEW 的子事务里。
10.5注解挂在哪些方法上不管用
这一组实验有意外。ModifierService 没有实现接口,所以 Spring 用 CGLIB 生成子类,
四种可见性的方法各挂一个 @Transactional,都在事务里插一行然后抛运行时异常:
=== case4 哪些方法上的 @Transactional 不管用 ===
注入对象类型: TxDemo$ModifierService$$SpringCGLIB$$0
public 执行中,事务活跃=true,this.jdbc=有值
抛出: RuntimeException
[public 方法] 行数=0 余额合计=0
final 执行中,事务活跃=false,this.jdbc=null(代理对象上没注入)
抛出: RuntimeException
[public final 方法] 行数=1 余额合计=1
protected 执行中,事务活跃=true,this.jdbc=有值
抛出: RuntimeException
[protected 方法] 行数=0 余额合计=0
private 执行中,事务活跃=false,this.jdbc=null(代理对象上没注入)
抛出: RuntimeException
[private 方法] 行数=1 余额合计=1
| 修饰符 | 事务活跃 | 行数 | 为什么 |
|---|---|---|---|
public | true | 0 | 正常被 CGLIB 重写,拦截器生效 |
public final | false | 1 | final 不能被重写 → 直接在代理对象上执行(日志里 this.jdbc=null 就是证据:代理对象没被注入) |
protected | true | 0 | 实测生效——这一条和绝大多数博客相反,见下方说明 |
private | false | 1 | CGLIB 生成的是子类,访问不到父类的私有方法,无法重写 |
protected 方法确实被代理了(事务活跃=true、行数=0)。
原因是 CGLIB 子类与父类同包,可以重写 protected 方法,而新版
AnnotationTransactionAttributeSource 放宽了可见性判定。面试怎么答:说「历史上只保证 public,6.x 起 protected 也能代理,但惯例仍然只写在 public 上—— 因为这是跨版本最安全的写法,也符合「对外才是事务边界」的设计」。
10.6传播行为:内层失败,外层还能提交吗
这是本章最重要的一组数据。场景统一为:外层 REQUIRED 先插一行,
调用内层(内层也插一行然后抛异常),外层 catch 住异常并继续提交。
只改内层的传播行为:
=== case5 传播行为:外层捕获内层异常会怎样 ===
外层 catch 住: 内层 REQUIRED 失败
外层继续执行,准备提交
提交时抛出: org.springframework.transaction.UnexpectedRollbackException
消息: Transaction rolled back because it has been marked as rollback-only
[外层 REQUIRED + 内层 REQUIRED(内层失败后被 catch)] 行数=0 余额合计=0
外层 catch 住: 内层 REQUIRES_NEW 失败
外层继续执行,准备提交
提交成功
[外层 REQUIRED + 内层 NEW(内层失败后被 catch)] 行数=1 余额合计=100
外层 catch 住: 内层 NESTED 失败
外层继续执行,准备提交
提交成功
[外层 REQUIRED + 内层 NESTED(内层失败后被 catch)] 行数=1 余额合计=100
内层 NOT_SUPPORTED,事务是否活跃: false
外层继续执行,准备提交
提交成功
[外层 REQUIRED + 内层 NOTSUPPORTED(内层失败后被 catch)] 行数=2 余额合计=110
| 内层传播 | 行数 | 外层能否提交 | 发生了什么 |
|---|---|---|---|
REQUIRED | 0 | 不能,抛 UnexpectedRollbackException |
内层加入外层事务;内层抛异常时把整个事务标记 rollback-only,外层 catch 也没用——提交时才发现只能回滚 |
REQUIRES_NEW | 1 | 能 | 内层挂起外层事务、自己开一个;内层回滚,外层不受影响(那 1 行是外层插的) |
NESTED | 1 | 能 | 内层建保存点;失败只回滚到保存点,外层继续(同样剩外层的 1 行) |
NOT_SUPPORTED | 2 | 能 | 内层挂起外层事务、无事务执行;内层那行立即自动提交,外层再提交自己的 1 行 → 共 2 行 110 |
catch 住了,代码继续往下跑,
甚至还打了「流程正常结束」的日志,结果提交时才炸出一个
UnexpectedRollbackException,前面的写全部没了。看到这个异常,不要去查数据库, 去查「内层调用了哪个 REQUIRED 方法并抛了异常」——
rollback-only 标记一旦打上,任何 catch 都救不回来。
10.7timeout:管 SQL,不管你的 sleep
@Transactional(timeout = 1) 看起来很安全,实测两次,差别巨大:
=== case6 timeout 到底管不管用 ===
已插入,开始 sleep 3000ms(timeout=1 秒)
sleep 结束,事务是否还在: true
方法正常返回
总耗时 3007 ms
[timeout=1 + sleep 3s(没有 SQL)] 行数=1 余额合计=1
已插入,开始执行 select sleep(3)(timeout=1 秒)
抛出: org.springframework.dao.QueryTimeoutException
消息: StatementCallback; SQL [select sleep(3)];
Statement cancelled due to timeout or client request
总耗时 1026 ms
[timeout=1 + select sleep(3)] 行数=0 余额合计=0
| 事务里做了什么 | 结果 | 原因 |
|---|---|---|
Thread.sleep(3000) | 3.0 秒后正常提交(行数=1) | timeout 落到 JDBC 的 Statement.setQueryTimeout() 上,没有 SQL 在执行就没有计时对象 |
select sleep(3) | 1.03 秒抛 QueryTimeoutException,回滚(行数=0) |
语句执行超时被驱动取消,事务跟着回滚 |
Future.get(timeout)、
或把远程调用挪出事务。顺带一句:timeout 从事务开始时计
(DataSourceTransactionManager 用 setTransactionTimeout + 语句初始化的差值),
所以内层 REQUIRES_NEW 会重新计时。
10.8readOnly 到底拦不拦写
很多人以为 readOnly = true 只是给 ORM 的「优化提示」,写操作照样能改。实测:
=== case7 readOnly 拦得住写操作吗 ===
[初始] 行数=1 余额合计=5
抛出: TransientDataAccessResourceException: StatementCallback;
SQL [update account set balance = balance + 1 where name = 'ro'];
Connection is read-only. Queries leading to data modification are not allowed.
[readOnly 里 update 之后] 行数=1 余额合计=5
[readOnly 里 select 之后] 行数=1 余额合计=5
| 结论 | 说明 |
|---|---|
| 写操作被拦下 | DataSourceTransactionManager 把连接设为 readOnly,
MySQL 驱动在客户端就拒绝了 DML,根本没发到服务端 |
| 异常类型 | TransientDataAccessResourceException(Spring 把 SQLException 翻译过的) |
| 能不能当防线 | 能,但只能当护栏。它防的是「只读方法里误写一行 update」这种低级错误, 不是权限控制——同一个库还有别的连接可以随便写 |
| 性能收益 | MySQL 下主要是「少一次事务提交」+ 让优化器少考虑回滚段,不要指望数量级提升 |
10.9一次事务到底多贵
把「有没有事务」这件事从其他变量里剥出来:同一个 select 1,一个走代理事务,一个不走,
各跑 300 次(先预热 20 次),连接池固定 2 个空闲连接:
=== case8 一次事务的真实开销 ===
无事务 select 1 x300: 70.9 ms(0.24 ms/次)
有事务 select 1 x300: 172.1 ms(0.57 ms/次)
事务额外成本: 0.34 ms/次
begin + 一次 commit 的网络往返
(本机 MySQL,两次 RTT + InnoDB 的 redo 刷盘策略决定)。换算成业务语言:循环里逐条调用
@Transactional 方法,1 万条就是 3.4 秒纯事务开销,
而数据量只有 1 万行。这就是为什么批量导入要「一个大事务 + 批量插入」,而不是 for 循环里调 service。
| 场景 | 事务次数 | 额外开销(本机实测口径) |
|---|---|---|
单条 insert | 1 | 约 0.34 ms |
| for 循环插 1 万条 | 10 000 | 约 3.4 s |
| 一个大事务 + 批量 1000/批 | 10 | 约 3.4 ms |
只读查询加 readOnly=true | 1 | 省掉提交阶段,收益小于上面的差值 |
10.10速记卡、自测与动手清单
五张速记卡
| 卡 | 内容 |
|---|---|
| 卡 1 · 两种代理 | 有接口 → JDK($Proxy,兄弟关系);无接口 → CGLIB($$SpringCGLIB$$,父子关系)。
final 类/方法、private 方法一律拦不到。 |
| 卡 2 · 失效六连 | 自调用、private、final、类没被 Spring 管理、异常被吞、
数据库/表不支持事务。六条里前四条都是同一个原因:没走代理。 |
| 卡 3 · 回滚规则 | 默认只回滚 RuntimeException / Error;
受检异常要写 rollbackFor;catch 住不抛 = 提交。 |
| 卡 4 · 传播三兄弟 | REQUIRED 加入(内层失败连累外层)、
REQUIRES_NEW 另起(互不影响)、NESTED 保存点(内层可单独回滚)。 |
| 卡 5 · 成本与边界 | 一次事务约 0.34 ms(begin+commit);
代理本身只多 42 ns;timeout 只管 SQL 不管代码;readOnly 真的拦写。 |
六个自测(能脱口而出才算过)
this.insert()为什么让@Transactional失效?代理对象在哪一步被绕过?- 同样是「内层抛异常、外层 catch」,为什么
REQUIRED会在提交时才报错?报什么? rollbackFor = Exception.class加与不加,实测行数差多少?为什么默认不包含受检异常?@Transactional(timeout = 1)的方法里sleep(3),事务会不会超时?为什么?final方法上的注解为什么无效?实测日志里this.jdbc=null说明了什么?- 一万条数据在 for 循环里各调一次事务方法,光事务开销大概几秒?怎么改?
@Transactional 不是「声明了一个事务」,
而是「在代理的方法调用前后加了个拦截器」。
凡是没经过代理的调用、没被重写的方法、没抛到方法边界的异常,
统统等于没有这行注解——而它不会给你任何提示。
动手清单与自查表
| # | 动手项 | 可观察的判据(做到了会看到什么) |
|---|---|---|
| 1 | 跑实验 2,对比 this.insert() 与 getBean().insert() |
行数 1 → 0,日志里 事务是否活跃 从 false 变 true |
| 2 | 把实验 3 的受检异常加上 rollbackFor |
行数 1 → 0;不加时异常照样抛,数据照样留 |
| 3 | 实验 3 里把 catch 换成 catch + throw |
行数 1 → 0:异常有没有跨过方法边界才是判据 |
| 4 | 实验 5 把内层 REQUIRED 换成 REQUIRES_NEW |
提交不再抛 UnexpectedRollbackException,行数 0 → 1 |
| 5 | 实验 6 两种写法都跑一遍 | sleep 那组 3.0 秒正常返回;select sleep(3) 那组 1.03 秒抛 QueryTimeoutException |
| 6 | 实验 8 把事务方法改成批量插 1000 条 | 总耗时增量基本不变——事务开销按次数算,不按数据量算 |
OrderServiceImpl 实现类上的
@Service 去掉(不给 Spring 管),再 new OrderServiceImpl() 手动调 insert()。
你会发现 一条 SQL 都不报错,事务零生效——因为它连代理都没生成。
「注解没生效」的第一排查项永远是:这个类到底是不是 Spring 管的 Bean。
| 卡住了怎么办 | 去哪查 |
|---|---|
| 不确定事务有没有开 | 方法里打 TransactionSynchronizationManager.isActualTransactionActive() |
| 不确定拿到的是不是代理 | 打 bean.getClass().getName(),找 $$SpringCGLIB$$ 或 $Proxy |
| 不确定为什么回滚 | 开 logging.level.org.springframework.transaction=DEBUG,看 Initiating transaction rollback |
| 不确定连接是不是只读 | 事务里打 DataSourceUtils.getConnection(ds).isReadOnly() |
| 想验证「是不是同一个事务」 | 在内层和外层各插一行、故意让外层失败,看内层那行有没有跟着消失 |