Spring WebClient 5高负载触发executor not accepting a task异常排查
Spring WebFlux高负载下WebClient抛出
executor not accepting a task异常根因分析 问题现象
- 生产环境部署Spring WebFlux应用,业务逻辑通过WebClient 5发起HTTP调用下游服务
- 异常仅在生产环境7K TPS高负载水位下触发,本地低负载环境无法复现
- 异常栈信息为:
java.lang.IllegalStateException: executor not accepting a task
相关代码片段
HTTP调用实现逻辑:
public Mono<Context> doHttp(WebClient webClient, WBConfig webClientConfiguration, String data, Context ctx) { return getWebclient(webClient, webClientConfiguration, data, ctx) .headers(httpHeaders -> logHeadersAndPayload(ctx, data, httpHeaders)) .retrieve() .bodyToMono(Object.class) .publishOn(TPHelper.getSharedPool()) .onErrorResume(getErrorHandleFunction(webClientConfiguration, ctx, taskName)) .retryWhen(buildRetrySpec(webClientConfiguration)) .flatMap(getResponseToContextMonoFunction(ctx, taskName, startTime)); }
自定义共享调度器逻辑(生产环境核心逻辑示意):
public class TPHelper { // 初始化后创建共享线程池 private static Scheduler tp; @PostConstruct void init() { this.tp = Schedulers.newBoundedElastic(100, Integer.MAX_VALUE, new ThreadFactoryBuilder().setNameFormat("http-threads").build(), 60); } public static Scheduler getSharedPool() { return this.tp; } }
根因分析
这个异常就是自定义BoundedElastic线程池任务过载触发拒绝导致的,和初步怀疑方向一致,核心触发逻辑如下:
- 链路中
.publishOn(TPHelper.getSharedPool())会将该算子之后的所有逻辑(错误处理、重试触发、响应转换业务逻辑)全调度到自定义的100线程BoundedElastic池执行,不再占用Netty EventLoop线程 - Reactor
BoundedElastic调度器的拒绝逻辑为:当工作线程数达到配置上限(当前配置为100),新提交任务进入全局排队队列,当排队任务数达到配置的队列容量上限时,直接抛出executor not accepting a task拒绝任务 - 7K TPS水位下,100个工作线程只要单任务平均耗时超过14ms就会达到处理瓶颈(100线程 * 1000ms/14ms ≈ 7142 TPS)。如果后续逻辑存在阻塞、重试风暴等问题,实际处理能力会远低于理论值,任务快速积压就会打满队列触发异常
- 贴出的TPHelper为逻辑示意,生产环境大概率没有把队列容量(
newBoundedElastic第二个参数queuedTaskCap)设为Integer.MAX_VALUE——如果真用无界队列不会抛这个拒绝异常,只会触发OOM。默认BoundedElastic队列容量仅为100000,高负载下很快就会打满 retryWhen放在publishOn之后,所有重试任务也会提交到这个共享线程池,一旦出现下游超时、响应慢的情况,重试流量会进一步加剧队列积压,形成雪崩。
排查方向
- 先核对生产环境实际运行的线程池初始化参数,不要参考示意代码:直接反编译生产部署包的TPHelper类,或通过监控系统查看线程池配置,重点确认
newBoundedElastic传入的线程上限、队列容量两个参数,90%的场景是队列容量实际配置过小,被突发流量打满 - 给自定义
http-threads线程池埋点监控核心指标:工作线程活跃数、队列等待任务长度、任务平均执行耗时、任务拒绝次数,异常抛出的时间点必然和队列长度达到上限的时间点完全重合 - 全链路排查
publishOn之后的所有执行逻辑:包括错误处理函数、重试判断逻辑、响应转换逻辑,绝对不能存在任何阻塞操作:比如同步数据库/缓存调用、文件IO、Thread.sleep()、重量级锁等待、同步HTTP调用。哪怕在BoundedElastic线程池上,阻塞操作会直接占死工作线程,让线程池吞吐量骤降,是这类问题最常见的诱因 - 检查算子顺序合理性:如果加
publishOn只是为了把较重的业务响应处理逻辑从EventLoop剥离开,应该把publishOn放到retryWhen之后,避免重试调度、重试判断这类轻量逻辑占用业务线程池资源;如果没有强制的线程隔离需求,完全没必要手动加这层publishOn,非阻塞的响应逻辑直接跑在Netty EventLoop上性能更高 - 排查线程池生命周期逻辑:确认TPHelper是单例Bean,初始化方法
init()仅在应用启动时执行一次,没有任何业务代码路径会主动调用这个Scheduler的dispose()方法,避免线程池被意外关闭后提交任务抛错。
临时优化方案
- 临时扩容线程池:根据实际任务平均耗时调整线程数上限,比如单任务平均耗时30ms,7K TPS下理论需要210个工作线程,预留30%冗余可以设为280,不要盲目设太大避免上下文切换开销
- 调整队列容量:不要设为
Integer.MAX_VALUE避免无界积压导致OOM,配置为可支撑10秒左右的流量积压即可(比如7K TPS设为70000),同时配置队列长度告警 - 优化重试逻辑:给重试加指数退避,严格限制最大重试次数,避免下游故障时重试风暴打满线程池。
内容的提问来源于stack exchange,提问作者Pavan Kumar
相关产品推荐
相关产品推荐

