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 次数明显变多

问题不在“异步任务慢”,而在“异步任务积压后,反向挤压了主链路资源”。

为什么会这样

很多人以为把逻辑塞进线程池,就等于隔离了风险。实际上线程池只是换了一种调度方式,不会凭空创造资源。

当异步任务处理速度跟不上生产速度时,会先出现两个现象:

  1. 队列开始堆积
  2. 每个任务占用的下游资源不释放,比如数据库连接、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 越用越慢”的问题,我建议按这个顺序看:

  1. 先看线程池活跃线程数、队列长度、完成任务数。
  2. 再看异步任务是不是和主链路共用数据库或 HTTP 连接池。
  3. 判断任务到底是 CPU 密集还是 I/O 密集。
  4. 把不同业务性质的任务拆池。
  5. 缩小队列,尽早让背压暴露。
  6. 对慢下游做并发限制,必要时迁到 MQ。

线程池调优这件事,本质不是把参数从 16 改成 32,而是承认系统吞吐有边界,然后把边界设计出来。

最后更新 3/31/2026, 11:42:15 PM