线程池参数不是公式题:如何根据业务流量科学配置?
“CPU 密集型设置为核数加一,I/O 密集型设置为两倍核数”只能当课堂直觉,不能直接成为生产配置。线程池是一套容量控制系统,参数必须回答:流量多大、任务多久、允许等多久、过载时牺牲什么。
七个参数分别控制什么
ThreadPoolExecutor 的核心参数包括核心线程数、最大线程数、存活时间、队列、线程工厂和拒绝策略。默认流程是:
- 未达到 corePoolSize:创建核心线程。
- 达到核心数:先入队。
- 队列满且未到 maximumPoolSize:再创建线程。
- 队列满且线程到上限:执行拒绝策略。
这意味着使用巨大无界队列时,maximumPoolSize 基本没有机会生效,过载表现为排队时间不断增长。
从流量模型得到初始值
Little 定律给出稳定系统中的关系:
系统中平均任务数 L = 到达率 λ × 平均停留时间 W
若峰值每秒 500 个任务,平均服务时间 40ms,仅维持平均流量约需 500 × 0.04 = 20 个并发。实际还要考虑 P95/P99、抖动和下游容量,但不能无限放大:下游数据库只允许 30 个有效并发时,配置 200 个线程只会制造连接池争抢和超时风暴。
CPU 任务可从可用核心数起步;混合任务可用测得的等待/计算比例估算:
线程数 ≈ CPU核数 × 目标利用率 × (1 + 等待时间/计算时间)
它仍只是压测起点,因为锁竞争、内存带宽、容器 CPU quota 和下游限额都会改变结果。
队列容量由等待预算决定
队列不是越大越安全。若允许的排队时间最多 200ms,消费能力约每秒 500 个,则可从约 500 × 0.2 = 100 个任务的量级起步,再通过压测修正。队列过大会让请求在客户端已经超时后仍被执行,浪费资源;过小则更早拒绝,需要上游具备降级能力。
ThreadPoolExecutor pool = new ThreadPoolExecutor(
20, 30, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
namedFactory("price-query-"),
new ThreadPoolExecutor.AbortPolicy());
业务代码应把 RejectedExecutionException 转换为明确的过载响应或降级,而不是吞掉。
拒绝策略决定过载语义
AbortPolicy 快速暴露过载;CallerRunsPolicy 让提交线程执行,形成自然反压,但若提交线程是事件循环或关键入口线程,可能拖垮整个入口;DiscardPolicy 与 DiscardOldestPolicy 会丢任务,只适合明确允许丢弃且有指标的场景。
定时任务、消息消费和 HTTP 请求的失败语义完全不同,不应共用一个“万能线程池”。按依赖或业务关键性隔离,才能避免一个慢下游占满所有工作线程。
压测方法
逐级增加到达率,记录吞吐、任务执行耗时、队列等待、端到端 P99、活跃线程、队列深度、拒绝数、CPU、GC 和下游连接池。拐点通常表现为吞吐不再增长而队列与延迟快速上升。配置应留在拐点之前,并验证突发流量和依赖变慢时能否快速失败。
任务执行耗时与队列等待必须分开埋点,否则看到“线程池任务很慢”时无法判断是代码慢还是排队久。
生产检查清单
- 峰值到达率和任务耗时分位数是多少?
- 下游允许的最大并发与连接池容量是多少?
- 队列有界吗,容量对应多少等待时间?
- 拒绝后是降级、重试、返回 429/503,还是允许丢弃?
- CPU quota 而非宿主机核数是多少?
- 是否按慢依赖和关键性隔离线程池?
- 是否监控队列等待、拒绝数和最大线程触达次数?
- 是否做过依赖延迟放大、超时和突发流量演练?
科学配置不是算出一个永远正确的数字,而是建立“测量—建模—压测—监控—调整”的闭环。