ARTICLE DETAIL

资讯详情

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

线程池明明配了 32 个线程,为什么请求还是排队到超时?

线程池明明配了 32 个线程,为什么请求还是排队到超时? 线程池明明配了 32 个线程为什么请求还是排队到超时假设你的接口把查询任务交给线程池corePoolSize8、maximumPoolSize32、队列容量100。压测时队列越来越长接口也越来越慢但一看监控池里怎么只有 8 个线程在跑很多人第一反应是最大线程数不是 32 吗系统为什么不扩容这跟ThreadPoolExecutor的接单顺序有关。它的逻辑是先补足核心线程接着把任务往队列里塞只有队列塞满了才会去创建非核心线程。maximumPoolSize并不是流量一上来就会立刻达到的目标值。如果没搞清这个顺序后续的调优基本就是盲人摸象。最大线程数为什么没生效我们可以极端一点假设所有任务都被下游卡住没有任何任务完成。以上面的参数为例前 8 个任务会创建 8 个核心线程。接下来的第 9 到 108 个任务会被放进队列等待。此时依然只有 8 个线程而队列逐渐被 100 个任务填满。直到第 109 个任务到来发现队列满了线程池才会继续创建线程直到总数达到 32。等第 133 个任务进来时队列和线程数都到上限了只能触发拒绝策略。现实情况当然是任务在并发完成队列在被消费。但这说明了一个核心事实只要队列没满线程池通常就不会为了排队任务去主动扩容到maximumPoolSize。这里经常会引出对Executors.newFixedThreadPool(n)的误解。它固定最多n个线程配的是无界队列。如果任务提交速度一直大于处理速度等待的任务就会不断积压。它绝对不是什么“流量大时会自动多开线程”的魔法池。很多人觉得无界队列肯定会 OOM有界队列就很安全。这种看法有些绝对了。无界队列的风险在于缺少可控的积压上限而有界队列只是让系统在触及上限时显式地选择该怎么处理新任务。如果你没设计好拒绝后的业务逻辑系统故障无非就是从“内存撑爆”变成了“请求大面积失败”。真正把接口拖慢的是等待时间当下游调用耗时从 100 ms 飙升到 500 ms单个线程每秒能完成的任务数就锐减了。线程池的整体处理能力跟着下降。如果请求还以原来的速度涌进来处理不掉的部分就会进入队列。这就好比每秒进 200 个任务但线程池一秒只能消化 100 个。容量 100 的队列大概 1 秒钟就满了。队列变长只是到达速度和处理速度失衡的结果单纯加大队列容量治标不治本。另外任务本身执行 100 ms调用方等的时间绝对不止 100 ms。如果任务在队列里排了 2 秒钟的队调用方感受到的延迟就是 2.1 秒。如果你平时只看“任务执行耗时”这个指标就会完美错过最致命的排队时间。排查延迟问题时记得把“排队等待时间”和“实际执行时间”拆开来看。看到 RejectedExecutionException先别急着加线程抛这个异常说明任务被拒绝了但这不代表线程池一定是被流量打爆的。比如线程池正在关闭新任务也会被拒。排查时可以先理一理先确认是哪个线程池有没有在关闭。应用重启或者配置热更时都可能触发拒绝。接着把线程数和队列结合起来看一段时间的趋势getActiveCount()的瞬时值往往不太准。最关键的是去看看任务到底在等什么。打个 jstack 看看是 CPU 忙、等数据库连接、RPC 变慢还是死锁了。如果瓶颈卡在下游的限流上你盲目加线程只会让更多的任务排排坐一起堵着。光看到“线程池满了”是没法做决策的你得结合提交速率、排队时间综合看才知道到底是该扩容、降载还是去修下游的服务。跑个代码看看“先排队后扩容”我们可以写个简单的代码验证一下。这里把核心线程设为 2、最大线程数 4、队列容量 2。用个CountDownLatch把任务卡住方便观察接单顺序。importjava.util.concurrent.ArrayBlockingQueue;importjava.util.concurrent.CountDownLatch;importjava.util.concurrent.ThreadPoolExecutor;importjava.util.concurrent.TimeUnit;importjava.util.concurrent.RejectedExecutionException;publicclassPoolOrderDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{CountDownLatchreleasenewCountDownLatch(1);ThreadPoolExecutorpoolnewThreadPoolExecutor(2,4,30,TimeUnit.SECONDS,newArrayBlockingQueue(2),newThreadPoolExecutor.AbortPolicy());try{for(inti1;i7;i){try{pool.execute(()-{try{release.await();}catch(InterruptedExceptione){Thread.currentThread().interrupt();}});System.out.printf(任务 %d线程%d排队%d%n,i,pool.getPoolSize(),pool.getQueue().size());}catch(RejectedExecutionExceptione){System.out.printf(任务 %d被拒绝%n,i);}}}finally{release.countDown();pool.shutdown();pool.awaitTermination(5,TimeUnit.SECONDS);}}}跑一下就会发现第 1、2 个任务分配了线程第 3、4 个进了队列等到第 5、6 个任务来的时候线程数才扩充到 4。第 7 个任务则被无情拒绝。参数怎么定拒绝了怎么办网上的八股文很喜欢背“CPU 核数 × 2”但实际场景要复杂得多。你得弄清楚任务是纯计算、短时 IO 还是长耗时的 RPC 调用。除此之外机器的内存、数据库连接池大小、下游的限流阈值都需要综合考虑。最靠谱的方法还是压测观察吞吐量、排队时间和报错率。队列容量也不是越大越好。太大会导致积压的请求排队过久直接触发接口超时太小又会过早地触发拒绝策略。通常的做法是按业务能接受的最大等待时间来估算队列大小。至于拒绝策略得跟着业务走用AbortPolicy抛异常外层代码就得做好降级或者重试。用CallerRunsPolicy让提交任务的线程去跑确实能反压减缓提交速度但如果提交任务的是 Tomcat 的 HTTP 线程可能会导致整个 Web 接口响应变慢甚至连接池耗尽。用DiscardPolicy静默丢弃就更坑了排查问题时连个响都听不到。如果任务很重要绝对不能丢光靠调个CallerRunsPolicy是兜不住的。还得靠业务自己来保证比如写数据库记录、发消息队列补偿等。线程池只是个调度工具不包你业务数据的最终一致。另外提一嘴动态调参。现在很流行热更线程池参数但标准的ArrayBlockingQueue容量是没法跟着最大线程数自动变大的。动态调参可以用来应急但它救不活一个本身就一直超时的下游服务。总结一下理清整个链路其实很简单下游变慢或者流量激增导致线程处理不过来。任务开始在队列里堆积排队时间拉高了接口的整体延迟。等队列塞满后线程池才极不情愿地去创建新线程。到最后实在装不下了就开始拒绝。线程池参数调优从来不是背诵一套公式而是在处理能力、排队时间和过载后果之间做权衡。下次再遇到“明明配了 32 个为啥只有 8 个在干活”的监控报警不妨先去看看队列长度和下游耗时别急着无脑加线程。参考JDK 21ThreadPoolExecutor文档、JDK 21Executors文档。
返回列表