什么是循环依赖?
- 标签:后端、Spring
🏛 业务架构师
循环依赖是 A 依赖 B、B 又依赖 A 的环状引用(也可以是 A→B→C→A 的长环)。
业务视角我重点讲它暴露的架构问题:循环依赖几乎总是职责划分错误的味道——两个模块互相需要对方,往往说明本该抽出的第三个概念被漏掉了。例:订单服务要调库存服务(下单扣减),库存服务又要调订单服务(回补时查订单状态)——正确做法是抽出事件:库存通过订阅"订单取消"事件获知状态,而不是反向调用,环就断了。
Spring 的解法是三级缓存提前暴露:A 实例化后先把"半成品工厂"放进三级缓存,注入 B 时 B 拿到 A 的早期引用完成创建,A 再继续完成属性填充——环得以闭合。但注意边界:
- 构造器注入的循环无法解决(半成品都造不出来)——Spring 直接报错,这其实是框架在告诉你"该重构了";
- @Async、代理增强场景的循环依赖需要特殊处理(提前暴露的是工厂就是为了代理)。
我的架构立场:
- 循环依赖是警告,不是 bug——用 @Lazy 压掉警告等于把肾结石止疼片当治疗。正确动作:重画依赖图,问"第三个概念是什么"(事件、共享的领域模型、下沉的服务);
- 依赖关系要架构治理:模块依赖规则(如 DDD 里 domain 不依赖 infra)进 ArchUnit 自动化检查——CI 里卡住环,比事后人肉排查便宜百倍;
- 团队规范:新模块接入先声明它依赖谁、被谁依赖,依赖图是架构的可视化资产。
一句话:三级缓存是框架的宽容,架构师的责任是不让框架需要宽容。
🏗 系统架构师
循环依赖指两个或多个 Bean 相互持有:A 依赖 B,B 又依赖 A(构造器或字段注入形成环)。Spring 场景下表现:容器启动时创建 A → 注入属性发现需要 B → 创建 B → 又需要 A → 无限递归。
危害:启动失败只是表象;设计层面循环依赖意味着职责边界模糊——两个模块互相纠缠,无法独立演化、无法独立测试,通常是领域建模出了问题(应该抽出的公共依赖没有抽出,或 A/B 本该是一个聚合)。
Spring 的处理边界:
- 字段/Setter 注入的单例循环:三级缓存可以解(见下一题);
- 构造器注入循环:无解,直接抛 BeanCurrentlyInCreationException——因为对象都没构造完,无法提前暴露引用;
- Prototype 作用域循环:无解(不缓存,无法提前暴露)。
我的治理姿势:
- 首选重构解耦:抽公共逻辑到第三个 Bean、事件机制(发布事件代替反向调用)、接口回调反转依赖方向;
- 实在要保留:
@Lazy打断(注入代理,首次使用才解析); - 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 互相调用说明职责边界没切好。我的治理方式:
- 把互相调用的公共逻辑下沉到第三个 Service;
- 单向依赖重构(B 需要通知 A 的场景改事件/回调);
- 实在绕不开用
@Lazy延迟注入(应急,不解决设计问题)。
Spring Boot 2.6 起 spring.main.allow-circular-references 默认 false,就是官方在表态:该修设计了,而不是靠框架兜底。