Skip to content

Spring 循环依赖:能启动不等于没问题 ​

循环依赖首先是设计信号,其次才是容器机制。构造器循环无法创建,字段循环能否启动取决于开关;就算启动成功了,容器也可能在你看不到的地方重建了 Bean,或者让某个注解静默失效。

一个应用从纯 Spring Framework 迁到 Spring Boot 4 后启动失败,报的是订单服务和库存服务互相依赖。之前一直「好好的」:纯 Framework 默认允许字段循环,Spring Boot 从 2.6 起默认禁止。另一个项目里,两个互相依赖的服务,其中一个带 @Async,应用照常启动,但它们的构造器都执行了两次。

本文用 Spring Framework 7.0.9 和 Spring Boot 4.1.1 实测各种循环依赖的结果,再讨论怎样真正消除它。

一、先说结论 ​

  • 构造器循环无法创建:两个对象都要对方先存在,容器无法打破,启动失败。
  • 字段循环能否启动取决于开关:Spring Framework 默认允许,Spring Boot 默认禁止,打开 spring.main.allow-circular-references 才能启动。
  • 容器靠「提前暴露引用」打破字段循环:带事务等 AOP 代理的 Bean 能提前拿到最终的代理,实测两边持有同一个代理。
  • @Async 参与的循环会触发重建:实测 Spring Framework 7.0.9 启动成功,但两个 Bean 各构造了 2 次,只留下一条措辞不相关的 INFO 日志;改为延迟初始化后直接失败。
  • @Lazy 能打破循环,但只是推迟:注入的是代理,第一次调用时才解析。
  • 真正的修法是改依赖方向:把共同的流程拆成第三个服务,两者互不依赖,Spring Boot 默认配置下直接启动。

二、实测结果一览 ​

循环方式Spring Framework 7.0.9 默认Spring Boot 4.1.1 默认
构造器循环启动失败启动失败
字段循环启动成功启动失败,打开开关后成功
prototype 字段循环getBean 失败—
字段循环,一侧带 @Transactional启动成功,两边是同一个代理同字段循环
字段循环,一侧带 @Async启动成功,但两个 Bean 各构造 2 次同字段循环
构造器循环,一侧加 @Lazy启动成功,注入的是代理启动成功
拆出第三个服务启动成功启动成功

构造器循环、Boot 默认下的字段循环、prototype 循环,报的都是同一个异常:

text
BeanCurrentlyInCreationException: Error creating bean with name '...FieldOrder':
Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?

Spring Boot 在启动失败时还会打印一段说明,画出循环的路径:

text
The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  ...FieldOrder (field public ...FieldInventory ...FieldOrder.inventory)
↑     ↓
|  ...FieldInventory (field public ...FieldOrder ...FieldInventory.order)
└─────┘

Action:
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove
the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting
spring.main.allow-circular-references to true.

「As a last resort」这几个字值得注意:打开开关是最后的手段,不是修复。

三、容器怎样打破字段循环 ​

以订单和库存为例,两者通过字段互相注入:

  1. 创建订单:调用构造器,得到一个还没有注入依赖的实例;
  2. 容器把一个「能拿到订单早期引用的工厂」登记起来;
  3. 给订单注入库存,发现库存还不存在,开始创建库存;
  4. 库存需要订单,从第 2 步的工厂拿到订单的早期引用,完成库存的创建;
  5. 回到订单,注入库存,执行初始化,完成。

第 2 步之所以登记「工厂」而不是直接登记实例,是为了 AOP:如果订单最终要被包装成代理,库存拿到的早期引用也必须是那个代理,否则系统里会同时存在原始对象和代理,两边的行为不一致。常说的「三级缓存」,指的就是完成的单例、提前暴露的引用、生成早期引用的工厂这三处。

1构造 A(还没有注入依赖)2登记「A 的早期引用工厂」3给 A 注入 B:开始创建 B4B 需要 A:调用工厂拿到早期引用(需要代理时就是代理)5B 完成;回到 A,初始化并完成@Transactional早期引用就是最终代理@Async早期是原始对象,最终被包装构造器循环没有第 2 步的机会;prototype 每次都新建,也无法复用早期引用
图 1 · 创建 A 后先登记一个能生成早期引用的工厂,再注入 B;B 需要 A 时从工厂拿到早期引用。能在早期生成代理的后处理器(事务、切面)让 B 拿到的就是最终的代理

负责事务和切面的自动代理创建器实现了「生成早期引用」这个扩展点,所以:

text
@Transactional 的 TxOrder 与 TxInventory 字段循环:启动成功;
容器里的 TxOrder 是代理=true,TxInventory 持有的也是同一个代理=true

四、@Async 参与的循环 ​

处理 @Async 的后处理器没有实现「生成早期引用」,它只在初始化之后才包装代理。于是第 4 步库存拿到的是订单的原始对象,第 5 步订单却被包装成了代理。容器在第 5 步末尾检查到这种不一致,抛出异常。

在 Spring Framework 7.0.9 里,这个异常在预实例化阶段被捕获了:

text
@Async 的 AsyncOrder 与 AsyncInventory 字段循环,容器启动时创建:启动成功;
AsyncOrder 构造 2 次,AsyncInventory 构造 2 次;最终两边持有同一个代理=true

INFO: Bean '...AsyncOrder' marked for pre-instantiation (not lazy-init) but currently initialized
      by other thread - skipping it in mainline thread

容器放弃了第一次创建的订单,接着创建库存时重新创建了订单,这一次顺序反过来,没有冲突。结果是启动成功、最终对象一致,但两个 Bean 的构造器都执行了两次(实验只计数了构造器,被放弃的那次创建同样走过了初始化流程),日志里只有一条 INFO,而且措辞说的是「被其他线程初始化」,与循环依赖看不出关系。

同样的循环改成延迟初始化,首次 getBean 时没有这层兜底,异常直接抛出:

text
Bean with name '...AsyncOrder' has been injected into other beans [...AsyncInventory] in its raw version as part of
a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version
of the bean.

这个行为依赖 Bean 的注册顺序和容器版本。它说明的不是「@Async 的循环已经安全」,而是:循环依赖在启动成功时也可能有副作用。构造器或初始化回调里有外部动作(注册监听、启动线程、建立连接)时,执行两次就是真实的问题。

创建 A原始对象注入 BA 被包装与 B 持有的不一致预实例化捕获异常INFO:…initialized by other thread创建 B 时重新创建 A启动成功;A、B 各构造 2 次延迟初始化,首次 getBean(A)…injected into other beans in its raw version…
图 2 · A 的原始对象注入 B 后,A 又被包装成代理,容器检测到不一致并抛出异常;Spring Framework 7.0.9 在预实例化阶段捕获它,接着创建 B 时重新创建 A:启动成功,但 A、B 各构造 2 次。延迟初始化时没有这层兜底,直接失败

五、@Lazy:推迟,而不是消除 ​

构造器循环可以在一侧参数上加 @Lazy:

java
public LazyOrder(@Lazy LazyInventory inventory) { this.inventory = inventory; }
text
构造器循环,一侧参数加 @Lazy:启动成功;注入的是代理=true,第一次调用时才解析,返回「inventory」

注入的是一个延迟解析的代理,第一次调用它的方法时才去容器里取真正的库存。依赖关系依然是双向的,只是创建时机错开了。它适合「依赖在逻辑上合理、只是启动时不需要立即可用」的情况,不适合作为消除循环的常规手段。

六、真正的修法:改依赖方向 ​

先画出依赖图,问一句:这两个服务为什么需要互相知道? 常见的原因和对应的改法:

原因改法
一个流程需要同时操作两者把流程拆成第三个服务,由它依赖两者,两者互不依赖
一方只是需要在某件事发生后通知另一方改成发布事件,见 观察者、事件与消息
查询和命令混在同一个服务里把查询拆出去,命令服务之间不再为了读数据互相调用
两个服务其实属于同一个聚合或同一个模块合并,或者重新划分边界,见 聚合边界
text
改之前:OrderService ↔ InventoryService
改之后:CheckoutService → OrderService
                     └→ InventoryService

实验里拆出 Checkout 之后,Spring Boot 4.1.1 在默认配置下直接启动成功,不需要任何开关。

构造器注入会让循环在启动时就暴露出来,这是它的优点:依赖完整、对象可以不可变、测试时不需要容器。发现构造器循环时,正确的反应是修改设计,而不是改回字段注入让它「能启动」。

七、常见误区 ​

  • 「Spring 用三级缓存解决了循环依赖」:只覆盖单例、字段或 setter 注入,并且要求代理能在早期生成;构造器循环、prototype 循环都不行。
  • 「升级后启动失败,打开 allow-circular-references 就好」:这是 Spring Boot 明确写着的最后手段。
  • 「能启动就没问题」:实测 @Async 参与的循环启动成功,但 Bean 被创建了两次。
  • 「@Lazy 修好了循环」:它推迟了解析,依赖仍然是双向的。
  • 「字段注入更灵活」:它让循环依赖更难被发现。

小结 ​

循环依赖的判断顺序是:先看它为什么存在,再看容器能不能处理。容器能处理的情况(单例、字段注入、代理可以提前生成)并不代表没有代价,实测里就有被静默重建的 Bean。Spring Boot 默认禁止循环依赖,是让问题在启动时暴露出来;遇到时,拆出第三个服务、改用事件,或者重新划分边界,比打开开关更可靠。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权