拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring循环依赖深究:三级缓存、AOP代理与治理实战

先说一个我自己的真实经历。某天下午准备发新版本应用启动到一半直接挂掉日志里躺着一行异常BeanCurrentlyInCreationException后面跟着一串-箭头指向的依赖链描述。排障排到晚上才确认A 服务要注入 B 服务B 服务又要注入 A 服务典型到不能再典型的 Spring 循环依赖。当时的第一反应是“先加个 Lazy 顶一下”但等到后来我把三级缓存机制真正吃透才发现那个 Lazy 只是把问题掩盖住并没有解决根因。这篇文章就从那次事故出发把 Spring 循环依赖的报错分析、三级缓存原理、AOP 代理对齐、不同边界场景以及日常治理方案完整拆一遍。不管你现在是在线上排障还是在准备面试或者只是单纯想弄明白 Spring 容器为什么能处理这种引用环这篇都能给你一套能直接用的思路。1. 线上事故现场BeanCurrentlyInCreationException 是怎么爆出来的很多第一次遇到循环依赖的人盯着异常堆栈看了半天也不知道问题出在哪。其实 Spring 已经把排查线索写得相当直白了关键是你得知道怎么读。1.1 报错堆栈的正确读法先找箭头链再看被谁触发先看一段典型的异常输出org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name aService: Requested bean is currently in creation: Is there an unresolvable circular reference?这行字的意思是容器正在创建aService但是在创建过程中又有人向容器要aService而它已经在创建流程里了没法再给你一个完整的对象。真正有价值的信息通常在更下面的依赖链描述里。Spring 在检测到循环引用时会把当前正在创建的 Bean 链完整打出来类似aService - bService - aService这条链就是整个循环依赖的“作案路径”创建aService时发现要注入bService于是转去创建bService结果bService又要注入aService转头一看aService还在创建中直接炸了。排障第一步永远不是去看哪个 Bean 漏配了而是先把这条箭头链还原出来沿着链去查每个箭头对应的注入点。注入点可能是一个Autowired字段、一个 setter 方法也可能是一个构造器参数。找到了注入点你就知道了是哪两个模块之间产生了“你离不开我、我也离不开你”的死结。1.2 为什么构造器注入的循环依赖Spring 也救不了这里要区分一个关键概念Spring 的循环依赖解决方案是有使用前提的。默认情况下它能处理的只有“非构造器注入”的循环依赖比如字段注入、setter 注入。构造器注入形成的环Spring 会直接放弃治疗。原因要回到 Bean 实例化的最早一步。对一个 Bean 来说实例化就是调用它的构造器。如果A的构造器需要一个B那么 Spring 在创建A时必须先把B创建出来而B的构造器又需要A此刻A还没从构造器里走出来自然谈不上提前暴露一个半成品对象给B用。用大白话说人还没出生你怎么让他先签字构造器注入的循环依赖就是这种逻辑死锁Spring 没办法用一个“不存在的早期引用”去打破它唯一能做的就是抛异常告诉你这个环解不开。我见过不少团队被这个问题搞崩心态最后的临时方案是“把其中一个 Bean 改成 setter 注入”。这确实能接通启动流程但代价是丢失了构造器注入带来的不可变性保证属于用设计质量的下降换取容器启动。既然是救火可以理解但如果长期这么搞代码会越来越难维护。1.3 三步定位法从异常到依赖环的完整链路总结一下我处理这类报错的固定套路照着做基本能快速定位问题过滤堆栈锁定依赖链。从BeanCurrentlyInCreationException往上翻找到第一处描述-链的日志通常就是循环链的所有参与者。梳理注入方式。把链路中每个 Bean 的构造器、字段、setter 注入全部列出来圈出最可疑的那个箭头。画无向图判断环的根节点。把 Bean 看作节点、注入关系看作边画完图之后通常一眼就能看到环在哪。环内如果存在构造器注入基本就是硬死结如果全是字段或 setter 注入理论上 Spring 自己可以解。这套方法对三五个 Bean 的小环很快但要是项目里有几十个服务互相引用画图也累。后面我会专门讲如何用代码层面约束去避免这种局面比事后排障高效得多。2. 三级缓存拆解Spring 凭什么能在“半成品”上做文章循环依赖之所以能“被解决”核心是 Spring 内部那套缓存机制允许在 Bean 还没完全构造好之前先把一个早期引用暴露给别人用。这就是传说中的三级缓存。2.1 三个 Map 的角色划分成品区、半成品区、工厂区Spring 的 DefaultSingletonBeanRegistry 里维护了三个关键集合很多文章直接叫它“三级缓存”缓存层级集合名称存放内容生活类比一级缓存singletonObjects已经完成全部创建流程、可以直接注入使用的单例 Bean已经出锅的成品菜二级缓存earlySingletonObjects已经实例化但还没完成属性填充和初始化的早期引用对象正在锅里炖、但提前打包留给客人的半成品三级缓存singletonFactories早期引用的工厂 ObjectFactory真正需要时才调用 getObject() 生成对象菜谱客人点菜了才按菜谱现做一级缓存最常见平时通过getBean拿到的绝大多数对象都在里面。二级缓存是“已经从工厂里产出了、还没装修完”的对象。三级缓存最特殊它存的不是对象而是“对象工厂”并且默认情况下只有产生了循环依赖需求时工厂里的getObject()方法才会被真正触发。很多人刚开始接触这三个 Map 时容易把二级缓存和三级缓存混为一谈。记住一点二级缓存里放的是“已经生成的实际对象”三级缓存里放的是“生成对象的延迟入口”。设计成“入口”而不是直接放对象就是整个循环依赖方案里最精妙的地方。2.2 从 getSingleton 到 addSingletonFactoryBean 创建的完整时序Spring 在创建单例 Bean 时流程可以简化为实例化 - 提前曝光 - 属性填充 - 初始化 - 放入一级缓存。核心代码在doCreateBean里有这么一段逻辑boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }也就是说Bean 实例化完成之后Spring 会立刻把一个ObjectFactory塞进“三级缓存”。这个工厂的getObject()指向的就是getEarlyBeanReference它是让后续 AOP 代理有机会提前介入的钩子。当外部需要获取这个 Bean 时走的是getSingleton(String beanName, boolean allowEarlyReference)方法查找顺序如下先从一级缓存singletonObjects取取不到且 Bean 正在创建中去二级缓存earlySingletonObjects取二级缓存也没有则从三级缓存singletonFactories拿到ObjectFactory调用getObject()生成早期引用生成后放到二级缓存并从三级缓存移除下次再取就直接命中二级缓存。这套流程保证了同一个单例 Bean 在整个创建周期内只会生成一次早期引用不会出现多个人各拿各的引用、最后互相不一致的问题。2.3 为什么非要用三级缓存两级行不行这是面试必考题也是理解循环依赖的关键。先说结论如果只是解决“循环依赖导致拿不到对象”的问题二级缓存就够了但要同时保证 AOP 代理不失效就必须上三级缓存。假设现在只有一级缓存和二级缓存没有三级缓存。那么 Bean 在实例化完成后可以直接把原始对象塞进二级缓存。后续B需要A的时候从二级缓存拿到的是A的“原始对象”。问题来了A上如果配置了TransactionalSpring 在 Bean 初始化最后阶段会通过BeanPostProcessor给A生成一个代理对象放回一级缓存。这也意味着最终容器里对外提供的是代理后的A而B手里拿到的却还是没经过代理的原始A。切面逻辑对B这条链路完全失效。三级缓存的核心价值就在于“延迟生成 可拦截”只有当真的有人需要早期引用时才会通过getEarlyBeanReference生成对象而这个方法会给后置处理器一个机会把代理逻辑提前执行掉。这样B拿到的A从一开始就是经过 AOP 包装的代理对象和最终容器里保存的对象版本保持一致。简单说三级缓存不是为“能不能拿到对象”设计的而是为“拿到的对象必须和最终版本长一样”设计的。如果你把三级缓存去掉循环依赖在简单场景下还能跑但一旦叠上切面、事务、缓存这些能力就会开始出现隐蔽的代理失效问题。2.4 一个典型的 A 依赖 B、B 依赖 A 全流程推演把理论变成时序走一遍最常见的字段注入循环依赖调用getBean(aService)开始创建AA完成实例化Spring 把A的ObjectFactory放入三级缓存开始属性填充发现A需要注入B于是调用getBean(bService)B完成实例化同样把B的工厂放入三级缓存填充B的属性时发现需要注入A再次调用getBean(aService)一级缓存里没有A但发现A正在创建中于是去二级缓存找也没有继续去三级缓存拿到A的工厂调用getObject()触发getEarlyBeanReference返回A的早期引用可能是原始对象也可能是代理对象把这个早期引用放到二级缓存并移除三级缓存里的工厂然后将其注入给BB完整走完初始化最终放入一级缓存回到A的创建流程A成功拿到B继续完成初始化最后放入一级缓存。整个循环被“提前暴露早期引用”这个操作从中间撕开。关键点在第 6 步A虽然没有完全造好但它的“半成品”已经可以交给别人了。3. 循环依赖的 AOP 难题提前曝光时代理是怎么对齐的理解了三级缓存的结构之后就要面对真正的复杂性AOP 代理怎样和循环依赖共存。很多线上事故不是循环依赖本身导致而是循环依赖和代理对象混在一起后产生的“二次翻车”。3.1 没有三级缓存时 AOP 会出什么乱子脑补一个场景TransactionService上有Transactional它依赖OrderServiceOrderService又依赖TransactionService。如果容器只提供了早期原始对象给OrderService那么OrderService内部调用的TransactionService方法就完全没有事务能力。原因不复杂Spring 的声明式事务靠的是代理对象Transactional方法只有通过代理调用时才会触发事务逻辑。OrderService如果拿到的是原始对象相当于绕过代理直接调底层类事务切面直接被跳过。轻则事务不生效重则出现连接泄漏、数据不一致这类更难排查的生产事故。所以可以这么记三级缓存保护的不只是“对象引用”还有“对象符不符合最终版本预期”。它留给 AOP 的钩子getEarlyBeanReference保证了对象在最坏情况下仍然能带上正确的代理。3.2 getEarlyBeanReference 与 postProcessAfterInitialization 的协同Spring 的SmartInstantiationAwareBeanPostProcessor里定义了getEarlyBeanReference而AbstractAutoProxyCreator实现了该方法负责提前创建代理。同时AbstractAutoProxyCreator内部维护了一个earlyProxyReferences集合用来记录哪些 Bean 已经被提前代理过。到了初始化阶段的postProcessAfterInitialization它第一件事就是去查earlyProxyReferences里有没有这个 Bean。如果有说明循环依赖过程中已经生成过早期代理直接返回现有对象不再重复创建代理如果没有才执行常规的 AOP 代理创建。这个设计解决了一个很实际的问题避免同一个 Bean 被代理两次。如果早期引用阶段已经生成了一次代理对象后期初始化时又生成一个就会造成“代理套代理”不仅浪费还会让this引用错乱事务、切面行为变得不可预期。3.3 Transactional、Async 等特殊代理的实战表现实际项目里最常见的循环依赖叠加 AOP 的坑集中在Transactional和Async。先说Transactional。如果是字段注入或者 setter 注入Spring 可以在早期引用阶段把事务代理生成出来循环依赖通常不会卡启动。但要小心行为并不总是直白如果A和B配置了不同的事务管理器或者代理创建顺序交错仍可能出现事务不生效的情况。我自己排查过一个案例表面上是循环依赖导致启动慢实际是代理类和目标类混乱事务日志里能看到大量“Skipping transactional execution because method is not public”这类提示本质是调用到了原始对象。再说Async。Async的代理和Transactional类似也是通过后置处理器生成的。但Async往往涉及线程池、异步上下文早期代理创建时如果相关基础设施还没完全就绪可能报一些莫名其妙的空指针。这种问题最阴的地方在于不是必现只有依赖某个 Bean 出现循环时才偶尔触发。所以我的实践经验是线上如果发现循环依赖和Async一起出现别先想着用三级缓存兜底优先级最高的是把循环依赖彻底拆掉。代理机制只能在容器语义内尽量帮你兜住“引用一致”但保不住“时序正确”。4. 远离深坑四类循环依赖场景的边界行为循环依赖不是所有场景都能被三级缓存化解。有些情况是 Spring 设计上就不支持有些是版本升级后新增的限制。这一节把最容易出事的几类场景列清楚。4.1 构造器注入硬失败没有任何商量余地前面已经解释过构造器注入时实例化尚未完成不可能提前曝光引用所以只要构造器之间形成环启动必炸。异常信息就是第一小节里的BeanCurrentlyInCreationException。遇到这种情况我的建议是按以下顺序处理先确认是不是有必要让两个对象在构造器里直接互相引用如果只是临时打通可以在构造器参数上加Lazy让 Spring 注入一个代理真正调用时才实例化如果是字段或 setter 注入能从设计上打破环就从设计上打破别依赖容器兜底。需要特别记住Lazy解决的是启动问题不是依赖关系问题。加了Lazy之后A 的构造器不再显示依赖 B 的完整对象而是依赖一个代理B 直到被真正调用时才会创建。这等于把“启动时报错”延迟到了“运行时可能报错”风险并没有消失只是后移了。4.2 原型作用域不缓存就没法破局原型Prototype作用域的 Bean 每次获取都会创建新实例Spring 不会把它们放进任何一级缓存自然也没有“提前曝光早期引用”的操作。原型 Bean 一旦形成循环依赖容器同样直接抛异常。这个场景的诡异之处在于有些人会想当然地认为“原型 Bean 不缓存、可以无限生成所以循环依赖能解”这是完全错误的理解。循环依赖的观测点是创建过程中的相互等待原型 Bean 因为每次都新建反而更容易陷入“创建 A 需要 B创建 B 需要 A”的递归陷阱。如果业务确实需要原型 Bean 互相引用通常会在中间加一层“获取器”比如ObjectProvider或者ApplicationContext.getBean()把“创建时机”和“使用时机”分开从而打掉环。4.3 Spring Boot 2.6 之后的默认禁入Spring Boot 2.6 开始项目默认禁止循环依赖这算是一次比较大的行为变更。之前能启动的项目升级后可能直接报The dependencies of some of the beans in the application context form a cycle: aService - bService - aService这个错误的背后是 Spring Boot 把spring.main.allow-circular-references默认值改成了false。也就是说Spring 框架本身还有能力处理循环依赖但 Boot 在应用层直接关掉了这个开关。为什么要这么干道理很直白循环依赖是典型的代码坏味道默认允许等于鼓励烂设计。容器为了处理循环引用会导致启动变慢还可能埋下 AOP 代理和懒加载的雷。官方宁可让你启动失败也不让你带病上线。包括后来 Spring 生态里不断加入的新组件整体的依赖治理策略都是往“依赖关系清晰”方向收紧的。4.4 使用 allow-circular-references 前你要想清楚什么如果确定短时间内没办法重构可以在application.properties里加一行spring.main.allow-circular-referencestrue但我强烈不建议把这个配置当成默认操作。它只是把 Boot 的禁令解除并没有让循环依赖变得无害。开启之后你至少要想清楚三件事启动耗时可能上升。循环依赖会让容器在创建 Beans 时反复切换创建上下文Bean 越多越明显。AOP 时序可能被打破。早期代理提前生成后后续初始化阶段会跳过代理创建如果某些后置处理器对顺序敏感行为会和预期不一致。使代码演进成本变高。今天用字段注入勉强解开明天加一个切面、换一个代理方式可能就是线上事故。我见过最极端的情况是项目里有十几个 Bean 之间存在复杂的循环依赖靠allow-circular-referencestrue强行启动。每次发版都提心吊胆后来花了一个迭代彻底重构把循环依赖全部消除启动时间直接下降了两三秒。5. 从救火到防火循环依赖的治理与实践建议理解原理是为了更好地使用 Spring而不是为了在循环依赖里掘金。日常开发里我更关注怎么提前发现环、怎么根治环。5.1 快速逃生方案对比Lazy、ObjectProvider、setter 注入如果你的处理目标是“让项目马上能跑”下表是几个常用逃生通道的对比方案核心原理适合场景副作用构造器参数加Lazy注入代理对象真正调用时再创建目标 Bean构造器注入形成的硬环延迟初始化错误被推迟到运行时字段注入 / setter 注入绕过实例化阶段的循环依赖三级缓存提前曝光不想改构造器、快速验证丧失不可变约束不推荐长期使用ObjectProviderT注入注入一个延迟获取容器需要时再getIfAvailable()可选依赖、需要延迟决策的场景需要在业务代码里感知容器抽象DependsOn调整顺序显式控制 Bean 的创建先后期望通过顺序打破启动环只治标不治本耦合依然存在分情况说如果是构造器硬环我一般优先用Lazy顶着但它必须搭配一个后续重构任务否则很容易被忘记如果是字段注入形成的环直接用三级缓存就能通不需要额外改动如果是可选依赖用ObjectProvider反而更优雅还能顺便满足注入容器引用安全性的需求。5.2 重构思路依赖倒置与事件驱动回到根治循环依赖这个问题方向其实很明确打破 A 到 B 之间不必要的直接依赖。最常见也最有效的重构姿势是“抽象下沉”。假设OrderService依赖UserServiceUserService又依赖OrderService查订单信息这说明订单查询逻辑不该放在OrderService里。把订单查询独立成OrderQueryService或者做成OrderRepository两个高层服务就不再互相咬着不放。另一种思路是事件驱动。A 不再直接注入 B而是发布一个事件由 B 异步监听处理。这样做有两个好处一是依赖方向从“A 依赖 B”变成“A 产生事件、B 消费事件”没有 Bean 级别的死锁二是天然适合异步化和削峰填谷。缺点是引入了消息或事件框架调试链路变长需要团队接受复杂度的增加。我实操中的经验是先画清楚依赖矩阵找出环内所有 Bean再逐个问“这个依赖真的需要吗能不能通过入参或者外部存储传数据”大多数循环依赖刨到最后都是设计阶段没有梳理清楚数据流向。5.3 代码评审与监控如何在项目里提前发现循环依赖最理想的情况是循环依赖压根进入不了代码库。这里分享几个我常用的预防手段。首先是配置约束。只要是新项目我都会在配置里明确关闭循环依赖spring.main.allow-circular-referencesfalse这样任何新加入的循环依赖都会在本地启动环节直接暴露而不是等到测试环境才炸。对旧项目这个过程可以用灰度方式推进先把开关打开修完一批环再慢慢关闭。其次是启动期检测。Spring 本身在启动时会构建 Bean 的依赖关系图遇到环会报错。CI 里可以加一个冒烟用例专门跑SpringApplication.run一旦出现循环依赖就直接挂掉阻断合并。这样就能让问题留在最早阶段。再就是代码评审环节。看 MR 的时候如果发现一个 Service 的Autowired字段越来越多我就格外警惕。Service 层之间互相注入本身就是架构报警信号应该在评审的时候就被打回而不是等它长成一个大环再清理。最后如果团队用的是比较新的 Spring 版本有条件的话可以用ApplicationContext里的 Bean 依赖关系接口或者借助 IDE 的依赖分析插件把 Bean 依赖图导出来定期检查。眼见为实依赖图会比代码评审更直观也更容易说服团队动手重构。我个人在实际项目里处理循环依赖的经验是能不用就不用能让容器简单就尽量简单。三级缓存的确是很精巧的设计但它的存在是为了兜底不是为了鼓励开发者在依赖关系上为所欲为。一个健康的 Spring 项目Bean 依赖视图应该像一棵树层次清晰、方向明确。当你发现依赖图开始出现环说明代码的边界已经模糊了这时候最该做的不是去研究缓存而是回到架构层面重新划分职责。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门