Spring 循环依赖踩坑:加一个 @Async 注解就启动失败的复盘

给一个老 service 加了个 @Async 注解,整个服务直接启动失败,报 unresolvable circular reference。两个 Bean 互相依赖好几年都没事,加个注解就崩。根因是 @Async 代理机制与 Spring 三级缓存解循环依赖的逻辑冲突。本文讲透三级缓存原理、@Async 为何冲突,以及重构消除循环、异步方法收拢、构造器注入等正确解法。

2024 年我们给一个老 service 加了个 @Async 注解,本来只想让某个方法异步执行,结果整个服务直接启动失败 —— 控制台报 "Requested bean is currently in creation: Is there an unresolvable circular reference?"。明明这两个 Bean 互相依赖了好几年都没事,加个注解就崩了。排查下来,根因是 @Async 的代理机制和 Spring 三级缓存解循环依赖的逻辑发生了冲突。本文复盘 Spring 循环依赖的完整实战。

问题背景

业务:订单服务,Spring Boot 2.7
改动:给 OrderService 的一个方法加了 @Async 让它异步执行
事故现象:
- 加注解前:服务正常启动运行了两年
- 加注解后:启动直接失败,起不来

启动报错:
***************************
APPLICATION FAILED TO START
***************************
The dependencies of some of the beans in the application
context form a cycle:

   orderService
      |
   notifyService
      |
   orderService  (循环!)

Error: Requested bean 'orderService' is currently in creation:
Is there an unresolvable circular reference?

根因:
1. OrderService 和 NotifyService 互相注入(字段注入,循环依赖)
2. 这个循环本来被 Spring 三级缓存悄悄解决了,没人察觉
3. 加了 @Async 后,OrderService 需要被 AOP 包装成代理
4. @Async 的代理时机和三级缓存的"提前暴露"逻辑冲突
   -> 三级缓存这次解不开了 -> 启动失败

修复 1:先搞懂什么是循环依赖

// === 循环依赖:A 依赖 B,B 又依赖 A ===
@Service
public class OrderService {
    @Autowired
    private NotifyService notifyService;     // 订单创建后要发通知
}

@Service
public class NotifyService {
    @Autowired
    private OrderService orderService;       // 发通知时要回查订单
}
// Spring 创建 OrderService -> 发现要先有 NotifyService
//      -> 创建 NotifyService -> 又发现要先有 OrderService
//      -> OrderService 还没创建完 -> 死锁式的鸡生蛋蛋生鸡

// === Bean 的创建分两步 ===
// 1. 实例化(instantiation):调构造方法,在堆上 new 出对象
//    —— 此时对象存在了,但字段都还是 null
// 2. 初始化(initialization):填充属性(@Autowired)、执行
//    init 方法、AOP 包装 —— 此时对象才真正"可用"
//
// 循环依赖能不能解开,关键就在于:
// 能不能在 A "实例化完、但还没初始化完" 的中间态,
// 先把这个"半成品 A"暴露出去给 B 用。

修复 2:三级缓存如何解循环依赖

// === Spring 的三级缓存(都在 DefaultSingletonBeanRegistry)===
// 一级缓存 singletonObjects      : 完全创建好的成品 Bean
// 二级缓存 earlySingletonObjects : 提前暴露的"半成品"Bean
// 三级缓存 singletonFactories    : 能产出半成品的"工厂"(ObjectFactory)

// === 解循环依赖的完整流程(以 A 依赖 B、B 依赖 A 为例)===
// 1. 创建 A:实例化出 A 的原始对象(字段还空着)
// 2. 把 A 的"工厂"放进三级缓存
//    —— 这个工厂调用时会返回 A(必要时返回 A 的代理)
// 3. A 开始填充属性,发现要注入 B -> 转去创建 B
// 4. 创建 B:实例化,B 的工厂也进三级缓存
// 5. B 填充属性,要注入 A -> 去缓存里找 A
//    一级没有 -> 二级没有 -> 三级找到 A 的工厂!
// 6. 调 A 的工厂拿到 A(半成品),把它升级进二级缓存,
//    然后注入给 B
// 7. B 拿到了 A(虽是半成品,但引用地址是对的),B 初始化完成,
//    B 进一级缓存
// 8. 回到第 3 步,A 拿到完整的 B,A 也初始化完成,进一级缓存
//
// === 为什么必须是三级,二级不够吗 ===
// 如果没有 AOP,二级缓存(直接存半成品)确实就够了。
// 三级缓存(存工厂而不是对象)是为了解决一个问题:
// 如果 A 需要被 AOP 代理,那 B 注入的应该是"代理后的 A",
// 而不是原始 A。三级缓存的工厂在被调用时,才决定
// "要不要包代理、包出代理对象",把"代理时机"延后到真正需要时。

修复 3:为什么 @Async 会让它解不开

// === 普通 AOP(如 @Transactional)能被三级缓存兼容 ===
// 三级缓存的工厂里有个钩子 getEarlyBeanReference,
// 普通 AOP 的 AnnotationAwareAspectJAutoProxyCreator 实现了它:
// 当 B 通过三级缓存提前拿 A 时,这个钩子会"提前"为 A 生成代理。
// 所以 B 拿到的就是代理后的 A,和最终成品一致 -> 没问题。

// === @Async 的代理是另一套机制 ===
// @Async 由 AsyncAnnotationBeanPostProcessor 处理,
// 它的代理是在 Bean 初始化的"最后一步"(postProcessAfterInitialization)
// 才生成的,且它【没有】参与三级缓存的 getEarlyBeanReference 提前代理。
//
// 冲突就发生了:
// 1. B 通过三级缓存提前拿到 A -> 此时 A 还没被 @Async 代理
//    -> B 持有的是【原始 A】
// 2. A 继续走完初始化,最后被 @Async 包装成【代理 A】
// 3. A 最终进一级缓存的是【代理 A】
// 4. 但 B 手里已经是【原始 A】了,两者不是同一个对象!
//
// Spring 在创建结束时有个校验:发现"提前暴露出去的 A"
// 和"最终成品 A"不是同一个对象,且 A 确实被提前引用了
// -> 抛异常:存在无法解决的循环依赖

// === 报错的本质 ===
// 不是 Spring 解不了循环依赖,而是 Spring 发现
// "我提前给出去的半成品,和最终成品对不上了",
// 为了不让你拿到一个错的引用,它选择直接启动失败。

修复 4:正确的解法 —— 打破循环依赖

// 治本之道不是"想办法让 Spring 解开循环",而是"消除循环本身"。
// 循环依赖往往是【职责划分不清】的信号。

// === 解法 1(首选):重构,抽出公共逻辑到第三个类 ===
// OrderService 和 NotifyService 互相调,说明有一块逻辑
// 既不该属于订单、也不该属于通知,把它抽出来:
@Service
public class OrderNotifyCoordinator {
    @Autowired private OrderService orderService;
    @Autowired private NotifyService notifyService;
    // 由协调者单向依赖两边,A 和 B 之间不再互相依赖
}
// 重构后依赖关系变成树形,循环自然消失。这是最干净的解法。

// === 解法 2:构造器注入会让循环依赖"早暴露" ===
@Service
public class OrderService {
    private final NotifyService notifyService;
    public OrderService(NotifyService notifyService) {  // 构造器注入
        this.notifyService = notifyService;
    }
}
// 注意:构造器注入的循环依赖,Spring 三级缓存【根本解不了】
// (实例化阶段就要对方,半成品都还没有,无从暴露)。
// 这【不是缺点】——它会在启动时立刻报错,逼你正视设计问题,
// 而不是像字段注入那样把循环"藏起来"埋雷。

// === 解法 3:@Lazy 延迟注入(治标,打破创建时的循环)===
@Service
public class OrderService {
    @Autowired
    @Lazy                          // 注入一个代理,真正用到时才创建 NotifyService
    private NotifyService notifyService;
}
// @Lazy 让注入的是代理对象,A 创建时不会立刻去创建 B,
// 循环在"创建时"被打破。但它只是绕过,设计上的循环还在。

修复 5:针对 @Async 场景的具体处理

// 如果暂时无法重构,针对 @Async + 循环依赖,有几种应对:

// === 方案 A:把 @Async 方法挪到一个独立的 Bean ===
// 不要在"参与循环依赖的类"上加 @Async。
// 单独建一个类承载异步方法,它不卷入任何循环:
@Service
public class OrderAsyncTask {
    @Async
    public void sendNotifyAsync(Long orderId) {
        // 异步逻辑
    }
}
// OrderService 注入 OrderAsyncTask 来调异步方法。
// OrderAsyncTask 是叶子节点,不在循环里 -> @Async 代理无冲突。
// 这是最推荐的做法:异步方法本就该收拢到专门的任务类。

// === 方案 B:用 @Lazy 打破循环(配合 @Async)===
@Service
public class OrderService {
    @Autowired @Lazy
    private NotifyService notifyService;

    @Async
    public void doAsync() { /* ... */ }
}
// @Lazy 让 NotifyService 注入的是代理,OrderService 创建时
// 不再触发那条循环链,@Async 代理就能正常在最后生成。

// === 方案 C:allow-circular-references(强烈不推荐)===
// spring.main.allow-circular-references=true
// Spring Boot 2.6+ 默认禁止循环依赖,这个开关能"恢复"旧行为。
// 但它只是把问题盖回去,@Async 这种场景照样可能拿到错引用。
// 把它当成"最后的临时止血",绝不是解决方案。

// === 自检:启动时打印循环依赖,别让它藏着 ===
// Spring Boot 2.6+ 默认就会在启动时报循环依赖并给出链路,
// 看到那条 A -> B -> A 的链,就该重构,而不是找开关关掉它。

修复 6:规避与监控

# === 团队层面的规避规范 ===
1. 统一用构造器注入,而非字段注入
   - 构造器注入让循环依赖在启动时立刻暴露,无法被"藏起来"
   - 字段注入会让循环依赖默默被三级缓存解决,埋下隐患
   - 加 @Async/@Transactional 等代理时才突然爆雷

2. 不要关闭 spring.main.allow-circular-references
   - 保持 Spring Boot 2.6+ 的默认"禁止循环依赖"
   - 让任何循环依赖在 CI 阶段、启动阶段就被拦下

3. 代码评审重点看依赖方向
   - 依赖关系应该是单向的、树形的、分层的
   - A 调 B,B 就不该反过来调 A —— 出现了就是设计信号

4. 给关键服务加"启动健康检查"
   - 启动失败要能第一时间在 CI / 部署流水线被发现
# 应用启动相关监控
groups:
- name: app-startup
  rules:
  # 1. 应用启动失败(循环依赖会直接导致启动失败)
  - alert: AppStartupFailed
    expr: up{job="order-service"} == 0
    for: 2m
    annotations:
      summary: "{{ $labels.instance }} 启动失败或宕机,检查启动日志"

  # 2. 启动耗时异常(循环依赖、Bean 过多会拖慢启动)
  - alert: AppStartupSlow
    expr: application_started_time_seconds > 60
    annotations:
      summary: "{{ $labels.instance }} 启动耗时 > 60s,检查 Bean 装配"

  # 3. 实例存活数不足(滚动发布时新实例起不来)
  - alert: AppInstanceShortage
    expr: count(up{job="order-service"} == 1) < 3
    for: 3m
    annotations:
      summary: "order-service 存活实例 < 3,可能有实例启动失败"

优化效果

指标                      治理前              治理后
=============================================================
服务启动                  加 @Async 后失败     正常启动
循环依赖数量              3 处(字段注入藏着)  0(全部重构掉)
注入方式                  字段注入为主         统一构造器注入
异步方法                  散落在业务类上       收拢到独立任务类
循环依赖暴露时机          加代理时才爆雷       启动 / CI 阶段即暴露
allow-circular-references 曾想开启绕过         保持默认禁止

改造过程:
- 定位 @Async 与三级缓存冲突根因:0.5 天
- 重构消除 3 处循环依赖(抽协调类):2 天
- 字段注入统一改构造器注入:1 天
- 异步方法收拢到 OrderAsyncTask:0.5 天
- 全链路回归 + 启动验证:1 天

避坑清单

  1. 循环依赖是 A 依赖 B、B 又依赖 A,本质往往是职责划分不清
  2. Bean 创建分实例化和初始化两步,循环能否解开取决于能否暴露半成品
  3. 三级缓存:一级成品、二级半成品、三级工厂,工厂是为了延后 AOP 代理时机
  4. 普通 AOP(@Transactional)能被三级缓存提前代理,所以不冲突
  5. @Async 代理在初始化最后一步才生成,不参与提前代理,故会冲突
  6. 报错本质是 Spring 发现提前暴露的半成品和最终成品不是同一对象
  7. 治本是重构消除循环,抽公共逻辑到第三个协调类,让依赖变树形
  8. 构造器注入的循环依赖根本解不了,但它启动即报错,反而是好事
  9. @Async 方法应收拢到不卷入循环的独立任务类,这是最推荐的做法
  10. 不要用 allow-circular-references 绕过,保持默认禁止,让循环尽早暴露

总结

这次因为加一个 @Async 注解就让服务启动失败的事故,让我对 Spring 的循环依赖机制有了远比之前透彻的理解。最反直觉的一点是:那两个互相依赖的 Bean 平稳运行了两年,大家都以为代码没问题,其实循环依赖一直都在,只是被 Spring 的三级缓存悄悄地、不动声色地解决了,没人察觉。这恰恰是字段注入最危险的地方 —— 它会把设计上的循环依赖隐藏起来,变成一颗埋着的雷,直到某天你给其中一个类加上 @Async、@Transactional 这类需要 AOP 代理的注解,雷才突然引爆。要理解为什么会爆,得先理解三级缓存到底在解决什么:Bean 的创建分实例化和初始化两步,循环依赖能被解开,靠的是在 A "实例化完但还没初始化完"的中间态,先把这个半成品 A 暴露出去给 B 用;而三级缓存里存的不是半成品本身、而是一个工厂,这个设计的精妙之处在于把"要不要给 A 包 AOP 代理、什么时候包"这个决定延后到工厂真正被调用的那一刻。普通的 AOP 比如 @Transactional 实现了三级缓存的提前代理钩子,所以 B 提前拿到的就是代理后的 A,和最终成品一致,平安无事;但 @Async 是另一套机制,它的代理是在 Bean 初始化的最后一步才生成的,且不参与提前代理 —— 于是 B 提前拿到的是原始 A,而 A 最终进容器的是代理 A,两个对象对不上,Spring 为了不让你拿到一个错误的引用,宁可让整个应用启动失败。明白了这个,解法也就清晰了:真正治本的从来不是去找 Spring 的开关把循环"允许"掉,而是消除循环本身 —— 循环依赖几乎总是职责划分不清的信号,把那块说不清归属的逻辑抽到第三个协调类里,依赖关系就从环变回了树。具体到 @Async 场景,最优雅的做法是把异步方法收拢到一个独立的、不卷入任何循环的任务类里。还有一个值得团队推广的习惯是统一用构造器注入:很多人觉得构造器注入解不了循环依赖是它的缺点,我现在的看法恰恰相反 —— 它让循环依赖在启动的第一时间就暴露报错,逼着你正视设计问题,而不是像字段注入那样把问题藏起来留给未来的自己。框架的便利不该成为掩盖设计缺陷的工具。

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

接口幂等设计实战:从一次重复扣款事故说起

2026-5-20 12:43:31

技术教程

MyBatis N+1 查询治理:列表页一次请求打了 120 条 SQL

2026-5-20 12:48:06

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