Spring 框架核心:IoC 超级工厂 + AOP 代理 + @Transactional 六种失效场景
@Transactional 失效的根因就一个:没走代理。6 种失效场景里,5 种都是因为 Spring AOP 没拦截到你的方法调用——同类内部调用 this.methodB()、方法不是 public、异常被 catch 吞了……这些本质上都是"绕过代理"。我排查过不下 20 次同类内部调用引发的失效,后来总结出这张对照清单——每次写完代码花 30 秒过一遍,再没出过问题。
阅读约 14 分钟 | 系列第 9/17 篇
一、IoC:控制反转的本质
IoC 到底是什么?
反转的是什么? 对象的创建和管理权从"程序员 new"反转给"Spring 容器"。
传统方式:UserService service = new UserService(); // 自行创建对象并管理依赖
IoC 方式:容器创建对象 → 容器注入依赖 → 容器管理生命周期
容器内部结构
Spring 启动时:
- 扫描配置 → 解析成 BeanDefinition(Bean 的配方:类名、scope、lazy-init、依赖关系...)
- 通过 BeanFactory 按配方生产 Bean(反射:
Class.newInstance()或 Constructor.newInstance()) - 放入单例池(本质是 ConcurrentHashMap)
- getBean() 时直接从 Map 中获取,不是每次反射创建
Bean 生命周期
实例化 → 属性注入(@Autowired/@Value)→ Aware 回调 → BeanPostProcessor 前置处理 → @PostConstruct → InitializingBean → BeanPostProcessor 后置处理 → 就绪 → @PreDestroy → 销毁。
三级缓存解决循环依赖
A 依赖 B,B 依赖 A。Spring 只解决单例 setter 注入的循环依赖:
singletonObjects(一级缓存,成品 Bean)
→ earlySingletonObjects(二级缓存,提前暴露的半成品引用)
→ singletonFactories(三级缓存,ObjectFactory 能生成早期引用)
getSingleton() 的查询逻辑(DefaultSingletonBeanRegistry 源码):
① 先从 singletonObjects(一级)取 → 有则直接返回成品 Bean
② 没有 → 从 earlySingletonObjects(二级)取 → 有则返回半成品引用
③ 还没有 → 从 singletonFactories(三级)取出 ObjectFactory
→ 调用 ObjectFactory.getObject() 生成早期引用
→ 将结果升级到 earlySingletonObjects(二级)
→ 从 singletonFactories 中删除该 ObjectFactory
→ 返回早期引用
为什么必须用三级缓存(ObjectFactory)而非直接存早期引用?
关键在于 AOP 代理的时机。如果直接将半成品对象放入二级缓存,此时对象尚未经过 BeanPostProcessor 后置处理——若该 Bean 需要 AOP 代理,放入二级缓存的将是原始对象而非代理对象,B 注入的 A 就不会是代理。三级缓存的 ObjectFactory 是一个 () -> getEarlyBeanReference(beanName, mbd, bean) 的 lambda:在 getSingleton() 被调用时才执行,此时 SmartInstantiationAwareBeanPostProcessor 已经可以介入,在暴露早期引用之前完成代理包装——保证 B 注入的 A 是代理。
循环依赖的完整流程:
① A 实例化(构造器 + 属性填充前)
② A 的 ObjectFactory 放入三级缓存:
singletonFactories.put("A", () -> getEarlyBeanReference("A", mbd, a))
③ A 属性填充:发现依赖 B → getBean("B")
④ B 尚未创建 → B 实例化 → B 的 ObjectFactory 放入三级缓存
⑤ B 属性填充:发现依赖 A → getBean("A")
⑥ getSingleton("A") 查询三级缓存 → 取出 ObjectFactory
→ 触发 getEarlyBeanReference(如有 AOP 则在此生成代理)
→ 升级到二级缓存 → B 注入 A(半成品引用)
⑦ B 完成属性填充 + 初始化 → 放入一级缓存 → B 创建完成
⑧ A 拿到 B → 完成属性填充 + 初始化 → 放入一级缓存 → A 创建完成
注意:构造器注入的循环依赖无法解决——步骤①构造器阶段即需要依赖 B,但此时 A 尚未实例化完毕,无法放入三级缓存。需要先有对象才能暴露早期引用,而构造器注入要求先有依赖才能完成实例化 → 死锁。
三种注入方式
| 方式 | 特点 | 建议 |
|---|---|---|
| 构造器注入 | 强制依赖不可为空、不可变(final)、测试友好 | 推荐(Spring 4.3+ 单构造器不用写 @Autowired) |
| Setter 注入 | 可选依赖、可运行时改变 | 可选依赖场景 |
| 字段注入 | 最简洁 | ❌ 不推荐(不能用 final、测试需反射注入、隐藏依赖) |
Bean 作用域
| 作用域 | 含义 | 适用场景 |
|---|---|---|
| singleton(默认) | 整个 IoC 容器中只有一个实例 | 无状态 Bean(Service/DAO) |
| prototype | 每次 getBean() 都创建新实例 | 有状态 Bean,每次使用不同配置 |
| request | 每个 HTTP 请求一个实例 | Web 应用中请求级数据 |
| session | 每个 HTTP Session 一个实例 | 用户登录信息 |
⚠️ prototype 的生命周期陷阱:Spring 只管理 prototype Bean 的创建(调用构造器+注入依赖),不管理完整的销毁生命周期。@PreDestroy 不会自动回调——需要自行注册 DestructionAwareBeanPostProcessor 或手动调用。
二、AOP:JDK 动态代理 vs CGLIB
核心概念链
Joinpoint(连接点)→ Pointcut(切入点,表达式筛选要增强的方法)→ Advice(增强逻辑:@Before/@After/@Around)→ Aspect(切面 = Pointcut + Advice)→ Weaving(织入,把切面应用到目标对象)
JDK 动态代理 vs CGLIB
| JDK 动态代理 | CGLIB | |
|---|---|---|
| 原理 | 基于接口,Proxy.newProxyInstance() 生成代理类实现接口 | 基于继承,生成目标类的子类重写方法 |
| 要求 | 必须有接口 | 类和方法不能是 final |
| 生成对象 | $Proxy0 类型 ≠ 目标对象类型,注入时要用接口类型接收 | 子类 instanceof 父类 ✓ |
| 关系 | 兄弟关系(实现同一接口) | 父子关系(继承) |
Spring Boot 策略演变:
- Boot 1.x:有接口用 JDK,无接口用 CGLIB
- Boot 2.x+:统一默认 CGLIB(不管有没有接口)
AOP 经典陷阱
同类内部调用不走代理:this.methodB() 直接调用目标对象本身,不经过代理 → @Transactional/@Cacheable 全部失效。
// ❌ 同一个类中,B 的 @Transactional 不生效
public void A() {
this.B(); // this 是原始对象,不是代理
}
@Transactional
public void B() { ... }
// ✅ 解决:注入自己,通过代理调用
@Autowired private XxxService self;
public void A() {
self.B(); // self 是代理,走 AOP 拦截链
}
@Async 异步处理
Spring 的 @Async 注解可以将方法调用异步化,基于 AOP 代理实现:
@Async
public CompletableFuture<String> processAsync() {
// 此方法在独立线程池中执行
return CompletableFuture.completedFuture("done");
}
关键配置:
- 必须用
@EnableAsync开启 - 默认使用
SimpleAsyncTaskExecutor(每次新建线程,生产不可用) - 生产必须自定义线程池:配置
ThreadPoolTaskExecutor(corePoolSize/maxPoolSize/queueCapacity),并设置RejectedExecutionHandler(推荐 CallerRunsPolicy——线程池满时退回调用线程同步执行,自然限流)
与 @Transactional 的交互:
- @Async 将方法提交到另一个线程执行——根据"失效场景六(多线程)",子线程拥有独立事务
- 如果主线程和异步方法都需要事务,各自独立管理,一个失败不回滚另一个
典型场景:
- 发邮件/短信通知(非核心链路,异步化不阻塞主流程)
- 写操作日志(记录审计数据,失败不影响业务)
- 调用"金融系统铁律"中的外部接口(先提交本地事务,再异步调用 ESB/RPC)
三、@Transactional 六种失效场景
@Transactional 基于 AOP 实现:@Transactional 注解 → 生成代理对象 → TransactionInterceptor 拦截 → 开启事务(setAutoCommit(false) + 绑定 ThreadLocal)→ 执行目标方法 → 提交/回滚。
失效场景一:同类内部调用(最常见)
同一个类中 A 调 B,B 上的 @Transactional 不生效——因为 this.B() 不经过代理。
失效场景二:非 public 方法
@Transactional 默认只对 public 方法生效。private 方法 CGLIB 无法重写。
失效场景三:异常被 catch 吞掉
自行 try-catch 了异常未向外抛出,Spring 无法感知异常。如需回滚:手动调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
失效场景四:rollbackFor 配错
默认只回滚 RuntimeException 和 Error。Checked Exception(如 IOException)默认不回滚。需要:@Transactional(rollbackFor = Exception.class)。
失效场景五:数据库引擎不支持事务
MyISAM 引擎不支持事务。
失效场景六:多线程
事务通过 ThreadLocal 绑定在当前线程上,子线程无法继承父线程的事务上下文——这是 ThreadLocal 的固有特性,与 Spring 无关。每个线程需要独立管理自己的事务(各自使用 @Transactional 注解,事务之间完全隔离)。将异步任务提交给线程池时,子线程中的数据库操作处于独立的事务中,失败不会回滚主线程的事务,反之亦然。
事务传播行为
| 传播行为 | 含义 | 场景 |
|---|---|---|
| REQUIRED(默认) | 有则加入,无则新建 | 最常用,共享一个事务 |
| REQUIRES_NEW | 挂起当前事务,新建独立事务 | 日志记录——日志失败不影响业务事务 |
| SUPPORTS | 有就用,无就无事务 | 查询方法 |
| NESTED | 嵌套事务,保存点机制 | 子事务回滚不影响外层 |
金融系统铁律:@Transactional 方法里绝不包含外部调用(ESB/RPC)。外部调用可能超时 30s+,事务长时间持锁会把整个连接池拖垮。做法:先提交本地事务,再调外部接口;外部失败走补偿逻辑。
四、Spring Boot 自动配置
核心注解 @SpringBootApplication
包含三个元注解:
@SpringBootConfiguration(即 @Configuration,标注配置类)@EnableAutoConfiguration(自动配置的开关)@ComponentScan(扫描同级包及子包下的 @Component/@Service/@Controller)
自动配置原理
① 加载 META-INF/spring/...AutoConfiguration.imports 文件(3.x 用 imports,spring.factories 已废弃)
② 里面 100+ 个自动配置类(DataSourceAutoConfiguration、RedisAutoConfiguration...)
③ 每个配置类上有条件注解:
- @ConditionalOnClass:classpath 有这个类才生效
- @ConditionalOnMissingBean:用户未自行定义 Bean 才采用默认配置
- @EnableConfigurationProperties:把 application.yml 绑定到 Properties 类
一句话:自动配置 = 条件装配 + 约定大于配置。Spring Boot 根据 classpath 中的 jar 自动推断所需配置,不满意时自行定义 Bean 覆盖默认配置即可。
五、SPI 机制:Spring Boot 自动配置的基石
SPI 是什么
SPI(Service Provider Interface)= JDK 内置的服务发现机制。核心思想:接口定义在 A 模块,实现类在 B 模块——A 不知道 B 的存在,但运行时通过 SPI 能加载 B 的实现。
① 定义接口(如 java.sql.Driver)
② 提供方在 META-INF/services/接口全限定名 文件中写实现类的全限定名
③ ServiceLoader.load(接口.class) 读取文件 → 反射实例化所有实现类
④ 调用方拿到 Iterator,遍历所有实现
经典案例:JDBC 驱动加载
// 你只需写这一行,DriverManager 内部通过 SPI 自动找到 MySQL Driver 并注册
Connection conn = DriverManager.getConnection("jdbc:mysql://...");
// META-INF/services/java.sql.Driver 文件内容:
// com.mysql.cj.jdbc.Driver
你从未手动 Class.forName("com.mysql.cj.jdbc.Driver") 但 DriverManager 找到了 MySQL 驱动——这就是 SPI。
Spring Boot 自动配置本质 = SPI 增强版
Spring Boot 的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 就是 SPI 文件——启动时读取所有 starter 的配置文件,加载对应的自动配置类。与 JDK SPI 的核心差异:
| JDK SPI | Spring Boot 自动配置 | |
|---|---|---|
| 加载策略 | 加载所有实现 | 按 @Conditional 条件加载(有对应 jar 才激活) |
| 配置文件 | META-INF/services/接口名 | META-INF/spring/...AutoConfiguration.imports |
| 发现机制 | ServiceLoader.load() 遍历 | Spring 容器启动时统一扫描 |
一句话:"SPI = JDK 版的插件机制——接口和实现解耦,实现类在运行时动态发现和加载。Spring Boot 的自动配置、JDBC 的驱动加载、SLF4J 的日志门面,背后都是 SPI 的思想。"
附:Java 14+ 新特性速览——代码更少但语义更清晰
金融系统 JDK 版本通常滞后(多数项目仍用 JDK 8),但理解语言演进方向有助于技术决策。以下聚焦四个关键新特性。
Records(JDK 16 正式):不可变数据载体
// 传统:50+ 行 POJO(字段 + getter/setter + equals + hashCode + toString)
// Record 一行搞定:
public record User(Long id, String name, String email) {}
// 使用:new User(1L, "张三", "zhang@example.com")
// getter 风格变为字段名本身:user.id(), user.name()
核心特性:不可变(final 字段,无 setter)→ 天然适合 DTO/VO/配置快照/返回值。限制:不能继承类(隐式 extends Record)。
Pattern Matching for instanceof(JDK 16 正式)
// 传统:判断 + 强转两步
if (obj instanceof String) { String s = (String) obj; ... }
// 新写法:一步到位
if (obj instanceof String s && s.length() > 5) { ... } // s 可直接使用
Switch 表达式(JDK 14 正式)
// 箭头语法消除穿透 + yield 返回值:
String result = switch (dayOfWeek) {
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "工作日";
case SATURDAY, SUNDAY -> "休息日";
}; // 编译器检查是否覆盖全部枚举值
// 复杂逻辑用 yield:
String msg = switch (status) {
case PAID -> "已支付";
case CANCELLED -> { log.info("订单取消"); yield "已取消"; }
default -> "未知";
};
Sealed Classes(JDK 17 正式)+ Text Blocks(JDK 15 正式)
// Sealed Classes:限制子类范围
public sealed class Payment permits AlipayPayment, WechatPayment, BankPayment { }
// Text Blocks:三引号多行字符串
String json = """
{ "name": "张三", "age": 30 }
""";
一句话:"Java 新特性的方向不是增加复杂度,而是减少样板代码的同时让语义更精确——Records 消灭 POJO 样板、Pattern Matching 消灭强转、Switch 表达式消灭穿透 bug、Sealed Classes 精确控制继承。金融项目保守是事实,但语言演进的方向是明确的。"
核心要点回顾
IoC 容器的核心是将对象的创建和管理权从"程序员 new"反转给"容器"。容器内部的工作流程是:扫描配置解析为 BeanDefinition(Bean 的配方——类名、scope、lazy-init、依赖关系)→ 通过反射创建实例 → 放入单例池(本质为 ConcurrentHashMap)。三级缓存是 Spring 解决循环依赖的关键设计:singletonObjects(一级,成品 Bean)→ earlySingletonObjects(二级,提前暴露的半成品引用)→ singletonFactories(三级,ObjectFactory 能生成早期引用)。A 依赖 B → B 依赖 A 的流程是:A 实例化后暴露 ObjectFactory 到三级缓存 → 注入 B 时触发 B 的实例化 → B 注入 A 时从三级缓存获取 A 的早期引用 → B 创建完成 → A 拿到 B → A 创建完成。构造器注入无法解决循环依赖——构造器阶段对象尚未放入缓存,需要先有依赖才能有对象,形成死锁。
AOP 两种代理有本质差异:JDK 动态代理基于接口(Proxy.newProxyInstance(),代理类与目标类是兄弟关系——注入时需用接口类型接收)vs CGLIB 基于继承(生成目标类的子类,父子关系,类和方法不能是 final)。Spring Boot 2.x 统一默认 CGLIB。同类内部调用不走代理是最常见的陷阱——this.methodB() 直接调用目标对象本身,不经过代理 → @Transactional/@Cacheable 全部失效,解决方案是注入自身代理对象 self.methodB()。@Transactional 六种失效场景中,同类内部调用加异常处理不当覆盖了 80% 的实际问题:同类调用(不经过代理)、非 public 方法(CGLIB 无法重写)、异常被 catch 吞掉(Spring 感知不到异常)、rollbackFor 配错(默认仅回滚 RuntimeException 和 Error)、MyISAM 引擎不支持事务、多线程(事务通过 ThreadLocal 绑定线程,子线程无法继承)。
自动配置的本质是条件装配加约定大于配置——启动时加载自动配置类,每个类通过 @ConditionalOnClass(classpath 有对应 jar 才激活)、@ConditionalOnMissingBean(用户未自行定义才采用默认)、@EnableConfigurationProperties(application.yml 绑定到 Properties 类)决定是否生效。其底层机制是SPI——JDK 内置的服务发现机制:接口定义在 A 模块、实现在 B 模块,运行时通过 ServiceLoader 反射实例化所有实现(如 JDBC 驱动加载——从未 Class.forName 但 DriverManager 找到了 MySQL 驱动)。Java 14+ 新特性(Records/Pattern Matching/Switch 表达式/Sealed Classes/Text Blocks)的方向是减少样板代码同时让语义更精确。
@Transactional 失效,99%的根因是"没走代理"。记住这一条,排查顺序从"同类内部调用"开始,比挨个看 6 种场景快得多。收藏这张对照清单,下次"加了注解没回滚"直接翻。
下一篇:《消息队列:支付成功余额却没扣?RocketMQ三段式兜底方案》 系列合集:掘金Java合集