Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 10Spring AOP 与事务

上一章结尾留了一个坑:Bean 被 BeanPostProcessor 包装成代理之后, 注入到别人手里的可能是裸对象。这一章就讲那个层——AOP 代理, 以及它最出名的应用:@Transactional。

这道题的可怕之处在于:它不报错。方法加了注解、代码逻辑完全正确、 编译通过、启动成功,就是不回滚。这一章用 MySQL 8.0 真库跑了 8 组实验, 把「加注解之后到底哪几种情况不生效」变成一行行行数变化—— 行数=1 就是没回滚,行数=0 才是真回滚。

学习目标
能说出六种失效场景,并解释每一种为什么失效
前置要求
读过第 09 章(Bean 生命周期与代理);会写 JDBC 增删改查
本章产出
8 组实测 + 4 份真实异常 + 5 张速记卡
建议学时
3–4 小时(务必自己连库跑一遍)

10.1为什么 AOP 和事务必须一起讲

面试里这两道题从来不是分开的。问 AOP 是为了问代理,问代理是为了问事务—— @Transactional 就是一个方法拦截器,除此之外什么都不是。

你听到的问题它其实在问说错的代价
「AOP 的底层是什么」JDK 动态代理 / CGLIB 子类只会说「面向切面编程」,说不出代理类的名字
「@Transactional 为什么失效」没走代理背得出「同类调用」,说不出为什么绕过了
「什么异常会回滚」默认只有 RuntimeException答「抛异常就回滚」→ 受检异常直接漏
「传播行为有哪些」REQUIRED / REQUIRES_NEW / NESTED 的差别背得出七个名字,说不出哪个会连累外层
「事务会不会影响性能」一次 begin+commit 的真实成本答「有一点影响」,说不出数量级
「readOnly 有什么用」它到底拦不拦写操作答「提示数据库只读」→ 实测是真拦
实验环境:Spring Framework 7.0.9(Boot 4.1 内核)+ JDK 17.0.12 + MySQL 8.0.44(InnoDB)+ HikariCP 7.0.2,库 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 后波动)
「慢 6 倍」不等于「有问题」。42 纳秒对比一次数据库往返的 340 微秒(见 10.9), 代理开销是万分之一。面试说「AOP 性能损耗大」是减分项,正确表达是 「单次几十纳秒,相对 IO 可忽略;真正的成本在被拦截方法内部」。

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回滚。异常最终还是出了方法边界
真实项目里最常见的是第 4 种。「记录失败日志,不让它影响主流程」这个需求, 写法一旦是 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
修饰符事务活跃行数为什么
publictrue0正常被 CGLIB 重写,拦截器生效
public finalfalse1final 不能被重写 → 直接在代理对象上执行(日志里 this.jdbc=null 就是证据:代理对象没被注入)
protectedtrue0实测生效——这一条和绝大多数博客相反,见下方说明
privatefalse1CGLIB 生成的是子类,访问不到父类的私有方法,无法重写
关于 protected:老资料(Spring 5 及以前)的结论是「只有 public 生效」。 本次在 7.0.9 上实测,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
内层传播行数外层能否提交发生了什么
REQUIRED0不能,抛 UnexpectedRollbackException 内层加入外层事务;内层抛异常时把整个事务标记 rollback-only,外层 catch 也没用——提交时才发现只能回滚
REQUIRES_NEW1能 内层挂起外层事务、自己开一个;内层回滚,外层不受影响(那 1 行是外层插的)
NESTED1能 内层建保存点;失败只回滚到保存点,外层继续(同样剩外层的 1 行)
NOT_SUPPORTED2能 内层挂起外层事务、无事务执行;内层那行立即自动提交,外层再提交自己的 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) 语句执行超时被驱动取消,事务跟着回滚
所以「加个 timeout 防止接口卡死」这个念头是错的。 它防的是慢 SQL,防不了慢代码、慢 RPC、慢锁等待。 真正的接口超时要在上层做:网关超时、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/次
0.34 毫秒买到了什么?一次 begin + 一次 commit 的网络往返 (本机 MySQL,两次 RTT + InnoDB 的 redo 刷盘策略决定)。
换算成业务语言:循环里逐条调用 @Transactional 方法,1 万条就是 3.4 秒纯事务开销, 而数据量只有 1 万行。这就是为什么批量导入要「一个大事务 + 批量插入」,而不是 for 循环里调 service。
场景事务次数额外开销(本机实测口径)
单条 insert1约 0.34 ms
for 循环插 1 万条10 000约 3.4 s
一个大事务 + 批量 1000/批10约 3.4 ms
只读查询加 readOnly=true1省掉提交阶段,收益小于上面的差值

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 真的拦写。

六个自测(能脱口而出才算过)

  1. this.insert() 为什么让 @Transactional 失效?代理对象在哪一步被绕过?
  2. 同样是「内层抛异常、外层 catch」,为什么 REQUIRED 会在提交时才报错?报什么?
  3. rollbackFor = Exception.class 加与不加,实测行数差多少?为什么默认不包含受检异常?
  4. @Transactional(timeout = 1) 的方法里 sleep(3),事务会不会超时?为什么?
  5. final 方法上的注解为什么无效?实测日志里 this.jdbc=null 说明了什么?
  6. 一万条数据在 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 条 总耗时增量基本不变——事务开销按次数算,不按数据量算
反例实验(做了会记一辈子):把实验 2 的 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()
想验证「是不是同一个事务」在内层和外层各插一行、故意让外层失败,看内层那行有没有跟着消失