给项目接上动态线程池:先讲原理,再动手,最后说清楚它解决不了什么
线上有个接口,平时 QPS 不过十几,某天被挂到了一个活动的 banner 上,量翻了十几倍。队列开始堆,日志里零星几个 RejectedExecutionException。你去翻代码,线程池是这么定义的:
java
@Bean("xxxExecutor")
public Executor xxxExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
...
}
想把 corePoolSize 从 4 改成 8,就是改代码、打包、发版、重启一套流程。等活动结束量回落了,再改回去,再来一轮。一个下午就这么没了。
问题不是参数不能调,而是调一次的成本太高,高到大部分时候你宁可忍着不改。
这篇文章想解决的就这件事。先讲清楚"动态"到底是怎么实现的(原理其实不复杂),再说为什么我不建议自己写,然后动手接一个现成组件,说一个跟线程池无关但很值得记的坑,最后交代这个方案解决不了什么。
一、先看"动态"的原理:其实只差几个 setter
很多讲动态线程池的文章,一上来就摆架构图、讲配置中心怎么对接,架势搞得很大。但它真正依赖的 JDK 能力其实很小,就是 ThreadPoolExecutor 自己提供的这几个方法:
java
public void setCorePoolSize(int corePoolSize)
public void setMaximumPoolSize(int maximumPoolSize)
public void setKeepAliveTime(long time, TimeUnit unit)
public void setRejectedExecutionHandler(RejectedExecutionHandler handler)
public void setThreadFactory(ThreadFactory threadFactory)
核心线程数、最大线程数、空闲存活时间、拒绝策略都能在运行时改。 现在就可以在业务代码里塞一句 executor.setCorePoolSize(10),它立刻就生效。
所以"动态线程池"这件事可以拆成两层来看,后面所有内容都落在这两层里:
- 第一层,JDK 已经给了:改参数的能力本身。就是上面这几个 setter,不用框架也做得到。
- 第二层,JDK 没给:谁来改、什么时候改、改的时候怎么保证不出错、改完怎么看效果。框架的价值全在这一层。
第二层里有两个地方比看上去麻烦,先说清楚,后面读接入步骤时才知道每一步在解决什么。
第一个:调大小是有顺序的,顺序错了会抛异常
setCorePoolSize 和 setMaximumPoolSize 之间有约束:corePoolSize 不能大于 maximumPoolSize。
所以改参数不能瞎调。往上调要先把 max 调大、再调 core;往下调反过来,先调小 core、再调小 max。
如果顺序反了,中间那一刻会出现 core > max 的状态,直接 IllegalArgumentException。
这看着像细节,但它必须是框架来保证的。业务代码自己调,很容易忘。
第二个:队列容量改不了
四个参数里,队列容量是最特殊的一个。
LinkedBlockingQueue 的容量字段是这么定义的:
java
private final int capacity; // ← final
final 意味着没有 setter 。你拿到一个已经建好的 LinkedBlockingQueue,没有办法改它的容量。这是 JDK 的设计,不是疏忽。
所以「动态调整队列容量」这件事,靠调用 API 是做不到的。
我一开始以为框架会用反射去改那个 final 字段。去反编译了 dynamic-tp 的类才发现,它根本没走这条路:
java
public class VariableLinkedBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, Serializable {
private int capacity; // ← 不是 final
public void setCapacity(int); // ← 正经的公开 setter
}
VariableLinkedBlockingQueue 不是 LinkedBlockingQueue 的子类 ,而是 dynamic-tp 自己从头写的一整套阻塞队列(takeLock / putLock / notEmpty / notFull,都跟 JDK 那套一个路子)。它做的就是把 capacity 重新定义成非 final,再开一个 setCapacity 方法。
setCapacity 本身也不复杂,除了赋值,只在"容量调大了、并且队列已经满了"时才唤醒卡住的 put 线程:
java
public void setCapacity(int capacity) {
int oldCapacity = this.capacity;
this.capacity = capacity;
int c = count.get();
if (capacity > c && c >= oldCapacity) {
signalNotFull();
}
}
结论 :想动态改队列容量,必须一开始就用 VariableLinkedBlockingQueue,用原生的那个是死路。
这条我在启动日志里得到了确认:
markdown
DynamicTp refresh, the blockingqueue capacity cannot be reset,
poolName: xxxExecutor, queueType LinkedBlockingQueue
接入的池用的就是原生 LinkedBlockingQueue,所以框架明确告诉你:这个池的队列容量改不了。它没有假装什么都支持,改不了就直说。
顺带一笔:并发安全
setCorePoolSize 这类方法内部是有锁的(mainLock),不是裸改字段。所以运行时调参本身是线程安全的,不用你操心。
原理就这么多。真要自己撸,工作量在于:写一个配置监听、把配置映射到 setter、处理上面那个顺序问题、再补上监控和告警。这些活值不值得自己干一遍,下面说我的判断。
二、自己不写,直接接组件
上面那套东西里没有任何一行是"你的业务"。配置监听、参数映射、调序保护、指标采集、告警通知,这是纯粹的通用基础设施,谁写出来都差不多,所以我的选择是不自己写。
这个判断还有两个具体的支撑:
- 重点其实是监控和告警,不是调参。 调参只解决"能改";真正让线程池不出事的是"提前知道它要出事":队列水位、拒绝次数、TP99 这些。自己写就得从头接监控系统。
- 边界情况多。 队列容量那个
final、调序保护、平滑关闭时等任务跑完,每一条都是坑,都在别人的源码里被踩过了。
所以我用了 dynamic-tp (dromara 组织下的开源项目,轻量、支持 Nacos/Apollo 等配置中心、自带监控告警)。
当然,如果你只是想搞懂原理,自己写一遍是很好的学习。但目标是"让线上线程池可控",我会直接选轮子。接下来是接入的具体步骤。
三、接入四步
第 1 步:确认依赖坐标
这里第一个坑就来了:dynamic-tp 有两套坐标。
| groupId | 最高版本 | Spring Boot |
|---|---|---|
cn.dynamictp(旧) |
1.1.2 | 只支持 Boot 2 |
org.dromara.dynamictp(新) |
1.2.2-x | 支持 Boot 3 |
本文环境是 Spring Boot 3.5,必须用 org.dromara.dynamictp,而且版本要带 -x 后缀 (1.2.2-x 这种)。不带后缀的那条线是给 Boot 2 用的。
这个信息在 Maven 中央仓库的搜索结果里看不出来。cn.dynamictp 搜出来也能下,1.1.2 的 jar 也正常,直到启动报错你才发现它不支持 Boot 3。得去翻官方文档才明确。
另外还有个容易混的:starter 分 nacos 和 cloud 两版。
xml
<artifactId>dynamic-tp-spring-boot-starter-nacos</artifactId>
<artifactId>dynamic-tp-spring-cloud-starter-nacos</artifactId>
差别是配置来源:cloud 版走 Spring Cloud 的 Config,nacos 版走独立的 nacos-config-spring-boot-starter。如果只用了 Nacos 的注册中心(discovery)、没启用 Spring Cloud 配置中心,就必须用前者。
第 2 步:加注解,不动业务代码
这一步是整个接入里成本最低的。如果线程池本来就是 @Bean 声明好的,业务代码按名字注入 Executor 类型,那改造只需要在 Bean 方法上加一个注解:
java
@Bean("xxxExecutor")
@DynamicTp // ← 只加了这一行
public Executor xxxExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(50);
executor.setTaskDecorator(new UserContextTaskDecorator());
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}
启动类上加 @EnableDynamicTp,收工。注入点没改、业务代码没改、参数值也没改。
这里有个选择要说明。dynamic-tp 提供了自己的 DtpExecutor 类型,用它也能接。但不必为了"用上框架推荐类型"去换,有两个原因:
- 现有代码注入的是
Executor接口,换成DtpExecutor要动一批注入点;而 Spring 的ThreadPoolTaskExecutor本身就实现了Executor,交给框架管理一样拿到动态能力。 ThreadPoolTaskExecutor上可能还挂着自定义的TaskDecorator(比如把请求线程的上下文传进子线程,像用户身份、TraceId 这类)。保持类型不变,这层语义不用重新验证。
改造的判据是"改动面最小",不是"用上框架的推荐姿势"。
第 3 步:配置(有个必须写的开关)
配置建议写两份:一份在 application.yml 里兜底,一份放配置中心。
yaml
dynamictp:
enabled: true
collector-types: micrometer # 指标走已有的 prometheus 端点
monitor-interval: 5
executors:
- thread-pool-name: xxxExecutor
core-pool-size: 2
maximum-pool-size: 4
queue-capacity: 50
rejected-handler-type: AbortPolicy
auto-create: false # ⚠️ 关键,见下
auto-create: false 必须写。 因为池是 @Bean 已经创建好的实例,框架只需要接管它。不加这行,框架会按配置再新建一个同名的池,两个池抢同一个名字。它是来接管你的池的,不是来替你建池的。
配置中心的文件建议单独放一个 data-id(例如 xxx-app-dtp.yml),不和业务配置混在一个文件里。这样运维调线程池参数时,不需要碰主配置。
还有个 bootstrap.enable: false:
yaml
nacos:
config:
data-ids: xxx-app-dtp.yml
bootstrap:
enable: false # Nacos 不可达时静默降级,不阻塞启动
这个开关的意思是不做启动期预加载 。配置中心挂了,服务该能起来,用 application.yml 里那份兜底配置。口径上是 fail-open:不能因为一个可选的增强组件挂了,导致主服务起不来。
第 4 步:按场景定拒绝策略,别按模板抄
多个池之间最有价值的差异是拒绝策略。举几个典型场景:
| 场景 | core/max/queue | 拒绝策略 | 理由 |
|---|---|---|---|
| 用户交互请求(接口同步等结果) | 2 / 4 / 50 | AbortPolicy | 拒绝后明确告知"繁忙",好过静默排队到超时 |
| 一轮内并行调多个下游 | 2 / 6 / 50 | AbortPolicy | 线程数随并行度变化,需要按峰值给 max |
| 可延迟、可重入的任务(如压缩、归档) | 1 / 1 / 20 | DiscardPolicy | 丢了下次触发会再来,fail-open 即可 |
| 只是"尽快试一次"的触发,真保证在别处 | 2 / 4 / 100 | AbortPolicy | 拒绝不影响正确性,因为正确性由落库 + 定时补偿保证 |
最后一行的模式值得展开:如果一个任务只是"优化触发",正确性由别处(落库 + 定时补偿)兜底,那它的拒绝策略就可以激进地选 Abort,队列也可以给大一点。 因为失败是安全的,多排一会儿没关系。
反过来推:拒绝策略该选什么,取决于"这个任务丢了会不会有人重做"。 有人重做 → Discard 甚至不需要;没人重做 → 只能 Abort,让上游知道。
验证:怎么确认真的接管了
启动日志里有两行是关键证据:
ini
DynamicTp register executor: TpMainFields(threadPoolName=xxxExecutor,
corePoolSize=2, maxPoolSize=4, ..., queueCapacity=50, rejectType=AbortPolicy),
source: beanPostProcessor
DtpRegistry has been initialized,
remote executors: [xxxExecutor, yyyExecutor, zzzExecutor],
local executors: []
两个地方要盯:
source: beanPostProcessor:说明它是通过注解拿到已有 Bean 实例的,走的是"接管已有池"这条路。如果这里显示别的来源,说明框架可能自己建池了。- 参数值逐一比对 :
corePoolSize=2, maxPoolSize=4, queueCapacity=50要和改之前的硬编码值完全一致。这一步是回归的锚点:只搬参数、不改行为,参数对上才算没改坏。
指标方面,collector-types: micrometer 让它复用已有的 /actuator/prometheus 端点,不用另建监控链路。TPS、TP99、队列水位这些从此能在 Grafana 直接查。
四、那个跟线程池无关的坑
dynamic-tp 官方提供了一个 BOM,按惯例导进父 pom 统一管理版本:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.dromara.dynamictp</groupId>
<artifactId>dynamic-tp-dependencies</artifactId>
<version>1.2.2-x</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
这看起来很标准,但服务会起不来:
bash
Caused by: java.lang.NoClassDefFoundError:
com/fasterxml/jackson/databind/cfg/DatatypeFeature
at org.springframework.http.converter.json.Jackson2ObjectMapperBuilder...
DatatypeFeature 这个类是 Jackson 2.15 才引入的。 Tomcat 都还没起来,Spring 创建 formContentFilter 时就炸了。
根因
去翻 dynamic-tp 那个 BOM 的 pom,第 49 行:
xml
<jackson.version>2.13.5</jackson.version>
并且:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
BOM 的 <dependencyManagement> 是"整包覆盖"的。
它不只管自己那几个构件,还顺手把 Jackson、OpenTelemetry、OkHttp 这些公共依赖的版本也写了进去。我一 import,Spring Boot 原本管着的 Jackson 2.21.4 就被 2.13.5 顶掉了。
用 dependency:tree 一看就清楚了:
bash
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
| 状态 | jackson-databind |
|---|---|
| 导入 BOM | 2.13.5 ❌ |
| 不导入 | 2.21.4 ✅ |
配 git stash 做前后对照,能直接坐实"是我这次引入的",不用猜。
修法
不导入 BOM,改成显式声明用到的两个 starter:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.dromara.dynamictp</groupId>
<artifactId>dynamic-tp-spring-boot-starter-common</artifactId>
<version>${dynamic.tp.version}</version>
</dependency>
<dependency>
<groupId>org.dromara.dynamictp</groupId>
<artifactId>dynamic-tp-spring-boot-starter-nacos</artifactId>
<version>${dynamic.tp.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
改完 Jackson 回到 2.21.4,启动正常。
结论可以带走:引入任何第三方 BOM 之前,先翻它的 <dependencyManagement> 看它管了哪些公共依赖。 它对公共库的版本主张,优先级是高于你框架自带的那份的。
以及一条排查经验:"加了个依赖,突然 NoClassDefFoundError"的首要怀疑对象是被降级,而不是缺 jar。 缺 jar 和无故降级报错长得一样,但 dependency:tree 一查就分开。
五、能调了,不代表该调
接入做完之后还有一件事要说清楚,不然前面这些能力容易被用歪。
看到队列堆积、拒绝增加,第一反应通常是"把线程数调大"。但线程池参数之间是有制约的,尤其当任务主要是在等下游(数据库、RPC、缓存)的时候,调大线程数可能是这样的走向:
队列堆积
↓ 调大线程数
下游并发请求增加
↓
下游响应变慢
↓
单个任务执行时间变长
↓
线程被占用更久,队列继续堆积
转了一圈,问题不但没解决,还把下游一起拖下水了。队列堆积经常不是"线程不够",而是"单次处理太慢",这时候加线程只是把压力转嫁给下游。
所以这几个指标要连起来看:队列堆积、拒绝次数、下游 RT。如果队列在堆的同时下游 RT 也在涨,那多半不是线程数的问题,而是任务本身或者下游有瓶颈,加线程只会让它更糟。
动态线程池给的是"能调"的能力,不是"该调"的答案。 什么时候调、调多少,还是得回到指标和业务语义上判断。
六、小结
回头看一下这条路径:
- 原理:第一层 JDK 早给了(那几个 setter),第二层才是框架的价值所在:谁来改、怎么改不出错、改完怎么看。难就难在第二层。自己写不是不能写,是这些坑得一个个趟过去。
- 决策:这块代码里没有一行是"我的业务",监控告警还得从头接,所以选了现成组件。讲原理是为了读接入步骤时知道每一步在解决什么。
- 接入:实际改动就一个注解加一份配置,四步走完,每个池都被框架接管。
真正花时间的是坑,它们的分布很典型:
| 坑 | 类型 | 可迁移的结论 |
|---|---|---|
| 坐标系选错 | 生态认知 | Boot 3 要用 org.dromara.dynamictp + -x 后缀版本 |
| BOM 降级 Jackson | 依赖管理 | 第三方 BOM 会整包覆盖公共库版本;引入前先翻它的 dependencyManagement |
这两条里,坐标系选错是"先踩后知道"的一次性成本:翻过一次官方文档,以后遇到任何要分新旧坐标的库都会先确认。BOM 那条才是真正通用的,你下次引入任何一个提供 BOM 的第三方库,都可能撞上同一个问题。
最后回到第五节的提醒:动态线程池解决的是"参数不再和发版绑在一起",但调参仍然要基于指标判断。 它把线程池从一段写死的代码,变成一个运行期可以观察、可以调整的资源。能观察是前提,能调只是手段。
接入这件事做完,看线程池配置的眼光会变。以前看到一段配置想的是 corePoolSize 配多少、queueCapacity 配多少;现在更该关心的是这个池为什么需要这么多线程、队列为什么会堆、拒绝之后任务去了哪、下游扛不扛得住更高的并发。线程池本身只是个执行任务的工具,真正值得花时间的是让它在运行期可观察、可判断。
边界说明
- 配置中心的文件需要手工上传才会走"远程下发"路径。不上传时走
application.yml兜底,池照常注册,本文的验证就是这条兜底路径。 - "运行时改参数秒级生效"这一条,需要配置中心在线 + 配置已上传才能验证,我没有实测过,所以不写成结论。
- 参数值与改造前完全一致,本次只做"参数从代码搬到配置"。调参是后续动作,不在本文范围内。
- 第一节讲的原理(
VariableLinkedBlockingQueue改容量、调序约束)来自阅读 dynamic-tp 源码与官方资料,不是我自己实现的 ;接入的池用的仍是原生LinkedBlockingQueue,所以队列容量目前是改不了的(启动日志有明确提示)。