一、💡 线程池是什么
上游调 aboss-code 查一批码值,服务要校验参数、读缓存,必要时查数据库,再把结果返回。有人同时在后台导出码表,服务还要查数据、整理文件、上传文件。
这些代码总得有线程来执行。线程池就是把一组线程管起来,让它们不断接任务、做完再接下一个,同时规定忙不过来时怎么办。
拿项目里的码表导出来说,一个线程做完这次导出后,不必马上销毁,下次缓存刷新或导出任务可以继续由它处理。这叫线程复用。它不是给每次导出都无限开新线程,否则请求一多,数据库、内存和 CPU 都可能吃不消。
数据库:同时找它干活的人变多了。
1.queryBatch 获取码表时,如果缓存未命中,就要查数据库。
2.假设很多请求同时没命中缓存,就会同时向数据库发起查询。
3.数据库的处理能力和连接数都有上限。查询来得比完成得快,就会排队;应用拿不到数据库连接,也要等。等待又让请求更晚结束,积压进一步增加。
内存:没处理完的请求,需要一直留着相关数据。
一次批量查询会带着入参列表,处理中还会有查询结果、筛选结果等对象。一次导出还可能把较多码值读进内存,转换成导出数据。
这些数据还在被使用,就不能回收。对象创建得越多,垃圾回收(GC)的工作也可能越多;如果回收速度跟不上分配速度,就会出现内存压力。线程本身也需要栈内存,线程无限增加同样有成本。
CPU:要执行的代码更多了。
项目的 queryBatch 会逐项校验参数、获取码表,再遍历寻找目标 code。同时来的请求越多,这些工作就要重复执行越多次。数据转换、结果序列化、日志处理和 GC 也都需要 CPU。
CPU 核心数有限。线程多了以后,要轮流获得执行机会;线程之间切来切去也有成本,增加线程不等于增加算力。
1.1 线程是什么
线程,就是程序里实际执行代码的一条执行路线。
拿这个项目来说:aboss-code 启动后,是一个运行中的程序,也就是一个进程。进程里有很多线程,负责执行不同的工作,比如查码值、导出文件、消费消息。
假设上游调用 queryBatch,服务会安排一个线程执行这次查询:
一个线程
→ 校验参数
→ 读取码表缓存
→ 缓存未命中时查询数据库
→ 找到需要的码值
→ 返回结果
这些步骤要有人执行,这个“执行者”就是线程。方法是要做什么,线程是沿着这些方法把事情做下去的执行路线。
如果同时来了多个请求,就可以由多个线程分别处理:
aboss-code 进程
├─ 线程 A:处理一次码值查询
├─ 线程 B:处理另一次码值查询
└─ 线程 C:执行码表导出
线程在等数据库返回时,虽然可能没怎么用 CPU,但它手上的请求还没处理完,也就不能回到池里接下一个任务。 很多线程都这样等着,后面的请求就可能积压。
所以这三个概念可以连起来记:
| 概念 | 在项目中的意思 |
|---|---|
| 进程 | 启动起来的 aboss-code 程序 |
| 线程 | 程序中执行查询、导出等代码的执行路线 |
| 线程池 | 管理一组线程,让它们做完任务后继续接下一项工作 |
1.2 线程与任务
在这个项目里,可以这样记:导出一份码表是任务,执行查询和文件上传的那条执行路线是线程。
| 对象 | 放到 aboss-code 里怎么理解 |
|---|---|
| 任务 | 执行一次 BasicCodesExportExe.execute,完成一次导出 |
| 工作线程 | 执行这个任务的线程,例如 Async-Executor-1 |
| 线程池 | 管理这一组工作线程的 taskExecutor |
| 任务队列 | 所有工作线程都忙时,让后续导出、缓存刷新任务先等着 |
可以把它想成办公室:线程是办事的人,任务是待办事项,队列是还没轮到的待办清单。这个比喻只用来理解分工;真实线程会共享 CPU,也可能大部分时间在等数据库或网络。
1.3 为什么用线程池
用户点了导出后,接请求的线程先把导出任务交给后台线程。交出去以后,它就可以结束这次请求,去处理其他任务;后台线程继续负责把文件生成并上传。
这里弄个简单的例子
比如你在页面点了一下“导出码表”,请求到了项目的 CodeFacadeImpl.exportCodes(接口)。
假设负责处理这个请求的是线程 A。如果所有工作都让 A 做:
线程 A:
接到导出请求
→ 查数据库
→ 整理导出数据
→ 上传文件
→ 告诉页面处理结果
如果整个过程需要 15 秒,A 就要负责到这 15 秒结束。期间其他请求可以由其他线程处理,但 A 自己还没空接下一项任务。
项目现在把工作分给了另一个线程。关键代码是:
// 这个方法带有 @Async("taskExecutor")。
// 正常情况下,Spring 会把导出任务交给后台线程池。
basicCodesExportExe.execute(basicCodesExportCmd);
// 当前请求可以先返回 true。
resultModel.setData(true);假设后台线程池安排线程 B 做导出:
线程 A:接到请求 → 把导出任务交出去 → 返回 true → 可以接下一项任务
↓
线程 B: 查数据库 → 整理数据 → 上传文件 → 更新操作状态
A 提前返回的意思是“导出任务已经交办”,不是“文件已经导出完成”。 文件可能还在由 B 生成,最终成功还是失败,要看后台任务更新的操作状态。
基础概念可以接着看 Java/基础/线程。下面我们就沿着“上游查码值”和“用户导出码表”两条真实路径往下看。
1.4 一个 Pod 里完全可以有多个线程池
一个 Pod
└─ aboss-code 的 Java 进程
├─ RPC 线程池
│ ├─ 工作线程
│ └─ 工作线程……
│
├─ 业务异步线程池 taskExecutor
│ ├─ Async-Executor-1
│ └─ Async-Executor-2……
│
└─ 其他线程,例如 GC 线程、消息消费线程等
Pod 是部署程序的运行环境,可以有多个线程池。 线程池是在 Java 程序内部创建和管理的,一个程序可以根据用途创建多个池。
不过,多个池仍然共享这个 Pod 可用的 CPU、内存,以及应用使用的数据库等资源。 分成不同池,能避免所有任务直接抢同一批工作线程,但不会凭空增加机器的处理能力。
比如后台导出把自己的 10 个线程全占了,RPC 池可能还有空闲线程;但如果导出同时把 CPU 或数据库连接占得很紧,RPC 查询仍然可能变慢。
二、🧭 项目中的线程池
2.1 RPC 请求处理
项目通过 SOFA Bolt 暴露远程接口。比如 CodeRPCFacadeImpl 上就有下面这段声明,节选自现有代码:
// @SofaService:把这个实现暴露为远程服务。
// bindingType = "bolt":上游通过 Bolt 协议调用。
@SofaService(interfaceType = CodeRPCFacade.class,
bindings = {@SofaServiceBinding(bindingType = "bolt")})上游请求进来时,有一组线程负责执行 RPC 查询;项目后台导出接口又用了另一组线程。(这是俩组不同的线程池)
上游查码值
→ RPC 框架安排线程
→ 执行 queryBatch
→ 返回查询结果
用户导出码表
→ RPC 框架安排线程接收请求
→ 将导出任务提交给 taskExecutor
→ 后台线程继续执行导出
2.2 业务异步任务
项目在 AsyncConfig.java 创建了一个名叫 taskExecutor 的 Bean。下面是配置节选,便于对照阅读:
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程:随任务到来逐步创建,正常情况下主要由这组线程接活。
executor.setCorePoolSize(5);
// 队列放不下时允许继续增加线程,但池内最多到 10 个。
executor.setMaxPoolSize(10);
// 暂时轮不到执行的任务,最多可以在这里等 100 个。
executor.setQueueCapacity(100);
// 抓线程快照时,可以用这个前缀找到业务异步池。
executor.setThreadNamePrefix("Async-Executor-");
// 池和队列都接不住时,让提交任务的线程自己执行。
executor.setRejectedExecutionHandler(
new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());哪些业务会用它?看方法上的 @Async("taskExecutor") 就能找到:
| 项目中的类或方法 | 交给后台做什么 |
|---|---|
BasicCodesExportExe.execute | 查询码表码值、整理导出数据、上传文件、更新操作状态 |
ImportAreasDataExe.execute | 导入地区数据 |
ExportAreasDataExe.execute | 导出地区数据 |
SyncCodesCacheByTypesExe.execute | 按类型刷新缓存 |
SyncAssociationCodeCacheExe.execute | 刷新关联关系缓存 |
CodeAssociationGatewayImpl 中多处异步方法 | 执行相应关联关系的后台处理 |
它们共享这个池。 不是导出有 10 个线程、刷缓存另有 10 个线程,而是这些任务一起使用最多 10 个池内线程。
2.3 配置是否生效
application.properties 还写着另一组值:核心 10、最大 200、队列 1000。但刚才的 AsyncConfig 直接写了 5、10、100,并没有读取这组属性。
所以,就当前代码创建的 taskExecutor 而言,修改配置文件里的 200,不会把它变成 200 个线程。 是否有外部依赖使用这些属性,还没有确认;也不能据此说 RPC 池就是 200。
项目还有 MQ 消费、调度任务各自的执行资源,例如数据中台消息消费者设置了消费线程数 1。它们与这次的重点不同,排查时只要记住:一个 Java 服务里通常不止一个池。
三、⚙️ 请求处理流程
3.1 批量查询
假设上游要查询 3 组 type + code,一次性调用 queryBatch。这里的 3 组只是示例输入,接口实际接收一个列表。
上游调用 CodeRPCFacade.queryBatch
→ SOFA RPC 接收并安排执行
→ CodeRPCFacadeImpl.queryBatch 校验这批参数
→ BasicCodeBatchQryExe.execute 逐项处理
→ 根据 type 获取码表列表
→ 从列表中寻找所需 code
→ 收集查到的结果
→ 返回这一批结果BasicCodeBatchQryExe 里用的是普通 stream().forEach,不是并行流,也没有 @Async。所以这里不会“3 组参数就分配 3 个异步线程”,而是在这次调用中逐项处理。
获取码表时会经过缓存逻辑。BasicCodeGatewayImpl.queryCacheByType(String) 配置了缓存注解;缓存命中可以复用结果,未命中时方法体会按类型查数据库。有 status 参数时,还会对取得的列表过滤。
这条路径上,业务处理期间执行请求的线程一直被占用:在计算时被占用,在等待数据库或缓存响应时也被占用。等这次处理结束,它才有机会接下一件事。
回到上游的报错
如果上游报的是查询 RPC 繁忙,首先要沿这条查询链看线程卡在哪。仅仅看
Async-Executor-*是否忙,可能看错了池。
3.2 异步导出
导出入口是 CodeFacadeImpl.exportCodes。它在业务处理回调中调用另一个 Bean:
// basicCodesExportExe 是注入的 Spring Bean。
// execute 上有 @Async("taskExecutor"),通过代理调用后交给指定执行器。
basicCodesExportExe.execute(basicCodesExportCmd);
// 设置接口返回值。这里的 true 不等于文件已生成成功。
resultModel.setData(true);
resultModel.setStatus(Status.S.getValue());这是现有方法内部的节选,参数和 resultModel 都来自外层方法。正常提交成功后,可以把流程理解成两条路线:
接请求的线程:校验参数 → 提交导出任务 → 返回 true
↓
后台工作线程:记录处理中 → 查码表和码值 → 整理数据
→ 上传文件 → 更新成功或失败状态所以,接口返回成功与导出任务最终成功是两件事。 导出过程中上传失败,后台代码还会记录失败状态;这时最初的接口可能已经返回了。
@Async 的作用也有前提:调用要经过 Spring 异步代理。这里是一个 Bean 调用另一个 Bean;如果改成同一个对象内部直接调自己的方法,默认代理模式通常不会触发异步。
3.3 任务分配
现在假设后台来了很多导出和缓存刷新任务,而且前面的任务一直没有结束。按照这个项目的 5 / 10 / 100 配置,过程是:
| 提交到哪一步 | 池里发生什么 |
|---|---|
| 第 1~5 个任务 | 逐步创建 5 个核心线程,开始执行 |
| 第 6~105 个任务 | 进入队列,最多排 100 个 |
| 第 106~110 个任务 | 队列已满,继续增加线程,池内最多到 10 个 |
| 第 111 个任务 | 池和队列都接不住,进入 CallerRunsPolicy |
这张表有一个必要前提:过程中没有任务完成,也没有队列位置被腾出来。 真实线上任务不断完成、入队和出队,不会永远按这个编号发展。
还要注意:核心线程不是指“此刻正在忙的线程数”。已有工作线程空闲时可以领取排队任务,通常也不会在一有请求时就直接创建到最大值。
3.4 饱和后的处理
项目选的策略叫 CallerRunsPolicy,翻成大白话就是:“你提交的,你自己做吧。”
如果这次是接收导出 RPC 的线程在提交任务,任务就可能直接由它执行。于是本来只负责交办的线程,也要亲自查数据、整理文件、等待上传,直到这段任务结束,调用才能继续往下走。
这就解释了一个看起来奇怪的现象:平时导出接口很快返回,忙的时候却突然等很久。它可能已经从“交给后台做”变成“提交者自己做”。是否确实如此,需要看现场堆栈,不能只凭耗时变长断定。
按照 JDK 规则,这个策略在池已关闭时会丢弃被拒绝任务,因此也不能把它理解成任何情况下都保证执行。
四、🔎 线程池为什么会满
4.1 线程被长期占用
假设导出线程正在等文件上传返回。它此时可能没怎么用 CPU,但仍然没办法接下一个缓存刷新任务。其他线程也各自等着数据库、缓存或网络,后来的任务就只能排队。
所以“CPU 不高,怎么还会线程池满”并不矛盾。要看的其实是:线程什么时候才能把手里的任务做完、腾出来。
请求多也好,任务变慢也好,只要在一段时间内进来的工作持续超过完成的工作,就会积压。
4.2 批量查询变慢
结合当前代码,可以具体检查这几个地方:
- 一批参数多。 入口逐项校验,业务也逐项查码值。批次变大,一次调用要完成的工作通常随之增加。
- 同一个 type 重复出现。 当前代码每个条目都会调用码表获取逻辑并筛选。即使缓存命中,也仍有读取和遍历成本;不能把重复调用直接当成重复查数据库。
- 缓存或数据库变慢。 未命中要查数据库;命中了也要考虑实际缓存读取、转换等耗时。是否发生需要监控证明。
- 附带 status 过滤,或码表列表较大。 过滤和逐项寻找也会花时间,是否构成瓶颈取决于数据规模和现场堆栈。
这些是从代码找出的排查方向,不是已经确认的事故根因。假如一个查询原来很快,后来因为这些因素迟迟不返回,就会有越来越多请求占着 RPC 线程,后面的请求只能等待或被框架拒绝。
RPC 池到底能排多少、何时拒绝,取决于它自己的生效配置。上一节的“第 111 个任务”只是在解释业务异步池,不能直接套在查询 RPC 上。
4.3 后台任务积压
BasicCodesExportExe 中,一次任务要查数据、转换对象、补充数据、调用文件服务上传,最后更新操作状态。在这些步骤结束前,工作线程仍被这次任务占用。
如果碰上多次导出或地区导入,又有人触发缓存刷新,任务会一起挤进 taskExecutor。前面的长任务没有做完,后面的短任务也要等。
接下来就可能出现:
导出、导入或缓存刷新变慢
→ 任务占用线程更久
→ 等待队列积压到 100
→ 工作线程扩到 10,仍然忙不过来
→ 后续提交者自己执行任务
→ 提交这些任务的请求也可能变慢这是一条代码机制允许出现的路径。它不代表 queryBatch 本身使用了这个异步池,也不证明历史 RPC 故障就是它引起的。
4.4 如何定位
Java/工作实践/压测下RPC接口大量失败排查记录 中,2026-10-28 的调用方日志记录了:
InvokeServerBusyException: Server thread pool busy!
被调用方:midaboss-code
方法:CodeRPCFacade.queryBatch
目标:10.197.136.170:13305这份历史证据指向当时目标实例的 RPC 服务端繁忙。对应排查顺序是:先找到那台 code 实例,再看查询线程正在做什么。当前报错是否同一类,需要用完整异常、时间和目标地址对照。
实际排查时,我会先拿这几样东西:
| 先拿到什么 | 用来回答什么问题 |
|---|---|
| 完整异常、接口方法、时间、traceId、目标实例 | 到底是谁的哪个池报忙 |
| 同一实例间隔采集的几份线程快照 | 线程是卡在查询、数据库、上传还是锁上 |
| 对应线程池的活跃数、队列长度、完成速度 | 是瞬间忙一下,还是持续堆积 |
| 同期请求量、批量条数、缓存/数据库耗时、GC | 为什么这一段时间突然处理不过来 |
对 taskExecutor 而言,如果活跃线程接近 10、队列接近 100,而且持续不下降,就很值得追。RPC 池要看它自己的容量和指标。线程数量、活跃数等都是动态观测值,单个快照不能说明整个故障过程。
别只凭 WAITING 就说线程卡死:工作线程在等新任务时也会是等待状态。要看它在等队列里的新活,还是业务执行到一半在等别的资源。
下次再看到“线程池打满”
先沿这条线问下去:哪一个池?哪些任务占着线程?这些任务为什么还没结束? 回到项目的具体调用链,才知道该优化查询、处理慢调用、拆开长短任务,还是评估扩容。
五、🛠️ 实践验证
5.1 准备与用途
下面的独立实验只需要 JDK 8 或以上,不依赖 Spring、数据库和业务项目。用途是亲眼看见“池满后,提交任务的线程会自己执行”。配置缩小为 1 / 2 / 1,方便快速观察;它不模拟 SOFA RPC 的容量或拒绝规则。
现场诊断则需要对应实例的访问权限、真实 Java 进程号,以及匹配的 JDK 工具。项目是否已有线程池监控接口尚未确认,不能直接假定某个监控地址可访问。
5.2 本地复现
把以下代码保存为本地实验目录中的 PoolSaturationDemo.java。为了快速观察,将配置缩小为核心 1、最大 2、队列 1;机制与项目有界队列相同。
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class PoolSaturationDemo {
public static void main(String[] args) throws InterruptedException {
// 让池内任务暂时无法结束,稳定构造“线程忙、队列满”。
CountDownLatch release = new CountDownLatch(1);
AtomicInteger sequence = new AtomicInteger();
ThreadPoolExecutor pool = new ThreadPoolExecutor(
1, 2, 60, TimeUnit.SECONDS,
// 有界队列:这里只允许一个任务排队。
new ArrayBlockingQueue<Runnable>(1),
// 给工作线程起名,便于和提交任务的 main 线程区分。
task -> new Thread(task, "demo-worker-" + sequence.incrementAndGet()),
// execute 无法提交时,直接在调用线程执行。
new ThreadPoolExecutor.CallerRunsPolicy());
Runnable blockingTask = () -> {
System.out.println("池内任务开始:" + Thread.currentThread().getName());
try {
// await:等待 main 释放,期间任务占用工作线程。
release.await();
} catch (InterruptedException e) {
// 保留中断状态,让上层仍能知道线程被要求中断。
Thread.currentThread().interrupt();
}
};
try {
pool.execute(blockingTask); // 第一个任务创建核心线程。
pool.execute(blockingTask); // 第二个任务进入唯一的队列位置。
pool.execute(blockingTask); // 队列满,第三个任务创建第二个线程。
System.out.printf("提交第四个任务前:pool=%d, queue=%d%n",
pool.getPoolSize(), pool.getQueue().size());
// 第四个任务不等待 release,避免 main 自己阻塞而无法释放任务。
pool.execute(() -> System.out.println(
"第四个任务执行线程:" + Thread.currentThread().getName()));
System.out.println("第四个任务结束后,execute 才返回");
} finally {
// countDown:放行阻塞的任务,让排队任务也能完成。
release.countDown();
// shutdown:停止接收新任务,但继续执行已提交任务。
pool.shutdown();
// awaitTermination:等待工作线程退出;超时则发出中断请求。
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
}
System.out.println("池内完成任务数:" + pool.getCompletedTaskCount());
}
}进入保存代码的目录,编译并启动:
# -encoding UTF-8:确保中文注释和输出字符串正确编译。
javac -encoding UTF-8 PoolSaturationDemo.java
# 本地验证输入就是 main 中连续提交的四个任务,无需 HTTP 请求。
java PoolSaturationDemo关键预期结果如下,工作线程输出的先后顺序可能变化:
提交第四个任务前:pool=2, queue=1
第四个任务执行线程:main
第四个任务结束后,execute 才返回
池内完成任务数:3第四个任务由池外的 main 完成,不计入线程池自身的完成任务数。因此监控仅统计池内完成量时,可能漏看调用线程承担的工作。
若提示找不到 javac,检查是否安装完整 JDK;若提示找不到主类,确认位于 .class 所在目录。若自己改代码后一直不退出,检查是否让第四个任务也等待 release,导致负责放行的 main 被卡住。
5.3 现场排查
如果你拿到了目标实例的诊断权限,可以在对应主机或容器内采线程快照。进程号要替换成真实值,容器内外的 PID 可能不同:
# 查看当前环境中的 Java 进程。
jcmd -l
# 示例 12345 需要替换;输出线程调用栈及锁信息。
jcmd 12345 Thread.print -l > /tmp/aboss-code-threads-1.txt
# 间隔采集,判断同一批线程是否一直停在相似位置。
sleep 10
jcmd 12345 Thread.print -l > /tmp/aboss-code-threads-2.txt
sleep 10
jcmd 12345 Thread.print -l > /tmp/aboss-code-threads-3.txt看业务池时找 Async-Executor-*;看 RPC 池时以现场真实线程名和调用栈为准。采集会有短暂诊断开销,不需要不停地抓。
如果很多线程都停在 BasicCodeBatchQryExe 这条链上,就接着看批次和码表处理。如果都在等数据库连接,就对照连接池等待数和获取连接耗时。如果都在文件上传,就沿文件服务查。线程快照给你的是“现在停在哪”,还要结合多次快照和监控解释“为什么停这么久”。
六、📚 代码与资料
本篇按 2026-11-04 工作区代码整理。原始创建时间保留在文件属性中。项目声明 Spring Boot 2.1.13.RELEASE、SOFA Boot 3.4.20,部署版本及配置需与实际实例核对。
在 /Volumes/data/ideaWorkSpace/aboss-code 中,可以按类名直接搜索:
| 想接着看什么 | 找哪个文件 |
|---|---|
| 业务异步池怎么建 | AsyncConfig.java |
| 上游批量查询入口 | CodeRPCFacadeImpl.java 的 queryBatch |
| 每个查询参数怎么处理 | BasicCodeBatchQryExe.java |
| 缓存获取、未命中查询、status 过滤 | BasicCodeGatewayImpl.java |
| 导出入口怎样提交任务 | CodeFacadeImpl.java 的 exportCodes |
| 导出任务具体做什么 | BasicCodesExportExe.java |
| 哪些方法共享异步池 | 搜索 @Async("taskExecutor") |
线程规模、排队、扩线程、拒绝处理的顺序参考 JDK 文档;Spring 文档补充解释项目的异步注解和代理。以下为此前实际查阅过的官方资料,本次沿用其中与当前代码有关的机制:
- JDK 8 ThreadPoolExecutor:核心线程、最大线程、有界队列和任务统计。
- JDK 8 CallerRunsPolicy:提交者执行,以及关闭后的处理。
- Spring Framework 5.1.14 异步执行:
@Async、执行器选择、代理调用限制。
@Async 的配置、调用过程及独立示例,见 如何使用@Async实现异步导出。