Spring 线程池异步任务治理实践
Spring 线程池异步治理实践在后端项目里线程池和异步任务很常见。很多时候我们只是把一个耗时方法加上Async或者随手写一个CompletableFuture.runAsync()功能看起来也能跑。但真正到了复杂业务链路里异步不是简单地“换个线程执行”。如果 OCR、PDF 解析、业务计算、失败重试、通知发送都混在一起跑线程池很容易从“提升性能的工具”变成“问题放大器”任务互相抢资源、队列堆积、接口被拖慢甚至出现线程饥饿和死锁。这篇文章就结合一次项目里的异步任务治理聊聊 Spring 线程池、Async和CompletableFuture在实际业务中应该怎么用。为了方便公开表达文中的类名、接口名和部分业务名称都做了脱敏处理只保留核心技术设计。一、为什么这个项目需要异步化项目里有几类典型的耗时任务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. 通知和低优先级后台任务线程池比如邮件通知、风险同步、刷新类任务可以使用独立的小线程池。这类任务通常不应该回退到主接口线程执行否则会影响接口响应。四、Async和CompletableFuture分别解决什么问题很多人会把Async和CompletableFuture混在一起说但它们解决的问题不一样。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 负责任务内部编排 多组线程池负责资源隔离 拒绝策略负责反压和保护 状态与监控负责可恢复和可观测线程池真正的价值不是让任务“并发起来”而是让耗时链路在高峰和异常情况下依然可控。