异步任务、线程池与定时任务

bee2026-10-0878 分钟0 次阅读
@Async 为什么默认不好用?自定义线程池、拒绝策略、上下文传递、优雅关闭;再配 @Scheduled 的三种调度方式与分布式下的重复执行问题。
1 / 168
小节
〇、30 秒看懂
2 / 168

这一篇只解决一件事:有些活不必让用户等着。下单要写库——这是主流程,用户必须等;下单后要发短信、发邮件、算积分——这些活和「下单成功」没关系,挪到后台慢慢做就行。Spring 给了两个注解来做这件事:@Async(别人帮我跑,我不等)和 @Scheduled(到点自己跑)。

3 / 168

但这两个注解本身不产生线程,它们只是「把任务交出去」。真正决定成败的是接住任务的那个东西——线程池。所以本篇一半篇幅在讲线程池,这不是跑题,而是重点。

4 / 168

先给五个词一句话解释(全文都会用到):

5 / 168
  • 线程:一条正在干活的执行线。你的请求默认跑在 Tomcat 的工作线程上,异步就是再另开一条线去跑
  • 线程池:一组预先造好、反复复用的线程,像排班好的工人队伍;任务是活儿,谁空谁领
  • 核心线程(corePoolSize):池子里常驻不走的那几个人,空闲了也不解散
  • 工作队列(queueCapacity):所有常驻的人都在忙时,新任务先在这里排队等叫号
  • 最大线程(maxPoolSize):队也排满了,才临时增聘人手,上限就是这个数
  • 拒绝策略(RejectedExecutionHandler):人和队都满了还来任务,怎么处理——抛异常、让提交者自己干、直接丢、还是丢最老的
6 / 168
类比

整篇可以钉在一个外卖平台上。核心线程是站里的正式骑手(固定几个,随叫随到);工作队列是爆单时挂在墙上的取餐小票(单子先挂着,骑手回来取);最大线程是高峰期临时叫来的众包骑手(只在墙挂满时才上场,闲了就退回);拒绝策略是彻底接不动时的四种规则——告诉顾客「下不了单」(抛异常)、店员自己骑车送(调用方线程执行)、默默撕单(丢弃)、退掉最早那张票只留最新(丢最旧)。而定时任务呢?它是每天固定时间自动开工的单子:如果站里只有一个骑手值班,他送一单慢的,后面所有定时单全得等——这就是第九节那个坑的全部真相。

7 / 168
架构图
图 · 默认执行器 vs 自定义线程池
图 · 默认执行器 vs 自定义线程池
8 / 168

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

9 / 168
原理动画
动图 · 任务提交的四个阶段(先入队,再扩容)
动图 · 任务提交的四个阶段(先入队,再扩容)
10 / 168

学完这一篇,你应该能回答三个问题:

11 / 168
  • 一个 @Async 方法里抛了异常,为什么日志和响应里都看不到?该加什么才能接住它?
  • 同一个类里 this.sendSms() 调自己的 @Async 方法,为什么同步执行了?
  • 服务起了 3 个副本,为什么每天的对账任务跑了 3 遍?怎么让它只跑一遍?
12 / 168
小节
一、从一次接口超时说起
13 / 168

线上反馈"下单接口偶尔要 8 秒才返回"。排查后发现:下单主流程里,紧接着写库之后,还同步调用了两个外部接口——发短信和发邮件。这两件事各自要 1~3 秒,而且和"下单成功"这个结果毫无关系:用户只需要知道下单成功,短信晚两秒发出去完全没关系。

14 / 168
对照表
方案主流程耗时用户体验短信失败的影响
全部同步1.5s + 2s + 3s ≈ 6.5s卡到想关页面短信服务抖动直接导致下单失败
短信 / 邮件异步≈ 1.5s秒回失败只写日志,不影响下单
15 / 168

这就是异步任务要解决的典型问题:把"与主流程结果无关、且耗时较长"的操作挪到后台线程,主流程立即返回。 Spring 给了两个注解来实现:@Async(异步执行)和 @Scheduled(定时执行)。它们用起来极简,但坑也极多,本节把两个注解从用法到线程池、从上下文传递到分布式重复执行全部讲透。

16 / 168
架构图
图 1 · 异步任务的两大执行模型
图 1 · 异步任务的两大执行模型
17 / 168
小节
二、@Async 起步:两行注解开启异步
18 / 168

第一步,在启动类或任意配置类上加 @EnableAsync,第二步,把要异步的方法标记为 @Async:

19 / 168
代码对照
代码java
@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 会为它生成一个代理,调用时把方法体丢进线程池执行。
20 / 168

返回值决定了调用方能拿到什么,这是最容易用错的地方:

21 / 168
对照表
返回类型调用方能力适用场景
void完全不管结果,异常也不会传回发短信、写日志等"即发即忘"
Future<T>可 get() 阻塞等结果已过时,不推荐
CompletableFuture<T>可组合、可回调、可超时需要编排多个异步结果时首选
22 / 168
原理动画
动图 · 一个异步任务的旅程
动图 · 一个异步任务的旅程
23 / 168

两行注解开了异步之后,真正要落地的是一串配置。别去抄别人的 yml——勾选你要的那些,生成一份,并且每一条都告诉你为什么存在、去掉会怎样:

24 / 168
生成器
生成器把异步与定时该配的一次配全application.yml2 / 4
先只勾「异步线程池」看最小可用配置;再叠加「服务器」与「优雅停机」相关项,对照第七节的停机链路看每一行落在哪一层
产物
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 走虚拟线程
勾了这些,代价与理由在这里
serverserver.port 被命令行 --server.port=8081 覆盖,也吃 SERVER_PORT 环境变量。
taskspring.threads.virtual.enabled=true 才让 @Async 走虚拟线程(Boot 3.2+,需 JDK 21)。
25 / 168
小节
三、为什么必须自定义线程池
26 / 168

@Async 默认用的执行器是 SimpleAsyncTaskExecutor,看名字很美好,看源码会吓一跳:它每来一个任务就 new Thread() 新建一个线程,且不复用、无上限。

27 / 168
java
// 简化示意:SimpleAsyncTaskExecutor 的核心行为protected void doExecute(Runnable task) {    Thread thread = createThread(task, this.threadNamePrefix);   // 每次都新建    thread.start();}
28 / 168

后果清单:

29 / 168
  • 不限制并发:瞬间一万个请求就新开一万个线程,直接把 CPU 与内存打爆,甚至 OOM。
  • 不复用线程:每个任务一生一死,线程创建/销毁的开销全部浪费。
  • 无法观测与治理:没有队列、没有拒绝策略、没有监控指标。
30 / 168

所以生产环境必须自定义线程池。用 ThreadPoolTaskExecutor:

31 / 168
代码对照
代码java
@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,你立刻知道这是异步通知线程。
32 / 168
小节
四、线程池参数与拒绝策略
33 / 168

线程池的工作顺序是:核心线程 → 工作队列 → 最大线程 → 拒绝策略。很多人以为"任务多了就开新线程到 max",其实是对外错误理解:

34 / 168
对照表
阶段触发条件行为
1线程数 < core新建核心线程执行
2线程数 = core 且队列未满任务进队列排队
3队列已满且线程数 < max新建临时线程执行
4队列已满且线程数 = max触发拒绝策略
35 / 168

上表这四行就是下面这段动画的脚本,逐帧对照看第 ②→③ 帧:中间隔的是「队列满了」这个条件,而不是「核心线程忙」——这一步记住了,第四节后面所有关于容量的讨论都不会再绕。

36 / 168
原理动画
动图 · 任务提交的四个阶段(先入队,再扩容)
动图 · 任务提交的四个阶段(先入队,再扩容)
37 / 168
坑

无界队列会让 maxPoolSize 永远失效。 如果队列容量设为 Integer.MAX_VALUE(LinkedBlockingQueue 默认就是这个),任务会无限堆积,永远到不了"队列满 → 扩到 max"这一步,最大线程数形同虚设,最后是内存被堆爆。一定要给队列设一个有限容量。

38 / 168

四种种拒绝策略,行为各异:

39 / 168
对照表
策略行为适用场景
AbortPolicy(默认)直接抛 RejectedExecutionException希望快速失败、能被上层感知
CallerRunsPolicy让提交任务的线程自己执行希望"降级为同步",保证任务不丢
DiscardPolicy静默丢弃,无任何提示允许丢的、非关键任务
DiscardOldestPolicy丢弃队列最老的任务,再重试提交只关心最新数据的场景
40 / 168

上表把四阶段写成了四行字,但"哪一步先发生"这种事,读是读不进去的,得点。下面这张图每一格都能点,点下去就是那一格在干什么:

41 / 168
交互图解
流程任务提交的四阶段(点着看)1 / 5
从 ① 点到 ⑤,注意第 ② 格——中间隔的是「队列满了」,不是「核心线程忙」
→
→
→
→
① 线程数 < core
每来一个任务就新建一条核心线程,一直到 corePoolSize。这一步没有任何悬念,也是最容易被误认为「一直会这样」的一步。
全部看懂了调参只有两个抓手:队列决定「等多久」,max 决定「最多几个人同时干」。
42 / 168

想看这三个数字互相怎么牵制,直接把队列容量拖一遍——同一个 @Async,队列从 0 拖到 2 万,命运完全不同:

43 / 168
参数调节台
调节台队列容量拖一动,行为全变
spring.task.execution.pool.queue-capacity
100个任务当前 0 – 20000
小队列:先攒一点,攒不下再加人
  • 突发小高峰被队列吸收,不至于立刻加线程
  • 队列满后 max 会被真正用上——这是它唯一的价值
  • 排队延迟有上界,可预估、可告警
  • 生产上最常配的量级
想让任务早点被处理就减小队列,想让系统别炸就设上限——两头都得管。
44 / 168
小节
五、@Async 失效场景大全
45 / 168

@Async 和 @Transactional 一样依赖代理,一旦调用没走代理,注解就"消失"了:

46 / 168
对照表
失效场景原因修复
同类内自调用this.method() 绕过代理把异步方法抽到另一个 Bean,或注入自身
方法是 private / final无法被代理重写改为 public、非 final
返回类型是基本类型异步只能包装为 void/Future用 CompletableFuture<T> 或 void
void 方法抛异常被吞无人接收返回的异常配置 AsyncUncaughtExceptionHandler 打日志
Bean 不是 Spring 管理new 出来的对象没有代理交给容器管理
忘了 @EnableAsync总开关没开加上注解
47 / 168

void 方法里抛出的异常,默认悄无声息地消失,必须显式接住:

48 / 168
代码对照
代码java
@Configuration@EnableAsyncpublic class AsyncConfig implements AsyncConfigurer {    @Override    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {        return (ex, method, params) ->            log.error("异步任务执行失败 method={} params={}", method.getName(), params, ex);    }}
解读
  • 有了它,void 异步方法里的异常至少能进日志,而不是凭空蒸发。
49 / 168

上面那张表有六行,背是背不住的——「注解失效」这件事,只有亲手配错过一次才记得住。来玩一局:先点左边的现象,再点你认为的病因,配错了当场告诉你为什么。

50 / 168
配对闯关
闯关@Async 失效:现象配病因已配对 0/6 · 配错 0
左边六个现象都是真实工单里的描述,右边是它们的病因
先点左边一个
51 / 168
小节
六、上下文传递:ThreadLocal 不跨线程
52 / 168

回看第三节的核心事实:异步任务跑在另一个线程上。而 Spring / SLF4J / 事务的很多上下文都存在 ThreadLocal 里,默认不会自动传过去:

53 / 168
  • MDC 的 traceId 丢失:主线程打的日志有 traceId,异步线程的日志却没有,链路断了。解法是用 TaskDecorator 在任务提交时复制 MDC:
54 / 168
代码对照
代码java
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 搬、事务搬不过去也不用搬。 尤其最后一条:不要指望"主流程回滚,异步任务也回滚",它们本就是两笔独立的事务。

55 / 168

上面这三条是结论,但"为什么断"这件事,看结论是看不进去的。下面这个实验在浏览器里真跑一遍:同一段代码,看它在 exec-3 和 task-1 两条线程上分别读到什么。

56 / 168
内核实验
TeaVM上下文为什么过不了线程未启动
先选「子线程读到 null」看断在哪一环;再切「随任务搬运上下文」看正解,最后切「异步里取 Request」看框架那句报错
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
57 / 168

再把它摊成一次单步执行。左边是你要调试的那六行,右边同步刷新「此刻的变量」和「调用栈」——连点「下一步」,盯住线程名从 exec-3 变成 task-1 的那一瞬间:

58 / 168
单步调试台
单步台跟着调试器走一遍:42 是怎么丢在半路的1 / 6
点「下一步」五次,注意第 4 步线程名换了
被调试的代码
1UserContext.set(userId); // 此刻在 http-nio-8080-exec-3
2notifier.sendAsync(order); // 这一跳走的是代理
3executor.submit(wrap(order)); // 任务交给线程池,主线程立刻返回
4// ---- 镜头切到 task-1 ----
5Long uid = UserContext.get(); // 同一个 ThreadLocal,换一个 map
6log.info("send to {}", uid); // uid = null
此刻的变量
线程名http-nio-8080-exec-3
UserContext42
singletonObjects184 个 Bean
调用栈
1OrderController.pay
2- request thread
1拦截器早先把当前用户放进了这条线程自己的 ThreadLocalMap。注意存储是「每条线程一份」,不是全局一张表。
59 / 168

上面第 ② ③ 步那个「经过代理」和「交给池」的动作,可以切到内核实验里单独看一遍——它会把 AsyncExecutionInterceptor 的每一步打给你看:

60 / 168
内核实验
TeaVM任务到底是怎么交出去的未启动
选「任务提交」看代理到 executor 的完整链路;切「队列堆积」正好解释上面 tuner 里那一档
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
61 / 168

搬运的正确姿势画成一张图,四个动作缺一不可(尤其是第 ④ 步的「还清」):

62 / 168
架构图
图 · 上下文怎么跟着任务过线程
图 · 上下文怎么跟着任务过线程
63 / 168
小节
七、优雅关闭:别让停机丢任务
64 / 168

应用收到停机信号时,如果直接杀进程,队列里还没跑完的异步任务会全部丢失。两个设置配合,构成完整停机链路:

65 / 168
yaml
server:  shutdown: graceful        # 1. Web 层:停止接收新请求,处理完在途请求spring:  lifecycle:    timeout-per-shutdown-phase: 30s   # 2. 给在途请求的处理时限
66 / 168
代码对照
代码java
// 3. 线程池层:等待队列任务跑完再关executor.setWaitForTasksToCompleteOnShutdown(true);   // 关闭时先处理完队列executor.setAwaitTerminationSeconds(30);              // 最多等 30 秒,超时强制结束
解读
  • 第一个设置管的是 HTTP 请求,第三个设置管的是异步任务,缺一不可。
  • 设置等待时长要小于编排平台的 terminationGracePeriodSeconds(如 K8s 默认 30s),否则容器会被强杀,等待白设。
67 / 168
小节
八、@Scheduled 全解:三种调度方式
68 / 168

定时任务用 @EnableScheduling 开启,然后 @Scheduled 标注方法。它有三种触发方式,语义差别很大:

69 / 168
对照表
方式语义上一次执行超过间隔会怎样
fixedRate按固定频率触发,上一次开始的时刻算起不会并发,但会立即补上下一次,任务堆积
fixedDelay上一次结束后再等固定时间触发永远不会堆积,自然顺延
cron按 cron 表达式在特定时刻触发同 cron 语义,注意多实例问题
70 / 168

时间轴示意:假设任务耗时 3 秒,间隔 2 秒。

71 / 168
代码对照
代码
fixedRate:  开始0  ———3———  开始3(0+2 早已到,立刻执行)———6———fixedDelay: 开始0  ———3———  等2秒  开始5  ———8———  等2秒  开始10
解读
  • fixedRate 的"频率"是从开始时刻算的,长任务会造成"上一次还没完、下一次已到期"的堆积(虽然不会并发,但会连着跑)。
  • fixedDelay 从结束时刻算间隔,天然不堆积,大多数场景更符合直觉。
72 / 168

cron 表达式是六段式:秒 分 时 日 月 周(注意 Java 的 cron 比 Linux 的多了"秒"这一段):

73 / 168
对照表
表达式含义
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 分钟
74 / 168

initialDelay 用于推迟第一次执行,常用于"等应用完全就绪再跑":

75 / 168
java
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)public void cleanExpiredTokens() {    // 启动 60 秒后才第一次执行,之后每 5 分钟一次(上次结束后算起)}
76 / 168
小节
九、定时任务线程池:默认单线程的坑
77 / 168

@Scheduled 默认使用单线程的 TaskScheduler。这意味着:所有定时任务共用一个线程,谁跑得久谁就阻塞别人。

78 / 168
坑

一个每 5 分钟执行一次、耗时 4 分钟的报表任务,会让所有其他定时任务一起"迟到"——因为它们在同一个线程里排队。

79 / 168

解法是自定义一个有多个线程的 TaskScheduler:

80 / 168
代码对照
代码java
@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;    }}
解读
  • 池大小要 ≥ 期望并行执行的任务数,否则又回到"互相阻塞"的老问题。
81 / 168

这段动画把这个坑演了一遍——注意第 ③ 帧:任务 B 到点了却没人执行,它不是失败了,是在等那条唯一的线程。

82 / 168
原理动画
动图 · 定时任务默认只有一条线程
动图 · 定时任务默认只有一条线程
83 / 168

把这个数字拖一下就能看清「1」意味着什么:

84 / 168
参数调节台
调节台调度器给你几条线程
spring.task.scheduling.pool.size
1条当前 1 – 16
全部串行:一个卡住,全体迟到
  • 默认值就是 1,所有 @Scheduled 共用 scheduling-1
  • 某个任务里一次慢 HTTP 调用,后面每个任务都跟着迟到
  • 表现是「任务偶尔没跑」,其实是跑晚了——日志里线程名永远是同一个
  • cron 表达式本身没错,错在只有一条线程
被拖累的任务88%
线程利用率100%
先问「有几个长任务」,再决定线程数——不是先加线程。
85 / 168
小节
十、分布式下的定时任务:重复执行的噩梦
86 / 168

以上都建立在"单实例"假设上。一旦服务部署多个副本,每个副本都会触发同一个定时任务:每 5 分钟的报表被跑了 3 遍,发出去的账单重复了。三种解法:

87 / 168
对照表
解法原理优缺点
数据库乐观锁更新时带版本号,只有一个实例能成功简单,但有数据库压力,粒度粗
Redis 分布式锁SET key value NX EX ttl 抢占执行权轻量快速,需处理锁过期与续期
调度中心(XXL-JOB)由中心统一调度,指定某个执行器执行功能全、可观测,但需额外部署运维
88 / 168

一个基于 Redis SETNX 的轻量防重实现:

89 / 168
代码对照
代码java
@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,必须引入续期(看门狗)机制,否则锁提前过期会造成并发执行。
90 / 168
内核实验
TeaVM线程与事务边界对照实验未启动
思考:@Async 方法里能拿到调用方的事务吗?用传播行为演示理解边界
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
91 / 168
小节
十一、把线程池摊在手上:四个可以按的实验
92 / 168

第十节的代码是「读」的,这一节开始「按」。先把本篇最重要的那条生活类比补全,它会贯穿下面四个实验:

93 / 168
类比

@Async 里抛出的异常为什么总是「消失」?把它想成你寄出去的快递丢了,收件人不会打电话告诉你。异步方法的执行线程就是那个收件人:主流程(你)把包裹交出去之后立刻转身走了,既没有留电话(返回值是 void),也没有约定回执(没有 Future)。包裹在半路烧掉(抛异常),除了快递公司自己的内部记录(日志),没有任何人会通知你。所以第五节那句「必须配 AsyncUncaughtExceptionHandler」不是风格建议,而是唯一能把这通电话补上的手段。

94 / 168
小节
11.1 提交、堆积、拒绝:一条曲线走完四阶段
95 / 168

第四节的表格里写着「核心 → 队列 → 最大 → 拒绝」,但先入队再扩容这个顺序反直觉到几乎没人第一次就记对。用实验把它按出来:

96 / 168
内核实验
TeaVM任务提交的四个阶段未启动
依次切 submit / queue / reject:submit 看常驻线程怎么一个个建起来,queue 看核心满了任务其实进了队列而不是新增线程,reject 看人和队都满时策略怎么触发
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
97 / 168

看完这条曲线,再回头看第三节的结论就具体了:默认那个每任务一线程的执行器根本没有第 ② 步——它不排队,来一个就开一个,所以第四节讲的整套参数在它身上全是摆设。第十二节沙盘里 200/200 那一档「线程越堆越多、下游被打垮、停机排不空」的症状,就是这个默认行为放大之后的样子。

98 / 168
小节
11.2 「异常去哪了」与「定时任务被谁拖住了」
99 / 168

这两个是本篇事故报告里出现频率最高的两类,且都是静默的:

100 / 168
内核实验
TeaVM异步方法抛异常后到底发生了什么未启动
选 exc:对比 void 方法与 CompletableFuture 两种返回类型下异常的落点,对应上面那段快递类比和第五节的处理器配置
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
101 / 168
内核实验
TeaVM一个慢任务拖住全部定时任务未启动
选 sched:调度器只有一个线程,报表任务跑 4 分钟期间,其余定时任务全在同一条线上排队等——第九节的坑动图版
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
102 / 168
小节
11.3 事件解耦与异步里的事务
103 / 168

@Async 最常见的用法之一,是和事件监听配合:主流程只发事件,副作用交给监听器。注意同步发布的默认语义——很多新手以为「发了事件就不管了」,其实默认是同一条线程里挨个调用的:

104 / 168
内核实验
TeaVM事件发布到底是同步还是异步未启动
先选 sync 看清 publishEvent 会阻塞发布者,再选 @Async 监听器看加上注解之后谁被挪到了别的线程;最后用 AFTER_COMMIT 时机确认「数据真的落库了才发通知」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
105 / 168

而一旦异步方法里要写库,就必须直面第六节那条边界:事务搬不过去。用传播行为把它演出来:

106 / 168
内核实验
TeaVM异步线程里的 @Transactional 是新事务还是继承别人的未启动
选 REQUIRES_NEW 与 NOT_SUPPORTED 对照:异步方法永远拿不到调用方那笔事务,它要么另开一笔,要么干脆没有
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
107 / 168
小节
11.4 借还现场:为什么这套模型能救主流程
108 / 168

最后回到资源本身。异步之所以能保住下单接口的响应时间,靠的是有界资源 + 排队这套模型;同一个模型在数据库连接上有个更出名的名字——连接池。看一眼它的借还现场,你会对「队列」这件事有更实的体感:

109 / 168
内核实验
TeaVM连接池的借还与排队未启动
依次看命中空闲、池空新建、达到上限排队、等待超时:前三段和线程池的核心/扩容/排队一一对应,第四段就是你没设好 awaitTerminationSeconds 时的味道
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
110 / 168

实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个容器,回答全部由内核算出来——先敲 beans 看看容器里有什么,再逐条 lab:

111 / 168
内核控制台
112 / 168
坑

lab threadlocal lost 和 lab threadlocal fix 要连着敲才有对比——前者的时间线在第 ③ 步落到 null,后者在第 ③ 步是「回放成功」。同一个 @Async,差别只在执行器上有没有挂 TaskDecorator。

113 / 168
小节
十二、沙盘:线程池三个数字怎么凑
114 / 168

corePoolSize / maxPoolSize / queueCapacity 三个数互相咬合,光看表格很难有手感。下面这个沙盘把它们做成两档可调的组合(第三个数固定为队列 500),每次切换同时给出吞吐、排队、拒绝、停机丢任务四组读数:

115 / 168
沙盘
沙盘线程池参数沙盘
运行结果
activeCount: 16 queuedTasks: 42 completedTaskCount: 1.2k/min
P99 下单接口耗时: 1.6s
# 短信耗时被隔离在 notify-* 线程里,不影响主流程
平衡态:主流程秒回,峰值靠队列吸收,超出上限快速失败并被上层感知。
116 / 168
说明

数字是示意的,三条结论是真的——线程不是越多越好(超过下游承受力后吞吐反而下降,还会打垮依赖方);队列长度决定的是「你能容忍多久的延迟」,不是「能不能不丢任务」;拒绝策略的选择本质是「出错时让谁承担代价」:抛给调用方、丢给业务、还是悄悄丢掉。

117 / 168

最后把本篇的分工画清:注解只负责「把活交出去」,线程池才负责「活怎么跑完」——左边那一列是你写的代码,右边那一列才是所有参数、坑与调优真正落地的地方:

118 / 168
架构图
图 · 异步任务的两大执行模型
图 · 异步任务的两大执行模型
119 / 168
类比

默认执行器像路边摊叫车——每来一个客人现找一辆车、跑完就把车砸了,客人多了只能满街乱抢(无限建线程);自定义线程池像有排班的出租车队——固定几辆车常驻(core)、候客区排队(queue)、高峰临时加车(max)、真加不动了就拒单或改派(rejection policy)。你在第十二节切的每一档,都只是在这支队伍的排班表上改数字。

120 / 168
小节
十三、随堂自测
121 / 168

先来一道热身题,考的是第四节那条最容易记反的顺序:

122 / 168
随堂自测
随堂自测一个 ThreadPoolTaskExecutor 配了 corePoolSize=8、maxPoolSize=16、queueCapacity=500。当前 8 个核心线程全在忙,第 9 个提交进来的任务会去哪里?
先自己选一个,选中立刻告诉你对不对
123 / 168

再来一道综合题,把第五、六、九、十节串起来:

124 / 168
随堂自测
随堂自测下单主流程在一个 @Transactional 方法里调用 @Async void sendNotify(),异步方法内部又写了 @Transactional 的落库逻辑。下列说法正确的是?
先自己选一个,选中立刻告诉你对不对
125 / 168
小节
十四、常见报错速查
126 / 168

下面每一行的「报错原文」都可以整段复制去搜索,别意译、别缩写。新手在这三个地方最容易卡住:任务被拒绝、异常找不到、定时任务不按时来。

127 / 168
对照表
报错原文(片段)真实原因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/16AbortPolicy` 档
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;这是第三节的全部内容本篇第三节 · 第十二节沙盘
128 / 168
提示

这一表里最值得贴在工位上的是第一条——did not accept task 后面的中括号里直接写着 pool size / active threads / queued tasks 三个实时数字,它就是线程池的诊断仪表盘,看一眼就知道该扩线程还是该扩队列。

129 / 168

第六节说过「异步里取 request 一定会炸」,那就用一段真堆栈收个尾。下面这段是线上出现频率最高的一类,先别看答案,点出你认为的凶手行:

130 / 168
报错急救
报错急救IllegalStateException: No thread-bound request found
异步方法里取 request 的报错现场

下单成功后异步发短信,日志里冒出一段异常,而下单接口全程 200。

java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing one outside the normally exposed via ContextLoaderListener?
at org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes(RequestContextHolder.java:131)
at com.bee.order.notify.SmsSender.fillLocale(SmsSender.java:58)
at com.bee.order.notify.SmsSender.sendAsync(SmsSender.java:41)
at java.base/java.lang.Thread.run(Thread.java:840)
Caused by: no RequestAttributes bound to thread task-3
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
131 / 168
小节
十五、动手练习
132 / 168
小节
第一档 · 照做
133 / 168

目标:用一个能直接跑的 Spring 工程,把「主流程立即返回 → 异步线程执行 → 异常只在异步线程里可见」这条链完整打出来,并亲眼看到自调用会让异步退化成同步。

134 / 168

第一步,pom.xml(Java 17;换成 Boot 的 spring-boot-starter 也一样跑):

135 / 168
xml
<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>
136 / 168

第二步,线程池 + 异常兜底(src/main/java/com/example/async/AsyncConfig.java)。一个类同时给出默认执行器和 void 异常的接住口:

137 / 168
java
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);    }}
138 / 168

第三步,异步服务(NotifyService.java)。它是被别的 Bean 调用的,所以调用会经过代理:

139 / 168
java
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("短信网关炸了");   // 没人接收的异常    }}
140 / 168

第四步,主流程(OrderService.java),里面同时放了正例与反例:跨 Bean 调用 vs 同类自调用。

141 / 168
java
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();            // 交给异步线程,异常不会回到这里    }}
142 / 168

第五步,启动器(Main.java):

143 / 168
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("---- 容器已关闭 ----");    }}
144 / 168

运行 main,预期输出(线程名是唯一的证据,逐行对照;[notify] 那行的先后可能因调度略有浮动):

145 / 168
text
[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)---- 容器已关闭 ----
146 / 168

验收清单:① 指出 [inline] 与 [notify] 两行的线程名差别,据此判断哪次调用真的走了异步;② 删掉 @EnableAsync 重跑——此时容器根本不会为这些 Bean 生成异步代理,你会观察到三处通知全打在 main 上、顺序变成严格的前后关系,而 IllegalStateException 不再出现在 AsyncConfig 的错误日志里,而是顺着调用栈一路抛回 main 并把程序打停(这恰好证明「异常有没有人接」取决于代理是否存在,而不是你写了多少 handler);③ 说清为什么「createOrder 返回耗时 = 2ms」而短信仍然发出去了。

147 / 168
小节
第二档 · 变体
148 / 168

每次只改一处,观察结论立刻翻转:

149 / 168
  1. 把 queueCapacity 从 10 改成 0(无界等价于设很大,此处先用 Integer.MAX_VALUE),再在循环里一次提交 5000 个任务。你会观察到:active threads 永远停在 corePoolSize=2,maxPoolSize=4 从未生效,任务全在队列里堆着,内存一路涨——这就是第四节那个「无界队列让 max 失效」的坑。
  2. 把 queueCapacity 改回 2、corePoolSize=2、maxPoolSize=4,然后并发提交 100 个任务。你会观察到:日志里出现 TaskRejectedException ... did not accept task,且中括号里 queued tasks = 2、active threads = 4 双双顶格——对照第十二节沙盘的 8/16|AbortPolicy 档读这三个数字。
  3. 把拒绝策略换成 new ThreadPoolExecutor.CallerRunsPolicy(),其余不变。你会观察到:拒绝异常消失,但主线程被迫自己去跑任务,createOrder 返回耗时 从 2ms 涨到几百毫秒——「一条不丢」的代价由提交方承担,即沙盘里说的天然背压。
  4. 加一个 @Scheduled(fixedRate = 1000) 的方法,里面 Thread.sleep(4000);同时再加一个每 2 秒打印一次的轻量任务。你会观察到:轻量任务也跟着每 4 秒才打一次——默认调度器只有一条线程。注册第九节那个 ThreadPoolTaskScheduler(poolSize=4) 后两条线各自独立。
  5. 在 failingAsync() 里加 @Transactional,让它先插一条记录再抛异常,同时让 createOrder 所在的主流程也带事务。你会观察到:两边回滚互不影响,异步那一笔的写入按自己的事务规则处理——第六节边界的实证,配合 txprop 实验的 REQUIRES_NEW 一起看。
150 / 168

提示:做完第 2、3 条再回到第十二节沙盘,把两个开关分别切到对应的档位,两边读数应当完全对得上。

151 / 168
小节
第三档 · 造一个
152 / 168

给自己写一个「异步任务观测台」组件,以后任何项目的线程池状态都能一眼看见、一键关掉。

153 / 168

需求:

154 / 168
  • 一个 PoolMetricsReporter,每 5 秒打印每个 ThreadPoolTaskExecutor 的:poolSize / activeCount / queueSize / queueRemainingCapacity / completedTaskCount / rejectCount
  • rejectCount 不能靠猜:包一层自定义 RejectedExecutionHandler,累加计数后再委托给原始策略(保留可配置 Abort / CallerRuns / DiscardOldest 三种)
  • 支持通过配置文件给不同的池设不同参数,并且启动时校验:若 queueCapacity == Integer.MAX_VALUE 或 maxPoolSize <= corePoolSize,直接打印一条明确的 WARN(把第四节和第十二节的两个坑变成开机自检)
  • 集成 TaskDecorator 搬运 MDC,并在异步任务的日志里带上提交时间,方便算「排队时长 = 开始执行 − 提交时刻」
  • 优雅停机时打印「还剩多少任务未跑完 / 实际等待了多少秒」,若超过 awaitTerminationSeconds 则明确告警
155 / 168

验收清单:① 压测时 queueSize 应随流量起伏,且能在拒绝发生的那一刻看到 rejectCount 递增;② 故意把队列设为 Integer.MAX_VALUE,启动必须打出那条 WARN;③ 停机日志里能看到「剩余 N 条、等待 Xs」;④ 异步任务日志的 traceId 与触发它的 HTTP 请求一致(MDC 搬运成功);⑤ 全部逻辑不修改任何业务方法。

156 / 168
小节
十六、要点自查
157 / 168
自检

不看上文,按顺序说出任务提交后的四个阶段,并解释为什么「先入队、再扩容」会让 maxPoolSize 在大队列下失效。

158 / 168
自检

@Async 的 void 方法抛出异常后,这条异常经过了哪些对象、最终落在哪里?你有几种方式把它接住?

159 / 168
自检

同一个类里调用自己的 @Async 方法会发生什么?这和 @Transactional 自调用失效是同一个原因吗?

160 / 168
自检

MDC、SecurityContext、事务上下文三者,哪些能搬到异步线程、用什么搬、哪个搬不了?搬不了的根本原因是什么?

161 / 168
自检

fixedRate 与 fixedDelay 相差的是「从哪一刻起算」?当一个任务耗时超过间隔时,两者的表现分别是什么?

162 / 168
自检

多副本部署下 @Scheduled 为什么会重复执行?Redis 锁的 key 为什么要带时间粒度、TTL 为什么要略小于执行间隔?

163 / 168
口诀

注解只管交活,池子才定生死——先排队再加人,加满就拒;void 的异常是丢了不报警的快递;线程搬得动数据,搬不动事务;多实例的定时任务,先抢锁再干活。

164 / 168
小节
十七、两个必须记住的坑
165 / 168
坑

@Scheduled 任务内部抛异常,不会中断后续调度——下一次到点照常执行,这本身是好事;但异常默认也不会让任务"停止",所以务必在任务内 try-catch 并打详细日志,否则你会看到"任务好像没跑",其实是每次都静默失败了。

166 / 168
坑

fixedRate 的语义是"按开始时刻的频率",长任务若超过间隔,会连着执行、任务堆积(注意:不会并发,因为单线程调度器会等上一个跑完)。若你不希望堆积,改用 fixedDelay,它以上一次结束为基准计算间隔。

167 / 168
决策
决策一个中小型项目的每日对账定时任务,要不要上调度中心(XXL-JOB)?
168 / 168
总结

本节的干货可浓缩成四句——@Async / @Scheduled 只是"提交方式",真正的行为由线程池(或调度器)决定;@Async 必须自定义线程池、必须处理 void 异常、必须注意自调用失效;上下文(MDC / SecurityContext / 事务)不会自动跨线程;多实例部署下定时任务会重复执行,必须用分布式锁或调度中心防重。把这四点固化下来,异步与定时就不再是"时好时坏的玄学"。