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 循环,报的都是同一个异常:
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 在启动失败时还会打印一段说明,画出循环的路径:
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」这几个字值得注意:打开开关是最后的手段,不是修复。
三、容器怎样打破字段循环
以订单和库存为例,两者通过字段互相注入:
- 创建订单:调用构造器,得到一个还没有注入依赖的实例;
- 容器把一个「能拿到订单早期引用的工厂」登记起来;
- 给订单注入库存,发现库存还不存在,开始创建库存;
- 库存需要订单,从第 2 步的工厂拿到订单的早期引用,完成库存的创建;
- 回到订单,注入库存,执行初始化,完成。
第 2 步之所以登记「工厂」而不是直接登记实例,是为了 AOP:如果订单最终要被包装成代理,库存拿到的早期引用也必须是那个代理,否则系统里会同时存在原始对象和代理,两边的行为不一致。常说的「三级缓存」,指的就是完成的单例、提前暴露的引用、生成早期引用的工厂这三处。
负责事务和切面的自动代理创建器实现了「生成早期引用」这个扩展点,所以:
@Transactional 的 TxOrder 与 TxInventory 字段循环:启动成功;
容器里的 TxOrder 是代理=true,TxInventory 持有的也是同一个代理=true四、@Async 参与的循环
处理 @Async 的后处理器没有实现「生成早期引用」,它只在初始化之后才包装代理。于是第 4 步库存拿到的是订单的原始对象,第 5 步订单却被包装成了代理。容器在第 5 步末尾检查到这种不一致,抛出异常。
在 Spring Framework 7.0.9 里,这个异常在预实例化阶段被捕获了:
@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 时没有这层兜底,异常直接抛出:
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 的循环已经安全」,而是:循环依赖在启动成功时也可能有副作用。构造器或初始化回调里有外部动作(注册监听、启动线程、建立连接)时,执行两次就是真实的问题。
五、@Lazy:推迟,而不是消除
构造器循环可以在一侧参数上加 @Lazy:
public LazyOrder(@Lazy LazyInventory inventory) { this.inventory = inventory; }构造器循环,一侧参数加 @Lazy:启动成功;注入的是代理=true,第一次调用时才解析,返回「inventory」注入的是一个延迟解析的代理,第一次调用它的方法时才去容器里取真正的库存。依赖关系依然是双向的,只是创建时机错开了。它适合「依赖在逻辑上合理、只是启动时不需要立即可用」的情况,不适合作为消除循环的常规手段。
六、真正的修法:改依赖方向
先画出依赖图,问一句:这两个服务为什么需要互相知道? 常见的原因和对应的改法:
| 原因 | 改法 |
|---|---|
| 一个流程需要同时操作两者 | 把流程拆成第三个服务,由它依赖两者,两者互不依赖 |
| 一方只是需要在某件事发生后通知另一方 | 改成发布事件,见 观察者、事件与消息 |
| 查询和命令混在同一个服务里 | 把查询拆出去,命令服务之间不再为了读数据互相调用 |
| 两个服务其实属于同一个聚合或同一个模块 | 合并,或者重新划分边界,见 聚合边界 |
改之前: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 默认禁止循环依赖,是让问题在启动时暴露出来;遇到时,拆出第三个服务、改用事件,或者重新划分边界,比打开开关更可靠。
配套实验
- codesphere-labs/spring/circular-dependencies:构造器、字段、prototype 循环,Spring Framework 与 Spring Boot 4 的默认值,
@Transactional与@Async参与的循环,@Lazy与拆出第三个服务(验证记录)
参考资料