Spring Boot循环依赖:三级缓存机制、配置方法与设计重构实战

发布时间:2026/7/30 8:26:36
Spring Boot循环依赖:三级缓存机制、配置方法与设计重构实战 1. 项目概述为什么我们会遇到循环依赖在Spring Boot项目开发中尤其是随着业务模块不断拆分和聚合我们经常会遇到一个经典的工程难题Bean A依赖Bean B而Bean B又反过来依赖Bean A。这就是所谓的“循环依赖”。很多开发者第一次遇到Spring抛出的“BeanCurrentlyInCreationException”异常时都会感到困惑。这个异常信息通常会指向一个Bean正在创建中但它的依赖项又需要它本身形成了一个死锁。实际上Spring框架本身提供了一定的机制来处理循环依赖但这并不意味着我们应该放任不管。Spring Boot作为Spring的“约定大于配置”的实践者其默认配置和自动装配机制在简化开发的同时也可能让循环依赖问题更隐蔽地出现。理解如何配置Spring Boot以允许或管理循环依赖本质上是在理解Spring容器的Bean生命周期、依赖注入机制以及如何在工程实践中做出更合理的设计决策。本文将从一个资深开发者的视角深入拆解Spring Boot中循环依赖的成因、Spring的默认处理机制、如何通过配置来允许它以及更重要的是探讨为什么我们最终应该致力于从设计上避免它。我会结合真实的项目场景分享配置的细节、背后的原理、遇到的坑以及排查技巧目标是让你不仅知道怎么配更明白为什么这么配以及何时不应该这么配。2. 循环依赖的根源与Spring的解决机制2.1 循环依赖是如何产生的循环依赖通常是不佳设计的一个信号但在复杂的业务系统中有时又难以完全避免。常见的产生场景有双向关联的业务对象例如OrderService需要调用PaymentService来处理支付而PaymentService在支付成功后需要回调OrderService来更新订单状态。如果两者都通过Autowired相互注入就构成了循环依赖。事件监听器之间的调用ServiceA发布一个事件EventListenerB监听并处理在处理过程中又需要调用ServiceA的某个方法如果EventListenerB也注入了ServiceA就可能形成循环。配置类之间的相互引用使用Configuration类时如果一个Bean方法需要引用另一个配置类中定义的Bean而那个配置类又反过来引用当前类的Bean也可能导致问题。从Spring容器的视角看创建一个Bean假设叫Bean A的过程大致是实例化调用构造器 - 属性填充注入依赖 - 初始化执行PostConstruct等方法。如果在属性填充阶段Spring发现需要注入Bean B而Bean B又尚未创建它就会去创建Bean B。如果创建Bean B的过程中又需要在属性填充阶段注入Bean A而此时Bean A正处于“正在创建”的状态已经实例化但未完成初始化矛盾就产生了。2.2 Spring的默认解决方案三级缓存Spring框架并非对循环依赖束手无策。它通过一个精妙的“三级缓存”机制默认支持解决单例Bean且通过属性注入Setter注入或字段注入Autowired构成的循环依赖。理解这个机制是理解后续所有配置和问题的基础。Spring容器内部维护了三个重要的Map我们称之为缓存一级缓存singletonObjects存放已经完全初始化好的Bean实例。我们通过ApplicationContext.getBean()获取到的就是这里的Bean。二级缓存earlySingletonObjects存放早期的Bean引用即已经实例化调用了构造器但尚未完成属性填充和初始化的“半成品”Bean。三级缓存singletonFactories存放Bean的工厂对象ObjectFactory。这个工厂能够在需要时返回该Bean的早期引用可能是一个代理对象。解决循环依赖的关键流程以A依赖BB依赖A为例开始创建A实例化A调用A的构造器然后将能够生成A早期引用的工厂放入三级缓存。准备为A填充属性发现需要B。于是去获取B。开始创建B实例化B将B的工厂放入三级缓存。为B填充属性发现需要A。于是去获取A。此时A尚未创建完成但在一级缓存找不到。Spring会依次检查二级和三级缓存。在三级缓存中找到了A的工厂。调用这个工厂的getObject()方法。这个方法可能会直接返回A的原始实例也可能返回一个A的代理对象如果A被AOP切面代理。然后将得到的这个“早期引用”放入二级缓存并从三级缓存移除A的工厂。将这个A的早期引用注入给B。B完成属性填充和初始化变成一个完整的Bean放入一级缓存。B创建完毕返回给第2步。A成功获得B的完整实例完成自己的属性填充和初始化也变成一个完整的Bean放入一级缓存。同时清理二级缓存中关于A的早期引用。注意这个机制仅对单例Bean有效。对于原型Prototype作用域的BeanSpring容器不负责其完整生命周期的管理每次请求都创建一个新实例因此无法解决其循环依赖会直接抛出异常。实操心得很多开发者知道Spring能解决循环依赖但不知道为什么。理解三级缓存机制不仅能让你在遇到相关异常时快速定位更能理解为什么构造器注入Constructor Injection无法被这种机制解决。因为构造器注入发生在实例化阶段此时Bean的实例还未产生更谈不上将工厂放入三级缓存所以一旦构造器参数形成循环Spring将无法处理。3. Spring Boot中配置允许循环依赖的几种方式Spring Boot 2.6版本是一个重要的分水岭。在这个版本之前Spring Boot默认是允许循环依赖的。但从2.6版本开始为了鼓励更好的设计实践Spring Boot默认将循环依赖视为错误并在应用启动时如果检测到就会报错终止。因此我们的“配置”主要针对的是Spring Boot 2.6及之后的版本。3.1 通过应用配置文件主流方式这是最常用、最推荐的方式因为它无需改动代码且意图明确。在application.properties中配置spring.main.allow-circular-referencestrue在application.yml中配置spring: main: allow-circular-references: true这个配置的作用是告诉Spring Boot的SpringApplication在准备应用上下文ApplicationContext时设置一个名为allowCircularReferences的标志为true。这个标志最终会影响AbstractAutowireCapableBeanFactory的行为使其在检测到循环依赖时不是直接抛出错误而是尝试使用上述的三级缓存机制去解决它。为什么推荐这种方式配置外置将这种“权宜之计”的配置放在外部配置文件里与业务代码分离清晰表明了这是一个为了兼容或过渡而采取的临时措施。环境可配你可以在开发环境的配置文件中设置为true以快速启动和调试而在生产环境的配置文件中保持为false或不配置即默认false强制要求代码质量。影响范围明确它作用于整个Spring应用上下文对所有Bean生效。3.2 通过启动类编程式配置如果你需要在代码中动态控制这个行为可以在main方法中通过SpringApplication进行配置。SpringBootApplication public class MyApplication { public static void main(String[] args) { SpringApplication app new SpringApplication(MyApplication.class); // 设置允许循环依赖 app.setAllowCircularReferences(true); app.run(args); } }这种方式和配置文件的效果是等价的。但通常不如配置文件灵活除非你有特殊的启动逻辑需要根据某些条件来决定是否允许循环依赖。3.3 理解配置的局限性设置allow-circular-referencestrue并不意味着你的所有循环依赖问题都能高枕无忧。它只是放开了Spring容器层面的限制允许容器尝试去解决循环依赖。最终能否成功解决还要取决于循环依赖的具体类型单例Bean 属性/Setter注入通常可以成功解决。原型PrototypeBean无论是否设置此标志都无法解决会抛出BeanCurrentlyInCreationException。构造器注入形成的循环依赖无法解决。因为如前所述在调用构造器时Bean的实例还未创建三级缓存机制无从谈起。你会看到类似“Requested bean is currently in creation: Is there an unresolvable circular reference?”的错误。涉及Async、Transactional等AOP代理的复杂情况即使允许循环依赖也可能因为代理创建时机问题导致注入的不是预期的代理对象进而引发功能异常如事务不生效。注意事项将这个配置设为true相当于关闭了Spring Boot 2.6提供的一个重要的代码质量守护机制。它可能会让潜在的设计缺陷被掩盖直到在更复杂的情况下如多线程、特定代理场景才暴露出来届时问题会更难调试。因此这绝对应该被视为一个临时解决方案而非永久措施。4. 超越配置从设计上破解循环依赖允许循环依赖的配置是一把“双刃剑”它解决了启动问题但可能引入了更深层次的设计隐患。一个健康的项目应该致力于从架构和设计层面消除循环依赖。以下是一些经过实战检验的破解方法。4.1 方法一使用Setter/字段注入替代构造器注入治标不治本这是最直接的代码改动。如果循环依赖双方都是通过构造器注入将其改为使用Autowired注解的字段注入或Setter方法注入配合spring.main.allow-circular-referencestrue配置通常能立即解决问题。修改前构造器注入有问题Service public class ServiceA { private final ServiceB serviceB; public ServiceA(ServiceB serviceB) { // 构造器注入 this.serviceB serviceB; } } Service public class ServiceB { private final ServiceA serviceA; public ServiceB(ServiceA serviceA) { // 构造器注入 this.serviceA serviceA; } }修改后字段注入可被解决Service public class ServiceA { Autowired private ServiceB serviceB; // 字段注入 } Service public class ServiceB { Autowired private ServiceA serviceA; // 字段注入 }评价这种方法能快速让应用跑起来但没有改变循环依赖的本质。它依然是代码中的一颗“定时炸弹”并且违背了使用final字段和构造器注入来实现不可变性和明确依赖的推荐实践。4.2 方法二提取公共功能到第三方面推荐分析ServiceA和ServiceB相互依赖的原因往往是因为它们有一部分共同关注的逻辑或数据。将这部分逻辑抽取到一个新的ServiceC或CommonService中让A和B都依赖于C从而打破A和B之间的直接循环。场景OrderService需要PaymentService来扣款PaymentService需要OrderService来更新订单状态。重构创建一个OrderStatusUpdateService专门负责订单状态的更新和相关的业务逻辑如发送通知。PaymentService在支付成功后调用OrderStatusUpdateService而OrderService也注入这个服务来更新状态。这样OrderService和PaymentService之间就不再需要直接依赖。这种方法从根本上解决了问题符合单一职责原则并且通常能更好地组织代码。4.3 方法三使用事件驱动模型解耦利器对于“做完A之后需要通知B而B又可能反过来影响A”这类场景事件驱动是绝佳的解决方案。Spring提供了强大的应用事件ApplicationEvent机制。重构步骤定义事件class PaymentSuccessEvent extends ApplicationEvent { private Order order; ... }在PaymentService中支付成功后发布事件applicationEventPublisher.publishEvent(new PaymentSuccessEvent(this, order))。让原本需要调用OrderService的EventListener来监听这个事件并在其中执行业务逻辑。这个监听器可以注入OrderService。移除PaymentService中对OrderService的直接依赖。这样一来PaymentService完全不知道OrderService的存在它只负责发布“支付成功”这个事实。监听器负责响应这个事实并协调后续操作。两者通过事件中介彻底解耦。4.4 方法四使用Lazy注解延迟注入在其中一个依赖上添加Lazy注解告诉Spring延迟初始化这个Bean或者注入一个代理对象。当实际需要调用这个Bean的方法时代理才会去触发真正的Bean创建和依赖解析此时第一个Bean可能已经初始化完成从而打破循环。Service public class ServiceA { private final ServiceB serviceB; public ServiceA(Lazy ServiceB serviceB) { // 对ServiceB使用Lazy this.serviceB serviceB; } } Service public class ServiceB { private final ServiceA serviceA; public ServiceB(ServiceA serviceA) { this.serviceA serviceA; } }在这个例子中创建ServiceA时由于ServiceB被标记为LazySpring会先注入一个ServiceB的代理对象给ServiceA而不是立即去创建ServiceB。因此ServiceA可以顺利完成实例化和初始化。当ServiceA初始化完成后ServiceB再被创建时就能成功注入已经就绪的ServiceA。实操心得Lazy是一个很有用的工具但它会引入代理可能会对调试栈信息变复杂和某些基于类型匹配的功能如Autowired按类型注入产生细微影响。它更适合用于解决因特定Bean初始化顺序或成本过高导致的依赖问题而非作为解决循环依赖的常规手段。4.5 方法五使用ObjectProvider或Provider进行延迟查找ObjectProviderSpring 4.3或JSR-330的Provider接口允许你延迟获取Bean实例而不是在注入时就立即解析。Service public class ServiceA { private final ObjectProviderServiceB serviceBProvider; public ServiceA(ObjectProviderServiceB serviceBProvider) { this.serviceBProvider serviceBProvider; } public void someMethod() { ServiceB serviceB serviceBProvider.getIfUnique(); // 在需要时才获取 // 使用serviceB } } Service public class ServiceB { private final ServiceA serviceA; public ServiceB(ServiceA serviceA) { this.serviceA serviceA; } }这种方式将依赖的获取从构造阶段推迟到了运行时的方法调用阶段从而打破了创建时的循环。它比Lazy更显式让你对Bean的获取有更强的控制力例如getIfAvailable,getObject()等。5. 常见问题排查与调试技巧实录即使配置了允许循环依赖在实际开发中你仍可能遇到各种诡异的问题。下面是我在多年项目中积累的一些排查经验和技巧。5.1 启动时报错BeanCurrentlyInCreationException这是最经典的错误。排查步骤确认Spring Boot版本首先检查是否是Spring Boot 2.6且未配置spring.main.allow-circular-referencestrue。这是最常见的原因。分析错误堆栈错误信息通常会给出形成循环的Bean名称链例如“Error creating bean with name ‘a’: Requested bean is currently in creation: Is there an unresolvable circular reference with ‘b’?”。顺着这个链就能找到罪魁祸首。检查注入方式如果错误链中的Bean都是构造器注入那么即使允许循环依赖也无济于事。必须改为Setter/字段注入或者采用Lazy、ObjectProvider等方案。检查Bean的作用域确认涉及的Bean都是单例Scope(“singleton”)默认即是。原型Bean的循环依赖无法解决。5.2 配置了allow-circular-referencestrue但依然报错这种情况需要深入分析。原型Bean的循环依赖如前所述此配置对原型Bean无效。涉及AOP代理的复杂循环如果Bean被Async、Transactional、Cacheable等注解代理Spring需要先创建代理对象。在复杂的循环依赖场景下代理的创建时机可能导致依赖注入混乱。可以尝试调整切面顺序或检查代理模式CGLIB vs. JDK动态代理。生命周期回调中的依赖在PostConstruct方法中调用另一个Bean而那个Bean又反过来依赖当前Bean也可能在允许循环依赖的配置下出现问题因为PostConstruct执行时Bean的早期引用可能已经被使用。使用DependsOn注解这个注解显式定义了Bean的初始化顺序如果配置不当可能会与容器解决循环依赖的自动顺序产生冲突引发意想不到的错误。5.3 运行时出现空指针或功能异常应用启动了没有报循环依赖错误但运行时调用某个方法却抛出NullPointerException或者Transactional不生效。这很可能是依赖注入成功了但注入的对象并不是你期望的那个。Lazy注入的代理对象未初始化如果你使用了Lazy并且注入的是接口类型Spring可能会注入一个代理。在调用该代理的方法前真正的目标对象可能还未被初始化。确保你在调用前该Bean已经被容器初始化通常第一次调用其方法时会触发。AOP代理对象问题在允许循环依赖的情况下如果A和B相互依赖且都被AOP代理Spring在解决循环依赖时注入的可能是原始对象的早期引用而不是最终的代理对象。这会导致后续的AOP增强如事务失效。这种情况比较复杂通常需要重构设计来避免。多例原型Bean注入单例Bean这不是循环依赖问题但症状类似。单例Bean中注入了一个原型Bean你期望每次调用都获得新实例但实际上Spring只在单例Bean初始化时注入一次原型Bean。这需要使用Lookup方法或ObjectProvider来解决。5.4 实用调试技巧开启Spring Debug日志在application.yml中添加logging.level.org.springframework.beans.factory.supportDEBUG。这会输出非常详细的Bean创建、依赖解析日志你可以清晰地看到每个Bean的创建步骤和它正在寻找的依赖是追踪循环依赖路径的终极武器。日志量会很大建议在复现问题时临时开启。使用IDE的Diagram工具IntelliJ IDEA Ultimate等IDE可以生成Spring Bean的依赖关系图直观地展示Bean之间的依赖关系帮助你快速发现循环。编写单元测试隔离为相互依赖的Service编写集成测试在测试环境中启动相关的Bean观察启动和调用过程比在全应用启动时调试更容易定位问题。分步重构当遇到一个复杂的循环依赖网时不要试图一次性重构完。可以先用Lazy或allow-circular-referencestrue让应用先跑起来然后逐个分析依赖关系优先重构最核心、最简单的循环一步步将网状结构梳理成清晰的层次结构。6. 项目实践中的决策与权衡经过上述分析我们应该形成一个清晰的决策路径遇到循环依赖错误第一步做什么如果是Spring Boot 2.6首先检查是否配置了spring.main.allow-circular-referencestrue。如果没有加上它看能否启动。但这只是获得一个调试机会不是最终方案。应用能启动后第二步做什么立即分析产生循环依赖的代码。使用上文提到的“提取公共功能”、“事件驱动”等方法制定一个重构计划。目标是彻底消除循环依赖。何时可以保留allow-circular-referencestrue遗留系统迁移在将一个老系统迁移到Spring Boot 2.6时可能暂时无法修改所有代码可以短期开启此配置作为过渡。第三方库的兼容性问题极少数情况下某些第三方库的自动配置可能无意中引入了循环依赖而你无法修改其源码。开发/测试环境为了不阻塞开发进度可以在开发环境配置中开启方便快速验证功能。但CI/CD流水线和生产环境必须关闭将其作为代码质量门禁。设计阶段如何预防优先使用构造器注入这能强制你在编码阶段就发现循环依赖因为构造器注入的循环依赖无法被Spring默认机制解决会立刻报错。遵循分层架构严格遵循Controller - Service - Repository/Mapper的调用方向禁止下层直接调用上层。模块化与接口隔离定义清晰的接口通过接口进行通信降低模块间的耦合度。定期进行架构评审使用工具分析项目依赖及时发现并讨论新引入的循环依赖。循环依赖就像代码中的“技术债”允许循环依赖的配置就像是申请了一笔“高息贷款”它能让你暂时渡过难关但长期不还会拖垮整个项目。一个优秀的开发者应该具备在“快速解决问题”和“构建稳健架构”之间做出明智权衡的能力。希望本文的深度拆解能让你不仅掌握Spring Boot中处理循环依赖的“术”更能理解其背后的“道”从而写出更清晰、更健壮、更易于维护的代码。