跳转到内容
123xiao | 无名键客

《Java 开发踩坑实战:定位并修复 Spring Boot 项目中的循环依赖与 Bean 初始化异常》

字数: 0 阅读时长: 1 分钟

背景与问题

Spring Boot 项目里,BeanCurrentlyInCreationExceptionUnsatisfiedDependencyExceptionBeanCreationException 这几类异常,几乎是很多 Java 开发者都绕不过去的坑。

我自己第一次遇到时,表面上只是“应用启动失败”,实际上背后可能混着几层问题:

  • 循环依赖:A 依赖 B,B 又依赖 A
  • 初始化时机错误:Bean 还没准备好,就被拿去用了
  • 构造器注入链过深:Spring 无法提前暴露对象
  • @PostConstruct、初始化方法里提前调用依赖
  • 配置类、代理类、AOP 增强后,依赖关系和你以为的不一样

尤其从 Spring Boot 2.6 开始,默认就不再鼓励循环依赖,很多过去“能跑”的项目,升级后直接在启动阶段炸出来。

本文不讲空泛理论,我会按**“先复现 -> 再定位 -> 最后修复”**的方式,带你把这个坑真正走一遍。


一个典型报错长这样

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'orderService':
Unsatisfied dependency expressed through field 'paymentService';
nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'paymentService': Requested bean is currently in creation:
Is there an unresolvable circular reference?

看到这类报错,很多人第一反应是:

“不是都加了 @Service@Autowired 了吗?为什么 Spring 还会起不来?”

问题恰恰出在这里:加了注解不代表依赖关系合理


现象复现

先做一个最小可运行示例,故意制造一个循环依赖。

示例结构

  • OrderService 依赖 PaymentService
  • PaymentService 又依赖 OrderService

如果使用构造器注入,这个问题最容易直接暴露。

实战代码(可运行)

1)启动类

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CircularDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(CircularDemoApplication.class, args);
    }
}

2)OrderService

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final PaymentService paymentService;

    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public String createOrder() {
        return "order created -> " + paymentService.pay();
    }
}

3)PaymentService

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final OrderService orderService;

    public PaymentService(OrderService orderService) {
        this.orderService = orderService;
    }

    public String pay() {
        return "payment success";
    }
}

4)Controller

package com.example.demo.controller;

import com.example.demo.service.OrderService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/order")
    public String order() {
        return orderService.createOrder();
    }
}

启动后,大概率会直接报循环依赖异常。


核心原理

要真正修好这个问题,不能只会“加个 @Lazy 试试看”。先把 Spring 的 Bean 创建机制理解到位,排查会快很多。

1. 什么是循环依赖

最简单的模型就是:

flowchart LR
    A[OrderService] --> B[PaymentService]
    B --> A

Spring 创建 OrderService 时,需要先拿到 PaymentService
而创建 PaymentService 时,又反过来需要 OrderService
如果两边都必须“现在立刻拿到一个完整对象”,就卡死了。


2. Spring 为什么有时能解决,有时不能

很多文章会说“Spring 可以解决循环依赖”,这句话不完整

更准确地说:

  • 单例 Bean
  • 非构造器强依赖
  • 允许提前暴露引用
  • 没有过于复杂的代理与初始化逻辑

在这些条件下,Spring 有机会通过三级缓存来兜底一部分循环依赖。

Bean 创建的简化过程

flowchart TD
    A[实例化 Bean] --> B[放入三级缓存 ObjectFactory]
    B --> C[属性注入]
    C --> D[初始化]
    D --> E[放入一级缓存 singletonObjects]

如果另一个 Bean 在属性注入阶段需要它,Spring 可能会从“早期引用”里拿一个尚未完全初始化的对象先顶上。

但注意:

  • 构造器注入时,对象连实例化后的“半成品”都还没法顺利流转
  • 如果涉及 @Transactional、AOP 代理、@Async 等,早期引用和最终代理对象可能不一致
  • Spring Boot 新版本默认更严格,很多循环依赖直接拒绝

3. 为什么构造器注入更容易把问题暴露出来

这其实是件好事。

构造器注入要求:

  • 依赖必须完整、明确
  • 对象在创建时就处于可用状态
  • 隐式依赖更难藏住

所以构造器注入虽然会让循环依赖“更早爆炸”,但它是在帮你发现设计问题。
相比之下,字段注入有时能“糊过去”,但问题会在运行期以更隐蔽的方式出现。


4. Bean 初始化异常不一定只是循环依赖

很多时候,表面是 BeanCreationException,根因却不止一个。常见链路如下:

sequenceDiagram
    participant App as SpringBoot
    participant Ctx as ApplicationContext
    participant A as OrderService
    participant B as PaymentService

    App->>Ctx: refresh()
    Ctx->>A: createBean(OrderService)
    A->>Ctx: need PaymentService
    Ctx->>B: createBean(PaymentService)
    B->>Ctx: need OrderService
    Ctx-->>B: BeanCurrentlyInCreationException
    B-->>Ctx: BeanCreationException
    Ctx-->>App: Application startup failed

这也是为什么你在日志里经常看到一长串异常嵌套。
真正要看的不是最外层,而是最深处的 root cause。


定位路径

实际排查时,我建议按下面这个顺序,不要一上来就全局搜索 @Autowired

第一步:看最深层异常

重点找这些关键词:

  • BeanCurrentlyInCreationException
  • Requested bean is currently in creation
  • UnsatisfiedDependencyException
  • Invocation of init method failed
  • Error creating bean with name

如果日志很多,可以直接搜:

currently in creation

或者:

nested exception

第二步:画出依赖链

比如报错里出现:

  • orderService
  • paymentService
  • couponService

那就很可能是:

flowchart LR
    A[OrderService] --> B[PaymentService]
    B --> C[CouponService]
    C --> A

实际项目里,循环依赖未必是“两两互相依赖”,经常是三角依赖配置类间接依赖事件监听回调依赖


第三步:确认依赖发生在哪个阶段

这是最容易被忽略的点。

依赖可能出现在:

  1. 构造器参数
  2. 字段注入 / Setter 注入
  3. @PostConstruct
  4. InitializingBean#afterPropertiesSet
  5. @Bean 方法内部调用
  6. 静态代码块 / 初始化表达式
  7. ApplicationRunner / CommandLineRunner 启动阶段

比如下面这种,就不是纯注入问题,而是初始化时机问题

package com.example.demo.service;

import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;

@Service
public class CacheWarmupService {

    private final RemoteConfigService remoteConfigService;

    public CacheWarmupService(RemoteConfigService remoteConfigService) {
        this.remoteConfigService = remoteConfigService;
    }

    @PostConstruct
    public void init() {
        remoteConfigService.load();
    }
}

如果 RemoteConfigService 初始化本身又依赖别的尚未就绪的 Bean,就会变成另一类 BeanCreationException


修复方案

修复循环依赖,最重要的原则不是“让 Spring 能启动”,而是:

让依赖关系回到合理的方向。

下面按“推荐程度”排序。


方案一:重构职责,打断双向依赖

这是最推荐的方式。

错误设计

  • OrderService 想调用支付
  • PaymentService 又回头操作订单状态
  • 两边彼此都知道对方

更合理的设计

引入一个中间协调者,例如 OrderPaymentFacade 或领域事件。

flowchart LR
    A[OrderController] --> F[OrderPaymentFacade]
    F --> B[OrderService]
    F --> C[PaymentService]

改造后的代码

OrderService

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class OrderService {

    public String createOrderRecord() {
        return "order created";
    }

    public String markPaid() {
        return "order marked paid";
    }
}

PaymentService

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    public String pay() {
        return "payment success";
    }
}

OrderPaymentFacade

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class OrderPaymentFacade {

    private final OrderService orderService;
    private final PaymentService paymentService;

    public OrderPaymentFacade(OrderService orderService, PaymentService paymentService) {
        this.orderService = orderService;
        this.paymentService = paymentService;
    }

    public String createAndPay() {
        String orderResult = orderService.createOrderRecord();
        String payResult = paymentService.pay();
        String statusResult = orderService.markPaid();
        return orderResult + " | " + payResult + " | " + statusResult;
    }
}

Controller

package com.example.demo.controller;

import com.example.demo.service.OrderPaymentFacade;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {

    private final OrderPaymentFacade facade;

    public OrderController(OrderPaymentFacade facade) {
        this.facade = facade;
    }

    @GetMapping("/order")
    public String order() {
        return facade.createAndPay();
    }
}

这种改法的好处是:

  • 消除双向依赖
  • 业务边界更清晰
  • 单元测试更好写
  • 后续接 MQ、事件驱动也更自然

方案二:使用 @Lazy 作为止血方案

如果线上故障紧急、短期没法重构,可以先止血。

示例

package com.example.demo.service;

import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final OrderService orderService;

    public PaymentService(@Lazy OrderService orderService) {
        this.orderService = orderService;
    }

    public String pay() {
        return "payment success";
    }
}

这样 Spring 会注入一个延迟代理,而不是启动时立刻要求完整 Bean。

适用场景

  • 紧急恢复启动
  • 历史项目包袱较重
  • 某些依赖确实只在少量路径中用到

边界条件

但我要强调,@Lazy 不是根治方案

  • 只是把“启动时失败”推迟到“运行时失败”
  • 如果懒加载后第一次调用链仍然闭环,照样会出问题
  • AOP、事务代理叠加时,调试复杂度会上升

方案三:改成事件驱动,解除直接引用

如果两个服务只是“一个动作完成后通知另一个”,那就不要互相注入。

发布事件

package com.example.demo.event;

public class OrderPaidEvent {

    private final String orderId;

    public OrderPaidEvent(String orderId) {
        this.orderId = orderId;
    }

    public String getOrderId() {
        return orderId;
    }
}
package com.example.demo.service;

import com.example.demo.event.OrderPaidEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final ApplicationEventPublisher publisher;

    public PaymentService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public String pay(String orderId) {
        publisher.publishEvent(new OrderPaidEvent(orderId));
        return "payment success";
    }
}

监听事件

package com.example.demo.listener;

import com.example.demo.event.OrderPaidEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class OrderPaidListener {

    @EventListener
    public void handle(OrderPaidEvent event) {
        System.out.println("update order status: " + event.getOrderId());
    }
}

这种方式特别适合:

  • 订单支付后更新状态
  • 发优惠券
  • 记审计日志
  • 推送消息通知

方案四:ObjectProvider / Provider 按需获取

如果确实需要偶发地拿某个 Bean,而不是强依赖,可以改成按需查找。

package com.example.demo.service;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final ObjectProvider<OrderService> orderServiceProvider;

    public PaymentService(ObjectProvider<OrderService> orderServiceProvider) {
        this.orderServiceProvider = orderServiceProvider;
    }

    public String pay() {
        OrderService orderService = orderServiceProvider.getIfAvailable();
        if (orderService != null) {
            // 按需使用
        }
        return "payment success";
    }
}

这个方案比字段注入 + 侥幸启动更清晰,但也要克制使用。
如果一个对象需要频繁 getBean,通常说明设计已经开始变味了。


常见坑与排查

这一节我专门列一些真实项目里高频但隐蔽的问题。


坑一:@PostConstruct 里做重操作

现象

应用启动慢,甚至启动失败。

典型问题代码

package com.example.demo.service;

import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;

@Service
public class DictService {

    private final RemoteApiClient remoteApiClient;

    public DictService(RemoteApiClient remoteApiClient) {
        this.remoteApiClient = remoteApiClient;
    }

    @PostConstruct
    public void init() {
        remoteApiClient.pullAll();
    }
}

问题点

  • 启动阶段就访问外部系统
  • 外部依赖不稳定时直接拖垮容器初始化
  • 容易和其他 Bean 的初始化链缠在一起

建议

  • 把重操作迁移到 ApplicationReadyEvent
  • 做超时控制和失败降级
  • 不要在 Bean 初始化阶段发起不可控的远程调用

坑二:@Configuration 类里相互调用 @Bean

现象

看起来不是 Service 循环依赖,但实际还是。

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AConfig {

    @Bean
    public AService aService(BService bService) {
        return new AService(bService);
    }
}
package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class BConfig {

    @Bean
    public BService bService(AService aService) {
        return new BService(aService);
    }
}

这本质上和 Service 双向依赖没有区别,只是藏在配置类里更难看出来。


坑三:事务代理导致“看起来没循环,实际有代理依赖”

例如:

  • AService 标注了 @Transactional
  • BService 注入 AService
  • AService 初始化时又依赖 BService

这里容器里真正参与注入的,可能是代理对象而不是原始对象。
一旦早期引用和最终代理不一致,异常会更绕。

建议

  • 不要把事务边界设计成互相回调
  • 事务方法尽量放在清晰的应用服务层
  • 避免一个事务服务反向依赖调用方

坑四:字段注入掩盖了设计问题

字段注入看起来很方便:

@Autowired
private PaymentService paymentService;

但它的问题是:

  • 依赖不显式
  • 单测不友好
  • 容易形成“随手注入”的双向网状依赖
  • 某些场景下问题不在编译期暴露

建议

优先用构造器注入
哪怕它更容易在启动时报错,也比上线后在某个冷门路径炸掉强得多。


坑五:为了启动成功,开启循环依赖容忍

有些人会这么配:

spring:
  main:
    allow-circular-references: true

这确实可能让一部分旧项目“先跑起来”,但我不建议把它当常规方案。

为什么不推荐

  • 掩盖架构问题
  • 升级框架时风险更大
  • 运行期行为更难预测
  • 新人接手后会持续复制坏味道

什么时候可以临时用

  • 老系统升级过渡期
  • 必须先恢复服务
  • 已经有明确的后续重构计划

如果要临时开,建议:

  1. 标注技术债
  2. 记录涉及的 Bean 链路
  3. 约定下个版本完成拆解

安全/性能最佳实践

循环依赖和初始化异常,表面是启动问题,往深了看,其实也会影响稳定性、安全性和性能。

1. 初始化阶段不要做外部调用洪峰

启动时如果几十个 Bean 都在 @PostConstruct 拉远程配置、预热缓存、建连接,很容易导致:

  • 启动雪崩
  • 外部依赖被瞬时打满
  • 容器反复重启

建议

  • 把预热逻辑迁移到应用就绪事件后
  • 增加限流、超时、重试上限
  • 关键初始化要可观测

2. 不要在初始化阶段处理敏感数据

比如:

  • 启动时解密全部密钥
  • 预加载所有用户令牌
  • 初始化日志里打印配置对象

建议

  • 敏感信息按需加载
  • 日志脱敏
  • 初始化失败日志中避免输出完整凭证、连接串、Token

3. 控制 Bean 的职责粒度

很多循环依赖的根因不是 Spring,而是类太胖

如果一个 Service 同时负责:

  • 业务编排
  • 数据访问
  • 缓存同步
  • MQ 通知
  • 审计日志

那它迟早会和一堆别的 Bean 缠在一起。

建议

按职责拆层:

  • Facade / ApplicationService:编排流程
  • DomainService:核心业务逻辑
  • Repository:持久化
  • EventPublisher:事件发布
  • Client:外部系统访问

4. 对初始化失败做快速失败与降级区分

不是所有初始化失败都应该阻止应用启动。

适合快速失败

  • 数据源不可用
  • 核心配置缺失
  • 加密密钥加载失败
  • 关键 Bean 创建失败

适合降级启动

  • 非关键缓存预热失败
  • 辅助字典加载失败
  • 报表模块初始化失败
  • 弱依赖外部接口不可用

这类边界划清楚,启动问题会少很多。


5. 给依赖图“瘦身”

如果项目越来越复杂,我建议定期做两件事:

  • 查看模块依赖图
  • 检查包之间是否出现双向引用

哪怕暂时不引入 ArchUnit 一类工具,至少也要在 code review 里盯住:

  • controller 不要反向依赖 service 之外的实现细节
  • service 之间尽量单向
  • config 不要承载业务逻辑

一套实用排查清单

出问题时,可以直接照着过一遍。

启动失败时先看什么

  • 是否包含 BeanCurrentlyInCreationException
  • 是否提示 Requested bean is currently in creation
  • 最深层 root cause 是哪个 Bean
  • 哪几个 Bean 在异常链中反复出现

快速确认循环依赖

  • A 是否依赖 B
  • B 是否依赖 A
  • 或 A -> B -> C -> A

判断是注入问题还是初始化时机问题

  • 报错发生在构造器?
  • 字段注入?
  • @PostConstruct
  • @Bean 方法?
  • ApplicationRunner

决定修复策略

  • 能否通过职责拆分消除双向依赖
  • 是否适合引入 Facade
  • 是否适合改事件驱动
  • 是否只能短期 @Lazy 止血
  • 是否存在必须延期处理的历史债

一个更稳妥的修复思路

如果你在线上接到这个故障,我建议按这个顺序处理:

  1. 先定位具体依赖链
  2. 判断是否能快速重构打断
  3. 不能重构时用 @LazyObjectProvider 临时止血
  4. 回归测试事务、AOP、启动流程
  5. 补上重构任务,避免问题反复出现

很多团队的问题不是不会修,而是“为了尽快恢复,留下了更大的坑”。
这个问题最怕的就是:启动恢复了,但设计更乱了。


总结

Spring Boot 中的循环依赖与 Bean 初始化异常,本质上不是一个“注解没加对”的小问题,而是对象关系、初始化时机和职责边界共同作用的结果。

你可以记住这几个核心结论:

  • 构造器注入更容易暴露真实问题,长期看是好事
  • 循环依赖首选重构,而不是配置容忍
  • @LazyObjectProvider 适合止血,不适合长期滥用
  • 很多 Bean 初始化异常并非单纯循环依赖,而是初始化阶段做了不该做的事
  • 把编排、业务、事件、外部访问拆开,依赖关系会自然清爽很多

如果只给一个最实用的建议,那就是:

当你看到两个 Service 互相注入时,先别急着让 Spring “兼容”,先问一句:这两个类是不是本来就不该互相知道对方?

很多坑,从这里开始就能少踩一半。


分享到:

上一篇
《安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见登录校验逻辑》
下一篇
《大模型应用中的 RAG 架构实战:从向量检索、提示编排到效果评估的落地方法》