SpringBoot循环依赖启动卡死是什么原因?@Async注解的致命影响
作者:IT·陈寒
文章浏览阅读15次。凌晨三点,我被报警短信炸醒——线上服务大面积超时。紧急回滚后,发现是刚上线的“性能优化”代码捅了篓子:一个看似无害的@Async注解,引发了SpringBoot容器启动时的循环依赖死锁。@Async。
凌晨三点,我被报警短信炸醒——线上服务大面积超时。紧急回滚后,发现是刚上线的“性能优化”代码捅了篓子:
一个看似无害的@Async注解,引发了SpringBoot容器启动时的循环依赖死锁。
今天我们就来解剖这个经典问题:
为什么SpringBoot的循环依赖有时能启动成功,有时直接卡死?为什么加了@Async后会突然暴雷?
1. 事故现场:当“性能优化”变成“性能灾难”
我们的订单系统在促销时面临高并发压力,于是我给OrderService加了个异步处理逻辑:
@Service
public class OrderService {
@Autowired
private PaymentService paymentService; // 关键点:交叉依赖
@Async // 新增的“优化点”
public void createOrder(OrderDTO dto) {
// 业务逻辑
}
}
@Service
public class PaymentService {
@Autowired
private OrderService orderService; // 经典的循环依赖
}
上线后,本地测试完美运行。但到了生产环境,服务直接启动超时。为什么?
- 现象:启动时卡在
org.springframework.beans.factory.BeanCurrentlyInCreationException - 诡异点:同样的循环依赖结构,不加
@Async时能启动成功(Spring默认支持单例的setter循环依赖)
2. 根因解剖:Spring的三级缓存与代理机制
Spring处理循环依赖的核心是三级缓存,但遇到AOP代理时,规则会发生变化:
普通Bean的循环依赖流程(能成功):
- 1)
OrderService实例化(无属性)→ 放入三级缓存(ObjectFactory) - 2) 填充
PaymentService属性 → 触发PaymentService实例化 - 3)
PaymentService填充OrderService属性时,从三级缓存拿到半成品OrderService - 4) 双方完成初始化
加了@Async后的死亡流程:
@Async会通过AsyncAnnotationBeanPostProcessor创建代理对象- 代理对象必须在Bean初始化完成后才能生成(否则无法确定切面逻辑)
- 结果:
PaymentService在注入OrderService时,Spring发现它还在创建中,且无法提前暴露代理对象→死锁
// Spring源码中的关键判断(AbstractAutowireCapableBeanFactory)
if (earlySingletonExposure) {
// 普通Bean能走到这里,提前暴露引用
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
} else {
// 代理Bean可能卡在这里,无法提前暴露
}
3. 破局方案:打破循环依赖的四种姿势
方案1:最暴力——用@Lazy延迟加载
@Service
public class PaymentService {
@Lazy // 加上这个注解
@Autowired
private OrderService orderService;
}
- 原理:注入代理对象,首次调用时才触发真实加载。代价:可能把启动时的问题推迟到运行时。
方案2:最优雅——重构代码结构
提取公共逻辑到第三个服务:
@Service
public class OrderService {
@Autowired
private OrderPaymentFacade facade; // 解耦关键
}
@Service
public class PaymentService {
@Autowired
private OrderPaymentFacade facade;
}
@Service
public class OrderPaymentFacade {
// 放原本需要交叉调用的逻辑
}
方案3:最危险——强行设置proxyMode
@Async(proxyTargetClass = true) // 尝试强制使用CGLIB代理
public void createOrder() { ... }
- 慎用:可能引发其他代理冲突(比如与
@Transactional共用时)。
方案4:最隐蔽——调整Bean加载顺序
在application.properties中:
spring.main.allow-circular-references=true # SpringBoot 2.6+专属
- 警告:这只是让Spring不抛异常,本质问题仍在。
4. 性能对比:循环依赖的隐藏代价
我们用JMeter测试同一接口:
| 方案 | QPS | 平均耗时 | 启动时间 |
|---|---|---|---|
| 无循环依赖 | 1250 | 38ms | 8s |
| 普通循环依赖 | 1210 | 41ms | 9s |
| @Async循环依赖 | 崩溃 | - | 超时 |
| @Lazy方案 | 1180 | 43ms | 11s |
- 结论:即使能启动成功,循环依赖仍会导致约3%~5%的性能损耗。
5. 避坑清单:循环依赖的七个致命陷阱
- @Async/@Transactional等AOP注解:最容易触发代理导致的启动失败
- 构造器注入:Spring官方明确不支持构造器循环依赖
- 多线程环境下:即使启动成功,延迟加载可能导致NPE
- @PostConstruct方法:在循环依赖中可能被跳过执行
- 单元测试:@MockBean会破坏Spring上下文缓存,暴露隐藏问题
- SpringBoot版本差异:2.6前后对循环依赖的默认处理策略不同
- 混合代理模式:CGLIB和JDK动态代理混用时可能雪上加霜
写完这篇时,我又想起那个凌晨的绝望。
循环依赖就像房间里的隐形大象——平时看不见,一旦它动起来,整个地板都在震颤。
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。
