ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

@Async 撞上循环依赖那天,异步方法被同步调用,下单链路堵了 40 秒:三级缓存只救了字段注入

@Async 撞上循环依赖那天,异步方法被同步调用,下单链路堵了 40 秒:三级缓存只救了字段注入 title: Async 撞上循环依赖那天异步方法被同步调用下单链路堵了 40 秒三级缓存只救了字段注入tags: [Spring, 循环依赖, 三级缓存, Async, 源码分析]category: 后端凌晨一点下单接口 P99 突然从 80ms 跳到 2 秒那是一次普通的服务发布我们没有动任何核心链路只给通知服务加了一个「下单成功后异步发短信」的能力。改动很克制就一行注解。发布完不到五分钟监控开始报警下单接口的 P99 从 80ms 一路爬到 2 秒慢请求里 90% 卡在等待短信网关返回。我们当时都懵了——发短信明明是异步的怎么会把下单线程也拖住翻代码才发现问题不在Async本身而在它和循环依赖撞在了一起。事故现场一个被「同步执行」的异步方法业务里有两块OrderService负责下单NotifyService负责发通知。下单时要通知用户通知失败又要把结果回写到订单于是两个 bean 互相引用构成字段注入的循环依赖。Service public class OrderService { Autowired private NotifyService notifyService; // 1. 下单依赖通知 public void createOrder(OrderCmd cmd) { // ... 落库逻辑 ... notifyService.sendAsync(cmd.getUserId(), 下单成功); // 2. 期望异步发送 } } Service public class NotifyService { Autowired private OrderService orderService; // 3. 通知又依赖订单形成循环 Async(notifyExecutor) // 4. 本应丢到线程池执行 public void sendAsync(Long userId, String text) { smsGateway.send(userId, text); // 5. 实际是同步阻塞在这里 } }翻代码时最反直觉的一点createOrder调用notifyService.sendAsync(...)后主线程确实在等smsGateway.send返回而不是立刻返回。Async像是「没生效」。OrderService手里拿到的NotifyService根本不是被代理过的那个而是一个早期暴露的原始实例。三级缓存为什么只「救一半」Spring 解决循环依赖靠三级缓存核心思路是bean 先实例化、还没初始化完就通过getEarlyBeanReference提前暴露一个引用让对方先注入等自己初始化完再换上最终版本。protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) { Object exposedObject bean; if (!mbd.isSynthetic() hasInstantiationAwareBeanPostProcessors()) { for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessors()) { // 1. 遍历所有 SmartInstantiationAwareBeanPostProcessor // 2. 这里 AbstractAutoProxyAutoProxyCreator 会给需要代理的 bean 生成早期代理 exposedObject bp.getEarlyBeanReference(exposedObject, beanName); } } // 3. 返回的就是可能被代理过的「早期引用」 return exposedObject; }关键在第二步。Async的代理不是在getEarlyBeanReference阶段产生的而是由AsyncAnnotationBeanPostProcessor在初始化之后的postProcessAfterInitialization阶段才套上去。而循环依赖的处理顺序是这样OrderService实例化 → 需要NotifyService触发创建NotifyServiceNotifyService实例化 → 需要OrderService此时OrderService通过singletonFactories三级缓存拿到早期引用注入NotifyService初始化完成Async代理此刻才生成替换掉容器里的notifyService实例但OrderService字段里早就塞好了第 2 步那个原始NotifyService它没被换掉。换句话说三级缓存救的是「字段注入能拿到对象、不报BeanCurrentlyInCreationException」这件事至于这个对象最终是不是被 AOP 代理包裹循环依赖场景下一旦「早期引用」先被注入后面生成的代理就套不进已经填好的字段里。Transactional、Cacheable同理都是依赖代理、又撞上循环依赖时最容易翻车的地方。排查过程先怀疑线程池再怀疑注解我们第一反应是线程池满了。看了notifyExecutor的活跃线程数只有 2/16根本没饱和。接着打了日志发现sendAsync根本没进线程池的Runnable而是直接在当前线程执行。真正定位靠的是一行AopUtils.isAopProxy(orderService.notifyService)——返回false。也就是说注入进来的NotifyService不是代理。顺着这个线索我们才意识到是循环依赖导致早期引用提前固化。// 在 OrderService 里加的临时诊断代码 public void diag() { Object ref this.notifyService; boolean proxy AopUtils.isAopProxy(ref); // false - 没被代理 boolean asyncProxy ref instanceof Advised; // false - 不是 Spring AOP 代理 log.warn(notifyService 是否为代理{}, 类型{}, proxy, ref.getClass().getName()); }这个项目跑在 Spring Boot 2.7.18、Spring Framework 5.3.31、JDK 17。需要说明的是从 Spring 6 / Boot 3 开始官方默认不再允许构造器注入的循环依赖很多场景下启动直接失败反而把这个坑提前暴露了但我们这套还是 Boot 2.7循环依赖被静默容忍坑就埋到了线上。四个可行方案代价不一样方案做法优点代价拆依赖把通知逻辑抽到第三个无依赖的NotifySender从根上消除循环要改调用结构涉及面大Lazy在注入点加Lazy延迟注入代理改动最小只隐藏问题没消除循环抽接口NotifyService拆成接口实现Async 只放实现字段注入的是接口代理多一层接口类数增加后置校验用SmartInitializingSingleton在启动后检查代理是否套上不阻断发布能报警只能发现问题不能解决我们最终选了「拆依赖」把发短信的动作下沉到一个不依赖OrderService的NotifySender循环依赖随之消失Async代理正常生成。代价是动了三个调用点但一劳永逸。复盘数字这次事故从报警到定位用了 38 分钟影响下单请求约 1.2 万笔其中慢请求1s占比 7.3%。加Async之前通知发送平均耗时 210ms 计入主链路修复后主链路 P99 回到 95ms发短信完全异步化。额外补了一个启动自检所有带Async的 bean若被循环依赖引用启动日志直接 WARN避免下一次有人悄悄加注解又踩。我的取舍判断我不建议用Lazy来「快速止血」。它能让发布通过但循环依赖本身还在下次谁在OrderService里直接调notifyService的任何代理增强方法事务、缓存、异步还会以同步方式执行而且没有任何报错——这是最危险的因为故障是静默的。我更偏向从设计层面拆掉循环依赖。循环依赖本身是代码坏味道Spring 用三级缓存兜底只是「让你能跑起来」不是「鼓励你这么写」。Async这种依赖代理才能生效的能力和循环依赖是天然冲突的宁可多改几个文件也别留个定时炸弹。修复后的样子通知下沉到一个不依赖订单的组件我们把发短信从NotifyService里抽出来单独放一个NotifySender它不注入OrderService也不被任何 bean 反向依赖循环依赖从根上消失Component public class NotifySender { private final SmsGateway smsGateway; public NotifySender(SmsGateway smsGateway) { this.smsGateway smsGateway; } Async(notifyExecutor) public void sendAsync(Long userId, String text) { smsGateway.send(userId, text); // 1. 此刻 NotifySender 是被代理的真正异步 } } Service public class OrderService { private final NotifySender notifySender; // 2. 只依赖 NotifySender循环断了 public void createOrder(OrderCmd cmd) { notifySender.sendAsync(cmd.getUserId(), 下单成功); // 3. 调的是代理异步生效 } }有一点容易改错把字段从NotifyService换成NotifySender之后调用点也要一并改掉否则旧的notifyService字段还在Async照样不生效。我们第一次改就漏了调用点又白排查 10 分钟。循环依赖消失后NotifySender正常走postProcessAfterInitialization生成Async代理OrderService注入的就是代理本身sendAsync真正丢进线程池。这个改动在 Spring Boot 2.7.18、Spring Framework 5.3.31 上验证下单链路 P99 从 2 秒回到 95ms慢请求占比从 7.3% 降到 0.2%。思考题你项目里有没有Transactional方法被另一个 bean 在循环依赖里注入后调用、却没回滚的情况试试用AopUtils.isAopProxy打一行日志看看注入进来的到底是不是代理。
返回列表