异步任务、线程池与定时任务
这一篇只解决一件事:有些活不必让用户等着。下单要写库——这是主流程,用户必须等;下单后要发短信、发邮件、算积分——这些活和「下单成功」没关系,挪到后台慢慢做就行。Spring 给了两个注解来做这件事:@Async(别人帮我跑,我不等)和 @Scheduled(到点自己跑)。
但这两个注解本身不产生线程,它们只是「把任务交出去」。真正决定成败的是接住任务的那个东西——线程池。所以本篇一半篇幅在讲线程池,这不是跑题,而是重点。
先给五个词一句话解释(全文都会用到):
- 线程:一条正在干活的执行线。你的请求默认跑在 Tomcat 的工作线程上,异步就是再另开一条线去跑
- 线程池:一组预先造好、反复复用的线程,像排班好的工人队伍;任务是活儿,谁空谁领
- 核心线程(corePoolSize):池子里常驻不走的那几个人,空闲了也不解散
- 工作队列(queueCapacity):所有常驻的人都在忙时,新任务先在这里排队等叫号
- 最大线程(maxPoolSize):队也排满了,才临时增聘人手,上限就是这个数
- 拒绝策略(RejectedExecutionHandler):人和队都满了还来任务,怎么处理——抛异常、让提交者自己干、直接丢、还是丢最老的
整篇可以钉在一个外卖平台上。核心线程是站里的正式骑手(固定几个,随叫随到);工作队列是爆单时挂在墙上的取餐小票(单子先挂着,骑手回来取);最大线程是高峰期临时叫来的众包骑手(只在墙挂满时才上场,闲了就退回);拒绝策略是彻底接不动时的四种规则——告诉顾客「下不了单」(抛异常)、店员自己骑车送(调用方线程执行)、默默撕单(丢弃)、退掉最早那张票只留最新(丢最旧)。而定时任务呢?它是每天固定时间自动开工的单子:如果站里只有一个骑手值班,他送一单慢的,后面所有定时单全得等——这就是第九节那个坑的全部真相。

上面这张对照图是本篇的第一道生死线:@Async 不配线程池时用的就是左边那列(每来一个任务就 new Thread(),无上限、不复用),第三节会给出源码证据。下面这张动图则是右边那列的内部走法,注意它最容易记反的地方——核心线程满了是先排队,不是先加人:

学完这一篇,你应该能回答三个问题:
- 一个
@Async方法里抛了异常,为什么日志和响应里都看不到?该加什么才能接住它? - 同一个类里
this.sendSms()调自己的@Async方法,为什么同步执行了? - 服务起了 3 个副本,为什么每天的对账任务跑了 3 遍?怎么让它只跑一遍?
线上反馈"下单接口偶尔要 8 秒才返回"。排查后发现:下单主流程里,紧接着写库之后,还同步调用了两个外部接口——发短信和发邮件。这两件事各自要 1~3 秒,而且和"下单成功"这个结果毫无关系:用户只需要知道下单成功,短信晚两秒发出去完全没关系。
| 方案 | 主流程耗时 | 用户体验 | 短信失败的影响 |
|---|---|---|---|
| 全部同步 | 1.5s + 2s + 3s ≈ 6.5s | 卡到想关页面 | 短信服务抖动直接导致下单失败 |
| 短信 / 邮件异步 | ≈ 1.5s | 秒回 | 失败只写日志,不影响下单 |
这就是异步任务要解决的典型问题:把"与主流程结果无关、且耗时较长"的操作挪到后台线程,主流程立即返回。 Spring 给了两个注解来实现:@Async(异步执行)和 @Scheduled(定时执行)。它们用起来极简,但坑也极多,本节把两个注解从用法到线程池、从上下文传递到分布式重复执行全部讲透。

第一步,在启动类或任意配置类上加 @EnableAsync,第二步,把要异步的方法标记为 @Async:
@Configuration@EnableAsync // 开启异步能力public class AsyncConfig { }@Servicepublic class OrderService { private final SmsClient smsClient; public OrderService(SmsClient smsClient) { this.smsClient = smsClient; } public Long createOrder(OrderCmd cmd) { Long orderId = doCreate(cmd); // 主流程:写库,必须同步完成 sendNotifyAsync(orderId, cmd.getPhone()); // 通知:异步,不阻塞主流程 return orderId; // 立即返回 } @Async // 这个方法会在另一个线程执行 public void sendNotifyAsync(Long orderId, String phone) { smsClient.send(phone, "下单成功:" + orderId); }}@EnableAsync是总开关,不加则@Async完全无效。@Async标注的方法,Spring 会为它生成一个代理,调用时把方法体丢进线程池执行。
返回值决定了调用方能拿到什么,这是最容易用错的地方:
| 返回类型 | 调用方能力 | 适用场景 |
|---|---|---|
void | 完全不管结果,异常也不会传回 | 发短信、写日志等"即发即忘" |
Future<T> | 可 get() 阻塞等结果 | 已过时,不推荐 |
CompletableFuture<T> | 可组合、可回调、可超时 | 需要编排多个异步结果时首选 |

两行注解开了异步之后,真正要落地的是一串配置。别去抄别人的 yml——勾选你要的那些,生成一份,并且每一条都告诉你为什么存在、去掉会怎样:
server:
port: 8080
servlet:
encoding: { charset: UTF-8, enabled: true, force: true }
compression: { enabled: true, min-response-size: 2048 }
spring:
application:
name: demo-service
task:
execution:
pool: { core-size: 8, max-size: 32, queue-capacity: 200 }
threads:
virtual:
enabled: false # JDK 21 打开后 @Async 走虚拟线程
@Async 默认用的执行器是 SimpleAsyncTaskExecutor,看名字很美好,看源码会吓一跳:它每来一个任务就 new Thread() 新建一个线程,且不复用、无上限。
// 简化示意:SimpleAsyncTaskExecutor 的核心行为protected void doExecute(Runnable task) { Thread thread = createThread(task, this.threadNamePrefix); // 每次都新建 thread.start();}后果清单:
- 不限制并发:瞬间一万个请求就新开一万个线程,直接把 CPU 与内存打爆,甚至 OOM。
- 不复用线程:每个任务一生一死,线程创建/销毁的开销全部浪费。
- 无法观测与治理:没有队列、没有拒绝策略、没有监控指标。
所以生产环境必须自定义线程池。用 ThreadPoolTaskExecutor:
@Configuration@EnableAsyncpublic class AsyncConfig implements AsyncConfigurer { @Bean("notifyExecutor") public ThreadPoolTaskExecutor notifyExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); // 核心线程数:常驻 executor.setMaxPoolSize(16); // 最大线程数:峰值时可临时扩到 executor.setQueueCapacity(200); // 队列容量:核心忙时的缓冲区 executor.setKeepAliveSeconds(60); // 超出核心的线程空闲多久回收 executor.setThreadNamePrefix("notify-"); // 线程名带业务前缀,便于排查 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); // 优雅关闭,见第七节 executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } /** 不指定线程池名时用的默认执行器 */ @Override public Executor getAsyncExecutor() { return notifyExecutor(); }}@Async("notifyExecutor")可以显式指定使用哪个线程池,不同业务用不同池,互不影响。- 线程名前缀是排查的救命稻草:日志里出现
notify-3,你立刻知道这是异步通知线程。
线程池的工作顺序是:核心线程 → 工作队列 → 最大线程 → 拒绝策略。很多人以为"任务多了就开新线程到 max",其实是对外错误理解:
| 阶段 | 触发条件 | 行为 |
|---|---|---|
| 1 | 线程数 < core | 新建核心线程执行 |
| 2 | 线程数 = core 且队列未满 | 任务进队列排队 |
| 3 | 队列已满且线程数 < max | 新建临时线程执行 |
| 4 | 队列已满且线程数 = max | 触发拒绝策略 |
上表这四行就是下面这段动画的脚本,逐帧对照看第 ②→③ 帧:中间隔的是「队列满了」这个条件,而不是「核心线程忙」——这一步记住了,第四节后面所有关于容量的讨论都不会再绕。

无界队列会让 maxPoolSize 永远失效。 如果队列容量设为 Integer.MAX_VALUE(LinkedBlockingQueue 默认就是这个),任务会无限堆积,永远到不了"队列满 → 扩到 max"这一步,最大线程数形同虚设,最后是内存被堆爆。一定要给队列设一个有限容量。
四种种拒绝策略,行为各异:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy(默认) | 直接抛 RejectedExecutionException | 希望快速失败、能被上层感知 |
| CallerRunsPolicy | 让提交任务的线程自己执行 | 希望"降级为同步",保证任务不丢 |
| DiscardPolicy | 静默丢弃,无任何提示 | 允许丢的、非关键任务 |
| DiscardOldestPolicy | 丢弃队列最老的任务,再重试提交 | 只关心最新数据的场景 |
上表把四阶段写成了四行字,但"哪一步先发生"这种事,读是读不进去的,得点。下面这张图每一格都能点,点下去就是那一格在干什么:
想看这三个数字互相怎么牵制,直接把队列容量拖一遍——同一个 @Async,队列从 0 拖到 2 万,命运完全不同:
- 突发小高峰被队列吸收,不至于立刻加线程
- 队列满后 max 会被真正用上——这是它唯一的价值
- 排队延迟有上界,可预估、可告警
- 生产上最常配的量级
@Async 和 @Transactional 一样依赖代理,一旦调用没走代理,注解就"消失"了:
| 失效场景 | 原因 | 修复 |
|---|---|---|
| 同类内自调用 | this.method() 绕过代理 | 把异步方法抽到另一个 Bean,或注入自身 |
方法是 private / final | 无法被代理重写 | 改为 public、非 final |
| 返回类型是基本类型 | 异步只能包装为 void/Future | 用 CompletableFuture<T> 或 void |
void 方法抛异常被吞 | 无人接收返回的异常 | 配置 AsyncUncaughtExceptionHandler 打日志 |
| Bean 不是 Spring 管理 | new 出来的对象没有代理 | 交给容器管理 |
忘了 @EnableAsync | 总开关没开 | 加上注解 |
void 方法里抛出的异常,默认悄无声息地消失,必须显式接住:
@Configuration@EnableAsyncpublic class AsyncConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> log.error("异步任务执行失败 method={} params={}", method.getName(), params, ex); }}- 有了它,
void异步方法里的异常至少能进日志,而不是凭空蒸发。
上面那张表有六行,背是背不住的——「注解失效」这件事,只有亲手配错过一次才记得住。来玩一局:先点左边的现象,再点你认为的病因,配错了当场告诉你为什么。
回看第三节的核心事实:异步任务跑在另一个线程上。而 Spring / SLF4J / 事务的很多上下文都存在 ThreadLocal 里,默认不会自动传过去:
- MDC 的 traceId 丢失:主线程打的日志有 traceId,异步线程的日志却没有,链路断了。解法是用
TaskDecorator在任务提交时复制 MDC:
public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable task) { Map<String, String> context = MDC.getCopyOfContextMap(); // 抓取提交线程的 MDC return () -> { if (context != null) MDC.setContextMap(context); // 在执行线程还原 try { task.run(); } finally { MDC.clear(); // 防止线程复用污染 } }; }}// 配置:executor.setTaskDecorator(new MdcTaskDecorator());- SecurityContext 丢失:异步方法里读不到当前登录用户。解法是把线程池包一层
DelegatingSecurityContextExecutor,它会自动搬运安全上下文。 - 事务上下文无法跨线程(重点):事务的连接绑定在发起事务的那个线程的
ThreadLocal上。异步方法在另一个线程里执行,拿不到调用方的事务,它要么没有事务,要么按自己的@Transactional另开一笔独立事务。
要点:记住三条边界——MDC 用 TaskDecorator 手动搬、SecurityContext 用 DelegatingSecurityContextExecutor 搬、事务搬不过去也不用搬。 尤其最后一条:不要指望"主流程回滚,异步任务也回滚",它们本就是两笔独立的事务。
上面这三条是结论,但"为什么断"这件事,看结论是看不进去的。下面这个实验在浏览器里真跑一遍:同一段代码,看它在 exec-3 和 task-1 两条线程上分别读到什么。
再把它摊成一次单步执行。左边是你要调试的那六行,右边同步刷新「此刻的变量」和「调用栈」——连点「下一步」,盯住线程名从 exec-3 变成 task-1 的那一瞬间:
UserContext.set(userId); // 此刻在 http-nio-8080-exec-3notifier.sendAsync(order); // 这一跳走的是代理executor.submit(wrap(order)); // 任务交给线程池,主线程立刻返回// ---- 镜头切到 task-1 ----Long uid = UserContext.get(); // 同一个 ThreadLocal,换一个 maplog.info("send to {}", uid); // uid = null| 线程名 | http-nio-8080-exec-3 |
| UserContext | 42 |
| singletonObjects | 184 个 Bean |
OrderController.pay- request thread上面第 ② ③ 步那个「经过代理」和「交给池」的动作,可以切到内核实验里单独看一遍——它会把 AsyncExecutionInterceptor 的每一步打给你看:
搬运的正确姿势画成一张图,四个动作缺一不可(尤其是第 ④ 步的「还清」):

应用收到停机信号时,如果直接杀进程,队列里还没跑完的异步任务会全部丢失。两个设置配合,构成完整停机链路:
server: shutdown: graceful # 1. Web 层:停止接收新请求,处理完在途请求spring: lifecycle: timeout-per-shutdown-phase: 30s # 2. 给在途请求的处理时限// 3. 线程池层:等待队列任务跑完再关executor.setWaitForTasksToCompleteOnShutdown(true); // 关闭时先处理完队列executor.setAwaitTerminationSeconds(30); // 最多等 30 秒,超时强制结束- 第一个设置管的是 HTTP 请求,第三个设置管的是异步任务,缺一不可。
- 设置等待时长要小于编排平台的
terminationGracePeriodSeconds(如 K8s 默认 30s),否则容器会被强杀,等待白设。
定时任务用 @EnableScheduling 开启,然后 @Scheduled 标注方法。它有三种触发方式,语义差别很大:
| 方式 | 语义 | 上一次执行超过间隔会怎样 |
|---|---|---|
fixedRate | 按固定频率触发,上一次开始的时刻算起 | 不会并发,但会立即补上下一次,任务堆积 |
fixedDelay | 上一次结束后再等固定时间触发 | 永远不会堆积,自然顺延 |
cron | 按 cron 表达式在特定时刻触发 | 同 cron 语义,注意多实例问题 |
时间轴示意:假设任务耗时 3 秒,间隔 2 秒。
fixedRate: 开始0 ———3——— 开始3(0+2 早已到,立刻执行)———6———fixedDelay: 开始0 ———3——— 等2秒 开始5 ———8——— 等2秒 开始10fixedRate的"频率"是从开始时刻算的,长任务会造成"上一次还没完、下一次已到期"的堆积(虽然不会并发,但会连着跑)。fixedDelay从结束时刻算间隔,天然不堆积,大多数场景更符合直觉。
cron 表达式是六段式:秒 分 时 日 月 周(注意 Java 的 cron 比 Linux 的多了"秒"这一段):
| 表达式 | 含义 |
|---|---|
0 0 3 ? | 每天凌晨 3 点整 |
0 /5 ? | 每 5 分钟 |
0 0 9-18 MON-FRI | 工作日 9~18 点整点每小时 |
0 30 2 1 * ? | 每月 1 号凌晨 2:30 |
0 0/30 * ? | 每 30 分钟 |
initialDelay 用于推迟第一次执行,常用于"等应用完全就绪再跑":
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)public void cleanExpiredTokens() { // 启动 60 秒后才第一次执行,之后每 5 分钟一次(上次结束后算起)}@Scheduled 默认使用单线程的 TaskScheduler。这意味着:所有定时任务共用一个线程,谁跑得久谁就阻塞别人。
一个每 5 分钟执行一次、耗时 4 分钟的报表任务,会让所有其他定时任务一起"迟到"——因为它们在同一个线程里排队。
解法是自定义一个有多个线程的 TaskScheduler:
@Configuration@EnableSchedulingpublic class ScheduleConfig { @Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(4); // 4 个线程,任务间互不阻塞 scheduler.setThreadNamePrefix("schedule-"); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(30); return scheduler; }}- 池大小要 ≥ 期望并行执行的任务数,否则又回到"互相阻塞"的老问题。
这段动画把这个坑演了一遍——注意第 ③ 帧:任务 B 到点了却没人执行,它不是失败了,是在等那条唯一的线程。

把这个数字拖一下就能看清「1」意味着什么:
- 默认值就是 1,所有 @Scheduled 共用 scheduling-1
- 某个任务里一次慢 HTTP 调用,后面每个任务都跟着迟到
- 表现是「任务偶尔没跑」,其实是跑晚了——日志里线程名永远是同一个
- cron 表达式本身没错,错在只有一条线程
以上都建立在"单实例"假设上。一旦服务部署多个副本,每个副本都会触发同一个定时任务:每 5 分钟的报表被跑了 3 遍,发出去的账单重复了。三种解法:
| 解法 | 原理 | 优缺点 |
|---|---|---|
| 数据库乐观锁 | 更新时带版本号,只有一个实例能成功 | 简单,但有数据库压力,粒度粗 |
| Redis 分布式锁 | SET key value NX EX ttl 抢占执行权 | 轻量快速,需处理锁过期与续期 |
| 调度中心(XXL-JOB) | 由中心统一调度,指定某个执行器执行 | 功能全、可观测,但需额外部署运维 |
一个基于 Redis SETNX 的轻量防重实现:
@Componentpublic class ReportJob { private final StringRedisTemplate redis; public ReportJob(StringRedisTemplate redis) { this.redis = redis; } @Scheduled(cron = "0 0/5 * * * ?") public void generateReport() { // SETNX + 过期时间:只有一个实例能抢到这把锁 String lockKey = "lock:report:" + ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); Boolean got = redis.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMinutes(4)); // TTL 略小于执行间隔 if (!Boolean.TRUE.equals(got)) { log.debug("报告任务已被其他实例执行,跳过"); return; // 没抢到锁,直接跳过,不做任何事 } try { doGenerateReport(); // 真正干活 } finally { // 注意:TTL 会自动释放,这里不必手动删除,避免误删别的实例新抢的锁 } }}- 锁的 key 带上时间粒度(精确到分钟),保证同一时间点只有一次执行,下一次时间点又是新 key。
- TTL 要略小于执行间隔,防止锁残留导致下一轮无法执行。
- 若任务可能执行超过 TTL,必须引入续期(看门狗)机制,否则锁提前过期会造成并发执行。
第十节的代码是「读」的,这一节开始「按」。先把本篇最重要的那条生活类比补全,它会贯穿下面四个实验:
@Async 里抛出的异常为什么总是「消失」?把它想成你寄出去的快递丢了,收件人不会打电话告诉你。异步方法的执行线程就是那个收件人:主流程(你)把包裹交出去之后立刻转身走了,既没有留电话(返回值是 void),也没有约定回执(没有 Future)。包裹在半路烧掉(抛异常),除了快递公司自己的内部记录(日志),没有任何人会通知你。所以第五节那句「必须配 AsyncUncaughtExceptionHandler」不是风格建议,而是唯一能把这通电话补上的手段。
第四节的表格里写着「核心 → 队列 → 最大 → 拒绝」,但先入队再扩容这个顺序反直觉到几乎没人第一次就记对。用实验把它按出来:
看完这条曲线,再回头看第三节的结论就具体了:默认那个每任务一线程的执行器根本没有第 ② 步——它不排队,来一个就开一个,所以第四节讲的整套参数在它身上全是摆设。第十二节沙盘里 200/200 那一档「线程越堆越多、下游被打垮、停机排不空」的症状,就是这个默认行为放大之后的样子。
这两个是本篇事故报告里出现频率最高的两类,且都是静默的:
@Async 最常见的用法之一,是和事件监听配合:主流程只发事件,副作用交给监听器。注意同步发布的默认语义——很多新手以为「发了事件就不管了」,其实默认是同一条线程里挨个调用的:
而一旦异步方法里要写库,就必须直面第六节那条边界:事务搬不过去。用传播行为把它演出来:
最后回到资源本身。异步之所以能保住下单接口的响应时间,靠的是有界资源 + 排队这套模型;同一个模型在数据库连接上有个更出名的名字——连接池。看一眼它的借还现场,你会对「队列」这件事有更实的体感:
实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个容器,回答全部由内核算出来——先敲 beans 看看容器里有什么,再逐条 lab:
lab threadlocal lost 和 lab threadlocal fix 要连着敲才有对比——前者的时间线在第 ③ 步落到 null,后者在第 ③ 步是「回放成功」。同一个 @Async,差别只在执行器上有没有挂 TaskDecorator。
corePoolSize / maxPoolSize / queueCapacity 三个数互相咬合,光看表格很难有手感。下面这个沙盘把它们做成两档可调的组合(第三个数固定为队列 500),每次切换同时给出吞吐、排队、拒绝、停机丢任务四组读数:
activeCount: 16 queuedTasks: 42 completedTaskCount: 1.2k/minP99 下单接口耗时: 1.6s# 短信耗时被隔离在 notify-* 线程里,不影响主流程
数字是示意的,三条结论是真的——线程不是越多越好(超过下游承受力后吞吐反而下降,还会打垮依赖方);队列长度决定的是「你能容忍多久的延迟」,不是「能不能不丢任务」;拒绝策略的选择本质是「出错时让谁承担代价」:抛给调用方、丢给业务、还是悄悄丢掉。
最后把本篇的分工画清:注解只负责「把活交出去」,线程池才负责「活怎么跑完」——左边那一列是你写的代码,右边那一列才是所有参数、坑与调优真正落地的地方:

默认执行器像路边摊叫车——每来一个客人现找一辆车、跑完就把车砸了,客人多了只能满街乱抢(无限建线程);自定义线程池像有排班的出租车队——固定几辆车常驻(core)、候客区排队(queue)、高峰临时加车(max)、真加不动了就拒单或改派(rejection policy)。你在第十二节切的每一档,都只是在这支队伍的排班表上改数字。
先来一道热身题,考的是第四节那条最容易记反的顺序:
再来一道综合题,把第五、六、九、十节串起来:
下面每一行的「报错原文」都可以整段复制去搜索,别意译、别缩写。新手在这三个地方最容易卡住:任务被拒绝、异常找不到、定时任务不按时来。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 | |
|---|---|---|---|---|
org.springframework.task.support.TaskRejectedException: Executor java.util.concurrent.ThreadPoolExecutor@...[Running, pool size = 16, active threads = 16, queued tasks = 500, completed tasks = 8123] did not accept task: ... | 线程已到 max 且队列已满,默认的 AbortPolicy 直接把任务拒了。注意它是 Spring 对 RejectedExecutionException 的包装 | 先看括号里的三个数字判断是哪一类超载:queued tasks 顶格说明队列小了,active threads 顶格说明并发不够;再决定扩容还是换 CallerRunsPolicy | 本篇第四节 + 第十二节沙盘 `8/16 | AbortPolicy` 档 |
java.util.concurrent.RejectedExecutionException: Task ... rejected from java.util.concurrent.ThreadPoolExecutor[...] | 与上一条同源,只是你手动调 executor.submit() 或在容器外用了裸 JDK 线程池 | 统一交给 ThreadPoolTaskExecutor 管理,并给池起 threadNamePrefix,否则日志里认不出是哪个池 | 本篇第三节 | |
@Async 方法里抛了异常,控制台一行错误都没有,接口还返回 200 | 方法返回 void,异常没有回传通道,默认实现只是 error 级别打一行甚至被吞掉 | 实现 AsyncConfigurer#getAsyncUncaughtExceptionHandler(第五节代码),或把返回类型改成 CompletableFuture<T> 并在调用侧 exceptionally 处理 | 本篇第五节 · 第十一节 exc 实验 | |
明明加了 @Async,日志却显示发送短信的那行仍打在 http-nio-8080-exec-1 上(没换线程) | 三种情况之一:① 同类内 this.method() 自调用绕过代理;② 方法是 private/final;③ 忘了 @EnableAsync | 先确认线程名:异步一定换到 notify-*;再用 AopUtils.isAopProxy(bean) 验证注入进来的是不是代理;最后检查总开关 | 本篇第五节失效场景表 | |
| 定时任务「偶尔不准时」,日志里能看到前一次执行还没结束 | 默认调度器只有一个线程,长任务会把后面所有任务一起延后(不是丢失,是排队) | 注册一个多线程 ThreadPoolTaskScheduler(第九节代码),poolSize ≥ 期望并行数 | 本篇第九节 · 第十一节 sched 实验 | |
同一个 @Scheduled(cron=...) 任务在多副本部署下每个副本都跑了一遍,账单重复发出 | @Scheduled 是进程内调度器,实例之间毫无协调 | 短期用 Redis SETNX + 带时间粒度的 key(第十节代码);正式方案引入 ShedLock(@SchedulerLock)或调度中心 XXL-JOB | 本篇第十节 | |
ShedLock 报 Cannot acquire lock 但任务从没执行成功过 | 锁的 lockAtMostFor 短于任务实际耗时,或 lockAtLeastFor 设置导致本轮直接被跳过;也可能是多个实例时钟不同步 | 把 lockAtMostFor 设为「最长可能耗时 + 冗余」,并开启 NTP;首次上线先用日志确认到底哪个实例拿到了锁 | 本篇第十节三种解法对比表 | |
| 应用重启后发现队列里的一批异步任务凭空消失 | 停机时没有等待:waitForTasksToCompleteOnShutdown 未开启,或等待时长大于编排平台的 terminationGracePeriodSeconds 被强杀 | 三件套一起配:server.shutdown: graceful + timeout-per-shutdown-phase + 池级 setAwaitTerminationSeconds,且池的等待 < 平台宽限期 | 本篇第七节 | |
| 异步任务日志里没有 traceId,链路追踪断成一截一截 | MDC 存在 ThreadLocal,不会自动跨线程传递 | 给池设置 setTaskDecorator(new MdcTaskDecorator())(第六节代码),并注意执行完要 MDC.clear() 防止线程复用污染 | 本篇第六节 | |
java.lang.OutOfMemoryError: unable to create new native thread,栈顶指向 SimpleAsyncTaskExecutor | 生产环境沿用了默认执行器:每任务一个新线程且不复用、无上限,突发流量直接把线程数堆爆 | 立刻换成 ThreadPoolTaskExecutor 并显式设 core/max/queue;这是第三节的全部内容 | 本篇第三节 · 第十二节沙盘 |
这一表里最值得贴在工位上的是第一条——did not accept task 后面的中括号里直接写着 pool size / active threads / queued tasks 三个实时数字,它就是线程池的诊断仪表盘,看一眼就知道该扩线程还是该扩队列。
第六节说过「异步里取 request 一定会炸」,那就用一段真堆栈收个尾。下面这段是线上出现频率最高的一类,先别看答案,点出你认为的凶手行:
下单成功后异步发短信,日志里冒出一段异常,而下单接口全程 200。
目标:用一个能直接跑的 Spring 工程,把「主流程立即返回 → 异步线程执行 → 异常只在异步线程里可见」这条链完整打出来,并亲眼看到自调用会让异步退化成同步。
第一步,pom.xml(Java 17;换成 Boot 的 spring-boot-starter 也一样跑):
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>6.1.8</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <!-- TaskExecutor 在这个包里 --> <version>6.1.8</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.5.6</version> </dependency></dependencies>第二步,线程池 + 异常兜底(src/main/java/com/example/async/AsyncConfig.java)。一个类同时给出默认执行器和 void 异常的接住口:
package com.example.async;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.scheduling.annotation.AsyncConfigurer;import org.springframework.scheduling.annotation.EnableAsync;import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;import java.util.Arrays;import java.util.concurrent.Executor;@Configuration@EnableAsync // 总开关:不加则 @Async 完全无效public class AsyncConfig implements AsyncConfigurer { private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class); @Bean("notifyExecutor") public ThreadPoolTaskExecutor notifyExecutor() { ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(2); ex.setMaxPoolSize(4); ex.setQueueCapacity(10); ex.setThreadNamePrefix("notify-"); // 关键:线程名带业务前缀,日志才认得出 ex.setWaitForTasksToCompleteOnShutdown(true); ex.setAwaitTerminationSeconds(10); ex.initialize(); // 裸容器要自己调;Boot 会自动调 return ex; } /** @Async 不写名字时用哪个池 */ @Override public Executor getAsyncExecutor() { return notifyExecutor(); } /** void 异步方法的异常只有在这里才能被接住 */ @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> log.error("异步任务失败 method={} params={}", method.getName(), Arrays.toString(params), ex); }}第三步,异步服务(NotifyService.java)。它是被别的 Bean 调用的,所以调用会经过代理:
package com.example.async;import org.springframework.scheduling.annotation.Async;import org.springframework.stereotype.Service;@Servicepublic class NotifyService { @Async("notifyExecutor") public void sendNotify(Long orderId) { System.out.println("[notify] 线程=" + Thread.currentThread().getName() + " 发短信 orderId=" + orderId); } @Async("notifyExecutor") public void failingAsync() { throw new IllegalStateException("短信网关炸了"); // 没人接收的异常 }}第四步,主流程(OrderService.java),里面同时放了正例与反例:跨 Bean 调用 vs 同类自调用。
package com.example.async;import org.springframework.scheduling.annotation.Async;import org.springframework.stereotype.Service;@Servicepublic class OrderService { private final NotifyService notifyService; public OrderService(NotifyService notifyService) { this.notifyService = notifyService; // 构造器注入:出生即完整 } public void createOrder(Long orderId) { System.out.println("[main] 线程=" + Thread.currentThread().getName() + " 下单写库 orderId=" + orderId); notifyService.sendNotify(orderId); // 跨 Bean 调用 → 走代理 → 真异步 } public void selfInvokeDemo() { System.out.println("[main] 自调用开始 线程=" + Thread.currentThread().getName()); sendNotifyInline(2002L); // this.xxx() 绕过代理 → 同步执行 System.out.println("[main] 自调用结束 线程=" + Thread.currentThread().getName()); } @Async("notifyExecutor") public void sendNotifyInline(Long orderId) { System.out.println("[inline] 线程=" + Thread.currentThread().getName() + " 发短信 orderId=" + orderId); } public void boom() { notifyService.failingAsync(); // 交给异步线程,异常不会回到这里 }}第五步,启动器(Main.java):
package com.example.async;import org.springframework.context.annotation.AnnotationConfigApplicationContext;public class Main { public static void main(String[] args) throws InterruptedException { try (AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AsyncConfig.class, OrderService.class, NotifyService.class)) { OrderService svc = ctx.getBean(OrderService.class); long t0 = System.currentTimeMillis(); svc.createOrder(1001L); // 外部调用:走代理 System.out.println("createOrder 返回耗时 = " + (System.currentTimeMillis() - t0) + "ms"); svc.selfInvokeDemo(); // 内部自调用:不走代理 svc.boom(); // void 异步里抛异常 Thread.sleep(2000); // 给异步线程一点时间 } System.out.println("---- 容器已关闭 ----"); }}运行 main,预期输出(线程名是唯一的证据,逐行对照;[notify] 那行的先后可能因调度略有浮动):
[main] 线程=main 下单写库 orderId=1001createOrder 返回耗时 = 2ms[main] 自调用开始 线程=main[inline] 线程=main 发短信 orderId=2002 ← 自调用退化成了同步![main] 自调用结束 线程=main[notify] 线程=notify-1 发短信 orderId=1001 ← 跨 Bean 调用才是真异步ERROR c.example.async.AsyncConfig - 异步任务失败 method=failingAsync params=[]java.lang.IllegalStateException: 短信网关炸了 at com.example.async.NotifyService.failingAsync(NotifyService.java:19)---- 容器已关闭 ----验收清单:① 指出 [inline] 与 [notify] 两行的线程名差别,据此判断哪次调用真的走了异步;② 删掉 @EnableAsync 重跑——此时容器根本不会为这些 Bean 生成异步代理,你会观察到三处通知全打在 main 上、顺序变成严格的前后关系,而 IllegalStateException 不再出现在 AsyncConfig 的错误日志里,而是顺着调用栈一路抛回 main 并把程序打停(这恰好证明「异常有没有人接」取决于代理是否存在,而不是你写了多少 handler);③ 说清为什么「createOrder 返回耗时 = 2ms」而短信仍然发出去了。
每次只改一处,观察结论立刻翻转:
- 把
queueCapacity从 10 改成 0(无界等价于设很大,此处先用Integer.MAX_VALUE),再在循环里一次提交 5000 个任务。你会观察到:active threads永远停在corePoolSize=2,maxPoolSize=4从未生效,任务全在队列里堆着,内存一路涨——这就是第四节那个「无界队列让 max 失效」的坑。 - 把
queueCapacity改回 2、corePoolSize=2、maxPoolSize=4,然后并发提交 100 个任务。你会观察到:日志里出现TaskRejectedException ... did not accept task,且中括号里queued tasks = 2、active threads = 4双双顶格——对照第十二节沙盘的8/16|AbortPolicy档读这三个数字。 - 把拒绝策略换成
new ThreadPoolExecutor.CallerRunsPolicy(),其余不变。你会观察到:拒绝异常消失,但主线程被迫自己去跑任务,createOrder 返回耗时从 2ms 涨到几百毫秒——「一条不丢」的代价由提交方承担,即沙盘里说的天然背压。 - 加一个
@Scheduled(fixedRate = 1000)的方法,里面Thread.sleep(4000);同时再加一个每 2 秒打印一次的轻量任务。你会观察到:轻量任务也跟着每 4 秒才打一次——默认调度器只有一条线程。注册第九节那个ThreadPoolTaskScheduler(poolSize=4)后两条线各自独立。 - 在
failingAsync()里加@Transactional,让它先插一条记录再抛异常,同时让createOrder所在的主流程也带事务。你会观察到:两边回滚互不影响,异步那一笔的写入按自己的事务规则处理——第六节边界的实证,配合txprop实验的REQUIRES_NEW一起看。
提示:做完第 2、3 条再回到第十二节沙盘,把两个开关分别切到对应的档位,两边读数应当完全对得上。
给自己写一个「异步任务观测台」组件,以后任何项目的线程池状态都能一眼看见、一键关掉。
需求:
- 一个
PoolMetricsReporter,每 5 秒打印每个ThreadPoolTaskExecutor的:poolSize / activeCount / queueSize / queueRemainingCapacity / completedTaskCount / rejectCount rejectCount不能靠猜:包一层自定义RejectedExecutionHandler,累加计数后再委托给原始策略(保留可配置 Abort / CallerRuns / DiscardOldest 三种)- 支持通过配置文件给不同的池设不同参数,并且启动时校验:若
queueCapacity == Integer.MAX_VALUE或maxPoolSize <= corePoolSize,直接打印一条明确的 WARN(把第四节和第十二节的两个坑变成开机自检) - 集成
TaskDecorator搬运 MDC,并在异步任务的日志里带上提交时间,方便算「排队时长 = 开始执行 − 提交时刻」 - 优雅停机时打印「还剩多少任务未跑完 / 实际等待了多少秒」,若超过
awaitTerminationSeconds则明确告警
验收清单:① 压测时 queueSize 应随流量起伏,且能在拒绝发生的那一刻看到 rejectCount 递增;② 故意把队列设为 Integer.MAX_VALUE,启动必须打出那条 WARN;③ 停机日志里能看到「剩余 N 条、等待 Xs」;④ 异步任务日志的 traceId 与触发它的 HTTP 请求一致(MDC 搬运成功);⑤ 全部逻辑不修改任何业务方法。
不看上文,按顺序说出任务提交后的四个阶段,并解释为什么「先入队、再扩容」会让 maxPoolSize 在大队列下失效。
@Async 的 void 方法抛出异常后,这条异常经过了哪些对象、最终落在哪里?你有几种方式把它接住?
同一个类里调用自己的 @Async 方法会发生什么?这和 @Transactional 自调用失效是同一个原因吗?
MDC、SecurityContext、事务上下文三者,哪些能搬到异步线程、用什么搬、哪个搬不了?搬不了的根本原因是什么?
fixedRate 与 fixedDelay 相差的是「从哪一刻起算」?当一个任务耗时超过间隔时,两者的表现分别是什么?
多副本部署下 @Scheduled 为什么会重复执行?Redis 锁的 key 为什么要带时间粒度、TTL 为什么要略小于执行间隔?
注解只管交活,池子才定生死——先排队再加人,加满就拒;void 的异常是丢了不报警的快递;线程搬得动数据,搬不动事务;多实例的定时任务,先抢锁再干活。
@Scheduled 任务内部抛异常,不会中断后续调度——下一次到点照常执行,这本身是好事;但异常默认也不会让任务"停止",所以务必在任务内 try-catch 并打详细日志,否则你会看到"任务好像没跑",其实是每次都静默失败了。
fixedRate 的语义是"按开始时刻的频率",长任务若超过间隔,会连着执行、任务堆积(注意:不会并发,因为单线程调度器会等上一个跑完)。若你不希望堆积,改用 fixedDelay,它以上一次结束为基准计算间隔。
本节的干货可浓缩成四句——@Async / @Scheduled 只是"提交方式",真正的行为由线程池(或调度器)决定;@Async 必须自定义线程池、必须处理 void 异常、必须注意自调用失效;上下文(MDC / SecurityContext / 事务)不会自动跨线程;多实例部署下定时任务会重复执行,必须用分布式锁或调度中心防重。把这四点固化下来,异步与定时就不再是"时好时坏的玄学"。