一个 @Async 让循环依赖暴雷:Spring 代理的暗坑

一个 @Async 让循环依赖暴雷:Spring 代理的暗坑

项目跑了半年没报错,加了 @Async 之后启动直接炸,BeanCreationException。循环依赖一直都在,只是 @Async 把它从"隐藏"变成了"致命"。


一、事故现场

周三上线一个新功能:订单完成后异步发送短信通知。改动很小,就在 NotifyService 里加了个 @Async 注解。

java 复制代码
@Service
public class NotifyService {

    @Autowired
    private OrderService orderService;  // 依赖 OrderService

    @Async("notifyThreadPool")
    public void sendSms(Long orderId) {
        Order order = orderService.getById(orderId);
        smsApi.send(order.getPhone(), "订单已完成");
    }
}

OrderService 本身依赖 NotifyService(因为 OrderService 完成订单后要调通知):

java 复制代码
@Service
public class OrderService {

    @Autowired
    private NotifyService notifyService;  // 依赖 NotifyService

    public void completeOrder(Long orderId) {
        orderMapper.updateStatus(orderId, "COMPLETED");
        notifyService.sendSms(orderId);  // 调通知
    }
}

NotifyService → OrderService → NotifyService,循环依赖。

但这里有个疑问:这个循环依赖之前就存在,项目跑了半年没报错。为什么加了 @Async 就炸了?

启动报错:

vbnet 复制代码
BeanCurrentlyInCreationException: Error creating bean with name 'notifyService':
Bean with name 'notifyService' has been injected into other beans [orderService] in its raw object version,
as part of a circular reference, but has eventually been wrapped.
This means that said other beans do not use the final version of the bean.

翻译过来:notifyService 在创建过程中被注入到 orderService 的是"原始对象"(还没被代理包装),但最终 notifyService 被 @Async 的 AOP 代理包装了。注入的和最终的不是同一个对象,Spring 认为这是错误的。


二、先搞清楚:Spring 的循环依赖是怎么解决的

Spring 用"三级缓存"解决循环依赖。理解了三级缓存,才能理解为什么 @Async 会让它失效。

2.1 三级缓存是什么

java 复制代码
// DefaultSingletonBeanRegistry.java

// 一级缓存:完整的 Bean(初始化完成,代理也生成完了)
Map<String, Object> singletonObjects;

// 二级缓存:提前暴露的 Bean(实例化了,但还没初始化完)
Map<String, Object> earlySingletonObjects;

// 三级缓存:Bean 工厂(能生产 Bean 或它的代理)
Map<String, ObjectFactory<?>> singletonFactories;

Spring 创建 Bean 的流程:

markdown 复制代码
1. 实例化 Bean(new 出来,还没注入属性)
   → 把 ObjectFactory 放入三级缓存

2. 注入属性(处理 @Autowired 依赖)
   → 如果依赖的 Bean 还没创建完,从三级缓存拿 ObjectFactory,调 getObject() 拿到早期引用
   → 拿到的早期引用放入二级缓存

3. 初始化(执行 @PostConstruct、AOP 代理等)
   → AOP 在这一步生成代理对象

4. 放入一级缓存,清理二三级缓存

2.2 正常循环依赖怎么解决的

以我们的场景为例:OrderService 和 NotifyService 互相依赖。

markdown 复制代码
1. 创建 OrderService
   → 实例化 OrderService,ObjectFactory 放入三级缓存
   → 注入属性时发现需要 NotifyService

2. 创建 NotifyService
   → 实例化 NotifyService,ObjectFactory 放入三级缓存
   → 注入属性时发现需要 OrderService
   → 从三级缓存拿到 OrderService 的 ObjectFactory,调 getObject()
   → 得到 OrderService 的早期引用(还没初始化完),放入二级缓存
   → NotifyService 属性注入完成

3. NotifyService 初始化完成,放入一级缓存

4. 回到 OrderService,拿到 NotifyService,属性注入完成
   → OrderService 初始化完成,放入一级缓存

关键在第二步:NotifyService 需要 OrderService,但 OrderService 还没创建完。Spring 从三级缓存拿到 OrderService 的早期引用,NotifyService 就能完成创建。然后 OrderService 也能拿到完成的 NotifyService,双方都创建完成。

这个机制能工作的前提是:三级缓存里拿到的早期引用,和最终放入一级缓存的对象是同一个。

2.3 AOP 代理什么时候生成

普通 AOP(比如 @Transactional)的代理在初始化后(postProcessAfterInitialization)生成(第 3 步)。但 Spring 为循环依赖提供了一个提前暴露的机制:如果代理创建器实现了 SmartInstantiationAwareBeanPostProcessorgetEarlyBeanReference() 方法,就能在第 2 步提前生成代理。

  • @Transactional 的代理创建器(AbstractAutoProxyCreator)实现了这个方法 → 提前生成代理,二级缓存存的是代理
  • @Async 的代理创建器(AsyncAnnotationBeanPostProcessor)没实现 → 不提前生成,二级缓存存的是原始对象

所以 @Transactional 的循环依赖能解决:代理在二级缓存阶段就提前生成了,注入的也是代理对象,跟最终的一致。


三、@Async 为什么让循环依赖失效

@Async 的问题在于:它的代理创建器(AsyncAnnotationBeanPostProcessor)没有实现 getEarlyBeanReference() 方法,无法在循环依赖时提前暴露代理。代理只在初始化后(postProcessAfterInitialization)才生成。

这导致一个时间差:

markdown 复制代码
1. 创建 NotifyService
   → 实例化,ObjectFactory 放入三级缓存
   → 注入属性,需要 OrderService
   
2. 创建 OrderService
   → 实例化,ObjectFactory 放入三级缓存
   → 注入属性,需要 NotifyService
   → 从三级缓存拿 NotifyService 的早期引用
   → 三级缓存的 ObjectFactory 调 getObject()
   → 此时检查:NotifyService 的代理创建器有没有实现 getEarlyBeanReference?
   → 没有(AsyncAnnotationBeanPostProcessor 没实现这个方法)
   → 返回原始对象(没有代理)
   → OrderService 拿到的是 NotifyService 的原始对象,注入完成

3. NotifyService 初始化
   → 初始化后,AsyncAnnotationBeanPostProcessor 生效
   → 为 NotifyService 生成 @Async 代理对象
   → 最终放入一级缓存的是代理对象

4. Spring 检查:注入给 OrderService 的是原始对象,一级缓存里是代理对象
   → 不是同一个对象!
   → 抛出 BeanCurrentlyInCreationException

问题就在这:@Transactional 的代理创建器实现了 getEarlyBeanReference(),循环依赖时能提前暴露代理。@Async 的代理创建器没实现,三级缓存暴露的是原始对象,代理在初始化后才生成,两者不一致就报错。

一句话总结:

Spring 三级缓存能解决 @Transactional 的循环依赖,因为它的代理创建器实现了 getEarlyBeanReference(),代理能提前暴露。但 @Async 的代理创建器没实现这个方法,三级缓存暴露的是原始对象,注入的和最终的不是同一个,Spring 检测到不一致就报错。


四、5 秒复现:加个注解就炸

4.1 复现代码

java 复制代码
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;

@SpringBootApplication
@EnableAsync
public class CircularDepAsyncApp {

    public static void main(String[] args) {
        try {
            ConfigurableApplicationContext ctx = SpringApplication.run(CircularDepAsyncApp.class, args);
            System.out.println("启动成功");
            ctx.close();
        } catch (Exception e) {
            System.out.println("启动失败: " + e.getClass().getSimpleName());
            System.out.println(e.getMessage());
        }
    }
}

@Service
class OrderService {

    @Autowired
    private NotifyService notifyService;

    public void completeOrder(Long orderId) {
        System.out.println("订单完成: " + orderId);
        notifyService.sendSms(orderId);
    }
}

@Service
class NotifyService {

    @Autowired
    private OrderService orderService;

    @Async("notifyThreadPool")  // 加了这个注解就炸
    public void sendSms(Long orderId) {
        System.out.println("发送短信: " + orderId);
    }
}

运行结果:

csharp 复制代码
启动失败: BeanCurrentlyInCreationException
Error creating bean with name 'notifyService': Bean with name 'notifyService' 
has been injected into other beans [orderService] in its raw object version, 
as part of a circular reference, but has eventually been wrapped.

4.2 对比:去掉 @Async 就不报错

@Async("notifyThreadPool") 注释掉,启动成功。循环依赖还是那个循环依赖,但没有 @Async 的代理包装,三级缓存暴露的原始对象和最终对象一致,Spring 不报错。

4.3 对比:换成 @Transactional 也不报错

java 复制代码
@Service
class NotifyService {

    @Autowired
    private OrderService orderService;

    @Transactional  // 换成 @Transactional,不报错
    public void sendSms(Long orderId) {
        System.out.println("发送短信: " + orderId);
    }
}

@Transactional 的代理创建器实现了 getEarlyBeanReference(),循环依赖时提前暴露代理对象,和最终一致,不报错。


五、怎么解决

方案 1:打破循环依赖(最根本)

循环依赖本身就是设计问题,最好的办法是消除它。把互相依赖拆开:

java 复制代码
// 把通知逻辑拆到独立的 Service,切断循环
@Service
public class SmsService {

    @Async("notifyThreadPool")
    public void sendSms(Long orderId, String phone) {
        smsApi.send(phone, "订单已完成");
    }
}

@Service
public class OrderService {

    @Autowired
    private SmsService smsService;  // 依赖 SmsService,不再依赖 NotifyService

    @Autowired
    private OrderMapper orderMapper;

    public void completeOrder(Long orderId) {
        Order order = orderMapper.selectById(orderId);
        orderMapper.updateStatus(orderId, "COMPLETED");
        smsService.sendSms(orderId, order.getPhone());
    }
}

@Service
public class NotifyService {

    @Autowired
    private OrderService orderService;

    // NotifyService 可以保留,但不被 OrderService 依赖
    // 或者直接把 NotifyService 删掉,逻辑合并到 SmsService
}

OrderService → SmsService,单向依赖,没有循环。@Async 在 SmsService 上,不影响。

这是最推荐的方案。循环依赖本身就应该消除,@Async 只是让它暴露了而已。

方案 2:用 @Lazy 延迟注入

java 复制代码
@Service
public class OrderService {

    @Autowired
    @Lazy  // 延迟注入,启动时不创建 NotifyService 的真实对象,注入一个代理
    private NotifyService notifyService;

    public void completeOrder(Long orderId) {
        orderMapper.updateStatus(orderId, "COMPLETED");
        notifyService.sendSms(orderId);
    }
}

@Lazy 让 Spring 注入一个 NotifyService 的代理(注意这个代理跟 @Async 的代理不是一回事)。实际使用 notifyService 时才真正创建,避开了启动时的循环依赖检测。

缺点:@Lazy 只是绕过了问题,循环依赖还在。如果 NotifyService 的初始化也依赖 OrderService,@Lazy 可能导致运行时 NPE。

方案 3:用 ApplicationContext 手动获取

java 复制代码
@Service
public class NotifyService {

    @Autowired
    private ApplicationContext applicationContext;

    private OrderService orderService;

    @Async("notifyThreadPool")
    public void sendSms(Long orderId) {
        if (orderService == null) {
            orderService = applicationContext.getBean(OrderService.class);
        }
        Order order = orderService.getById(orderId);
        smsApi.send(order.getPhone(), "订单已完成");
    }
}

不在构造时注入 OrderService,而是运行时从 ApplicationContext 获取。打破了启动时的循环依赖。

缺点:代码不够优雅,而且 ApplicationContext.getBean 有性能开销(虽然很小)。不推荐作为首选方案。

方案 4:用事件驱动替代直接调用

java 复制代码
// OrderService 发布事件,不直接调 NotifyService
@Service
public class OrderService {

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    public void completeOrder(Long orderId) {
        orderMapper.updateStatus(orderId, "COMPLETED");
        eventPublisher.publishEvent(new OrderCompletedEvent(orderId));
    }
}

// NotifyService 监听事件,异步处理
@Service
public class NotifyService {

    @Autowired
    private OrderService orderService;  // 这个依赖可以去掉,直接查 orderMapper

    @Async("notifyThreadPool")
    @EventListener
    public void onOrderCompleted(OrderCompletedEvent event) {
        Order order = orderService.getById(event.getOrderId());
        smsApi.send(order.getPhone(), "订单已完成");
    }
}

用 Spring 事件机制解耦。OrderService 不直接调 NotifyService,而是发布"订单完成"事件。NotifyService 监听事件并异步处理。OrderService 不需要依赖 NotifyService,循环依赖自然消失。

这是最优雅的方案。不仅解决了循环依赖,还解耦了业务逻辑。如果后续加更多通知方式(邮件、推送),只需要加新的 @EventListener,不用改 OrderService。


六、@Transactional vs @Async 循环依赖对比

@Transactional @Async
代理创建器 AbstractAutoProxyCreator AsyncAnnotationBeanPostProcessor
是否实现 getEarlyBeanReference 是,循环依赖时提前暴露代理 否,暴露的是原始对象
代理实际生成时机 初始化后(postProcessAfterInitialization),循环依赖时提前到属性注入阶段 仅初始化后(postProcessAfterInitialization)
循环依赖是否报错 不报错 报错 BeanCurrentlyInCreationException
解决方式 不用处理 打破循环 / @Lazy / 事件驱动

核心区别:@Transactional 的代理创建器实现了 getEarlyBeanReference(),循环依赖时能提前暴露代理。@Async 的代理创建器没实现,三级缓存暴露的是原始对象,代理在初始化后才生成,两者不一致就报错。


七、CheckList:循环依赖 + AOP 排查

# 检查项 风险点 正确做法
1 @Async 方法所在 Bean 参与循环依赖 启动报错 打破循环或用事件驱动
2 循环依赖 + 任何后置 BeanPostProcessor 同样可能报错 检查是否有自定义 BeanPostProcessor
3 用 @Lazy 绕过循环依赖 运行时可能 NPE 优先打破循环而非 @Lazy
4 @Async 自调用 AOP 代理失效,异步变同步 拆到另一个 Bean
5 循环依赖本身 设计问题,应该消除 用事件驱动或重新划分 Service 职责
6 Spring Boot 2.6+ 默认禁止循环依赖 启动直接报错 spring.main.allow-circular-references=true 或消除循环

注意第 6 条:Spring Boot 2.6 开始默认禁止循环依赖。如果你的项目升级到 2.6+,之前"跑得好好的"循环依赖会直接启动报错。@Async 只是提前暴露了这个问题,不升级也会有隐患。


八、总结

回到这次事故:NotifyService 和 OrderService 互相依赖,跑了半年没报错。加了 @Async 之后,因为 @Async 的代理创建器没实现 getEarlyBeanReference(),循环依赖时三级缓存暴露的是原始对象,跟最终代理对象不一致,Spring 检测到后报错。

记住这三点:

  1. Spring 三级缓存能解决普通 AOP(@Transactional)的循环依赖,但不能解决 @Async
  2. 区别在于代理创建器是否实现了 getEarlyBeanReference():@Transactional 实现了,能提前暴露代理;@Async 没实现,注入的是原始对象,和最终代理不一致
  3. 循环依赖本身是设计问题,@Async 只是让它暴露了。正确做法是消除循环,用事件驱动解耦

下次遇到"加了注解就启动报错"的问题,先检查是不是循环依赖 + 后置代理的组合。


附录:本地复现完整代码

java 复制代码
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;

@SpringBootApplication
@EnableAsync
public class CircularDepAsyncApp {

    @Bean("notifyThreadPool")
    public Executor notifyThreadPool() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("notify-");
        executor.initialize();
        return executor;
    }

    public static void main(String[] args) {
        try {
            ConfigurableApplicationContext ctx = SpringApplication.run(CircularDepAsyncApp.class, args);
            System.out.println("✅ 启动成功");
            ctx.close();
        } catch (Exception e) {
            System.out.println("❌ 启动失败: " + e.getClass().getSimpleName());
            System.out.println(e.getMessage());
        }
    }
}

@Service
class OrderService {

    @Autowired
    private NotifyService notifyService;

    public void completeOrder(Long orderId) {
        System.out.println("订单完成: " + orderId);
        notifyService.sendSms(orderId);
    }
}

@Service
class NotifyService {

    @Autowired
    private OrderService orderService;

    // 加了 @Async → 启动报错 BeanCurrentlyInCreationException
    // 去掉 @Async → 启动成功
    // 换成 @Transactional → 启动成功(代理创建器实现了 getEarlyBeanReference,能提前暴露代理)
    @Async("notifyThreadPool")
    public void sendSms(Long orderId) {
        System.out.println("发送短信: " + orderId);
    }
}

运行方式:直接在 Spring Boot 项目里运行 main 方法。

复现要点:

  1. 保持 @Async 注解 → 启动报错 BeanCurrentlyInCreationException
  2. 去掉 @Async → 启动成功(循环依赖被三级缓存解决)
  3. 换成 @Transactional → 启动成功(代理创建器实现了 getEarlyBeanReference,能提前暴露代理)

对比三种情况,理解 @Async 代理创建器缺少 getEarlyBeanReference 跟循环依赖的冲突。

相关推荐
IT大白鼠2 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz2 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
lbb 小魔仙2 天前
OpenClaw + cpolar 实战:远程 NAS、分享小游戏、RDP,再配置公网 AI 入口
数据库·人工智能·redis·oracle·prometheus
夕除2 天前
redis--018
redis
两锭子2 天前
Redis基础
redis
烟沙九洲2 天前
Redis 缓存穿透、缓存击穿、缓存雪崩
数据库·redis
字节探索2 天前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
于樱花森上飞舞2 天前
【Redis】哨兵详解
java·开发语言·数据库·redis
Rain的Java大神之路3 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
Wang's Blog3 天前
Java 接入Redis: 通用命令与键管理
java·服务器·redis