Spring 异步任务把线程池打满后,我是怎么把接口延迟降下来的
Spring 异步任务把线程池打满后,我是怎么把接口延迟降下来的
线上接口突然从 80ms 抬到 3s,表面看是“异步任务变慢了”,实际上是线程池设计把整个应用拖下水了。
这类问题在业务里非常常见:下单后发通知、导出报表、批量同步第三方、补偿任务扫表,这些看上去都适合扔进 @Async。问题是,一旦异步任务和核心请求共用资源,异步就不再是“隔离”,而是“放大器”。
事故现场
当时的场景是一个 B2B 订单系统。用户提交订单后,主流程里会异步触发三类任务:
- 发站内消息
- 调 ERP 同步库存
- 写审计日志
代码很典型:
@Async
public void syncOrder(Order order) {
erpClient.push(order);
messageService.send(order);
auditService.record(order);
}
一开始系统流量不高,这么写没问题。后来运营搞活动,订单峰值从每分钟几百涨到几千,主接口开始超时。应用没有直接报错,只是:
- Tomcat 工作线程占满
- 数据库连接池频繁借不到连接
@Async线程池队列堆积到几万- GC 次数明显变多
问题不在“异步任务慢”,而在“异步任务积压后,反向挤压了主链路资源”。
为什么会这样
很多人以为把逻辑塞进线程池,就等于隔离了风险。实际上线程池只是换了一种调度方式,不会凭空创造资源。
当异步任务处理速度跟不上生产速度时,会先出现两个现象:
- 队列开始堆积
- 每个任务占用的下游资源不释放,比如数据库连接、HTTP 连接、对象内存
如果线程池配置又是下面这种:
executor.setCorePoolSize(16);
executor.setMaxPoolSize(64);
executor.setQueueCapacity(50000);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
问题会被隐藏得很深。
大队列不是缓冲,是延迟仓库
队列容量 50000 看起来“保险”,其实是在把问题延后暴露。
比如 ERP 接口平均处理 300ms,峰值流量每秒打进来 800 个任务,而线程池真实吞吐只有每秒 150 个。差额那 650 个任务不会消失,只会在队列里积压。十几秒后,任务已经不是“异步处理”,而是“延迟几分钟后处理”。
这时候你看到的不是线程池拒绝,而是:
- 任务越来越旧
- 内存越来越高
- 下游请求雪崩
异步任务不是独立进程,它和主线程抢同一批资源
即使 @Async 用了单独线程池,它还是可能跟主链路共用:
- 数据源连接池
- Redis 连接池
- HTTP 连接池
- CPU 时间片
- 堆内存
所以当异步任务暴涨时,主请求照样会被拖慢。
我们当时最大的坑,是 ERP 同步逻辑用了和主业务相同的数据库连接池去查订单扩展信息。异步任务越多,连接占用越久,主接口借连接的时间就越长。
排查过程里最有用的几个指标
这个问题光看 CPU 和内存没有用,必须把“线程池本身”打透。
我最后盯的指标就这几个:
ThreadPoolExecutor executor = (ThreadPoolExecutor) asyncExecutor.getThreadPoolExecutor();
int active = executor.getActiveCount();
int poolSize = executor.getPoolSize();
int queueSize = executor.getQueue().size();
long completed = executor.getCompletedTaskCount();
线上把这些打成监控图后,趋势很清楚:
activeCount长时间顶满queueSize单边上涨completedTaskCount增长平缓
这说明不是偶发慢,而是处理能力长期小于输入流量。
如果你们系统里还没有线程池监控,第一步不是调参数,是先把这些指标打出来。不然就是盲调。
我最后是怎么拆的
核心思路就一句话:把“异步任务”拆成不同优先级和不同资源模型,不要一锅炖。
1. 把任务按业务性质拆线程池
原来所有异步任务都走一个池子,后来拆成三类:
- 核心业务后置任务:要求秒级完成
- 外部系统同步:允许延迟、容易阻塞
- 审计日志:允许降级
配置示意:
@Bean("coreAsyncExecutor")
public ThreadPoolTaskExecutor coreAsyncExecutor() {
return buildExecutor(16, 32, 200, "core-async-");
}
@Bean("erpAsyncExecutor")
public ThreadPoolTaskExecutor erpAsyncExecutor() {
return buildExecutor(8, 16, 100, "erp-async-");
}
@Bean("auditAsyncExecutor")
public ThreadPoolTaskExecutor auditAsyncExecutor() {
return buildExecutor(4, 8, 500, "audit-async-");
}
拆完之后,ERP 抖动不再直接影响通知和审计。
2. 缩小队列,尽早暴露背压
一开始团队很抗拒这件事,总觉得队列小了会丢任务。实际上,大队列只是把失败变成“更晚才失败”。
我把队列从 50000 改到 100~500 这个量级,让系统更快暴露处理不过来的事实。
private ThreadPoolTaskExecutor buildExecutor(
int core,
int max,
int queueCapacity,
String prefix
) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(core);
executor.setMaxPoolSize(max);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix(prefix);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
这里我没有用 DiscardPolicy,因为业务不能静默丢。
也没有继续用 AbortPolicy 直接打爆调用方,而是先用 CallerRunsPolicy 做温和背压,让上游感知到系统繁忙。这不是最终形态,但在业务能接受的场景里很有效。
3. 对外部依赖增加限流和熔断
ERP 同步最大的问题是它本身不稳定,慢的时候单请求会拖到 1~2 秒。
如果线程池不做限制,任务会疯狂堆积。我的处理方式是给 ERP 同步单独做并发限制:
private final Semaphore erpSemaphore = new Semaphore(20);
public void pushToErp(Order order) {
boolean acquired = erpSemaphore.tryAcquire();
if (!acquired) {
throw new BizException("ERP sync is busy");
}
try {
erpClient.push(order);
} finally {
erpSemaphore.release();
}
}
这段代码不高级,但在工程上很实用。它直接把下游并发压在一个可控范围里,避免把线程池全部耗死在外部 I/O 上。
4. 能丢到 MQ 的,不要硬塞本地线程池
后面我们把审计日志和部分 ERP 补偿任务迁到了消息队列:
- 主流程只负责投递消息
- 消费端独立扩缩容
- 失败重试和积压可观察
伪代码是这样的:
public void createOrder(CreateOrderCmd cmd) {
Order order = orderService.create(cmd);
mqTemplate.send("order-created-topic", new OrderCreatedEvent(order.getId()));
}
本地线程池适合轻量、低延迟、短生命周期任务;只要涉及重试、补偿、削峰,就该考虑 MQ。
一个常被忽略的点:线程池参数要跟任务类型匹配
不要拿一套线程池模板到处复用。
简单判断原则:
- CPU 密集型任务:线程数接近 CPU 核数
- I/O 密集型任务:线程数可以更高,但要盯住下游承载能力
- 高延迟外部调用:优先控并发,而不是盲目加线程
ERP 这类任务就是典型 I/O 密集,但真正瓶颈不是 JVM 线程,而是对方接口和本地连接池。线程越多,问题只会越快暴露。
这次优化后的结果
做完任务拆分、队列收缩、并发限制和 MQ 迁移后,核心指标变化很直接:
- 订单接口 TP99 从 3s 降到 220ms
- ERP 同步延迟从分钟级回到秒级
- 高峰期数据库连接池不再被异步任务抢空
- 线程池队列长度稳定在可控范围
最重要的是,后面再来活动流量,系统会先“限速”,而不是先“雪崩”。
最后给一个排查顺序
如果你们也遇到“@Async 越用越慢”的问题,我建议按这个顺序看:
- 先看线程池活跃线程数、队列长度、完成任务数。
- 再看异步任务是不是和主链路共用数据库或 HTTP 连接池。
- 判断任务到底是 CPU 密集还是 I/O 密集。
- 把不同业务性质的任务拆池。
- 缩小队列,尽早让背压暴露。
- 对慢下游做并发限制,必要时迁到 MQ。
线程池调优这件事,本质不是把参数从 16 改成 32,而是承认系统吞吐有边界,然后把边界设计出来。
