凌晨三点,我被报警短信炸醒——线上服务大面积超时。紧急回滚后,发现是刚上线的“性能优化”代码捅了篓子:
一个看似无害的@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动态代理混用时可能雪上加霜
写完这篇时,我又想起那个凌晨的绝望。
循环依赖就像房间里的隐形大象——平时看不见,一旦它动起来,整个地板都在震颤。
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论