Spring Bean 循环依赖:为什么有时能过、有时启动就挂

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」那么直观。排查时:

  1. 从报错给出的 cycle 列表抄下来
  2. 画成有向图,找回路
  3. 环上任意一环若是构造器注入,整环都更容易挂(不必两两都是构造器)

2. 为什么「有时能过」:三级缓存直觉版

Spring 单例 创建大致要过三关(名字记不住没关系,记住分工):

层级(俗称) 放什么 干什么
一级缓存 成品 Bean 完全初始化好的单例
二级缓存 早期引用(可能已是代理) 给别人注入用的「半成品引用」
三级缓存 对象工厂 需要时才生成早期引用 / 代理

字段 / Setter 注入 时,常见路径是:

  1. 先把 A 实例 new 出来(此时属性还没灌完)
  2. 把「能拿到 A 早期引用」的工厂放进三级缓存
  3. 给 A 注入 B → 去创建 B
  4. 创建 B 时发现需要 A → 从三级缓存拿出 A 的早期引用给 B
  5. 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、空依赖
  • 「只在某个环境复现」(创建顺序不同)

约定:

  1. @PostConstruct / InitializingBean 里 不要 回调依赖环上的对方做业务
  2. 初始化只做「自己本地」的事;跨 Bean 协作放到 首次请求 或 ApplicationReadyEvent(仍要注意事务边界)
  3. 能过启动检查,仍要把环拆掉------半成品引用是框架妥协,不是设计许可

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. 怎么确认是不是循环依赖

  1. 看启动栈里是否出现 BeanCurrentlyInCreationException、cycle 相关提示。
  2. 画出「构造器 / @RequiredArgsConstructor / @Autowired 字段」依赖箭头(含三角环)。
  3. 最近是否改过:构造器注入、Lombok、@Transactional、拆类、Boot / Spring 升级。
  4. 是否有人用 @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. 五分钟自检清单

  1. 报错里有没有 cycle / BeanCurrentlyInCreationException?
  2. 是两两环还是三角(多跳)环?画出来。
  3. 环上是否存在构造器 / @RequiredArgsConstructor?
  4. 最近是否加了 @Transactional、@Async、新切面,或 升级了 Boot?
  5. @PostConstruct 是否回调了环上的对方?
  6. 能否改成单向依赖或下沉 Handler?
  7. 是否已经在用 @Lazy / allow-circular-references / 换 @Resource 顶着?有的话排期拆。
  8. 是否有 prototype 不该进环?

10. 实践约定(可直接贴团队规范)

  1. 禁止 新代码依赖 allow-circular-references=true。
  2. 应用服务之间 单向依赖;交叉能力下沉到无反向依赖的组件。
  3. 构造器注入为默认;遇到环 先拆设计 ,再考虑 @Lazy。
  4. 禁止 为启动成功把 @Autowired 改成 @Resource 当「修复」。
  5. 事务、异步注解加在「环上的 Bean」前,先看一眼依赖图。
  6. @PostConstruct 不做跨环业务调用。
  7. Code Review 看到 A↔B(或 A→B→C→A)互相注入,按缺陷处理,不按风格偏好放行。

11. 一句话总结

字段注入的环,Spring 有时能用早期引用「扛过去」------但半成品在初始化阶段回调依然危险;构造器注入的环、再加上事务代理或 Boot 2.6+ 默认策略,启动期就更容易挂。能过不等于正确------正确做法是拆成单向依赖,而不是关掉循环检测、换注入注解或到处 @Lazy。

排障顺序建议:先画依赖图(含多跳)→ 看是否构造器环 / 是否新加代理 / 是否 Boot 升级 → 再决定拆方向、下沉还是临时 @Lazy。


12. 和上一篇怎么连着读

篇目 核心问题
@Transactional 不生效 运行期调用没走代理
本文循环依赖 启动期创建顺序与代理曝光对不上;半成品引用的运行期风险

都记住一句:Spring 里你以为的「自己」,经常是代理;生命周期和调用路径要对上号。

相关推荐
缓慢更新7 小时前
审批的加签与会签:多人意见怎么合
android·java·数据库·低代码·rxjava
yuniko-n8 小时前
【JUC】volatile 非原子性深入解析
java
sun_weitao9 小时前
极简版 MCP 工具实现方案
java·linux·服务器
金銀銅鐵10 小时前
[Java] 展示增删元素时 LinkedList 的内部变化
java·后端·ai编程
布吉岛的石头10 小时前
Java 程序员第 50 阶段3:数据工程:预训练语料清洗与去重
java·开发语言·人工智能·深度学习·transformer
大文说跨境11 小时前
防关联浏览器的指纹覆盖到什么程度:六类参数的实测与自检方法
java·指纹浏览器·tiktok运营
Carol063011 小时前
上肢及肩肘关节
java·服务器·数据库
yliucherish11 小时前
Log4Shell 复现实录:我读 OpenJDK 源码,发现两道防线的默认值竟然相反
java
维拉德11 小时前
Java新人第一次接打标需求:从抓包找接口到客户端展示
java
luobobo89611 小时前
支付回调验签连续失败三天,最后发现是网关的时间戳单位问题
java