一个 @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 跟循环依赖的冲突。

相关推荐
犯困蛋挞yy21 小时前
用Claude快速解决Redis代码报错反复无解的问题
redis
用户3169353811837 天前
Java连接Redis
redis
小小工匠9 天前
Redis - 事务机制:能实现 ACID 属性吗
数据结构·redis·性能优化·并发·持久化
taocarts_bidfans9 天前
反向海淘跨境缓存架构优化:taocarts Redis分层缓存实战技术
redis·缓存·架构·反向海淘·taocarts
炘爚9 天前
Linux——Redis
数据库·redis·缓存
csjane10799 天前
Redisson 限流原理
java·redis
ThanksGive9 天前
Go 服务里的 Redis 锁惊群问题:一次本地合流优化实践
redis
小挪号底迪滴9 天前
Redis 和 MySQL 数据不一致怎么办?缓存更新策略实战
redis·mysql·缓存