@Transactional加了没回滚?排查了不下20次,总结出这张6种失效场景对照清单

40 阅读13分钟

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 启动时:

  1. 扫描配置 → 解析成 BeanDefinition(Bean 的配方:类名、scope、lazy-init、依赖关系...)
  2. 通过 BeanFactory 按配方生产 Bean(反射:Class.newInstance() 或 Constructor.newInstance())
  3. 放入单例池(本质是 ConcurrentHashMap)
  4. 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 SPISpring 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合集