Spring 任务调度与异步执行详解
定位:讲透线程池抽象(TaskExecutor)、@Async 异步机制与上下文问题、@Scheduled 调度模型与分布式注意、高频陷阱
适用版本:Spring Framework 6.x(JDK 17+)
说明:本篇讲 Spring 层的用法与坑
目录
- 一、线程池抽象
- [二、@Async 异步执行](#二、@Async 异步执行)
- [三、@Scheduled 定时任务](#三、@Scheduled 定时任务)
- 四、陷阱与纪律
- 五、总结
- 六、常见高频面试题
一、线程池抽象
1.1 TaskExecutor 体系
Spring 把"异步执行"抽象为 TaskExecutor(继承 JDK Executor),调度抽象为 TaskScheduler:
| 实现 | 特点 | 使用建议 |
|---|---|---|
ThreadPoolTaskExecutor |
包装 JDK ThreadPoolExecutor,主力 | 显式配置后用于 @Async |
ScheduledThreadPoolTaskScheduler |
定时调度池 | @Scheduled 专用 |
SimpleAsyncTaskExecutor |
每次新建线程,不复用 | 仅测试,生产禁用 |
1.2 必配的五个参数
java
@Bean("bizExecutor")
public ThreadPoolTaskExecutor bizExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setMaxPoolSize(32);
ex.setQueueCapacity(200);
ex.setThreadNamePrefix("biz-async-"); // 排障必需:有名线程
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
ex.setWaitForTasksToCompleteOnShutdown(true); // 优雅停机
ex.setAwaitTerminationSeconds(30);
return ex;
}
要点:
- 线程命名是纪律 :无名线程池(
pool-1-thread-3)在生产排障时是噩梦; - 拒绝策略是决策:CallerRunsPolicy(调用方执行,天然降速)适合不允许丢的任务;Abort 抛异常快速失败;Discard 静默丢(慎用);
- 队列容量不是越大越好:大队列 = 任务延迟堆积 + 内存风险,宁可拒绝也要暴露过载。
二、@Async 异步执行
2.1 用法与本质
① @EnableAsync 开启(Boot 中加即可)
② 方法标 @Async,可指定线程池:@Async("bizExecutor")
③ 调用该方法时,代理把调用提交到线程池,立即返回
本质是 AOP 代理(FW-03)------所以自调用失效在这里同样成立:同类内部调用 @Async 方法,同步执行。
2.2 返回值与异常
java
@Async
public void fire() { ... } // 异常默认只打日志,调用方无感知
@Async
public CompletableFuture<Result> calc() { // 异常封装在 Future
return CompletableFuture.completedFuture(doCalc());
}
| 返回 | 异常去向 |
|---|---|
| void | 交给 AsyncUncaughtExceptionHandler(默认打日志) |
| CompletableFuture | 封装在 Future,调用方 get/exceptionally 处理 |
纪律:关键异步逻辑要么返回 Future 让调用方可感知,要么配置全局异常处理器 + 告警,不能静默丢。
2.3 上下文不传播(最重要的一组坑)
异步切换线程后,以下上下文默认全部丢失:
| 上下文 | 影响 | 解法 |
|---|---|---|
| 事务 | @Async 内的操作不在原事务中 | 异步方法自开事务,或事件 @TransactionalEventListener |
| 安全上下文(Security) | 拿不到当前用户 | DelegatingSecurityContextExecutor 装饰 |
| 请求属性/ThreadLocal(traceId) | 链路断 | 显式传参或上下文快照装饰器 |
| MDC 日志上下文 | 日志串不起来 | TaskDecorator 复制 MDC |
TaskDecorator 是统一的解法入口:
java
ex.setTaskDecorator(runnable -> {
Map<String, String> mdc = MDC.getCopyOfContextMap(); // 提交时快照
return () -> {
try { MDC.setContextMap(mdc); runnable.run(); } // 执行时恢复
finally { MDC.clear(); }
};
});
2.4 @Async 与循环依赖
02 篇已述:@Async 代理生成时机与三级缓存提前暴露冲突,循环引用 @Async Bean 会启动报错。解法:重构打破循环、@Lazy、或把 @Async 方法拆到独立 Bean。
三、@Scheduled 定时任务
3.1 三种触发方式
java
@Scheduled(cron = "0 0 2 * * ?") // 每天 2 点(6 位:秒 分 时 日 月 周)
@Scheduled(fixedRate = 60000) // 每 60 秒触发一次(不等上次完成)
@Scheduled(fixedDelay = 60000) // 上次【结束】后 60 秒再触发
辨析:fixedRate 按固定频率触发,若任务执行超过间隔会顺延排队;fixedDelay 保证两次执行间有固定间隔,永不重叠。可能超时的任务用 fixedDelay。
3.2 默认单线程陷阱
默认调度器只有 1 个线程!
任务 A 执行 5 分钟 → 任务 B、C 全部排队等待
必须配置调度线程池:
# Boot
spring.task.scheduling.pool.size = 8
3.3 分布式环境的重复执行
应用部署 3 实例 → @Scheduled 在 3 台各跑一次 → 重复执行
对策(按复杂度递增):
- 分布式锁:执行前抢锁(Redis/DB),抢到才执行;
- 调度中心:任务从应用里抽出(归中间件知识库的调度平台范畴);
- 业务幂等:允许重复执行但结果一致。
四、陷阱与纪律
汇总清单("现象 → 根因 → 解法"):
| 现象 | 根因 | 解法 |
|---|---|---|
| @Async 同步执行了 | 自调用绕过代理 / 未 @EnableAsync / 方法非 public | 跨类调用、检查开关与可见性 |
| 异步任务异常无感知 | void 返回 + 无异常处理器 | Future 返回或全局 AsyncUncaughtExceptionHandler |
| 异步里拿不到登录用户/事务/traceId | 上下文不跨线程 | 装饰器快照恢复 / 显式传参 |
| 启动报循环依赖(@Async Bean) | 代理时机与提前暴露冲突 | 拆 Bean / @Lazy |
| 定时任务互相延迟 | 默认单线程调度 | 配置调度池大小 |
| 停机丢在途异步任务 | 未配置等待完成 | waitForTasksToCompleteOnShutdown + 等待时长 |
| 任务积压越来越多 | 无界/过大队列 + 生产速度超消费 | 有界队列 + 拒绝策略 + 监控队列水位 |
五、总结
- 线程池抽象:ThreadPoolTaskExecutor 是主力;线程命名、有界队列、拒绝策略、停机等待是四个必配纪律;SimpleAsync 生产禁用。
- @Async:本质是 AOP 代理(自调用失效);void 返回的异常默认静默;事务/安全/traceId 等上下文不跨线程,用 TaskDecorator 快照恢复或显式传递。
- @Scheduled:cron/fixedRate/fixedDelay 三选一,超时任务用 fixedDelay;默认单线程调度必须扩池;多实例部署必须解决重复执行(分布式锁/调度中心/幂等)。
- 停机与积压:优雅停机等待在途任务;队列水位是核心监控指标。
六、常见高频面试题
1. @Async 的原理是什么?为什么会失效?
要点:@Async 靠 AOP 代理实现------@EnableAsync 注册异步注解处理器(BeanPostProcessor),为标注方法生成代理,调用时把任务提交到线程池立即返回。失效场景与 AOP 一致:同类自调用绕过代理、未开启 @EnableAsync、方法非 public、对象不是 Spring Bean。另外 @Async Bean 参与循环依赖时,代理生成时机与三级缓存提前暴露冲突会启动报错。
2. @Async 方法里能拿到原方法的事务、登录用户、traceId 吗?
要点:默认都不能------异步切到新线程执行,事务上下文(绑定原线程的连接)、SecurityContext、ThreadLocal/MDC 都不自动传播。解法:事务让异步方法自行开启或改用 @TransactionalEventListener;安全上下文用 DelegatingSecurityContextExecutor 装饰;MDC/自定义上下文用 TaskDecorator 在提交时快照、执行时恢复;简单场景直接显式传参。
3. fixedRate 和 fixedDelay 的区别?
要点:fixedRate 按固定频率触发,不等上次完成------任务执行超过间隔会顺延(可能排队堆积);fixedDelay 在上次执行结束后再等固定延迟才触发------保证不重叠。任务耗时稳定且短用 fixedRate(频率精确),任务可能超时或耗时波动用 fixedDelay(安全)。两者都受调度线程池大小制约。
4. Spring 定时任务默认有什么问题?生产怎么配?
要点:默认调度器只有 1 个线程,多个 @Scheduled 任务互相阻塞排队;且多实例部署时每个实例都会执行,造成重复。生产配置:① 扩大调度线程池(spring.task.scheduling.pool.size);② 重复执行治理------分布式锁抢执行权、接入调度中心、或业务幂等;③ 任务加监控(执行耗时、失败率、漏跑)。
5. 线程池的拒绝策略怎么选?
要点:按"任务能否丢"决策。CallerRunsPolicy:调用线程自己执行,天然降速反压,适合不允许丢且能接受变慢的;AbortPolicy:抛异常快速失败,让调用方感知过载,适合需要及时响应的;DiscardPolicy:静默丢弃,只适合可丢的统计/埋点类。同时要配有界队列------大队列把过载藏起来变成延迟堆积与内存风险;队列水位是必监控指标。
6. void 的 @Async 方法抛异常会怎样?
要点:调用方拿不到任何异常(已返回),异常交给 AsyncUncaughtExceptionHandler 处理,默认实现只打日志------等于静默丢失。正确做法:关键逻辑返回 CompletableFuture 让调用方用 exceptionally/whenComplete 感知;或自定义 AsyncConfigurer 注册全局异常处理器做告警/落库;同时监控异步失败率。
7. 如何实现优雅停机不丢异步任务?
要点:ThreadPoolTaskExecutor 配置 setWaitForTasksToCompleteOnShutdown(true) 与 setAwaitTerminationSeconds(N):容器关闭时停止接收新任务、等待存量任务完成(最长 N 秒)再关闭。配合发布侧的摘流与宽限期,保证在途异步任务跑完。不配置则停机时线程池直接关闭,队列与执行中任务丢失。
8. 为什么说 SimpleAsyncTaskExecutor 不能用于生产?
要点:它不复用线程,每次提交都新建线程------高并发下线程数失控(创建销毁开销 + 内存 + 调度压力),且无队列与拒绝控制。它的存在意义是测试与极轻场景。生产一律用显式配置的 ThreadPoolTaskExecutor:核心/最大线程数、有界队列、命名、拒绝策略、停机等待。
9. 定时任务执行时间超过了间隔会发生什么?
要点:取决于触发方式与线程池。fixedRate 场景:上次未完成就到下一次触发,任务会在调度线程上顺延排队(同一任务默认不并发执行),间隔语义实际变成"执行完立即下一次";若多个任务共享默认单线程,还会互相阻塞。对策:任务改 fixedDelay、优化任务耗时、扩调度线程池、或把任务改异步/分布式。监控任务执行时长与调度延迟可提前发现。
10. 异步执行和消息队列都能解耦耗时操作,怎么选?
要点:按可靠性与范围选。@Async 是进程内线程池:无持久化、进程死即丢、无法跨服务、容量受单机线程池限制,适合"失败可接受"的轻量旁路(记日志、推通知)。消息队列跨进程、持久化、可重试、削峰填谷、天然跨服务,适合可靠投递与流量整形。常见组合:事务提交后发消息(@TransactionalEventListener + MQ),兼顾解耦与可靠。
