那件事发生在周四下午离业务高峰还有不到一小时。订单域里一个很普通的同步服务接口耗时突然从 P99 50ms 涨到了 4 秒错误率跟着往上跳。打开监控一看机器 CPU 利用率只有 20% 左右但线程池的活跃线程数已经打满 100%队列深度从 0 一路飙到几千。按理说这个服务只有 30% 的请求会同步调用外部渠道做风控校验剩下的 70% 都是走本地缓存和数据库查询的快任务怎么就被打死了事后复盘下来问题恰恰就出在那 30% 的阻塞任务上。它们数量不多但持有线程的时间是快任务的上千倍最终把整个线程池的 100% 线程全部蚕食干净连那 70% 的快任务也无法幸免。这篇复盘我会把事故的现象、线程池的原理、根因和改造方案完整写出来给所有用过线程池的同学一个可参考的案例。1. 事故回顾一次“不算复杂”的线上故障1.1 故障现象与业务背景先说业务背景。这个服务负责订单状态同步链路大致是这样的接单后先做本地缓存查询和数据库更新这部分耗时非常短大约 10ms 以内其中有 30% 的订单需要额外同步调用一个外部渠道做风控评分或银行鉴权正常情况下这个外部调用在 50ms 左右返回。服务的线程池核心线程和最大线程都设置为 100队列用的无界队列代码里把外部调用的连接超时和读超时都设成了 15 秒。这套配置在平时跑得挺稳P99 稳定在 50ms每天处理几十万单。故障当天的情况却是这样的外部渠道发布新版本后响应时间从 50ms 一路恶化到 8~10 秒偶尔还会直接超时。由于代码里设置了宽松的 15 秒超时每一次外部调用都会让线程持有至少 10 秒。从监控上看线程池活跃线程数在 20 分钟内从 40 多涨到 100队列深度从 0 涨到 6000随后接口开始大面积超时部分请求直接被 ThreadPoolExecutor 的默认拒绝策略抛出 RejectedExecutionException。这里有一个很反直觉的现象CPU 利用率不高。明明线程池已经满负荷运转但 CPU 只有 15% 到 20%。原因很简单线程都在等外部接口返回属于 IO 密集型的等待不是在做计算。恰恰是“CPU 低 线程池满 队列深”这三条同时出现基本就能断定问题出在阻塞任务上而不是机器资源不够或者代码写得慢。1.2 当时的排查路径按照常规思路我们一开始并没有直接怀疑线程池。第一轮排查先看了看数据库慢查询日志里只有几条平时就存在的长 SQL数量级和平时没差异GC 日志也比较正常YGC 频率没有异常升高FGC 几乎没有。于是把目光转向外部依赖这时候发现渠道方的响应确实变慢了但当时的第一反应是“外部变慢就让它慢吧我们线程池有 100 个线程撑住应该没问题”。这个想法恰恰是事故扩大的根源。线程池的 100 个线程不是“100 个独立工人”而是“100 个被任务占用的容器”。当外部调用平均耗时 10 秒时每一个阻塞任务占住一个线程的时间足够快任务完成 1000 次。30% 的流量比例在时间维度上被放大了一千倍线程池当然会被打满。等到重启了两次实例都是只恢复了十分钟又再次被打满我们才真正意识到问题不是瞬时抖动而是持续性的线程占用。后来通过 jstack 抓线程栈发现大量线程停在 SocketInputStream.socketRead0也就是阻塞在等待外部 socket 响应这才彻底坐实了根因阻塞任务耗尽了线程池的全部资源。整个过程最耗时间的不是定位而是破除“线程数100 就够用”这个错误预设。2. 线程池为何会被“少量”阻塞任务拖垮2.1 线程池任务流转的底层逻辑要理解这个问题得先把 ThreadPoolExecutor 的任务流转流程弄清楚。向线程池提交一个任务后它的处理顺序是固定的当前线程数小于 corePoolSize 时直接新建线程执行任务。当前线程数已经达到 corePoolSize任务先放入阻塞队列等待。阻塞队列满了且当前线程数小于 maxPoolSize才创建新线程执行任务也就是线程池扩容。阻塞队列满了而且当前线程数已经达到 maxPoolSize触发拒绝策略。这里最容易忽略的是第二步和第三步的先后关系。只有当队列真的满了线程池才会尝试扩容到 maxPoolSize。如果你用的是无界队列比如 new LinkedBlockingQueue() 默认容量是 Integer.MAX_VALUE那 maxPoolSize 就等于虚设队列永远不会满线程数也永远停留在 corePoolSize后面所有任务都会堆在队列里慢慢等。用生活场景类比就是银行柜台corePoolSize 是固定开放的柜台队列是等候区maxPoolSize 是备用的临时柜台。如果等候区是无限大的银行永远不会增开临时柜台所有客户都在排队如果前面几个客户办理的都是复杂业务一个人占住柜台半小时后面哪怕只是取个零钱也得干等着。线程池本身是不区分任务类型的。它不会因为你提交的任务“应该很快”就优先调度一切都是先到先服务。当一个阻塞任务先占据了线程这个线程在任务结束前不会释放后面的快任务即便排在队列前面也拿不到线程。这一点是理解整个事故的核心。2.2 30% 为什么等于 100%QPS × 延迟的账很多人对“30% 的阻塞任务会打满 100% 的线程池”这个结论不理解觉得比例上说不通。要算清楚这笔账得记住一个公式线程池并发需求量 QPS × 平均响应时间这其实是 Little’s Law 的简化版意思是每秒进来多少任务每个任务平均处理多长时间两者相乘就是系统需要同时持有的线程数。只要这个值超过线程池容量任务就会开始堆积线程池就会饱和。回到事故场景。假设这个服务的总 QPS 在高峰是 5000 左右其中快任务占比 70%也就是 3500 QPS平均耗时 10ms阻塞任务占比 30%也就是 1500 QPS正常情况下平均耗时 50ms。正常情况下的并发需求量是快任务3500 × 0.01s 35 个线程阻塞任务1500 × 0.05s 75 个线程合计110 个线程线程池 100 个线程虽然已经在临界边缘但还能勉强跑。渠道方故障后阻塞任务的平均响应时间变成 10 秒此时阻塞任务1500 × 10s 15000 个线程快任务仍然是 35 个线程合计需要 15035 个线程一个只有 100 个线程的池子要承载 15000 个并发需求结果只有一种100 个线程被阻塞任务全部占满队列里堆满剩余任务。70% 的快任务数量并没有变但线程池里已经一个空位都没有了它们只能排队排队期间接口 RT 自然飙升。所以“30% 的阻塞任务让 100% 的线程池瘫痪”这句话本质不在 30% 这个比例而在阻塞任务的耗时被放大了上千倍。正常情况下 30% 只占 75 个线程的地盘故障时它需要 15000 个线程的地盘多出来的部分全都是从快任务那边抢过来的。2.3 扩容救不了为什么 maxPoolSize 也撑不住事故中有个细节很值得反思当时的线程池配置是 core50、max200。按理说线程数还有从 50 扩展到 200 的空间怎么还会被打满原因就在于队列。我们用了一个无界队列按照前面说的流转逻辑队列永远满不了线程数会一直停留在 corePoolSize50maxPoolSize200 从未真正生效。也就是说从故障开始到线程池完全瘫痪实际可用的线程只有 50 个另外 150 个线程一直处于“备用但从未启动”的状态。即便当时把队列换成了有界队列让线程数真的扩到 200也只能再撑一会儿。阻塞任务持续每秒 1500 个每个 10 秒算下来依然需要 15000 个线程。扩容到 200 个线程相当于把瘫痪的时间点往后推了一点根本问题不会解决。这也是为什么遇到外部依赖变慢时单纯调大线程池参数是最没用的优化方式。你调大一倍慢任务就能多吃一倍线程你把 max 调到 10001000 个线程也会被 10 秒级的阻塞任务全部占住。线程池并不是一个无限吸收慢调用的容器你需要做的是把慢任务限制在一个可控范围而不是让它无限占用资源。3. 事故根因线程池配置与阻塞队列选型的三个大坑3.1 坑一线程数拍脑袋没按并发需求量核算复盘时我们仔细看了线程池配置发现一个很典型的问题core 和 max 没有按任务类型拆开算而是统一拍了个“100 线程应该够了”。很多团队对线程池有个误区一看到“IO 密集型任务要把线程数调大”就随手设成 100、200却从不计算这些线程到底会被什么任务占用。你要处理的外部调用越多、响应时间越不稳定需要的线程数就越难估算。正确做法是把任务拆成不同类别分别估算每类的 QPS 和平均耗时再代入并发需求量公式看看到底需要多少个线程。CPU 密集型的任务线程数可以参考 CPU 核数 1因为这种任务本身就在吃 CPU开太多线程只会增加上下文切换成本。IO 密集型的任务确实可以多开线程但不代表“无脑开大线程数”就能解决一切。线程数开得越大慢任务能占用的资源池子就越大故障影响面反而越大。你要的不是无限大的池子而是刚好能消化正常流量的池子加上对慢任务进行隔离和限制。3.2 坑二队列选型错误无界队列是隐形炸弹这次事故里最直接的坑就是队列选型。我们用了 LinkedBlockingQueue 的无参构造也就是默认容量 Integer.MAX_VALUE。这种配置看起来“永远不拒绝任务”实际上是把风险全部转移给了内存和任务等待时间。无界队列带来三个直接后果maxPoolSize 完全失效线程数永远不会超过 corePoolSize。任务无限堆积队列深度不断上涨内存压力跟着上升。任务的等待时间无限拉长接口 RT 持续恶化最终用户侧表现为雪花一样的超时。如果你是非阻塞的短任务无界队列问题还不大因为任务很快就能被消费掉。但任务里只要有阻塞调用无界队列就成了灾难放大器。阻塞任务占住线程不释放后面的任务全压队列里队列只会越堆越长绝不会触发扩容或者拒绝策略整个故障被掩盖在“看起来很忙”的队列深度里。阻塞队列选的正确思路是优先用有界队列比如 ArrayBlockingQueue给队列一个明确的上限。队列满了以后线程池才会扩容到 maxPoolSizemaxPoolSize 也满了就会触发拒绝策略让问题暴露出来而不是无限堆积。3.3 坑三拒绝策略和超时配置双双失守事故里还有两个看起来很小、但影响巨大的配置细节。首先是拒绝策略。默认的 AbortPolicy 会在队列满且线程池满时直接抛 RejectedExecutionException而我们的上层代码把异常吞掉了一部分只打了日志用户看到的依然是超时问题容易掩盖。如果当时用的是 CallerRunsPolicy情况会更糟阻塞任务会被回抛给提交任务的 Tomcat 线程等于把线程池的压力传导到了 Web 容器线程造成级联故障。拒绝策略必须按业务场景选择。如果是非关键的辅助任务可以考虑 DiscardPolicy 或 DiscardOldestPolicy但必须记录指标和日志如果是核心链路应该自定义拒绝策略在拒绝时返回降级结果或者把任务转存到消息队列异步处理而不是直接把异常抛给用户。其次是超时配置。外部调用的连接超时和读超时都设成了 15 秒这个数字在当时看来是为了“稳定性”实际上反而让线程被阻塞的时间拉长到了 15 秒。一个线程被占 15 秒和被占 1 秒对线程池容量的消耗差了 15 倍。阻塞任务的超时时间不是越长越安全而是越短越能保护线程池。外部依赖抖动时你希望在 2 秒内失败并走降级而不是让线程池硬扛 15 秒直到全线崩溃。4. 现场排查与问题复现4.1 用 jstack 看线程状态坐实阻塞点事故当天定位到线程池问题后我们抓了一份线程 dump。当时看到最多的是类似下面的栈pool-3-thread-27 #127 prio5 os_prio0 cpu0.50ms elapsed102.3s tid0x00007f nid0x7f5 running [0x00007f] java.lang.Thread.State: TIMED_WAITING (parking) at jdk.internal.misc.Unsafe.park at java.util.concurrent.locks.LockSupport.parkNanos at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire ... at java.net.SocketInputStream.socketRead0不要被这一大串栈吓到。关键信息就两点线程状态是 TIMED_WAITING而且看到 socketRead0说明线程正阻塞在 socket 读取上等待外部 HTTP 响应。elapsed 102.3s 说明这个线程已经在这个等待状态里待了超过 100 秒也就是说任务根本没在正常处理业务而是被外部慢接口拖住了。再向上翻 ThreadPoolExecutor 的线程名确认这些都是同一个线程池的线程数量级接近 corePoolSize。到这一步根因基本就定了外部调用响应超时线程全部卡在 socket 读取上池子被阻塞任务填满。如果当时线程状态是 WAITING停在 AbstractQueuedSynchronizer 的 park 方法上那可能是在等锁或者等条件变量如果大量线程是 RUNNABLE那才需要考虑 CPU 密集计算问题。线程 dump 不会直接告诉你答案但能帮你快速缩小范围。4.2 本地复现30% 阻塞任务如何打满线程池为了让团队其他同学直观理解这个事故我写了一个很简单的模拟程序。线程池设置为 core100、max100阻塞队列是无界队列提交 1000 个任务其中 30% 的任务模拟外部调用sleep 10 秒剩下 70% 的任务做 10ms 的快速计算。ExecutorService pool new ThreadPoolExecutor( 100, 100, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy() ); for (int i 0; i 1000; i) { if (i % 10 3) { pool.execute(() - { try { TimeUnit.SECONDS.sleep(10); // 模拟阻塞的外部调用 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } else { pool.execute(() - { // 模拟快任务10ms 内存计算 long sum 0; for (int j 0; j 100000; j) { sum j; } }); } }运行结果非常直观前 300 个阻塞任务在 10 秒内把 100 个线程全部占完队列里堆着 700 个快任务但它们的执行时间全部被拖到 10 秒以上。正常时 100ms 能跑完的 1000 个任务现在整整花了 100 多秒而且期间线程池的活跃线程数一直保持在 100。这个复现过程让我意识到事故里真正起作用的不是“30%”这个数字本身而是它对线程占用时间的放大效应。一个 10ms 的任务和一个 10s 的任务在同一个池子里前者的执行时间被后者拖累了一千倍。4.3 事故时间线从渠道方发版到服务恢复复盘的另一个重要产出是时间线。把故障的每一步串起来大家才明白为什么中间重启没用时间现象判断14:00渠道方发布新版本响应耗时缓慢上升外部依赖波动继续观察14:20服务接口 P99 从 50ms 涨到 300ms线程池活跃线程数 80线程池资源开始紧张14:30活跃线程数打满 100队列深度从 0 涨到 6000开始出现拒绝异常线程池已被阻塞任务占满14:45重启实例10 分钟后再次被打满确认是持续性问题不是瞬时流量15:00jstack 抓栈大量线程阻塞在 socketRead0坐实外部调用拖死线程池15:15熔断渠道调用开关接口 RT 恢复验证根因与修复方向重启只能清空队列但阻塞任务还会持续到来新线程一样会被占住所以“重启治标不治本”是这个场景的特点。熔断外部调用之后线程池的压力在几十秒内就降下来了说明外部依赖是唯一的根因来源线程池本身没有任何任务分类或隔离机制来抵御这种冲击。5. 解决方案与改造实践5.1 核心手段快慢任务分池彻底隔离阻塞任务事故后的第一轮改造就是做线程池隔离。所谓隔离不是简单地把线程池从一个变成两个而是要让不同的任务类型各用各的池子互不抢占。我们当时把原来的单一线程池拆成了两个本地快任务线程池处理缓存查询、本地数据库操作等短任务core50max100队列用有界队列容量 2000。外部调用线程池处理所有需要同步调用外部渠道的慢任务core20max40队列用有界队列容量 500。这里有个很多人会问的点外部调用线程池为什么设置得这么小原因很简单我们要的不是“能承载多少阻塞任务”而是“允许最多多少阻塞任务同时存在”。外部调用本身有超时和熔断机制一旦渠道方变慢我们希望阻塞任务的并发数被限制在 40 以内剩下的流量全部走降级或者快速失败。线程池小一点故障半径就小一点。如果你担心核心业务线程池也会被慢任务影响记得给核心业务线程池单独设置拒绝策略和降级逻辑不要把两个池子混在一起用。之前那种“一个大池子接所有任务”的方式本质上就是拿快任务的生命周期去给慢任务陪葬。5.2 给外部调用加信号量限流超时时间坚决收紧线程池隔离只能防止慢任务传染给快任务但外部调用线程池内部依然可能被堵死。所以第二层防护是给外部调用加上信号量限流。信号量的思路和线程池有点像但更轻量。它不做任务排队只控制“同时最多有多少个线程可以进入这个区块”。拿 Semaphore 做一个并发上限比如 30超过这个数量直接失败或走降级不占用线程池线程。Semaphore remoteSemaphore new Semaphore(30); try { if (remoteSemaphore.tryAcquire(2, TimeUnit.SECONDS)) { try { // 调用外部渠道 remoteClient.call(); } finally { remoteSemaphore.release(); } } else { // 降级逻辑直接返回兜底结果或抛出可控异常 fallback(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); }信号量配合线程池使用效果是双层的线程池控制的是任务调度的边界信号量控制的才是真实的外部并发度。这样即便外部调用线程池配置失误信号量也会拦住超额流量。超时时间也做了调整外部 HTTP 调用的连接超时统一设为 2 秒读超时设为 3 秒不允许业务方随意调大。超时设置的原则是上限必须小于用户的忍耐阈值也要小于线程池能够承受的阻塞时长。与其让线程被慢调用拖到崩溃不如让它快速失败然后走降级。5.3 有界队列 自定义拒绝策略让故障可观测第三轮改造把原来的无界队列全部换成了有界队列同时为不同的业务场景定制了拒绝策略。有界队列选的是 ArrayBlockingQueue容量根据业务 QPS 和线程数来估算。估算公式还是那个并发需求量公式只不过这里的队列容量要能吸收短时突刺流量又不能无限堆积。我们一般按 5 到 10 秒的积压量来设置比如快任务线程池每秒能消费 500 个任务队列容量就设在 3000 到 5000。拒绝策略上默认 AbortPolicy 已经不用了。我们实现了一个自定义拒绝策略拒绝时做三件事记录指标、输出告警日志、走降级逻辑。核心业务线程池拒绝时直接返回一个“系统繁忙请稍后重试”的兜底响应外部调用线程池拒绝时直接把任务转存到 MQ由下游异步补偿。public class FallbackRejectedHandler implements RejectedExecutionHandler { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 1. 记录指标方便告警 Metrics.counter(threadpool.rejected, executor.getCorePoolSize()); // 2. 输出诊断日志 log.warn(task rejected, pool{}, queueSize{}, activeCount{}, executor.getPoolSize(), executor.getQueue().size(), executor.getActiveCount()); // 3. 降级处理 if (r instanceof RemoteCallTask) { ((RemoteCallTask) r).fallback(); } } }拒绝策略的意义不在于“把任务丢掉”而在于给系统一个明确的失败信号让故障不再被无界队列掩盖。你可以拒绝任务但必须让报警和日志同步触发否则排查起来依然要靠猜。5.4 线程池监控与动态调整别等出事才看数据改造完线程池之后最容易被忽略的一步是监控。单靠配置正确并不能保证以后不出问题因为流量、依赖、代码都在变。我们需要知道线程池在任何时刻的真实状态才能提前发现问题。我建议至少把以下指标接入监控大盘活跃线程数 activeCount接近核心线程数时就要注意。队列深度 queueSize持续上涨说明消费速度跟不上提交速度。完成任务数 completedTaskCount用来算吞吐量变化。拒绝次数 rejectedCount一旦有拒绝必须立刻告警。任务平均执行时间用来判断是否有任务异常变慢。这些指标在 ThreadPoolExecutor 里都拿得到封装一个线程池工具类定期打印或者上报到监控系统就行。我们当时的教训是线程池满了不会直接反映为 CPU 高如果只盯 CPU 和内存很难第一时间发现。加了活跃线程数和队列深度监控之后再遇到类似情况基本能提前十分钟发现问题。如果你用了配置中心还可以把线程池的 core、max、队列容量做成动态配置。线上出现问题时不一定要重启直接调整参数或者临时扩缩容。这个能力很有用但要注意动态调整是对问题的临时兜底不能替代慢任务隔离和超时治理否则你只是把线程池从“崩溃”调成“慢性崩溃”。5.5 进一步优化异步化和故障演练最后说说后续还有哪些值得做的事。第一把阻塞调用尽量异步化。比如订单同步时外部风控校验的结果并不一定要在同步链路里即时拿到可以考虑把请求发到 MQ消费端去调用外部渠道把结果异步写回。这样同步链路的线程池压力会大幅下降风险也会小很多。第二定期做故障演练专门模拟外部依赖变慢的情况。我们后来有一个小工具可以手动把某个下游客户端的响应时间设置成 10 秒然后观察线程池指标变化验证隔离和熔断是否生效。压测的目的不是证明系统没问题而是提前暴露配置盲区。第三给所有的 RPC 和 HTTP 调用配置统一的超时中间件禁止业务代码里自己写死超时时间。超时这块如果没有统一规范总有人为了方便把超时调大下次事故可能就因为一个 30 秒的调用再次发生。6. 写在最后踩坑之后的三条经验这次事故让我对线程池有了完全不同的认识。以前看线程池觉得它就是“开几个线程跑任务”的工具配置起来无非是调大调小。现在再看线程池的本质是资源分配器它把有限的线程资源按“先到先得”的规则分配给无限到来的任务。而阻塞任务恰恰是利用这种分配规则用超长的持有时间绑架了大部分资源。我个人现在给自己定了三条规矩。第一任何外部依赖调用都必须有独立的线程池或者独立的信号量绝对不允许慢调用和快任务共享一个池子。第二所有外部调用的超时时间必须经过团队评审连接超时和读超时都不能拍脑袋写宁可让接口快速失败也不能让线程无限等待。第三线程池的活跃线程数、队列深度和拒绝次数必须出现在监控大盘上一旦指标出现趋势性上涨宁可提前熔断也不要硬扛。如果你现在正准备为一个服务配置线程池我建议你先别急着决定线程数是多少先问自己这几个问题这个线程池里的任务最长可能耗时多少如果依赖变慢线程会被占住多久队列满了以后业务希望看到什么样的失败结果这些问题想清楚了线程池的配置思路自然就清晰了。希望这次事故复盘能给你一些启发至少让你在下次看到线程池活跃线程数打满时不再觉得只是“线程数不够”而已。