Spring 线程池异步治理实践

在后端项目里,线程池和异步任务很常见。很多时候,我们只是把一个耗时方法加上 @Async,或者随手写一个 CompletableFuture.runAsync(),功能看起来也能跑。

但真正到了复杂业务链路里,异步不是简单地“换个线程执行”。

如果 OCR、PDF 解析、业务计算、失败重试、通知发送都混在一起跑,线程池很容易从“提升性能的工具”变成“问题放大器”:任务互相抢资源、队列堆积、接口被拖慢,甚至出现线程饥饿和死锁。

这篇文章就结合一次项目里的异步任务治理,聊聊 Spring 线程池、@AsyncCompletableFuture 在实际业务中应该怎么用。

为了方便公开表达,文中的类名、接口名和部分业务名称都做了脱敏处理,只保留核心技术设计。


一、为什么这个项目需要异步化

项目里有几类典型的耗时任务:

PDF 加载、分页和渲染
OCR 识别
图纸相关业务计算
失败重试
风险类任务同步
邮件或通知发送

这些任务有一个共同特点:耗时不可控。

比如 PDF 处理可能受文件大小、页数、图片渲染影响;OCR 可能依赖外部服务或模型接口;业务计算有时也会随着图纸复杂度增加而变慢。

如果这些逻辑全部同步执行,会带来几个问题。

第一,接口响应时间变长。用户只是上传文件或触发处理,但请求线程可能一直等到 OCR、计算、通知都完成后才返回。

第二,Web 容器线程会被长期占用。请求线程本来应该快速处理请求,如果被大文件处理和外部调用占住,高峰期很容易把接口拖慢。

第三,不同任务会互相影响。一个大 PDF 任务可能占满线程,导致 OCR 任务排队;一批失败重试任务可能挤占正常用户任务的资源。

所以这个项目引入线程池,不是为了“用异步显得高级”,而是为了把耗时任务从请求线程中剥离出来,并且按任务类型做资源隔离。


二、线程池不是越少越好:为什么要拆成多组

很多项目一开始会定义一个公共线程池,所有异步任务都往里面丢。

这种方式简单,但问题也很明显。

不同任务的资源特征不一样:

OCR:更偏 I/O 等待,外部调用耗时不稳定
PDF 渲染:更偏 CPU 和内存消耗
业务计算:可能持续时间较长
失败重试:优先级低,不应该影响正常任务
通知发送:量通常不大,但不能阻塞主链路
风险同步:耗时长,适合后台执行

如果所有任务共用一个线程池,会出现一个典型问题:某一类任务高峰把池子打满,其他任务全部跟着排队。

比如失败重试任务突然堆积,本来是补偿逻辑,却把正常用户的新任务卡住了。再比如大 PDF 任务占满线程池,OCR 任务和业务计算任务只能等待,整体链路就会被放大拖慢。

所以这里的设计思路是:

按任务类型拆池
按优先级隔离
按业务影响面配置拒绝策略

也就是说,不是“项目里有一个线程池”,而是根据不同任务的资源特点和业务优先级,拆成多组线程池。


三、线程池大致怎么拆

脱敏后可以把线程池分成几类。

1. OCR 线程池

负责正常 OCR 识别任务。

这类任务通常依赖外部识别能力,耗时不稳定,但又是主业务链路的重要步骤。所以它需要独立线程池,避免被 PDF 渲染、重试任务或通知任务影响。

2. 计算线程池

负责 OCR 之后的业务计算。

这样做是为了避免 OCR 和计算互相抢线程。OCR 负责识别,计算负责后续处理,两类任务生命周期不同,最好分开治理。

3. PDF 主任务线程池

负责 PDF 的主流程协调,比如加载 PDF、读取页数、拆分批次、调度页面任务。

它更像一个“协调者”,不应该和页面级子任务混在同一个小池子里。

4. PDF 页面任务线程池

负责每一页的实际处理,比如渲染图片、生成页面记录、上传中间结果等。

PDF 主任务和页面子任务拆成两个池,是这次设计里比较关键的一点,后面会单独说。

5. 重试线程池

失败重试任务单独拆池。

重试任务本质上是补偿逻辑,优先级应该低于正常用户任务。如果和正常任务共用线程池,失败越多,越可能拖慢新请求,最后形成恶性循环。

6. 通知和低优先级后台任务线程池

比如邮件通知、风险同步、刷新类任务,可以使用独立的小线程池。

这类任务通常不应该回退到主接口线程执行,否则会影响接口响应。


四、@AsyncCompletableFuture 分别解决什么问题

很多人会把 @AsyncCompletableFuture 混在一起说,但它们解决的问题不一样。

1. @Async:方法级异步

@Async 更适合把一个完整方法丢到指定线程池执行。

比如:

上传接口触发 PDF 处理
  ↓
主接口快速返回
  ↓
PDF 处理方法在后台线程池执行

它解决的是“不要让当前调用线程继续等”。

适合场景:

整个方法都应该异步执行
调用方不需要立即拿到结果
任务可以通过状态表或回调更新进度

2. CompletableFuture:方法内部的并行编排

CompletableFuture 更适合在一个大任务内部拆分多个子任务。

比如 PDF 已经进入后台处理,但里面有 50 页,每一页都可以独立渲染:

后台 PDF 主任务
  ↓
第 1 页子任务
第 2 页子任务
第 3 页子任务
...
  ↓
等待本批页面处理完成
  ↓
汇总成功页和失败页

这时就可以用 CompletableFuture 做局部并行和结果聚合。

一句话概括:

@Async 解决方法级异步
CompletableFuture 解决方法内部的任务编排

项目里比较常见的组合是:

先用 @Async 把大任务丢到后台线程池
再在大任务内部用 CompletableFuture 拆分子任务并行执行

五、PDF 主任务和子任务为什么要拆池

这是线程池设计里最容易被忽略,但也最值得讲的点。

PDF 处理一般会有一个主任务:

加载 PDF
读取总页数
按批次切分页面
提交页面子任务
等待页面子任务完成
更新处理进度

而页面子任务负责真正的处理:

渲染单页
生成图片
保存页面记录
更新单页状态

如果主任务和子任务共用同一个线程池,就可能出现线程饥饿问题。

假设线程池很小,主任务已经把线程占满了,每个主任务又都在等待自己提交的页面子任务完成。

但页面子任务还在队列里,拿不到线程执行。

于是就变成:

主任务占着线程等待子任务
子任务排队等待线程
线程池没有空闲线程

这就是典型的线程饥饿型死锁。

所以这里把 PDF 主任务线程池和页面任务线程池拆开:

PDF 主任务线程池:负责协调和等待
PDF 页面任务线程池:负责实际页面处理

这样主任务就算在等待,也不会占用子任务执行所需的线程资源。

这个设计比单纯说“我们用了线程池并发处理 PDF”更有工程价值。


六、重试任务为什么要单独拆池

失败重试任务看起来也是异步任务,但它和正常任务的优先级不同。

正常任务代表用户当前正在等待的处理结果;重试任务更多是补偿历史失败数据。

如果重试任务和正常任务共用线程池,可能会出现:

某一批历史失败任务开始重试
  ↓
线程池被重试任务占满
  ↓
正常用户提交的新任务只能排队
  ↓
用户体验变差

所以重试任务应该使用独立线程池。

这背后的原则是:

补偿任务不能反过来拖垮主链路

重试可以慢一点,但不能影响新任务。


七、拒绝策略为什么不能一刀切

线程池满了以后,拒绝策略怎么选很关键。

项目里主要使用两类策略:

CallerRunsPolicy
AbortPolicy

它们适合的场景不一样。

1. 主链路任务:更适合 CallerRunsPolicy

像 OCR、PDF、业务计算这类任务,通常不能静默丢弃。

如果线程池和队列都满了,让提交任务的线程自己执行,相当于给上游加了一层反压。

也就是说:

系统忙不过来时
提交方会被拖慢
任务不会直接丢

这适合结果比响应速度更重要的场景。

当然,CallerRunsPolicy 也不是万能的。如果调用线程是接口线程,就可能导致接口变慢。所以它适合用在可以接受反压的链路上,而不是所有地方都使用。

2. 通知类、提交类任务:更适合 AbortPolicy

有些任务不希望回退到当前调用线程执行。

比如通知发送、后台刷新、异步同步任务提交。如果线程池满了,还让当前接口线程自己执行,可能会把接口一起拖慢。

这类场景更适合直接拒绝,然后由上层明确处理:

记录失败
返回稍后重试
写入补偿表
触发兜底任务

也就是说:

CallerRunsPolicy 更偏反压
AbortPolicy 更偏保护调用线程

拒绝策略不是统一标准答案,而是要结合任务是否允许丢、是否能补偿、是否能阻塞当前线程来判断。


八、异步任务不能只提交,还要能看见运行状态

线程池一旦用起来,就必须有监控。

否则任务堆积时,开发只会看到“接口慢了”或者“任务没处理完”,但不知道具体是哪里堵住了。

至少要能看到这些信息:

核心线程数
最大线程数
当前活跃线程数
当前线程池大小
队列容量
当前队列长度
已完成任务数

这些指标能帮助判断:

  • 是线程数不够;

  • 是队列堆积;

  • 是任务执行太慢;

  • 是某类任务突然高峰;

  • 还是线程池参数配置不合理。

如果再进一步,可以补充:

任务拒绝次数
任务平均等待时间
任务平均执行耗时
任务最大执行耗时
队列峰值水位

这样线程池才不是一个黑盒。


九、异步链路里的几个常见坑

1. @Async 同类内部调用不生效

@Async 依赖 Spring 代理。

如果在同一个类里面直接调用另一个 @Async 方法,可能不会经过代理,异步就不会生效。

解决方式通常是:

把异步方法拆到独立组件
通过 Spring Bean 调用
避免 this.xxx() 自调用

这也是为什么很多项目里会单独拆一个异步处理组件,而不是把所有逻辑都塞在一个 service 里。

2. CompletableFuture 不指定线程池会走公共池

如果写:

CompletableFuture.runAsync(() -> {
    // do something
});

没有传入自定义 executor,默认会使用 ForkJoinPool.commonPool()

这会带来几个问题:

线程资源不可控
不在业务线程池监控范围内
无法按业务隔离
高峰时可能和其他任务互相影响

所以更推荐写成:

CompletableFuture.runAsync(() -> {
    // do something
}, customExecutor);

这样线程池资源才在自己的治理范围内。

3. 异步异常不能只打印日志

异步任务异常不会像同步接口那样直接返回给用户。

如果只是打印日志,数据库状态可能一直停留在处理中,后续无法重试,也无法判断任务是否失败。

更合理的方式是:

捕获异常
记录失败原因
更新任务状态
保留重试上下文
必要时触发告警

异步任务一定要有状态兜底。


十、为什么没有一开始就上消息队列

有人可能会问:既然是异步任务,为什么不用 MQ?

这个问题要看任务特点。

线程池更适合:

单服务内部异步
任务和当前进程上下文关系较强
任务链路相对短
需要低成本快速改造

消息队列更适合:

跨服务解耦
削峰填谷
任务需要持久化排队
消费者可以独立扩缩容
需要更强的失败重试和投递保障

在这个项目阶段,很多任务和本地文件、PDF 对象、当前服务上下文关系比较紧,先用 Spring 线程池完成异步拆分和资源隔离,改造成本更低。

如果后续任务量继续增长,或者需要跨服务消费,再把部分链路拆到 MQ 会更合理。

技术选型不是“线程池和 MQ 谁更高级”,而是看当前复杂度和收益是否匹配。


十一、这套线程池治理带来的价值

这次异步治理真正解决的不是“让代码异步执行”,而是几个更具体的问题。

1. 请求线程不再承担长耗时任务

上传、触发、提交类接口可以更快返回,真正耗时的逻辑交给后台线程池。

2. 不同任务之间资源隔离

OCR、PDF、计算、重试、通知各自使用不同线程池,某一类任务高峰不会轻易拖垮其他链路。

3. 大任务内部可以局部并发

通过 CompletableFuture 对页面级任务、识别任务做局部并行,提高整体吞吐。

4. 避免主子任务共池导致线程饥饿

PDF 主任务和页面子任务拆池,避免协调任务占着线程等待子任务时产生死锁风险。

5. 失败重试不会影响正常任务

重试任务独立拆池,保证补偿逻辑不会挤占正常用户任务资源。

6. 运行状态可观测

通过线程池运行指标,可以看到活跃线程数、队列长度、完成任务数等信息,为后续调参提供依据。


十二、还有哪些可以继续优化

这套方案已经能解决大部分异步任务互相影响的问题,但并不是终点。

后续还可以继续完善几个方向。

1. 收口所有默认公共线程池调用

项目里如果仍然存在没有指定 executor 的 CompletableFuture,应该逐步收口到自定义线程池中。

这样才能保证所有异步任务都在可控范围内。

2. 补充更细的监控指标

除了活跃线程数和队列长度,还可以增加:

拒绝次数
任务等待耗时
任务执行耗时
队列峰值
线程池水位告警

这样可以更早发现任务堆积和线程池容量不足。

3. 增强异步 trace

异步任务跨线程执行后,日志链路很容易断。

后续可以统一传递:

traceId
requestId
taskId
业务单号

这样从接口提交到后台执行,再到最终结果更新,都能串成一条链路。

4. 部分任务演进到 MQ

如果后续任务量继续增长,可以把一些低优先级、可延迟、需要更强可靠性的任务迁移到 MQ。

例如:

通知发送
失败补偿
批量刷新
跨服务同步

线程池和 MQ 可以共存,不需要二选一。


十三、总结

这次线程池治理最大的体会是:异步不是简单地换一个线程跑。

真正有价值的异步设计,至少要回答几个问题:

哪些任务应该异步?
不同任务是否应该共用线程池?
任务高峰时谁优先?
线程池满了以后应该反压还是拒绝?
异步任务失败后状态怎么恢复?
运行时怎么监控线程池是否健康?

如果这些问题没有想清楚,只是简单加一个 @Async,系统可能短期看起来更快,但高峰期会变得更不可控。

在这个项目里,最终形成的思路是:

@Async 负责方法级异步
CompletableFuture 负责任务内部编排
多组线程池负责资源隔离
拒绝策略负责反压和保护
状态与监控负责可恢复和可观测

线程池真正的价值,不是让任务“并发起来”,而是让耗时链路在高峰和异常情况下依然可控。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐