拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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 依赖 B,B 依赖 C,C 又依赖 A,本质上都是同一个问题。

很多教程喜欢用“先有鸡还是先有蛋”来解释,我觉得更贴切的是两个人互相等对方烧开水:A 说“你先烧,我等你”,B 说“你先烧,我等你”,结果谁都没动。Spring 里如果不做任何处理,A 要实例化时发现需要 B,B 要实例化时发现需要 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,于是去创建 B,B 同样先被 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 依赖 MessageService,MessageService 又依赖 UserService 去查询用户昵称。稍微重构一下,创建一个UserQueryService,专门负责用户基础信息查询;UserService 依赖 MessageService 做通知,MessageService 依赖 UserQueryService 拿昵称,不再依赖 UserService 本体。这样依赖图就变成了单向链路,测试时也能分别 mock。

从工程收益看,这样的重构至少带来三点好处:职责边界更清晰,每个类只需要关心自己的核心能力;可测试性变好,mock 对象规模明显缩小;后续改造时,比如把消息推送换成 MQ 异步发送,也只改动消费侧,不需要连带改 UserService。我团队里有个老项目就是靠这种方式一次性消除了五六个循环依赖点,没有任何一个靠“允许循环依赖”蒙混过关。

2.3 无法快速重构时的过渡选择:@Lazy

当然,理想归理想,现实里你很可能在一个六七年历史的老项目里,代码层层缠绕,一时半会根本不敢大改。这时@Lazy是一个非常有价值的过渡方案。

@Lazy加在依赖注入的位置后,Spring 注入的不再是目标 Bean 的完整实例,而是一个懒加载代理对象。只有当这个代理被真正调用方法时,容器才会去创建并缓存真正的目标 Bean。构造器循环依赖之所以无解,是因为构造函数需要完整的实参;但如果你把其中一个构造器参数标记为@Lazy,Spring 就可以先塞一个代理进去,绕开“必须现在就要成品”的约束。

我个人的用法是:在确定要重构但暂时没排期的模块上,先用@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-references=true。但我必须提醒你,这是一种“战术性妥协”,不是“终极解决”。开了开关只是让 Spring 尝试用三级缓存兜底,并不代表所有循环都能被兜住。构造函数注入的循环照样报错,代理场景照样可能出问题,所以这个开关更像是给你争取重构时间的临时工具。

3.2 三级缓存能兜底的前提条件速查

不是所有循环依赖都能被三级缓存解决,能不能兜住,要同时满足几个硬性条件。我把这些条件整理成了一张速查表,排查时可以对着看:

条件说明不满足时的后果
Bean 作用域是单例只有单例才走三级缓存,原型作用域不缓存原型 Bean 循环时直接抛异常
注入方式为属性注入或 setter 注入构造器注入发生在实例化阶段,尚无缓存可查BeanCurrentlyInCreationException
未越过 Spring 循环依赖开关Boot 2.6 之后默认禁止启动即报错
依赖关系不涉及相同 Bean 的多个代理需求复杂 AOP 场景可能出现早期代理与最终代理不一致拿到不符合预期的代理对象

这里最容易被忽视的是作用域问题。@Scope("prototype")的 Bean 不会进入一级缓存,Spring 每次使用都要新创建一个,所以 A 是单例、B 是原型,A 注入 B 没问题;但 A 和 B 都是原型且互相注入时,A 创建需要 B,B 创建需要 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 加了@Transactional,B 在属性填充阶段提前拿到了 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 存在循环依赖,而你强行给两边都加@DependsOn,Spring 会直接识别出初始化顺序上的环,照样抛BeanCurrentlyInCreationException。所以不要指望用它解开循环,它只会让循环初始化问题更明确地暴露出来。

真实项目里我更喜欢把它用在“启动阶段任务编排”上,比如系统启动时要先建立数据库连接池,再启动 MQ 消费者,再预热缓存。这些任务没有互注关系,但顺序错了就会出问题,@DependsOn或ApplicationRunner都是比休眠等待更可靠的手段。

4.3 ObjectProvider:从容器延迟获取依赖

ObjectProvider<T>是 Spring 4.3 提供的一个间接层,它本身是一个注入点,但它不是直接把 T 注入进来,而是一个能用来获取 T 的“取货凭证”。最常见的写法是:

@Component public class A { private final ObjectProvider<B> bProvider; public A(ObjectProvider<B> 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,比如List<Handler>,这些 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,比看一长串异常堆栈直观太多了。

返回列表