加个功能就启动失败:一次 Spring 循环依赖排查的复盘

老项目加个看似无害的新功能,启动就直接失败,报 BeanCurrentlyInCreationException。诡异的是项目里早有字段注入的循环依赖一直正常,这次构造器注入却炸了。几天彻底搞清:循环依赖、三级缓存、构造器注入为何解不开、AOP 代理、Spring Boot 2.6+ 默认禁止循环依赖、提取公共类与事件解耦。

2024 年我们一个老项目在加了一个看似无害的功能后,启动就直接失败了,控制台上赫然写着一行 BeanCurrentlyInCreationException——循环依赖。诡异的是,这个项目里其实早就存在字段注入形式的循环依赖,一直跑得好好的,怎么这次加个新功能就突然炸了?排查后才发现,我们这次用的是构造器注入,而 Spring 对构造器注入的循环依赖根本无能为力。这件事把我们推着去把 Spring 的三级缓存、循环依赖的解决边界彻底搞清楚了一遍。投了几天梳理,本文复盘这次实战。

问题背景

业务:Spring Boot 老项目,加新功能后启动失败
事故现象:
- 应用启动直接失败,无法拉起
- 报错 BeanCurrentlyInCreationException
- 项目里早有循环依赖,以前一直正常,这次才炸

现场排查:
# 1. 看启动报错
Error creating bean with name 'orderService':
  Requested bean is currently in creation:
  Is there an unresolvable circular reference?

# 2. 看新加的两个类
@Service
public class OrderService {
    private final CouponService couponService;
    // 构造器注入
    public OrderService(CouponService couponService) {
        this.couponService = couponService;
    }
}
@Service
public class CouponService {
    private final OrderService orderService;
    public CouponService(OrderService orderService) {
        this.orderService = orderService;
    }
}
# OrderService 要 CouponService,CouponService 又要 OrderService
# 两个都用构造器注入 -> Spring 解不开

# 3. 项目里老的循环依赖为什么没事:
# 老代码用的是 @Autowired 字段注入,Spring 能靠三级缓存解开

根因:
1. 新功能引入了两个 Bean 的相互依赖(循环依赖)
2. 用了构造器注入,而构造器注入的循环依赖 Spring 无法解决
3. 团队对"Spring 能解哪种循环依赖、不能解哪种"没有概念
4. 更深层:两个 Service 互相依赖,本身是设计耦合问题

修复 1:什么是循环依赖

=== 循环依赖的定义 ===
两个或多个 Bean 互相依赖,形成一个闭环:
  A 依赖 B,B 依赖 A          (直接循环)
  A 依赖 B,B 依赖 C,C 依赖 A (间接循环)
Spring 创建 A 时发现要先有 B,创建 B 时又发现要先有 A,
转了一圈回到原点 —— 死循环。

=== 为什么 Spring 创建 Bean 会卡在这 ===
Spring 创建一个 Bean 分两大步:
1. 实例化(instantiation):调构造器,在堆上 new 出对象
2. 初始化(initialization):给属性赋值(依赖注入)、
   执行各种初始化回调
循环依赖卡住的位置,取决于"依赖注入"发生在哪一步。

=== 三种注入方式,注入时机不同 ===
- 构造器注入:依赖在【第 1 步实例化时】就要,
  对象都还没 new 出来,就要求依赖先就位
- 字段注入(@Autowired 字段)/ Setter 注入:
  依赖在【第 2 步初始化时】才注入,
  此时对象已经被 new 出来了,只是属性还没填

=== 关键结论(先记住,后面解释为什么)===
- 字段注入 / Setter 注入的循环依赖:Spring 能解决
- 构造器注入的循环依赖:Spring 解决不了,启动直接失败
这就是我们老代码没事、新代码炸了的根本原因。

修复 2:Spring 的三级缓存怎么解循环依赖

// === Spring 用"三级缓存"来解决字段/Setter 注入的循环依赖 ===
// 它们是 DefaultSingletonBeanRegistry 里的三个 Map:

// 一级缓存 singletonObjects:
//   存【完全创建好】的成品 Bean(可以直接用的)
private final Map singletonObjects;

// 二级缓存 earlySingletonObjects:
//   存【提前曝光】的半成品 Bean(已实例化、还没初始化完)
private final Map earlySingletonObjects;

// 三级缓存 singletonFactories:
//   存【能生产半成品 Bean 的工厂】(ObjectFactory)
private final Map> singletonFactories;

// === 以 A 依赖 B、B 依赖 A(均字段注入)为例,走一遍 ===
// 1. 创建 A:实例化 A(new 出来,半成品)
//    -> 把"能产出 A 的工厂"放进【三级缓存】
// 2. A 要注入 B -> 去创建 B
// 3. 实例化 B(new 出来)-> 把"B 的工厂"放进三级缓存
// 4. B 要注入 A -> 从缓存找 A:
//    一级没有 -> 二级没有 -> 三级缓存有 A 的工厂!
//    -> 用工厂拿到 A 的【半成品引用】,
//       同时把这个半成品挪到【二级缓存】
// 5. B 拿到了 A 的(半成品)引用,B 初始化完成 -> 进一级缓存
// 6. 回到 A:A 现在能拿到完整的 B 了,A 初始化完成 -> 进一级缓存
// 关键:B 注入的 A 一开始是"半成品引用",但因为是同一个对象引用,
//       等 A 最终初始化完,B 手里的 A 自然也就是完整的了。

// === 为什么构造器注入解不开 ===
// 三级缓存能工作的前提是:对象【已经被 new 出来了】(有引用了),
// 只是还没初始化完 —— 这样才能"提前曝光"一个半成品引用。
// 但构造器注入,依赖是在 new 这一步就要的:
// 创建 A 调 A 的构造器,就要 B;创建 B 调 B 的构造器,就要 A;
// 谁都没能成功 new 出来,三级缓存里根本【没有半成品可曝光】,
// 这个环就死锁在了实例化阶段,Spring 无解。

修复 3:为什么需要"三级",两级不够吗

=== 一个常见疑问:解循环依赖,二级缓存不就够了吗 ===
确实,如果不考虑 AOP 代理,二级缓存(存半成品)就够了。
三级缓存里那个"工厂",是专门为了 AOP 代理而存在的。

=== 问题出在 AOP 代理 ===
如果 Bean A 需要被 AOP 增强(比如有 @Transactional),
那么最终注入到别处、放进容器的,不应该是原始的 A,
而应该是【A 的代理对象】。
而代理对象,通常是在 Bean 初始化的【最后一步】才生成的。

=== 三级缓存里的"工厂"做了什么 ===
三级缓存存的不是半成品对象本身,而是一个工厂(ObjectFactory)。
当别的 Bean 提前来取 A 时,调用这个工厂,
工厂会判断:A 需不需要被代理?
 - 需要 -> 当场【提前】生成 A 的代理对象返回
 - 不需要 -> 返回原始的 A
这样,即使 A 要被 AOP 代理,在循环依赖场景下,
别人拿到的也是"将来那个代理对象",引用一致,不会出错。

=== 所以三级缓存的设计意图是 ===
- 二级缓存:存"提前曝光的半成品",解决基本的循环依赖
- 三级缓存的工厂:在"需要提前曝光"时,正确地处理 AOP 代理,
  保证提前曝光的对象和最终成品是【同一个】(代理)对象
理解到这一层就够了:三级缓存是"循环依赖"与"AOP 代理"
两个机制必须共存时,Spring 给出的精巧解法。

修复 4:几种解决循环依赖的办法

// === 办法 1(治标):构造器注入改字段/Setter 注入 ===
// 这是最快让应用能启动的办法,利用三级缓存绕过去。
@Service
public class OrderService {
    @Autowired
    private CouponService couponService;   // 改成字段注入
}
// 注意:这只是"让 Spring 能解开",循环依赖本身还在。

// === 办法 2(治标):@Lazy 延迟注入 ===
@Service
public class OrderService {
    private final CouponService couponService;
    public OrderService(@Lazy CouponService couponService) {
        // @Lazy:这里注入的是一个【代理】,
        // 不会立即去创建真正的 CouponService,
        // 等第一次真正调用它的方法时才创建 -> 打破构造期的环
        this.couponService = couponService;
    }
}

// === 办法 3(治本):重新设计,消除循环依赖 ===
// 循环依赖几乎总是【设计问题】的信号:
// 两个 Service 互相依赖,说明职责划分出了问题。

// 思路 A:提取第三个类,把"共同依赖的逻辑"抽出去
// OrderService 和 CouponService 都依赖某段逻辑 ->
// 把那段逻辑抽到 OrderCouponHelper,两者都依赖它,
// 但 Helper 不依赖它们 -> 环被打开。

// 思路 B:用事件 / 消息解耦
// A 做完事不直接调 B,而是发一个事件,B 监听这个事件。
// 发布-订阅让 A 不再"编译期依赖"B。
@Service
public class OrderService {
    private final ApplicationEventPublisher publisher;
    public void createOrder(Order order) {
        // ... 下单逻辑
        publisher.publishEvent(new OrderCreatedEvent(order));
        // 不直接调 CouponService,发事件出去
    }
}

// === 优先级 ===
// 能治本就治本(办法 3),它消除的是耦合本身。
// 办法 1、2 只是让 Spring 不报错,坏味道还在代码里。

修复 5:Spring Boot 2.6+ 默认禁止循环依赖

=== 一个重要变化:Spring Boot 2.6 起,默认禁止循环依赖 ===
从 Spring Boot 2.6 开始,即使是字段注入的、
本来三级缓存能解开的循环依赖,默认也会让应用启动失败:
  Error: ... requires a bean ... circular reference

=== 为什么官方要这么做 ===
官方的态度很明确:循环依赖虽然技术上能被解开,
但它【本身就是一种不好的设计】,
默认禁止,是为了逼开发者正视并消除它,
而不是依赖框架的"魔法"把问题盖住。

=== 升级时遇到这个报错,有两条路 ===
// 路 1(不推荐,治标):配置允许循环依赖,恢复老行为
spring.main.allow-circular-references=true
// 这只是把问题重新藏起来,不解决根本设计问题。

// 路 2(推荐,治本):借这次报错,把循环依赖真正消除掉
// 报错信息会明确告诉你是哪几个 Bean 成环,
// 顺着去重新设计它们的依赖关系。

=== 怎么看待这件事 ===
把它当成一次免费的"设计体检":
框架在启动时就帮你把架构里的耦合环揪了出来,
这比让这个环一直藏在运行的系统里要好得多。
正确的做法不是去配置开关把检查关掉,
而是接受框架的提醒,把环解开。

修复 6:循环依赖的预防与排查

// === 预防 1:统一用构造器注入(官方推荐的注入方式)===
// 构造器注入的好处:依赖不可变(final)、不会是 null、
// 还能【在编译期/启动期就暴露循环依赖】——
// 它解不开循环依赖,恰恰是个优点:逼你当场把环消除,
// 而不是用字段注入把环悄悄藏到运行时。
@Service
public class OrderService {
    private final CouponRepository couponRepository;
    // 构造器注入:依赖一目了然,有环立刻启动失败
    public OrderService(CouponRepository couponRepository) {
        this.couponRepository = couponRepository;
    }
}

// === 预防 2:Service 之间尽量不要相互调用 ===
// 循环依赖几乎都发生在 Service 层互相注入。
// 经验做法:让 Service 依赖 Repository、依赖工具类,
// 而 Service 与 Service 之间的协作,
// 上移到 Controller / 应用层去编排,或用事件解耦。

// === 排查:看启动日志的报错链 ===
// BeanCurrentlyInCreationException 的报错里,
// Spring 会把成环的 Bean 一个个列出来:
//   orderService -> couponService -> orderService
// 顺着这条链,就能定位到环上的所有 Bean。

// === 排查:画依赖图 ===
// Bean 多了之后,人工看不清依赖关系。
// 可以借助 IDE 的依赖分析,或在启动时打印 Bean 依赖,
// 把"谁依赖谁"画成图,环一眼就能看出来。
# 治理期间的临时配置:升级 Spring Boot 2.6+ 遇循环依赖报错时
# 先用开关让应用能启动,但必须建 issue 跟踪、限期消除
# 注意:这只是过渡,不是终态
spring.main.allow-circular-references=true

# 终态目标:删掉上面这行,所有循环依赖都已在设计层面消除,
# 应用在默认的"禁止循环依赖"配置下也能正常启动

优化效果

指标                      治理前              治理后
=============================================================
启动状态                  循环依赖,启动失败   依赖无环,正常启动
注入方式                  字段/构造器混用      统一构造器注入
循环依赖                  多处隐藏的字段注入环 设计层面全部消除
Service 间耦合            互相注入,环交错     事件解耦 / 提取公共类
对三级缓存的认知          不了解,出错全靠猜   理解解决边界与原理
Spring Boot 2.6+ 兼容     升级即报错           默认禁止循环依赖也能启动
allow-circular-references 依赖此开关            无需开启
潜在循环依赖              藏在运行时           启动期即暴露

治理过程:
- 定位构造器注入循环依赖报错:0.5 天
- 梳理项目里所有隐藏的循环依赖:1 天
- 用事件解耦 / 提取公共类消除环:2 天
- 注入方式统一为构造器注入:1 天
- 升级验证 + 团队规范沉淀:0.5 天

避坑清单

  1. 循环依赖是两个或多个 Bean 互相依赖形成闭环,Spring 创建时会转圈卡住
  2. 字段/Setter 注入的循环依赖 Spring 能解,构造器注入的解不开、启动直接失败
  3. 构造器注入解不开是因为依赖在实例化阶段就要,对象还没 new 出来无法提前曝光
  4. 三级缓存:一级存成品、二级存半成品、三级存能产出半成品的工厂
  5. 三级缓存里的工厂是为 AOP 代理设计的,保证提前曝光的与最终成品是同一个代理对象
  6. Spring Boot 2.6+ 默认禁止循环依赖,即使字段注入能解的也会启动失败
  7. allow-circular-references=true 只是把问题藏起来,不解决设计耦合
  8. 循环依赖几乎总是设计问题,治本要提取公共类或用事件/消息解耦
  9. 推荐统一用构造器注入,它能在启动期暴露循环依赖,逼你当场消除
  10. Service 之间尽量不互相注入,协作逻辑上移到应用层编排或用事件解耦

总结

这次循环依赖的排查,起点是一个很容易让人误判的现象:同一个项目,老代码里明明早就存在循环依赖、一直跑得安然无恙,这次只是加了个新功能就启动失败了。如果不深究,很容易得出"是新功能有 bug"这种错误结论,而真相是,问题不在功能本身,而在我们用了一种 Spring 处理不了的注入方式——构造器注入。要理解这一点,得先想清楚 Spring 创建一个 Bean 的过程其实分成两大步:第一步是实例化,也就是调用构造器、在内存里把这个对象 new 出来;第二步是初始化,也就是给这个已经 new 好的对象的各个属性赋值、做依赖注入。循环依赖到底能不能被解开,关键就取决于"依赖注入"这个动作发生在哪一步。字段注入和 Setter 注入,它们的依赖是在第二步初始化时才注入的,这意味着当 Spring 要给 A 注入 B 的时候,A 这个对象本身其实已经被 new 出来了,只是属性还空着——正因为 A 已经"存在"了、已经有一个引用了,Spring 才能施展它的核心魔法:把这个还没初始化完的"半成品 A"提前曝光出去,让正在等 A 的 B 先拿到这个半成品引用,从而打破僵局。而构造器注入的依赖,是在第一步实例化时就要的,创建 A 必须调 A 的构造器,而 A 的构造器张口就要一个完整的 B,要创建 B 又得调 B 的构造器,B 的构造器张口又要 A——两个对象谁都没能成功地被 new 出来,内存里根本就不存在哪怕一个"半成品"可供提前曝光,这个环就死死地锁在了实例化这一步,Spring 对此确实无能为力。把这条因果链想通之后,Spring 那套著名的三级缓存也就不再神秘了:一级缓存存的是完全可用的成品 Bean,二级缓存存的是被提前曝光的半成品,而最绕的三级缓存,它存的不是对象而是一个工厂,这个工厂的全部存在意义,是为了优雅地处理"循环依赖"和"AOP 代理"这两件事的冲突——当一个会被 AOP 增强的 Bean 需要被提前曝光时,这个工厂能保证提前交出去的,正是它将来那个代理对象,而不是一个会被丢弃的原始对象。不过,这次复盘真正改变我认知的,并不是三级缓存的精巧,而是 Spring Boot 从 2.6 版本开始做出的那个决定:默认禁止一切循环依赖,哪怕是三级缓存技术上完全能解开的字段注入循环依赖,也一律让应用启动失败。这个决定背后的态度非常鲜明:框架有能力把循环依赖这个问题在运行时悄悄解决掉,但它选择不这么做,而是把这个问题在启动的那一刻就明明白白地摊在你面前。因为循环依赖从来都不只是一个技术现象,它本质上是一个设计信号——两个 Service 互相依赖,几乎总是意味着它们的职责边界划分出了问题,有一段逻辑放错了位置,或者它们之间本不该有这么紧的耦合。框架能用三级缓存这样的魔法把环"解开",但它解开的只是"启动不报错"这个表象,代码里那个真实的耦合环依然存在,依然会让这两个类难以独立测试、难以单独演进。所以这次治理我们最终的选择,不是去配置那个 allow-circular-references 开关把检查关掉、让问题重新藏回运行时,而是把每一个循环依赖都当成一次免费的设计体检——顺着框架报错列出的成环 Bean,要么提取一个两者都依赖、但不反过来依赖它们的第三个类,要么干脆用事件发布订阅的方式把直接调用解耦掉。我也由此接受了官方那个看似"反直觉"的建议:统一使用构造器注入。它解不开循环依赖,过去我会觉得这是它的短板,现在我明白这恰恰是它的价值——它不给你藏污纳垢的余地,有环就当场启动失败,逼着你在写下这段代码的此时此刻,就把设计问题解决掉,而不是留给未来的某个人在某个深夜的线上故障里去发现。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一周三次被打垮数据库:缓存穿透击穿雪崩的复盘

2026-5-20 13:49:20

技术教程

写完马上读却读到旧数据:一次主从延迟踩坑的复盘

2026-5-20 13:56:06

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索