Spring Bean 循环依赖:为什么有时能过、有时启动就挂
和《Spring 事务为什么「加了
@Transactional却不生效」》同一条线:都跟 代理、Bean 创建顺序、注入方式 有关。本文用直觉讲清循环依赖何时能过、何时必挂、半成品引用的运行期风险,以及怎么拆到可维护。
1. 什么是循环依赖
两个(或多个)Bean 创建时互相需要对方:
text
两两环: A ──► B
▲ │
└──────┘
三角环: A ──► B ──► C
▲ │
└────────────┘
表现往往是:
- 启动报错:
BeanCurrentlyInCreationException/The dependencies of some of the beans in the application context form a cycle - 或启动「碰巧能过」,上线后某次改注入方式 / 加了
@Transactional/ 升级 Spring Boot 突然挂掉
后者最折磨人:不是业务变了,是依赖图、代理方式和框架默认策略变了。
1.1 三角环为什么更难一眼看出来
日志里可能是一长串:
text
orderAppService → paymentHandler → inventoryHelper → orderAppService
不是「A 和 B 互相 @Autowired」那么直观。排查时:
- 从报错给出的 cycle 列表抄下来
- 画成有向图,找回路
- 环上任意一环若是构造器注入,整环都更容易挂(不必两两都是构造器)
2. 为什么「有时能过」:三级缓存直觉版
Spring 单例 创建大致要过三关(名字记不住没关系,记住分工):
| 层级(俗称) | 放什么 | 干什么 |
|---|---|---|
| 一级缓存 | 成品 Bean | 完全初始化好的单例 |
| 二级缓存 | 早期引用(可能已是代理) | 给别人注入用的「半成品引用」 |
| 三级缓存 | 对象工厂 | 需要时才生成早期引用 / 代理 |
字段 / Setter 注入 时,常见路径是:
- 先把 A 实例
new出来(此时属性还没灌完) - 把「能拿到 A 早期引用」的工厂放进三级缓存
- 给 A 注入 B → 去创建 B
- 创建 B 时发现需要 A → 从三级缓存拿出 A 的早期引用给 B
- B 完成 → 再把 B 注入回 A → A 完成
所以:属性注入的环,Spring 往往能靠「先曝光半成品引用」解开。
这不是魔法允许随意循环,而是:在「实例已存在、只差填属性」的阶段,允许别人先拿着这个引用。
2.1 为什么要「三级」而不是两级?(和 AOP 有关)
若只有「半成品对象」一种缓存,遇到需要 提前暴露代理(事务、异步等)时,可能出现:
- 有人拿到了原始对象,有人拿到了代理
- 或多次生成不一致的早期引用
三级缓存用 工厂延迟创建 :真正有别的 Bean 来要引用时,再决定曝光「原始早期引用」还是「代理早期引用」。
直觉一句:第三级是「按需生成正确早期引用(含代理)」的工厂位。
不必背源码方法名;知道「和 AOP 早期代理有关」即可。
2.2 只对单例生效;原型 Bean 别指望同一套
三级缓存机制主要服务 singleton。
@Scope("prototype")每次都是新实例,不会像单例那样稳稳「曝光半成品再回填」- 原型参与环时,行为更绕、更易出意外
实践:业务 Service 保持单例;原型只用于明确「每次新对象」的场景,并尽量不要进依赖环。
3. 「能启动」≠「运行安全」:半成品引用的坑
字段注入环被解开后,B 手里可能是 尚未完成属性注入 / 初始化 的 A。
java
@Service
public class PaymentHandler {
@Autowired
private OrderAppService orderAppService;
@PostConstruct
public void init() {
// ❌ 危险:此时 OrderAppService 可能还没注入完其它依赖
orderAppService.warmup();
}
}
典型症状:
- 启动阶段偶发 NPE、空依赖
- 「只在某个环境复现」(创建顺序不同)
约定:
@PostConstruct/InitializingBean里 不要 回调依赖环上的对方做业务- 初始化只做「自己本地」的事;跨 Bean 协作放到 首次请求 或 ApplicationReadyEvent(仍要注意事务边界)
- 能过启动检查,仍要把环拆掉------半成品引用是框架妥协,不是设计许可
4. 为什么「有时启动就挂」
4.1 构造器注入形成环(最常见必挂)
java
@Service
@RequiredArgsConstructor
public class OrderAppService {
private final PaymentHandler paymentHandler; // 构造器注入
}
@Service
@RequiredArgsConstructor
public class PaymentHandler {
private final OrderAppService orderAppService; // 构造器注入
}
构造器执行时,对方 必须已经是成品 。两边都在等对方构造完成 → 死锁式环,三级缓存救不了。
4.2 一边构造器、一边属性:仍可能挂
构造器那一侧要求对方完整可用;若环里 任意一环 必须在构造期拿到对方,就容易挂。
三角环同理:不必三个都是构造器,一个构造器卡在环上 就够呛。
4.3 加上 @Transactional / @Async 之后「本来能过」变挂
事务、异步等会给 Bean 套 代理。
早期曝光的引用有时必须是 代理对象,创建顺序和缓存暴露时机更敏感。再叠构造器注入、或多个切面,环更容易在启动期爆掉。
对照事务那篇:
| 问题 | 根因关键词 |
|---|---|
@Transactional 不生效 |
调用没走代理(如自调用) |
| 循环依赖启动失败 | 创建期谁先谁后、要不要代理,对不上 |
都是 代理与生命周期,不是两件无关的事。
4.4 Spring Boot 版本差异:升级后突然挂
| 时期 | 大致策略 |
|---|---|
| Boot 2.6 之前 | 对循环依赖更宽容,字段注入环常能启动 |
| Boot 2.6+ | 默认禁止 循环依赖,启动直接失败 |
| 仍可用配置放宽 | spring.main.allow-circular-references: true(过渡用) |
因此常见叙事是:
「代码没改业务,只升了 Boot / 父 POM,服务起不来了。」
排查顺序:看是不是 cycle 报错 → 是否依赖了默许循环的旧行为 → 拆环,而不是长期打开开关。
4.5 换 @Resource / 按名注入不能当解法
@Autowired(按类型)和 @Resource(默认按名)可能改变 注入点匹配与部分创建体感,偶尔让人误以为「换注解就好了」。
真相:
- 没有消除环,只是有时碰巧换了创建顺序或报错形态
- 不是方案;Code Review 看到「为了启动把
@Autowired改成@Resource」应打回
4.6 RPC / 远程引用
@DubboReference、OpenFeign、HTTP Client 等远程引用本身常是代理,一般 不参与 本地构造器环;真正成环的多半是本地两个 @Service 互咬。排查时先分清:本地 Bean 环 vs 远程依赖。
5. @Lazy 在干什么(直觉)
java
public PaymentHandler(@Lazy OrderAppService orderAppService) { ... }
构造期注入的往往不是最终 OrderAppService 本体,而是一个 懒加载代理:
- 启动时先把代理塞进构造器,环能「绕过去」
- 第一次真正调用 时才解析到目标 Bean
所以:
- 能启动,环还在
- 第一次调用才暴露初始化问题
- 应标成技术债,并计划拆掉
6. 怎么确认是不是循环依赖
- 看启动栈里是否出现
BeanCurrentlyInCreationException、cycle 相关提示。 - 画出「构造器 /
@RequiredArgsConstructor/@Autowired字段」依赖箭头(含三角环)。 - 最近是否改过:构造器注入、Lombok、
@Transactional、拆类、Boot / Spring 升级。 - 是否有人用
@Lazy、allow-circular-references、@Resource「先顶上」。
依赖图画出来,一半问题已经定位。
7. 怎么拆(推荐顺序)
7.1 改调用方向(优先)
让依赖变成单向:
text
❌ OrderAppService → PaymentHandler → OrderAppService
✅ OrderAppService → PaymentHandler
✅ PaymentHandler → OrderRepository(或专用小组件),不要再调回门面 Service
7.2 抽出第三方向下依赖
text
OrderFacade ──► OrderPayService
└──► FulfillmentService
两个能力都依赖 Facade,或都只依赖基础设施,而不是互为依赖。
7.3 通用拆分示意(电商 / 支付类系统)
text
❌ 门面大 Service 既下单又在支付回调里互相调用 Handler,Handler 再调回门面改状态
✅ 下单:OrderAppService → 库存 / 优惠 / OrderRepository
✅ 支付收尾:PaymentHandler → OrderRepository / 履约组件
(状态推进用 Repository 或极窄的状态组件,不反向依赖 AppService)
✅ 履约:FulfillmentService → 自身仓储 / 远程接口
门面只编排;收尾与履约沉到 不反向依赖门面 的组件。
7.4 事件解耦(适合「做完 A 顺便通知 B」)
text
支付成功 → 发 ApplicationEvent / MQ
履约监听方单独消费
注意:
- 同进程同步事件仍在同一线程
- 要 事务提交后再干活,用事务同步或 Outbox,别默认「发了事件就已提交」
7.5 @Lazy(权宜之计)
能启动,但环还在。文档和 Code Review 里应标成技术债,并计划拆掉。
7.6 配置放宽(不推荐当方案)
yaml
spring.main.allow-circular-references: true
仅用于遗留系统升级过渡(例如刚升到 Boot 2.6+)。新代码不要靠这个「合法化」环。
8. 和注入方式的对照表
| 注入方式 | 循环依赖 | 说明 |
|---|---|---|
| 构造器注入 | 环则易挂 | 依赖显式、宜测;环要靠设计拆,不是靠缓存 |
字段 @Autowired |
环有时能过 | 隐藏依赖,难测;能过 ≠ 设计对;半成品仍有运行期风险 |
| Setter 注入 | 类似字段 | 少用 |
@Resource 换掉 @Autowired |
不消除环 | 禁止当解法 |
@Lazy |
可绕过启动失败 | 治标;注入懒代理 |
| 事件 / 单向拆分 | 从根上消环 | 推荐 |
打开 allow-circular-references |
掩盖问题 | 仅过渡 |
团队约定建议:默认构造器注入;出现环就重构,而不是改回字段注入或换注解骗过启动。
9. 五分钟自检清单
- 报错里有没有 cycle /
BeanCurrentlyInCreationException? - 是两两环还是三角(多跳)环?画出来。
- 环上是否存在构造器 /
@RequiredArgsConstructor? - 最近是否加了
@Transactional、@Async、新切面,或 升级了 Boot? @PostConstruct是否回调了环上的对方?- 能否改成单向依赖或下沉 Handler?
- 是否已经在用
@Lazy/allow-circular-references/ 换@Resource顶着?有的话排期拆。 - 是否有
prototype不该进环?
10. 实践约定(可直接贴团队规范)
- 禁止 新代码依赖
allow-circular-references=true。 - 应用服务之间 单向依赖;交叉能力下沉到无反向依赖的组件。
- 构造器注入为默认;遇到环 先拆设计 ,再考虑
@Lazy。 - 禁止 为启动成功把
@Autowired改成@Resource当「修复」。 - 事务、异步注解加在「环上的 Bean」前,先看一眼依赖图。
@PostConstruct不做跨环业务调用。- Code Review 看到 A↔B(或 A→B→C→A)互相注入,按缺陷处理,不按风格偏好放行。
11. 一句话总结
字段注入的环,Spring 有时能用早期引用「扛过去」------但半成品在初始化阶段回调依然危险;构造器注入的环、再加上事务代理或 Boot 2.6+ 默认策略,启动期就更容易挂。能过不等于正确------正确做法是拆成单向依赖,而不是关掉循环检测、换注入注解或到处 @Lazy。
排障顺序建议:先画依赖图(含多跳)→ 看是否构造器环 / 是否新加代理 / 是否 Boot 升级 → 再决定拆方向、下沉还是临时 @Lazy。
12. 和上一篇怎么连着读
| 篇目 | 核心问题 |
|---|---|
@Transactional 不生效 |
运行期调用没走代理 |
| 本文循环依赖 | 启动期创建顺序与代理曝光对不上;半成品引用的运行期风险 |
都记住一句:Spring 里你以为的「自己」,经常是代理;生命周期和调用路径要对上号。