Skip to content

什么是循环依赖? ​

  • 标签:后端、Spring

🏛 业务架构师 ​

循环依赖是 A 依赖 B、B 又依赖 A 的环状引用(也可以是 A→B→C→A 的长环)。

业务视角我重点讲它暴露的架构问题:循环依赖几乎总是职责划分错误的味道——两个模块互相需要对方,往往说明本该抽出的第三个概念被漏掉了。例:订单服务要调库存服务(下单扣减),库存服务又要调订单服务(回补时查订单状态)——正确做法是抽出事件:库存通过订阅"订单取消"事件获知状态,而不是反向调用,环就断了。

Spring 的解法是三级缓存提前暴露:A 实例化后先把"半成品工厂"放进三级缓存,注入 B 时 B 拿到 A 的早期引用完成创建,A 再继续完成属性填充——环得以闭合。但注意边界:

  • 构造器注入的循环无法解决(半成品都造不出来)——Spring 直接报错,这其实是框架在告诉你"该重构了";
  • @Async、代理增强场景的循环依赖需要特殊处理(提前暴露的是工厂就是为了代理)。

我的架构立场:

  1. 循环依赖是警告,不是 bug——用 @Lazy 压掉警告等于把肾结石止疼片当治疗。正确动作:重画依赖图,问"第三个概念是什么"(事件、共享的领域模型、下沉的服务);
  2. 依赖关系要架构治理:模块依赖规则(如 DDD 里 domain 不依赖 infra)进 ArchUnit 自动化检查——CI 里卡住环,比事后人肉排查便宜百倍;
  3. 团队规范:新模块接入先声明它依赖谁、被谁依赖,依赖图是架构的可视化资产。

一句话:三级缓存是框架的宽容,架构师的责任是不让框架需要宽容。

🏗 系统架构师 ​

循环依赖指两个或多个 Bean 相互持有:A 依赖 B,B 又依赖 A(构造器或字段注入形成环)。Spring 场景下表现:容器启动时创建 A → 注入属性发现需要 B → 创建 B → 又需要 A → 无限递归。

危害:启动失败只是表象;设计层面循环依赖意味着职责边界模糊——两个模块互相纠缠,无法独立演化、无法独立测试,通常是领域建模出了问题(应该抽出的公共依赖没有抽出,或 A/B 本该是一个聚合)。

Spring 的处理边界:

  • 字段/Setter 注入的单例循环:三级缓存可以解(见下一题);
  • 构造器注入循环:无解,直接抛 BeanCurrentlyInCreationException——因为对象都没构造完,无法提前暴露引用;
  • Prototype 作用域循环:无解(不缓存,无法提前暴露)。

我的治理姿势:

  1. 首选重构解耦:抽公共逻辑到第三个 Bean、事件机制(发布事件代替反向调用)、接口回调反转依赖方向;
  2. 实在要保留:@Lazy 打断(注入代理,首次使用才解析);
  3. Spring Boot 2.6 起默认禁止循环依赖,正好倒逼治理——我们升级时用 allow-circular-references=false 保持严格,逐个解决历史环,长期收益远大于绕过。

💻 全栈开发专家 ​

循环依赖 = A 的创建需要 B,B 的创建又需要 A,形成闭合环。

java
@Service
public class A {
    @Autowired private B b;   // A 依赖 B
}
@Service
public class B {
    @Autowired private A a;   // B 又依赖 A
}

Spring 的处理边界(必须答准确):

  • 构造器注入的循环依赖:无法解决,直接抛 BeanCurrentlyInCreationException——因为实例化阶段就互相要,没有"半成品"可提前暴露;
  • setter/字段注入:单例下靠三级缓存解决(先实例化 A 暴露早期引用,B 拿到 A 的早期引用完成自己,A 再继续初始化);
  • prototype 作用域的循环依赖无解(不缓存实例);
  • @Async 注解的 Bean 参与循环依赖会报错(代理生成时机冲突)。

工程态度:循环依赖是分层混乱的信号——Service 互相调用说明职责边界没切好。我的治理方式:

  1. 把互相调用的公共逻辑下沉到第三个 Service;
  2. 单向依赖重构(B 需要通知 A 的场景改事件/回调);
  3. 实在绕不开用 @Lazy 延迟注入(应急,不解决设计问题)。

Spring Boot 2.6 起 spring.main.allow-circular-references 默认 false,就是官方在表态:该修设计了,而不是靠框架兜底。