ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring循环依赖与三级缓存:原理、方案与排查实战

2026/9/30 18:13:10 拓冰建站 浏览量
Spring循环依赖与三级缓存:原理、方案与排查实战 做 Spring 项目最怕什么不是 NPE不是 SQL 写错而是启动到一半控制台突然甩出一行 Error creating bean with name xxxService后面跟着BeanCurrentlyInCreationException再往下看是一个词Circular dependency。循环依赖这词对刚接触 Java 开发的同事来说有点玄乎但老手都明白这通常不是框架坏了而是你的 Bean 之间绕成了一个环。很多人一见到这个报错就本能地搜“怎么关掉它”却不知道真正该做的是先看懂 Spring 为什么要拦你、它到底能自己扛住多少环、哪些环扛不住必须拆。这篇就围绕循环依赖的成因、Spring 三级缓存机制、三种主流处理方案以及一堆我实际踩过坑之后的排查经验一次性讲透。1. 循环依赖的底层逻辑先搞懂 Spring 为什么会报这个错1.1 到底什么是循环依赖Spring 能解决却还是报错循环依赖也叫 Circular dependency简单说就是 Bean A 在创建时需要注入 Bean B而 Bean B 创建时又需要注入 Bean A两者互相“点名”形成了一个环。三个甚至更多 Bean 形成环也一样A 依赖 BB 依赖 CC 又依赖 A本质上都是同一个问题。很多教程喜欢用“先有鸡还是先有蛋”来解释我觉得更贴切的是两个人互相等对方烧开水A 说“你先烧我等你”B 说“你先烧我等你”结果谁都没动。Spring 里如果不做任何处理A 要实例化时发现需要 BB 要实例化时发现需要 A此时 A 还没创建完容器拿不出完整的 A 来给 B 用于是直接抛出异常。但另一个迷惑点是为什么有人说 Spring 能“自动解决”循环依赖因为 Spring 确实有一套缓存机制可以让部分场景下的循环依赖悄无声息地跑通。你能在项目里碰到的循环依赖有很大一部分属于“框架其实能处理只是版本策略变了或者你的写法触发了框架的底线”。关键区别在于你用的是构造器注入还是属性注入以及是不是真的满足 Spring 的缓存兜底条件。所以我一直建议团队里遇到这个报错第一反应不是去网上复制“关闭循环依赖检测”的配置而是把当前这段 Bean 关系的创建顺序彻底理一遍。1.2 构造器注入循环与属性注入循环的本质差异同样是循环依赖构造器注入和属性注入在 Spring 眼里完全不是一个难度等级。核心原因是 Bean 的创建要经历三个阶段实例化new 出来、属性填充给字段注入依赖、初始化执行 Aware 回调、init 方法。这个顺序决定了框架能在哪个阶段做“缓冲”。构造器注入里依赖是在实例化阶段就通过构造函数参数要走的。A 实例化的第一步就需要 B 的完整对象但 B 此时也停在实例化第一步等待 A。两边都在“要成品”谁也没提前暴露任何半成品Spring 无计可施只能抛BeanCurrentlyInCreationException。这有点像相亲网站要求双方都必须先提交实名认证才能互看资料可恰恰两个人都在等对方先提交流程当场卡死。属性注入setter 注入或Autowired字段注入不一样。A 先被 new 出来此时它是一个只有默认值的半成品对象Spring 把这个半成品提前放进一个“临时窗口”再开始给 A 填充属性。填充时发现需要 B于是去创建 BB 同样先被 new 出来B 填充属性时发现自己需要 A此时它可以从“临时窗口”里拿到那个半成品的 A。于是 B 顺利完成A 再拿到 B 也完成收尾。整个流程就靠着“提前暴露半成品”跑通了。理解这个差异后你就知道为什么网上很多人说“构造器循环依赖无解”这句话不严谨更准确的说法是构造器循环依赖不能依靠 Spring 默认的三级缓存解决需要借助Lazy或重构。而属性注入循环则可以靠默认机制解决前提是 Spring 的循环依赖开关没有关。1.3 三级缓存机制Spring 默认的“兜底”逻辑三级缓存是 Spring 解决属性注入循环依赖的关键也是面试管最爱问的点。它实际上是DefaultSingletonBeanRegistry里的三个 Map一级缓存singletonObjects存放完整的单例 Bean也就是大家最终拿到的成品。二级缓存earlySingletonObjects存放提前暴露的原始对象可以理解为还没完成属性填充的半成品。三级缓存singletonFactories存放的是ObjectFactory类型的工厂对象它能在需要时生成一个早期引用甚至可以在这个阶段生成代理对象。流程可以这样串创建 A 时实例化完成之后Spring 会向第三级缓存放入一个ObjectFactory这个工厂内部关联着 A 的原始实例。接着填充属性发现需要 B于是调getSingleton(b)去拿。B 创建到一半需要 A此时容器确认 A 正在创建中就从第三级缓存找到 A 的工厂调用getEarlyBeanReference拿到 A 的早期引用放到二级缓存返回给 B。B 属性填充完成后走初始化成为成品放进一级缓存。A 再继续填充 B完成初始化最终也放入一级缓存。第三级缓存的作用经常被误解有人问“二级缓存就够了要三级干嘛”。关键在代理如果 A 要被 AOP 增强比如加了Transactional那么在 B 提前拿到 A 的引用时这个引用应该是代理对象而不是原始对象。三级缓存放工厂就是为了让 AOP 代理在“被提前引用”时能够被正确地生成。如果只需要二级缓存Spring 就需要在实例化后立刻决定是不是生成代理但这会破坏正常创建流程里 AOP 代理的生成时机。所以三级缓存本质上是把“是否需要代理”这个决策点往后延迟只在确实被提前引用了才触发。用食堂窗口打比方普通场景下菜做好了你来取供应不上时窗口会先把半成品菜放出来等真正有人点单时再临时加工成能吃的版本——半成品窗口是二级后厨加工能力就是三级工厂。2. 方案一重构依赖结构从设计上根治循环依赖2.1 发现坏味道双向依赖往往是边界划分问题循环依赖在代码里通常不是凭空出现的它往往是设计上的坏味道。我见过最典型的场景是订单服务需要调用库存服务扣减库存库存服务又需要反过来调订单服务查订单状态。乍看每一步都有业务理由但把两个服务的依赖关系画出来就是一条双向边。这种双向耦合造成的麻烦远不止启动报错。单元测试时订单服务要 mock 库存服务库存服务又 mock 订单服务测试之间互相拉扯改一个服务的接口另一个服务也跟着编译失败如果走微服务拆分这种互相调用的关系还会形成网络层面的循环请求甚至引发超时雪崩。所以当项目里出现循环依赖先别急着找配置开关应该先问一句这两个类真的必须互相知道对方吗判断标准我一般看三步一这个互相调用是否发生在同一个完整业务链路里二能不能把其中一个方向上的调用改成事件发布而不是直接引用三两个类之间是否有一个可以剥离的公共上下文比如某个查询接口或某个领域服务。这三步走完通常循环依赖都能找到出口。2.2 中间层与依赖倒置的落地案例重构循环依赖最朴素的办法是引入中间层。假设 A 需要 B 提供的能力B 也需要 A 提供的能力双方互不相让。这时可以把双方都需要的那部分逻辑抽到一个独立的 Service 或 Holder 里让 A 和 B 都只依赖这个第三方环自然就断了。举个例子用户注册后要发欢迎消息同时消息服务要记录用户资料变更历史。一种坏设计是 UserService 依赖 MessageServiceMessageService 又依赖 UserService 去查询用户昵称。稍微重构一下创建一个UserQueryService专门负责用户基础信息查询UserService 依赖 MessageService 做通知MessageService 依赖 UserQueryService 拿昵称不再依赖 UserService 本体。这样依赖图就变成了单向链路测试时也能分别 mock。从工程收益看这样的重构至少带来三点好处职责边界更清晰每个类只需要关心自己的核心能力可测试性变好mock 对象规模明显缩小后续改造时比如把消息推送换成 MQ 异步发送也只改动消费侧不需要连带改 UserService。我团队里有个老项目就是靠这种方式一次性消除了五六个循环依赖点没有任何一个靠“允许循环依赖”蒙混过关。2.3 无法快速重构时的过渡选择Lazy当然理想归理想现实里你很可能在一个六七年历史的老项目里代码层层缠绕一时半会根本不敢大改。这时Lazy是一个非常有价值的过渡方案。Lazy加在依赖注入的位置后Spring 注入的不再是目标 Bean 的完整实例而是一个懒加载代理对象。只有当这个代理被真正调用方法时容器才会去创建并缓存真正的目标 Bean。构造器循环依赖之所以无解是因为构造函数需要完整的实参但如果你把其中一个构造器参数标记为LazySpring 就可以先塞一个代理进去绕开“必须现在就要成品”的约束。我个人的用法是在确定要重构但暂时没排期的模块上先用Lazy解开环并给代码注释里写明“此处临时使用懒加载后续拆分公共模块后移除”。同时一定要去跟产品和技术负责人对齐把重构任务排进迭代计划。因为Lazy本质上是在拖延 Bean 的创建时点如果被拖延的那个 Bean 初始化逻辑很重运行时第一次调用可能会明显变慢而且出现代理因初始化失败而抛异常时报错点会离真实调用链很远排错会更有难度。3. 方案二让 Spring 三级缓存替你兜底配置与原理3.1 Spring Boot 2.6 之后的默认策略变更很多老项目升级 Spring Boot 后突然报循环依赖错误第一反应是“以前跑得好好的怎么升级就挂了”。这不是你代码坏了而是 Spring Boot 的默认行为在 2.6 版本发生了调整spring.main.allow-circular-references被默认为false。在 Spring Boot 2.6 之前只要代码满足三级缓存条件属性注入循环一般能自动跑通开发者甚至感知不到内部发生了什么。2.6 之后 Spring 官方决定把“允许循环依赖”这个开关默认关掉原因很简单循环依赖容易掩盖设计问题而且三级缓存迟早会在某些组合下失效默认关闭能提前暴露风险。如果你接手的老项目确实存在大量历史遗留的循环依赖短期最快恢复启动的办法就是明确开启这个开关spring: main: allow-circular-references: true或者用 properties 文件spring.main.allow-circular-referencestrue。但我必须提醒你这是一种“战术性妥协”不是“终极解决”。开了开关只是让 Spring 尝试用三级缓存兜底并不代表所有循环都能被兜住。构造函数注入的循环照样报错代理场景照样可能出问题所以这个开关更像是给你争取重构时间的临时工具。3.2 三级缓存能兜底的前提条件速查不是所有循环依赖都能被三级缓存解决能不能兜住要同时满足几个硬性条件。我把这些条件整理成了一张速查表排查时可以对着看条件说明不满足时的后果Bean 作用域是单例只有单例才走三级缓存原型作用域不缓存原型 Bean 循环时直接抛异常注入方式为属性注入或 setter 注入构造器注入发生在实例化阶段尚无缓存可查BeanCurrentlyInCreationException未越过 Spring 循环依赖开关Boot 2.6 之后默认禁止启动即报错依赖关系不涉及相同 Bean 的多个代理需求复杂 AOP 场景可能出现早期代理与最终代理不一致拿到不符合预期的代理对象这里最容易被忽视的是作用域问题。Scope(prototype)的 Bean 不会进入一级缓存Spring 每次使用都要新创建一个所以 A 是单例、B 是原型A 注入 B 没问题但 A 和 B 都是原型且互相注入时A 创建需要 BB 创建需要 A两边都无法提前暴露只能报错。记住一个口诀要让 Spring 兜底先保证每个环节都是单例并且不是构造器注入。3.3 源码走读从 getSingleton 到 getEarlyBeanReference读完源码才能理解为什么三级缓存能解决属性循环也能解释为什么有些组合会让它失效。关键入口是DefaultSingletonBeanRegistry.getSingleton(String beanName)这个方法的思路是逐级查找先看一级缓存有没有成品没有就看二级缓存有没有半成品还没有就查三级缓存里的ObjectFactory取出工厂并发它getObject()把得到的早期引用放进二级缓存并移除三级缓存工厂。而ObjectFactory是什么时候放进三级缓存的在AbstractAutowireCapableBeanFactory.doCreateBean里Spring 实例化 Bean 后会调用addSingletonFactory把当前 Bean 的早期引用工厂注册进三级缓存。这一步非常关键它发生在属性填充之前。所以循环依赖能成立的内在逻辑是A 刚 new 出来还没填充属性时就已经把“可提前暴露的入口”登记在案了。getEarlyBeanReference也不是直接返回原始对象它会遍历所有注册的SmartInstantiationAwareBeanPostProcessor让这些后置处理器有机会返回代理对象。这就是 AOP 代理能参与循环依赖的原因。整个链路跟面试常问的“Spring 什么时候创建代理”有关普通场景下 AOP 代理是在 Bean 初始化之后由后置处理器生成但循环依赖场景会在早期引用阶段就触发代理生成以避免后续依赖方拿到的是普通对象而不是增强后的对象。3.4 代理对象与循环依赖的微妙关系循环依赖一旦碰上代理复杂性会明显上升。最常见的一个坑是A 加了TransactionalB 在属性填充阶段提前拿到了 A 的早期引用此时三级缓存工厂生成了一个事务代理塞给 B。等 A 走完正常初始化流程容器里的最终 A 又是另一个代理对象。如果两个代理不一致后续通过 B 拿到的 A 和直接从容器拿到的 A 可能不是同一个对象事务边界、内部方法调用这类行为就会变得诡异。另一个经典场景是Async。被Async标注的方法会走异步代理如果这个 Bean 出现在循环依赖中早期暴露的代理可能只是满足“被提前引用”的临时代理异步代理的完整逻辑未必能正确生效。我遇到过的情况是启动不报错但运行时不走代理方法数据一致性当场出问题查了很久才发现是循环依赖加异步代理惹的祸。所以遇到循环依赖里带事务、异步、缓存切面的 Bean我的建议是优先重构不要依赖缓存兜底。三级缓存能解决“能不能启动”但解决不了“代理行为对不对”。4. 方案三善用注解与间接层做精准控制4.1 Lazy注入一个懒加载代理Lazy解决循环依赖的原理前面已经提到这里补充具体用法和避坑点。使用方式很灵活可以加在字段上Autowired Lazy private B b;也可以加在构造器参数上public A(Lazy B b) { this.b b; }。加在构造器参数上解决构造器循环的效果立竿见影因为它让 Spring 在构造阶段不用立即创建 B而是注入一个代理占位。但是要留意两点。第一Lazy注入的是代理对象而你一般意识不到这一点如果代码里有对目标对象类型做instanceof判断、直接比较getClass()或者强转就有可能在运行时出现类型相关的奇怪错误。第二懒加载会推迟 Bean 的创建时点如果这个 Bean 初始化时顺便做了数据预热、缓存刷新那么第一次真正调用时会有明显延迟。最好在应用启动后主动触发一次预热调用别指望用户帮你热。4.2 DependsOn控制初始化顺序的正确用法DependsOn经常被当成循环依赖的解法推荐但它实际上解决的是“初始化顺序”问题不是“循环引用”问题。它的作用是强制 Spring 在创建当前 Bean 前先创建指定的另一个 Bean。例如缓存预热组件需要依赖一个字典数据加载器提前加载但又没有字段注入关系这时DependsOn(dictionaryLoader)就能保证顺序正确。反过来如果两个 Bean 存在循环依赖而你强行给两边都加DependsOnSpring 会直接识别出初始化顺序上的环照样抛BeanCurrentlyInCreationException。所以不要指望用它解开循环它只会让循环初始化问题更明确地暴露出来。真实项目里我更喜欢把它用在“启动阶段任务编排”上比如系统启动时要先建立数据库连接池再启动 MQ 消费者再预热缓存。这些任务没有互注关系但顺序错了就会出问题DependsOn或ApplicationRunner都是比休眠等待更可靠的手段。4.3 ObjectProvider从容器延迟获取依赖ObjectProviderT是 Spring 4.3 提供的一个间接层它本身是一个注入点但它不是直接把 T 注入进来而是一个能用来获取 T 的“取货凭证”。最常见的写法是Component public class A { private final ObjectProviderB bProvider; public A(ObjectProviderB bProvider) { this.bProvider bProvider; } public void doSomething() { B b bProvider.getIfAvailable(); // ... } }这种写法在构造器阶段不会触发 B 的创建B 到真正调用getIfAvailable时才被创建。它跟Lazy有点像但语义上更清晰它表达的是一种“运行时按需获取依赖”的拉模式设计而不是一个字段引用。我通常把ObjectProvider用在两类场景一是依赖可能不存在需要优雅降级二是 Bean 的创建时机希望尽量晚避免启动链路过长。它还有一个好处是能配合getIfAvailable(Supplier)、stream() 等方法处理多个候选 Bean灵活性比硬编码注入高不少。4.4 多个 Bean 实例与异步场景的特殊处理循环依赖的处理并不局限于单例但多实例场景很难。假如你注入的是一个 List 或多实现候选 Bean比如ListHandler这些 Bean 之间如果存在循环引用容器在收集候选时就会被某一个正在创建中的 Bean 卡住。这类问题常规的Lazy不怎么好使更好的办法是让候选实现之间不互相依赖各实现只依赖公共抽象和基础服务。异步场景前面已经提过这里再强调一个排查思路如果你发现某个循环依赖的 Bean 上同时标着Transactional、Async、Cacheable这类注解先检查代理配置是不是在用 CGLIB以及后置处理器的执行顺序。很多时候启动能过但运行时不生效就是因为代理在早期引用阶段已经定型后续增强逻辑没有覆盖到。遇到这种情况果断把有切面增强的 Bean 从循环链中摘出来比花一整个下午调试代理行为划算得多。5. 避坑指南常见报错形态、排查思路与修复清单5.1 BeanCurrentlyInCreationException 报错拆解循环依赖最典型的报错关键词是BeanCurrentlyInCreationException但仅仅知道这个名字不够要会看堆栈。Spring 的报错信息往往会在最下方或中段明确给出“cycle”的描述比如The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | userService ↓ ↑ | permissionService └─────┘看到这种图形化的依赖环第一时间就能定位到是哪两个 Bean 互相引用。如果没有这种输出也可以通过堆栈里的 Bean 名称逐个去搜。我排查时的顺序是先搜BeanCurrentlyInCreationException确认是否有“Currently in creation”字样再在日志里找cycle关键字画出 Bean 关系图最后打开源码断点isSingletonCurrentlyInCreation看容器里到底哪些 Bean 是创建中的挂起状态。5.2 三级缓存“失效”的几种典型场景很多人以为开了allow-circular-references就万事大吉实际上三级缓存会静默失效。失效的第一种典型场景是构造器注入前面讲过原理这里就不重复了。第二种是 Bean 被DependsOn拖入“必须先创建”的状态但创建过程中又遇到循环引用Spring 会提示bean ... depends on ... which is currently in creation。第三种是Async或者自定义BeanPostProcessor提前调用了getEarlyBeanReference导致二级缓存中存放的早期引用和后续完整代理不一致。第四种比较复杂是在Configuration类里使用Bean方法互相调用结合循环依赖时也可能出现“方法调用返回的不是代理对象”的怪问题。5.3 排查循环依赖的实操手段如果只想快速看当前项目有哪些循环依赖可以借助 Spring Boot Actuator 的/actuator/beans端点排查思路是导出所有 Bean 的依赖关系再人工过滤被两个及以上 Bean 互相引用的实例。不过 Bean 一多人工看图效率并不高我更喜欢在本地用断点调试DefaultSingletonBeanRegistry里几个关键方法比如getSingleton、doCreateBean观察singletonsCurrentlyInCreation集合。另外在团队里我强烈建议给核心模块补上基于 ArchUnit 的依赖规则测试用代码约束禁止新增“模块间反向依赖”。这类测试跑在 CI 上能在合并请求阶段就拦截掉新增的循环依赖设计比等人跑到启动报错再修效率高一个量级。下面是一个很简单的 ArchUnit 示例限制某个包不能依赖另一个包AnalyzeClasses(packages com.example.project) public class DependencyRuleTest { Test void serviceShouldNotDependOnService() { JavaClasses classes new ClassFileImporter().importPackages(com.example.project); ArchRule rule noClasses() .that().resideInAPackage(..service.orders..) .should().dependOnClassesThat() .resideInAPackage(..service.inventory..); rule.check(classes); } }5.4 一张速查表判断“该不该动代码”我把处理循环依赖的选择过程整理成了一张决策表每次遇到问题照着这个逻辑走基本不会跑偏现状推荐方案不建议方案老项目少量循环重构成本高先开启允许循环依赖风险跟踪立刻大范围重构新项目或核心模块出现循环重构依赖抽中间层开启全局开关构造器循环Lazy 或重构期望三级缓存兜底循环链上 Bean 带 AOP/异步切面优先重构移除循环仅凭开关硬扛原型作用域循环改造为单例或调整设计使用缓存兜底这张表的底层逻辑是能重构就重构不能重构就通过注解或间接层精准解耦把“全局开关”当作最后手段而不是默认选项。6. 实战复盘从一次升级事故看循环依赖处理全过程6.1 事故现场依赖升级触发循环报错前年我维护的一个老项目从 Spring Boot 2.5 升级到 2.7升级完启动直接失败报错信息一眼扫过去就是典型的循环依赖环userService和permissionService互指。这个项目沉淀了好几年类多、字段注入多之前 2.5 能跑纯属靠三级缓存默认兜底。升级后 Boot 默认关闭了循环依赖这些历史问题就全浮上来了。当时第一反应是“能不能先开开关让系统跑起来”因为升级窗口很短。同事也提议直接改配置文件。我顶着压力没有立刻改配置而是先用 Actuator 的 beans 端点导出了依赖图发现 80% 的循环集中在用户体系与权限体系两个 service 之间实际可以围绕一个公共UserContext服务解决。6.2 处理过程三层递进式的解决思路处理节奏我分了三步。第一步是做一个“止血快照”先把allow-circular-references: true加上让升级后的系统能启动这是为了减少升级窗口内的不可用时间但我在代码评审记录里明确标记了这只是临时方案。第二步是梳理循环依赖的 Bean 清单逐一判断哪些是“真业务联系”哪些是“可以拆掉的互相拉取”。第三步是分批重构把UserService对PermissionService的直接调用拆到PermissionQueryService同时引入领域事件兜住用户变更后的权限刷新逻辑。最终改动覆盖二十多个类核心变化是依赖图从双向边变成了单向多条链。重跑架构测试时循环依赖数量从最初的 11 组降到了 0 组同时把allow-circular-references开关重新关回 false。这次升级之后项目再没出现过同类启动报错。6.3 最终选型与长期维护建议那次事故给我的教训很深循环依赖不是“偶尔吐出来的异常”而是设计退化的报警器。处理它最好的时机是在架构评审阶段比如画模块依赖图时发现双向边就当场打回其次是每次升级依赖前的自查阶段用脚本扫一遍Autowired对应的依赖关系最差才是启动报错时来补救。长期看我建议团队逐步统一构造器注入风格因为构造器注入会让循环依赖在编译和设计层面就被发现而不是运行时诡异救场。同时对模块边界做一些架构约束比如 ArchUnit 禁止服务层横向互相访问只允许通过 Facade 或事件通信。如果项目已经积重难返那就先列一张“循环依赖存量清单”每次迭代顺手消掉一两组别总指望一晚上彻底翻新。我个人在实际项目里最深的感受是遇到 Circular dependency 报错最好的时机其实是你第一次见到它的时候认真去把依赖图画一遍后面能少走好几倍的弯路。allow-circular-references可以救你一时的启动但真正能让你睡得安稳的永远是单向清晰的依赖边界。最后再分享一个小技巧如果你不想装任何插件直接在 Spring 启动方法里断点打DefaultSingletonBeanRegistry的isSingletonCurrentlyInCreation把返回 true 的 BeanName 打印出来那个列表就是当前被卡住的所有 Bean比看一长串异常堆栈直观太多了。