🕐 线上事故 — 周五 23:47
🚨 你加了 @Transactional,但异常时数据还是提交了
你写了一个订单创建方法,加了 @Transactional,测试环境一切正常。
上线后,用户反馈"下单失败但扣了钱"——异常抛出了,数据库却没有回滚。
你百思不得其解:明明加了 @Transactional,为什么事务没生效?

🔍 排查发现:你的 batchCreate() 方法内部调用了 this.create()

💥 this 是原始对象,不是代理对象 → 绕过了代理 → 事务增强根本没执行

💡 Spring 对你隐瞒了什么?它把你的 Service 替换成了一个"替身"(代理对象),

  但你在类内部用 this 调用时,走的是原始对象,替身根本没机会介入。

👉 本页核心问题:Spring 运行时到底做了什么?你的代码真的在按你以为的方式执行吗?

Spring 运行真相

7大核心主题 · 6个SVG · 8个IDE代码实战 · 8道面试FAQ · 5种❌✅对比

Bean 生命周期 AOP 代理原理 @Transactional 真相 CGLIB vs JDK 代理失效5大坑
🗺️ 学习路线图 基础认知(Spring帮你做了什么) ① Bean从无到有 ② 实例化≠初始化(90%搞混) ③ AOP代理=替身演员 ★核心★ 实战应用(理解代理后,坑就不再是死记硬背) ④ CGLIB怎么代理(源码级) ⑤ @Transactional失效5大坑 ★面试必考★ ⑥ 完整请求链路串起来 总结速查(面试秒答) ⑦ 高频疑问8道(可折叠)
三层结构:基础认知 → 实战应用 → 总结速查。★ 标记的是核心/面试高频章节
① Bean 从无到有:Spring 启动时做了什么
你以为 @Service 只是标记?启动时它经历了一条完整的流水线
Bean 生命周期的完整流水线 1 扫描与注册 BeanDefinition 读取 @Component/@Service/@Configuration → 创建 BeanDefinition("图纸") 2 实例化(createBeanInstance) 用构造方法 new 出对象(此时属性全是默认值,还没有注入依赖) 3 属性注入(populateBean) @Autowired/@Value/@Resource → Spring 找到依赖注入到字段中 4 Aware 回调 注入 ApplicationContext/BeanName/Environment 等容器信息 5 BeanPostProcessor 前置 + 后置处理 @PostConstruct 在这里执行 ★ AOP 代理也在这里创建(后置处理中) 6 初始化完成 → 放入 IoC 容器(单例池)→ 就绪可用 从此之后所有 @Autowired 注入的都是这个"成品"(可能是代理对象) ★ 步骤5 的 AOP 代理创建是整个流程中最关键的一步
Spring 启动时对每个 Bean 走这条流水线,AOP 代理在步骤5 中悄悄替换了原始对象
🏭 类比:工厂流水线生产一个产品

📐 BeanDefinition = 产品图纸(描述要生产什么、有什么参数)

🔨 实例化 = 把原材料铸成毛坯(壳子有了,但零件还没装)

🔩 属性注入 = 装零件(把依赖的螺丝/电路板装上去)

📋 Aware 回调 = 贴产品编号(告诉它自己在哪条产线)

🎨 BeanPostProcessor = 喷漆/贴标/质检(这里是 AOP 代理"包装"产品的环节)

放入仓库 = 质检合格的成品入库,随时发货

关键认知 Spring 启动时就把所有 Bean 创建完毕并放好了。运行时只是从容器里拿现成的。这和你写的 new 完全不同——Spring 帮你 new,帮你注入依赖,帮你创建代理,最后给你一个"成品"。这就是 IoC(控制反转):对象的创建权从你的代码转移给了 Spring。
👆 上面看到 Bean 创建有6个步骤,其中步骤2和步骤5最容易搞混。下面我们拆开看——实例化和初始化到底什么区别?为什么90%的新手在这个地方踩坑?
② 实例化 ≠ 初始化:这两个概念必须区分
90% 的困惑来自把"实例化"和"初始化"混为一谈。最直接的后果:构造器里用 @Autowired 字段 → NPE
📁 步骤拆解
📁 OrderServiceImpl
📄 实例化(步骤2)
📄 注入(步骤3)
📄 初始化(步骤5)←
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; // 步骤3注入 @Value("${app.name}") private String appName; // 步骤3注入 // ====== 步骤2:实例化 ====== // JVM 执行:new OrderServiceImpl() // 此时:orderMapper = null, appName = null // 对象已经存在,但内部是"空壳" @PostConstruct public void init() { // ====== 步骤5:初始化 ====== // 此时 orderMapper 已经注入好了,可以安全使用 System.out.println(appName + " 启动完成"); // ✅ 安全:orderMapper 不为 null } }

为什么区分这么重要?

阶段做了什么此时 @Autowired 字段能安全使用吗
实例化new 构造方法❌ null❌ 构造器中不能用
属性注入Spring 反射注入✅ 已注入⚠️ 但实例不完整
初始化@PostConstruct✅ 可用✅ 完全安全
📁 ❌ NPE 常见写法
📁 常见错误
📄 构造器中用依赖
📄 BadService.java ←
@Service public class BadOrderService { @Autowired private OrderMapper orderMapper; public BadOrderService() { // ❌ 构造器执行时机 = 步骤2(实例化) // 此时 Spring 还没执行步骤3(属性注入)! // orderMapper 是 null! orderMapper.selectAll(); // 💥 NullPointerException! } }
📁 ✅ 正确写法
📁 两种正确方式
📄 @PostConstruct
📄 构造器注入
📄 GoodService.java ←
// ✅ 方式1:@PostConstruct(最常用) @Service public class GoodOrderService { @Autowired private OrderMapper orderMapper; @PostConstruct // ← 步骤5才执行,此时orderMapper已注入 public void init() { orderMapper.selectAll(); // ✅ 安全!orderMapper 不为 null } } // ✅ 方式2:构造器注入(Spring推荐的写法) @Service public class BetterOrderService { private final OrderMapper orderMapper; @Autowired // ← Spring 先创建依赖,再调构造器 public BetterOrderService(OrderMapper orderMapper) { this.orderMapper = orderMapper; // ✅ 构造器注入,不为 null orderMapper.selectAll(); // ✅ 安全! } }
判断口诀 构造器中用字段 → 问自己:此时字段注入了吗?如果是 @Autowired 字段注入 → 还没注入(步骤2先于步骤3)→ ❌ NPE。如果是构造器参数注入 → Spring 保证先创建依赖再调构造器 → ✅ 安全
👆 上面看到 Bean 创建有6步,其中步骤5的 BeanPostProcessor 有一项秘密任务——AOP 代理创建。下面我们就揭开这个替身的真面目:你以为你在调原始对象,其实你在调一个"替身"。
③ AOP 代理到底是什么
这是 Spring "魔法"的核心——你以为你在调原始对象,其实你在调替身
没有 AOP vs 有 AOP:方法调用对比 没有 AOP 的情况 Controller 调用 orderService.create() OrderServiceImpl.create() 直接执行 → new Order() → mapper.insert() → return 返回结果 直接调用,无任何增强 没有事务、没有日志、没有限流 → 所有横切关注点都得手写在代码里 有 AOP 的情况(真实 Spring) Controller 调用 orderService.create() ① 代理:@Before → 记日志 ② 代理:开启事务(getConnection) ③ 真正调用 OrderServiceImpl.create() ④ 代理:提交事务(commit) ⑤ 代理:@After → 记返回日志 代理对象层层包裹! 你的代码是③,①②④⑤ 都是代理自动加的
AOP 代理像一个"洋葱"——你的代码在核心,外面裹着日志、事务、权限等增强层
🎬 类比:电影里的替身演员

🎥 导演(Controller)说"开拍!"——他以为自己对着明星本人喊的。

🎭 但实际走出来的可能是替身(代理对象)。替身做了大部分准备工作,只有危险动作的瞬间才让真正的明星(你的业务代码)上场。

🎬 替身(代理)做什么:

 ① 开拍前化妆、道具准备(@Before / 开启事务)

 ② 明星本人表演核心镜头(你的 Service 代码)

 ③ 收工后场务清理、结算(@After / 提交事务)

导演完全无感——他以为自己一直在跟明星说话。Controller 也是——它以为自己调的是原始 Service。

AOP 代理洋葱模型:调用顺序与返回顺序 日志切面 @Before → 记录入参 事务切面 → getConn + setAutoCommit(false) 你的业务代码 orderService.create(dto) 调用→ 外→内 ←返回 内→外 事务切面 → commit() 或 rollback() 日志切面 @After → 记录返回值
调用时从外到内(日志→事务→业务),返回时从内到外(业务→事务→日志)。这就是"洋葱模型"
👆 现在你知道 AOP 代理是"替身"了。但这个替身是怎么被创造出来的?CGLIB 用字节码技术偷偷生成了你的类的子类。下面看源码级拆解。
④ CGLIB 代理是怎么创建的
Spring 在启动时(步骤5)偷偷用字节码技术生成了一个子类
📁 代理创建过程
📦 Spring 启动
扫描到 OrderServiceImpl
检测到 @Transactional
CGLIB 生成子类 ←
代理对象注入容器
📦 运行时
注入的是代理对象
// ========== 你写的原始类 ========== @Service @Transactional public class OrderServiceImpl implements OrderService { public Order create(OrderDTO dto) { // 业务代码 return order; } } // ========== Spring 用 CGLIB 生成的子类(运行时动态创建,你看不到)========== // CGLIB 通过 ASM 字节码操作,在内存中生成了类似下面的类: public class OrderServiceImpl$$EnhancerByCGLIB$$abc1234 extends OrderServiceImpl { // ← 继承你的类! private MethodInterceptor interceptor; // 拦截器链 @Override public Order create(OrderDTO dto) { // 这就是代理逻辑!先增强,再调父类(即原始代码) return interceptor.intercept( this, // 代理对象 OrderServiceImpl.class.getMethod("create"), // 目标方法 new Object[]{dto}, // 参数 methodProxy // 调用父类原始方法的代理 ); // interceptor.intercept 内部逻辑: // 1. 执行 @Before(日志等) // 2. 开启事务(getConn + setAutoCommit(false)) // 3. super.create(dto) ← 调用父类,即你写的真正代码 // 4. commit() 或 rollback() // 5. 执行 @After // 6. 返回结果 } } // ========== Controller 注入的是谁?========== @Autowired private OrderService orderService; // ↑ 实际注入的是 OrderServiceImpl$$EnhancerByCGLIB$$abc1234 // ↑ 不是原始的 OrderServiceImpl!

CGLIB vs JDK 动态代理

对比项JDK 动态代理CGLIB 代理(Spring 默认)
原理基于接口,生成实现类基于继承,生成子类
要求目标类必须实现接口不需要接口,但不能是 final 类
代理对象类型接口的实现类目标类的子类
Spring Boot 2.x+默认不使用★ 默认使用
性能创建快,调用稍慢创建稍慢,调用快
final 方法接口中的 default 可代理❌ final 方法不能被重写→不能代理
💡 Spring Boot 2.x 之后默认用 CGLIB

spring.aop.proxy-target-class=true(默认值)。即使你的类实现了接口,也用 CGLIB(生成子类)。所以你经常会看到 class 名带 $$EnhancerByCGLIB$$ 后缀。

👆 代理原理搞清楚了。现在最关键的问题来了:既然代理对象帮你开启事务,那什么情况下事务会"不生效"?答案是:凡是绕过代理的调用,事务都不生效。下面5大坑,面试必考。
⑤ @Transactional 失效的5大经典场景(❌✅代码对照)
一旦理解了代理原理,这些"坑"就不再是死记硬背——所有失效的根因都一样:请求没有经过代理对象
坑1:类内部方法调用(最常见的坑,占失效案例60%+)

失效原因:内部调用走的是 this.method(),而 this 是原始对象,不是代理对象 → 绕过了代理 → 事务增强不生效。

📁 ❌ vs ✅ 坑1
📁 内部调用
❌ this.create()
✅ self.create()
📄 OrderService.java ←
@Service public class OrderServiceImpl { @Autowired private OrderService self; // ← 注入自己(代理对象) public void batchCreate(List<OrderDTO> list) { for (OrderDTO dto : list) { // ❌ 错误:this.create() 走的是原始对象,事务不生效 // this.create(dto); // ✅ 正确:通过 self(代理对象)调用,事务生效 self.create(dto); } } @Transactional public Order create(OrderDTO dto) { return orderMapper.insert(dto); } }
坑2:@Transactional 加在 private/final/static 方法上

失效原因:CGLIB 代理通过生成子类 + 重写方法实现。private 方法子类看不到,final 方法不能重写,static 方法属于类不属于实例 → 代理对象拦截不到 → 事务不生效。

📁 ❌ vs ✅ 坑2
📁 方法修饰符
❌ private/final/static
✅ public
📄 TxMethodDemo.java ←
@Service public class TxMethodDemo { @Transactional private void privateCreate() { // ❌ private: CGLIB子类看不到这个方法 orderMapper.insert(order); // → 代理拦截不到 → 事务不生效 } @Transactional public final void finalCreate() { // ❌ final: CGLIB子类不能重写 orderMapper.insert(order); // → 代理拦截不到 → 事务不生效 } @Transactional public static void staticCreate() { // ❌ static: 不属于实例,代理管不到 orderMapper.insert(order); // → 代理拦截不到 → 事务不生效 } @Transactional public void publicCreate() { // ✅ public: CGLIB子类可以重写 orderMapper.insert(order); // → 代理正常拦截 → 事务生效 } }
坑3:异常被 catch 吃掉了

失效原因:事务回滚的触发条件是代理对象检测到异常从方法中抛出。如果你在方法内部 try-catch 了异常,代理对象看不到异常 → 认为执行成功 → commit 而不是 rollback。

📁 ❌ vs ✅ 坑3
📁 异常处理
❌ catch吞掉
✅ re-throw / setRollback
📄 TxExceptionDemo.java ←
@Transactional public void createOrder(OrderDTO dto) { try { orderMapper.insert(order); detailMapper.insert(detail); } catch (Exception e) { log.error("创建失败", e); // ❌ 异常被catch吞掉了! // 代理看不到异常 → commit → 数据已入库,无法回滚! } } // ✅ 修复方式1:re-throw @Transactional(rollbackFor = Exception.class) public void createOrderGood1(OrderDTO dto) { try { orderMapper.insert(order); detailMapper.insert(detail); } catch (Exception e) { log.error("创建失败", e); throw new RuntimeException(e); // ✅ 重新抛出,代理能检测到 } } // ✅ 修复方式2:手动标记回滚 @Transactional public void createOrderGood2(OrderDTO dto) { try { orderMapper.insert(order); detailMapper.insert(detail); } catch (Exception e) { log.error("创建失败", e); TransactionAspectSupport.currentTransactionStatus() .setRollbackOnly(); // ✅ 手动标记回滚 } }
坑4:新线程丢失事务上下文

失效原因:Spring 事务基于 ThreadLocal 绑定 Connection。新线程有新的 ThreadLocal → 拿不到当前事务的 Connection → 新线程的数据库操作不受事务管控。

📁 ❌ vs ✅ 坑4
📁 多线程
❌ CompletableFuture
✅ 同线程/编程式事务
📄 TxThreadDemo.java ←
@Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(order); // 主线程:有事务 // ❌ 新线程:ThreadLocal 是空的 → 不在事务中 CompletableFuture.runAsync(() -> { logMapper.insert(log); // 💥 不受事务管控! }); } // ✅ 修复:在新线程中手动开启编程式事务 @Autowired private PlatformTransactionManager txManager; public void createOrderGood(OrderDTO dto) { orderMapper.insert(order); CompletableFuture.runAsync(() -> { TransactionTemplate txTemplate = new TransactionTemplate(txManager); txTemplate.execute(status -> { // ✅ 新线程独立事务 logMapper.insert(log); return null; }); }); }
坑5:checked 异常默认不回滚

失效原因:Spring 默认只对 RuntimeExceptionError 回滚。如果你抛出的是 checked exception(如 IOException),默认不回滚。

📁 ❌ vs ✅ 坑5
📁 异常类型
❌ checked不回滚
✅ rollbackFor=Exception
📄 TxCheckedDemo.java ←
@Transactional public void uploadAndSave() throws IOException { orderMapper.insert(order); fileService.upload(); // 抛出 IOException (checked) // ❌ IOException 是 checked exception // Spring 默认不回滚 → order 已经入库了! } // ✅ 修复:显式指定 rollbackFor @Transactional(rollbackFor = Exception.class) // ← 加上这个! public void uploadAndSaveGood() throws IOException { orderMapper.insert(order); fileService.upload(); // IOException → ✅ 回滚! }

🔴 @Transactional 失效速查表

❌ 错误写法✅ 正确写法根因
1. 内部调用this.create()self.create() / AopContext绕过代理
2. 方法修饰符private/final/staticpublic代理无法重写
3. 异常吞掉catch 后不 re-throwthrow / setRollbackOnly代理看不到异常
4. 新线程CompletableFutureTransactionTemplateThreadLocal 隔离
5. checked 异常@Transactional (无参)rollbackFor=Exception.class默认只回滚 RuntimeException
核心逻辑 所有 @Transactional 失效的根因都一样:请求没有经过代理对象。记住这个判断链:@Transactional 是否生效 → 取决于调用是否经过代理 → 取决于调用方式是否通过 Spring 注入的 Bean → 类内部 this.method() 不经过代理 → 失效。理解了代理原理,这5个坑不需要死记。
👆 5大坑搞清楚了。现在我们把前面的所有概念串成一条完整的执行链——从 Controller 收到请求到数据库写入再回来,看看代理、ThreadLocal、连接池是怎么协作的。
⑥ 完整请求链路:从 Controller 到 DB 再回来
把前面的所有概念串成一条完整的执行链
① Controller 接收请求

Tomcat 线程执行 OrderController.createOrder()

Controller 中注入的 orderService 实际是 代理对象

② 代理前置

进入代理对象的 create() → CGLIB 拦截器介入

执行 @Before 通知 → 开启事务:从连接池获取 Connection → setAutoCommit(false)

关键:Connection 绑定到 ThreadLocal,同一线程内后续的 Mapper 调用共用这个连接

③ 调用原始方法

代理对象通过 methodProxy.invokeSuper() 调用父类(原始 OrderServiceImpl)

你的业务代码开始执行:new Order() → set 属性 → orderMapper.insert()

④ Mapper 执行

OrderMapper 也是代理对象(MyBatis 用 JDK 动态代理)

MyBatis 代理:找 SQL → 参数绑定 → 从 ThreadLocal 获取 Connection → JDBC execute → MySQL

注意:Mapper 和 Service 用的是同一个 Connection(通过 ThreadLocal 传递),这就是事务能保证原子性的原因

⑤ 返回原始方法

Mapper 返回 → Service 返回 Order → 回到代理对象

⑥ 代理后置

无异常 → connection.commit() → 事务提交

有异常 → connection.rollback() → 事务回滚

执行 @After 通知 → Connection 归还连接池 → ThreadLocal 清理

⑦ 返回 Controller

代理返回 Order → Controller 包装成 Result → Jackson 序列化 → 响应浏览器

Spring 事务本质:代理 + ThreadLocal + 连接池 CGLIB 代理对象 ① getConn → setAutoCommit(false) → ③ commit/rollback → ④ 归还连接 ThreadLocal ② 绑定Connection到当前线程 OrderServiceImpl.create() OrderMapper.insert() 共用同一个 Connection(从 ThreadLocal 取) HikariCP 连接池 ① borrow → ④ return 🔑 代理管事务开关 + ThreadLocal管连接传递 + 连接池管连接复用 = 三者缺一不可
事务的本质:代理负责开关事务,ThreadLocal 保证同线程共享连接,连接池管理连接生命周期

🔑 事务的本质

Spring 事务 = 代理 + ThreadLocal + 连接池。代理负责在方法前后开关事务,ThreadLocal 保证同一线程内共用一个 Connection,连接池负责管理连接。三者缺一不可。

这就是为什么 @Transactional 只在通过代理调用时生效——代理是事务的发起者,绕过代理就没有人帮你 commit/rollback 了。

👆 完整链路串完了!最后来高频疑问速查——点击展开查看,面试前快速过一遍。
⑦ 高频疑问(点击展开)
Q1:Bean 是单例的,200 个线程同时调用同一个 Service 会冲突吗?

不会冲突,前提是你不使用成员变量存请求数据。因为虽然 Service 是单例(堆中一个对象),但每个线程执行时的局部变量在各自的栈上——线程私有。

Thread-1 的 create() 栈帧里的 order 变量和 Thread-2 的完全隔离。只要你的 Service 没有可变的成员变量(只有无状态方法),它就是天然线程安全的。

危险写法:在 Service 中加 private int count = 0; 然后在方法中 count++ → 200 个线程同时修改堆中同一个 count → 并发问题。

Q2:Spring 启动很慢,Bean 创建做了什么?

启动时要对每个 Bean 走完整生命周期:实例化 → 注入依赖 → 初始化。如果项目有几千个 Bean,每个 Bean 还要依赖其他 Bean(有依赖链),整个过程可能需要十几秒到几十秒。

这就是为什么 Spring Boot 2.2+ 引入了懒加载spring.main.lazy-initialization=true)——启动时不创建 Bean,第一次被使用时才创建。但生产环境一般不用,因为第一个请求会变慢。

Q3:循环依赖是什么?Spring 怎么解决?

A 依赖 B,B 又依赖 A → 创建 A 时需要 B,创建 B 时又需要 A → 死循环。

Spring 用三级缓存解决:创建 A 时先把 A 的"半成品"(刚实例化还没注入依赖)放进缓存 → 去创建 B → B 注入时从缓存拿到 A 的半成品 → B 创建完成 → 回来把 B 注入到 A → A 完成。

限制:构造器循环依赖无法解决(因为构造器阶段还没法把半成品放进缓存)。只有 setter/字段注入的循环依赖才能自动解决。

Q4:拦截器(Interceptor)、过滤器(Filter)、AOP 有什么区别?

执行顺序:Filter → Interceptor → Controller → Interceptor → Filter

Filter:Servlet 规范级别,在请求到达 Spring 之前就执行。可以修改 request/response。常用于:编码、CORS、日志。

Interceptor:Spring MVC 级别,在 DispatcherServlet 路由前后执行。能拿到 HandlerExecutionChain。常用于:鉴权、限流。

AOP:Bean 级别,通过代理拦截方法调用。粒度最细(到具体方法)。常用于:事务、日志、缓存。

它们是不同层级的拦截机制,可以同时使用。

Q5:为什么 Spring 选择 CGLIB 而不是 JDK 动态代理?

JDK 动态代理要求目标类必须实现接口。但很多 Service 类只有一个接口,或者根本没接口(直接是类)。JDK 代理无法覆盖。

CGLIB 通过生成子类实现代理,不需要接口,覆盖面更广。Spring Boot 2.x 开始默认 proxy-target-class=true,统一用 CGLIB,避免"有时候生效有时候不生效"的困惑。

限制:CGLIB 不能代理 final 类和 final 方法(不能继承/重写)。所以你的 Service 类不要加 final。

Q6:@Transactional(propagation = ...) 有几种传播行为?什么时候用?

7种传播行为,最常用的3种:

REQUIRED(默认):当前有事务就加入,没有就新建。95%的场景用这个。

REQUIRES_NEW:总是新建事务,挂起当前事务。用于:独立操作(如日志),不受外层事务回滚影响。

NESTED:在当前事务中创建保存点(savepoint),回滚只回到保存点。用于:部分失败不影响整体。

其余4种(SUPPORTS/NOT_SUPPORTED/MANDATORY/NEVER)极少使用,面试说出上面3种就够了。

Q7:怎么确认我的 Bean 到底是不是代理对象?

在代码中打印 orderService.getClass().getName()

• 如果类名带 $$EnhancerByCGLIB$$ → CGLIB 代理对象 ✅

• 如果类名带 $Proxy → JDK 动态代理对象 ✅

• 如果类名就是原始类名 → 没有被代理(没有 AOP 增强)

也可以在 IDEA 调试时查看 Variables 面板,代理对象会显示特殊类型。

Q8:@Async 和 @Transactional 能一起用吗?

可以一起用,但要注意顺序:

@Async @Transactional → 先创建事务代理,再创建异步代理。异步线程中事务正常工作。

• 如果只在方法上标了 @Async,异步线程中的数据库操作没有事务(新线程的 ThreadLocal 是空的)。

• 如果异步方法需要事务,必须同时标注 @Transactional,Spring 会在异步线程中独立开启事务。

🎯 心智模型总结

Spring 的"魔法"本质上只有两件事:IoC(帮你创建和管理对象)和 AOP(帮你在方法前后增强逻辑)。理解了 Bean 生命周期和代理原理,你就理解了 Spring 90% 的行为。

下次遇到"为什么不生效",问自己:这次调用经过了代理对象吗? 如果没有,那就是答案。

📁 运行原理图解 / 03-Spring运行真相 — 配套阅读:① Java请求全链路 · ② JVM内存模型 · Spring深度图解
核心三原则速记

Spring三原则:① Bean默认单例,不要在Bean中存请求级状态 → ② @Transactional不能在类内部调用中生效 → ③ 循环依赖能用但不推荐