最近在弄一个基于 Spring Boot 的大学生就业推荐系统,里面最有意思也最让人头疼的模块,就是那堆异步任务。学生上传简历后要异步解析、异步抽取技能标签,推荐引擎要在一个请求里并行召回多路岗位再打分排序,邮件通知、站内信发送也全是异步。一开始我天真地认为“异步就是另起线程执行,反正主流程不用等”,结果线上被折磨了几次:一次简历批量导入,几百个解析任务同时涌进线程池,队列直接打满;一次推荐算法里某个远程接口卡了 30 秒,整条链路的线程全被占住,新的异步任务集体排队,接口一个接一个超时。后来我才意识到:异步任务如果不管超时,就等于把故障往后面延迟,它不会消失,只是换了个时间点爆发。
这篇文章把我在 Spring Boot 异步任务超时控制这件事上的完整思考、方案选型、代码实现和踩坑记录整理出来。无论你是在做推荐系统、简历解析,还是任何依赖 @Async 和线程池的业务,这套思路应该都能直接用上。
1. 为什么异步任务比同步任务更怕超时
1.1 就业推荐系统里的典型异步场景
我做的大学生就业推荐系统里,异步任务大概分三类。第一类是简历解析,用户上传 PDF 或 Word 简历后,系统要提取姓名、院校、技能标签、项目经历,这类任务耗时差异极大,小简历几百毫秒,带图表的复杂简历可能要十几秒,高峰期一次性录入几百份简历非常常见。第二类是推荐引擎,一个推荐请求要同时查用户画像、岗位库、历史行为、热门岗位缓存,多路召回后还要统一打分排序,单机 QPS 一旦上来,任何一路耗时抖动都会影响整体响应时间。第三类是通知发送,邮件、短信、站内信,这类依赖第三方渠道,网络抖动和渠道限流是常态,慢起来能拖到几十秒。
这些任务的共同点是:都不适合放在 HTTP 请求线程里同步执行。同步执行的结果就是用户请求被无限拉长,数据库连接、Tomcat 线程、前端连接全被一个慢操作占住。异步确实能把主线程释放出来,但很多同学忽略了另一面——异步线程也是资源,而且比 Tomcat 线程更隐蔽。它不会直接导致 HTTP 超时,而是让线程池慢慢堆满,任务排进队列,队列再慢慢堆满,直到内存被撑爆或者新任务全部被拒绝。这就是异步任务必须做超时控制的原因:同步任务超时只会影响一个请求,异步任务超时会毒化整个线程池,进而拖垮所有依赖这个线程池的业务。
1.2 超时控制的方案选型对比:不要一上来就用 CompletableFuture
我在调研阶段列过一张方案对比表,先说结论:超时控制没有银弹,关键是匹配场景。Spring 的 @Async 本身不提供任何超时参数,你只能靠任务执行器(Executor)和调用方包装来限制。最初我想到的是用 Future.get(timeout),它是 JDK 最基础的手段,在拿到 Future 后调用 get(5, TimeUnit.SECONDS),超时就抛 TimeoutException。这种方案适合“批量提交一批任务,统一等结果”的场景,缺点是很难精细化处理单个任务的不同超时。
后来我试了 CompletableFuture.orTimeout(),这是 JDK 9 引入的 API,可以在任务管道上设置超时时间,超时后直接用 TimeoutException 完成那个 Future,配合 exceptionally 做降级非常顺手。但它同样有个隐藏问题:orTimeout 只是“让 Future 提前完成”,底层任务线程如果卡在阻塞 IO 上,并不会被真正打断。也就是说,这个线程依然被占用,只是你已经不关心它的结果而已。
再后来我看了线程池的拒绝策略。CallerRunsPolicy 能在线程池满的时候让调用线程自己执行任务,相当于“用调用方的 CPU 换任务的可靠执行”,但设置不当会产生完全相反的效果——调用线程一旦开始执行异步任务,原本该快速返回的请求就变慢了。最后我的选择是组合拳:资源维度用自定义 ThreadPoolTaskExecutor 控制线程数和队列长度,任务维度用 invokeAll(timeout) 做批量整体超时,用 CompletableFuture.orTimeout() 做多路并行单路超时。不同场景用不同方案,不要试图用一个工具解决所有问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把线程池管好:超时控制的前提
2.1 不要用默认的 SimpleAsyncTaskExecutor
很多人用了 @Async 之后发现系统响应确实快了,但线程数疯狂飙升,查了一下原来是 Spring Boot 默认注册的 SimpleAsyncTaskExecutor 在起作用。这个执行器的行为是“每次提交任务都 new 一个线程”,执行完就丢,既不复用,也不限制并发数。它只适合本地快速验证,放到生产环境就是资源黑洞。我亲眼见过一个用了默认执行器的服务,异步任务并发量稍大一点,操作系统线程数直接破千,CPU 全耗在线程切换上。
所以在项目里第一件事就是自定义一个 ThreadPoolTaskExecutor,再注入到 Spring 容器里。下面这段配置是我在就业推荐系统里的基础版本,后续又根据场景做了拆分。
java复制@Bean("jobTaskExecutor")
public ThreadPoolTaskExecutor jobTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数:推荐和简历解析混合场景,建议保守一点
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("async-job-");
executor.setKeepAliveSeconds(60);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
关键参数我在下面展开讲,这里先说两个容易踩坑的点。setWaitForTasksToCompleteOnShutdown(true) 表示应用关闭时先等已接收任务执行完再销毁线程池,否则正在跑的异步任务会被直接打断,日志里还会报中断异常;setAwaitTerminationSeconds(30) 是最大等待时间,防止某些慢任务让应用无限期关不掉。这两个参数是配合使用的,只设前者不设后者,一旦业务里有永远卡住的任务,停机就成了一场灾难。
2.2 线程池参数怎么定才不拍脑袋
线程池的核心线程数、最大线程数、队列长度这三个值是最容易引起争议的。网上到处都是“CPU 密集型设 N+1,IO 密集型设 2N”这类公式,但实际项目里很少是纯粹一种类型。就业推荐系统的线程池同时承载简历解析(IO 密集)和推荐打分(CPU 密集),所以我用了更保守的方式。
先看核心线程数。我的机器是 8 核,如果全部跑 CPU 密集型的打分任务,理论核心线程数可以设 9(N+1),但考虑到同一个 JVM 里还有 Tomcat 线程和其他业务线程,我不能把所有核都占满,最终核心线程数设为 8。再看最大线程数。最大线程数不是越高越好,它代表的是线程池在队列满之后还能再临时扩出来的线程。我把最大线程数设为 16,是基于“高峰期如果队列都满了,允许线程数短期翻倍来扛住流量洪峰”的假设。这个值不能长期维持,因为 16 个线程如果都在做 CPU 密集计算,CPU 上下文切换开销已经很可观了。
队列长度我用了一个简单的估算公式:队列长度 ≈ 每秒钟产生的任务数 × 单任务平均耗时 × 可接受的缓冲秒数。比如我们高峰时每秒产生约 100 个异步任务,单任务平均耗时 500ms,希望任务即使不能立刻执行,也至少在队列里缓冲 4 秒,那么队列长度就设为 100 × 0.5 × 4 = 200。这个公式不算严谨,但它比拍脑袋强太多。你根据线上的真实 QPS,去做一次最小化部署,观察两三天,比任何理论公式都靠谱。
拒绝策略这块我单独提醒一句。CallerRunsPolicy 听起来很温柔:线程池满了就让提交任务的线程自己干。但它有个副作用——如果提交异步任务的是 Tomcat 请求线程,那这个请求就会被强制拉长,本来该 100ms 返回的接口,硬生生变成了 2 秒。我最后的方案是:推荐引擎这种需要“尽力而为”的场景用 CallerRunsPolicy,简历解析和邮件发送这种有后续补偿机制的场景改用 AbortPolicy 并捕获异常,把失败任务丢进一个本地重试队列,不让前端线程遭殃。
2.3 线程池指标必须可观测
超时控制只是防线,真正要紧的是随时知道线程池已经到什么水位了。我在项目里写了一个定时任务,每隔 30 秒输出一次线程池核心指标,这在排查“为什么异步任务越来越慢”的时候救命了。
java复制@Component
public class ThreadPoolMonitor {
private final ThreadPoolTaskExecutor jobTaskExecutor;
public ThreadPoolMonitor(@Qualifier("jobTaskExecutor") ThreadPoolTaskExecutor jobTaskExecutor) {
this.jobTaskExecutor = jobTaskExecutor;
}
@Scheduled(fixedRate = 30_000)
public void monitor() {
ThreadPoolExecutor pool = jobTaskExecutor.getThreadPoolExecutor();
log.info("active={}, poolSize={}, maxPoolSize={}, queueSize={}, completed={}, taskCount={}",
pool.getActiveCount(),
pool.getPoolSize(),
pool.getMaximumPoolSize(),
pool.getQueue().size(),
pool.getCompletedTaskCount(),
pool.getTaskCount());
}
}
不要小看这一行日志。我遇到过一个问题:接口偶尔很慢,但看线程池的 poolSize 一直没到 maximumPoolSize,以为资源充足。后来加了队列大小监控才发现,queueSize 长期在 180 左右徘徊,说明任务并没有被完全消费掉,只是排着队慢慢处理。如果没有这个指标,你很难判断是任务本身慢,还是线程池已经接近饱和。生产环境还可以接 Micrometer 的 Gauge 指标,映射到 Prometheus 做告警,但开发和联调阶段先用日志就够了。
3. 两类异步任务的超时控制实现
3.1 批量任务整体超时:ExecutorService.invokeAll
简历批量解析最适合用 invokeAll。为什么不用前面提到的逐任务 Future.get(timeout)?因为你想要的通常是“这一批解析任务整体不要超过 30 秒”,而不是“每个任务单独不超过 15 秒”。如果一批有 100 个任务,每个任务单独限制 15 秒,极端情况整体可能要 100 × 15 秒才能全部处理完,这对业务是不可接受的。
ExecutorService.invokeAll(Collection<Callable<T>> tasks, long timeout, TimeUnit unit) 提供了整体超时语义:全部任务同时开始执行,一旦达到超时时间,还没完成的任务会被自动取消,已经完成的会保留结果。下面是关键代码。
java复制public List<ParseResult> parseResumeBatch(List<Long> resumeIds) {
List<Callable<ParseResult>> tasks = resumeIds.stream()
.map(resumeId -> (Callable<ParseResult>) () -> parseOneResume(resumeId))
.collect(Collectors.toList());
ThreadPoolTaskExecutor executor = springContext.getBean("jobTaskExecutor", ThreadPoolTaskExecutor.class);
List<Future<ParseResult>> futures;
try {
futures = executor.getThreadPoolExecutor().invokeAll(tasks, 30, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BizException("简历解析任务被中断", e);
}
List<ParseResult> result = new ArrayList<>();
for (Future<ParseResult> future : futures) {
try {
if (future.isCancelled()) {
// 已经被整体超时打断,按失败处理或补录重试队列
result.add(ParseResult.timeout());
} else {
result.add(future.get());
}
} catch (ExecutionException e) {
// 单个任务内部异常,不要影响整批
result.add(ParseResult.failed(e));
}
}
return result;
}
这里的核心点是 future.isCancelled()。invokeAll 超时后,未完成任务会被 cancel,所以遍历结果时不能直接 future.get(),否则会抛 CancellationException。另外,如果一个任务自身抛了异常,它不会直接中断整个批次,而是被包进 ExecutionException,循环体里单独捕获即可。这种“整体超时 + 单个异常隔离”的模式非常适合批量场景。
3.2 多路召回局部超时:CompletableFuture.orTimeout
推荐引擎的多路召回是另一个典型场景。一个推荐请求会同时发起“热门岗位召回”“按技能标签召回”“按历史行为召回”三路查询,任何一路慢都不应该拖垮整体。这里我用的是 CompletableFuture.supplyAsync().orTimeout() 组合。
java复制public RecommendResult recommend(Long userId) {
ThreadPoolTaskExecutor executor = springContext.getBean("jobTaskExecutor", ThreadPoolTaskExecutor.class);
CompletableFuture<List<RecommendItem>> hotFuture =
CompletableFuture.supplyAsync(() -> recallHot(userId), executor)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> Collections.emptyList());
CompletableFuture<List<RecommendItem>> tagFuture =
CompletableFuture.supplyAsync(() -> recallByTag(userId), executor)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> Collections.emptyList());
CompletableFuture<List<RecommendItem>> historyFuture =
CompletableFuture.supplyAsync(() -> recallByHistory(userId), executor)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> Collections.emptyList());
try {
CompletableFuture.allOf(hotFuture, tagFuture, historyFuture)
.get(2500, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
// 整体超过 2.5 秒,直接使用当前能拿到的部分召回结果
log.warn("recommend overall timeout, userId={}", userId);
} catch (Exception e) {
Thread.currentThread().interrupt();
log.error("recommend allOf failed", e);
}
List<RecommendItem> merged = new ArrayList<>();
merged.addAll(hotFuture.join());
merged.addAll(tagFuture.join());
merged.addAll(historyFuture.join());
return reRank(merged);
}
这段代码的关键设计是“局部超时 + 整体超时”两层控制。每路召回最多跑 800ms,超时后返回空列表,不影响其他路;三路汇总阶段再限制最长 2500ms,即使每路都没有超时,如果三路整体加起来超过了 2.5 秒,也会走到整体超时分支。join() 在这里是安全的,因为这些 Future 要么正常完成,要么已经被 exceptionally 降级成空列表,不会发生阻塞。
orTimeout 底层其实是把这个 CompletableFuture 包装成一个带超时调度的 TimeoutFuture,到点后用 TimeoutException 强制完成。这个过程中原任务线程如果还卡在数据库查询或者远程调用上,它不会真的停下来。所以我在设计里还做了一个小约定:所有参与多路召回的方法,内部都不允许做长时间无中断的循环,至少每 200ms 检查一次当前线程的中断状态。这样做一方面是为了配合 orTimeout 的语义,另一方面也让线程池里的线程不至于被一个超时任务白白占住。
3.3 超时后的降级与补偿,比抛异常更重要
超时控制最有意思的部分不是“怎么拦截超时”,而是“超时之后怎么办”。对就业推荐系统来说,如果推荐引擎超时了,最坏的结果不是抛出异常,而是用户看到的推荐页永远加载不出来。我的策略是三级降级:第一级,单路召回超时返回空列表;第二级,所有路都空的时候,用本地缓存的“全站热门岗位 Top 100”顶着,保证页面永远有东西;第三级,处理失败的任务落库,写进 async_retry 表,后台定时重试。
很多团队在做 CompletableFuture 的 exceptionally 时,习惯直接返回默认值,但默认值本身也要考虑业务语义。比如简历解析超时,如果把解析结果标记为“成功但内容为空”,那后续的职位匹配就会全乱掉。我这里的处理是:解析超时返回 ParseResult.timeout(),由上层决定是让用户重新上传,还是进入人工审核队列。超时标记必须显式存在,不能悄悄吞掉。
4. 踩坑实录与排查思路
4.1 @Async 自调用失效,同一类内异步“失灵”
这是新手必踩的坑。我在简历服务里写了这样一个方法:generateResumeReport() 内部直接调用同类的 asyncSendEmail(),而 asyncSendEmail 标了 @Async。结果邮件发送时间点完全不对,后面查了 AsyncAnnotationBeanPostProcessor 的源码才发现问题。Spring 的 @Async 在运行时真正执行的是一个代理对象,外部调用 proxy.asyncSendEmail() 时,代理会拦截方法并把任务提交到线程池。但同类内部调用走的是 this.asyncSendEmail(),压根没经过代理,注解自然就失效了。
解决办法有几种。最简单的是把异步方法拆到另一个 Service 里,让客户端通过注入的 Bean 调用;或者在当前类里注入 ApplicationContext,用 applicationContext.getBean(CurrentService.class).asyncSendEmail() 强制拿到代理对象。这里还要提醒一个更容易忽视的坑:如果 @Async 放在接口方法上,而实现类里重写了该方法,Spring 的代理可能作用在实现类方法上,注解却不在这里,结果同样不生效。我的经验是注解和实现方法放在同一个类里,保持简单直接。
4.2 异步线程里事务失效,异常也被吞掉
另一个频繁翻车的点是我以为 @Async 方法加了 @Transactional 就能自动提交事务,实际上完全不是。@Transactional 依赖 ThreadLocal 去绑定连接,Spring 开启事务时会把连接放到当前线程的 ThreadLocal 里;异步任务跑在完全不同的线程中,那个线程里根本没有事务上下文,所以事务完全失效。如果必须在异步任务里写库,我建议用编程式事务,在方法内部手动 TransactionTemplate.execute()。
@Async 方法内的异常处理也是个问题。外部同步调用时,方法的异常可以直接抛给调用方;异步任务执行时异常发生在线程池的 Worker 线程里,调用方拿不到。Spring 会调用 AsyncUncaughtExceptionHandler 来处理这些异常。如果不配置,默认的 SimpleAsyncUncaughtExceptionHandler 只会打印一行日志,异常看起来就像被吞掉了一样。我的习惯是自定义一个全局 handler,把异常上下文(任务名、参数、异常栈)发到告警群并落库,这样至少能确保每一次异步失败都有人追责。
java复制@Component("customAsyncUncaughtExceptionHandler")
public class CustomAsyncUncaughtExceptionHandler implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
log.error("异步任务执行失败, method={}, params={}", method.getName(), params, ex);
// 落库到 async_error_log,方便后续手工补偿
}
}
4.3 监控到“线程池满了”:拒绝策略是决策点
线程池满的现象非常典型:日志里出现 TaskRejectedException,或者接口调用方在高峰期频繁看到 500。我遇到过一次,简历解析任务大量积压,核心线程 8 个全忙,队列 200 也满了,最大线程数 16 因为 keepAlive 机制还没完全扩展到位,于是 CallerRunsPolicy 开始生效。本来它是想保证任务不丢,结果它在 Tomcat 线程里执行了解析,直接把我的 HTTP 接口拖到 3 秒以上。那次排查花了我半个下午,最后发现“异步任务”竟然会跑到请求线程里执行。
定位方式是把线程池指标监控打开,看 activeCount 是否长期等于 maximumPoolSize,以及 queueSize 是否长期接近队列容量。临时缓解可以调大 queueCapacity 到 500,但这不是长久之计。真正合理的做法有两个方向:如果任务允许延迟,就放进消息队列异步消费,不要和请求线程抢资源;如果任务必须实时完成,就要考虑拆分线程池,让推荐引擎、简历解析、邮件发送各自使用独立的执行器,互不干扰。
4.4 常用排查命令与 JVM 观测
遇到线程池卡死,我最常用的命令是 jstack。先找到 Java 进程的 PID,然后执行 jstack <pid> > /tmp/stack.log。在 dump 文件里搜 async-job- 这个线程名前缀,看这些线程都在什么状态,是在执行哪个方法,是 WAITING(线程池空闲等待任务)还是 RUNNABLE(正在执行),如果在 RUNNABLE 且堆栈长时间不变,多半是卡在了 IO 或锁上。
还可以用 jstat -gcutil <pid> 1000 看 GC 压力。线程池打满之后,大量任务等待,会产生比正常状态更多的对象,老年代增长快,GC 频繁,最后表现为 CPU 飙升。如果怀疑任务堆积导致内存问题,直接 jmap -heap <pid> 看一下堆使用情况,再配合线程池的队列大小日志,大概率能在几分钟内定位问题。总的排查思路是:先看线程,再看内存,最后看任务是否正常消费。
5. 从超时控制到任务编排:后续扩展方向
5.1 统一异步任务执行器入口
超时控制做到后面,我发现每写一个异步任务都要重复一遍 orTimeout、exceptionally、失败落库 这些样板代码,很烦。所以我把异步调用封装成了一个统一的执行器入口,便于复用。核心思路是用函数式接口包装业务动作,内部统一提交到指定线程池、统一设置超时、统一处理降级和失败记录。
java复制@Component
public class AsyncRunner {
private final ThreadPoolTaskExecutor jobTaskExecutor;
public <T> CompletableFuture<T> runAsync(String taskName,
Duration timeout,
Supplier<T> action,
Supplier<T> fallback) {
return CompletableFuture
.supplyAsync(() -> {
log.info("async task start, name={}", taskName);
return action.get();
}, jobTaskExecutor)
.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
.exceptionally(ex -> {
log.error("async task failed, name={}, timeout={}", taskName, timeout, ex);
return fallback.get();
});
}
}
这样业务代码就不需要关心超时细节了。当然封装层不能做重,否则会让阅读代码的人找不到真实逻辑。我封装的边界是:只处理超时和降级,不处理业务本身的失败——业务失败依然要显式抛出来,方便调用方做差异化兜底。这套封装在全系统里用了大概三个月,改动成本很低,收益却很明显,几乎所有新接手的人一看方法签名就知道该传什么参数。
5.2 长任务要交给消息队列,而不是硬等
不是所有异步任务都适合放在内存线程池里。我后来把简历解析拆成了两类:实时的小规模解析(几秒钟以内)留在线程池,超长的批量解析(几十秒到几分钟)直接投递到消息队列,由一个独立消费者慢慢处理。用户在页面上看到的状态是“解析中”,解析完成后通过 WebSocket 或者轮询接口更新进度。这比把几十个线程全压在内存里要稳定得多。
我的判断标准是:任务平均耗时 < 5 秒,就用线程池做异步,配好超时控制;任务平均耗时 > 5 秒,或者偶发波动极大,就进消息队列,用消息的 ACK 机制和延迟队列来做重试,配合全链路状态机保证数据最终一致。就业推荐系统里的“就业报告生成”就是这么设计的,不是所有事情都必须在一个请求链路里硬等。
5.3 最后再分享一个小经验
超时时间不能凭感觉拍。我一开始把推荐引擎的单路召回超时设成 2 秒,上线后发现接口平均耗时变成了 3 秒多。后来把单路超时改成 800ms,整体超时 2.5 秒,又用压测跑了一轮,发现 95% 请求都能在 2.5 秒内返回,那 5% 的超时请求因为有降级兜底,页面也没出现空白。超时时间不是越小越好,太小的超时会让很多正常任务被误杀,用户看到的推荐内容反而变差。调参一定要看真实的耗时分布数据,用百分位指标去定,而不是拿平均值来定。平均值很容易被少数慢任务拉高,看 P95 和 P99 更接近真实体验。我自己后来的固定动作是:每个异步任务上线前,都先打印一段耗时分布,把 P50、P95、P99 捞出来,再回过头来定超时阈值,这套方法在就业推荐系统里一直用到现在,省掉了大量无谓的线上事故。
