Note 20. 弹性设计与并发:异步任务、定时调度与虚拟线程

Note 20. 弹性设计与并发:异步任务、定时调度与虚拟线程

摘要: 本章我们将深入 Spring Boot 的并发编程模型。我们将学习如何使用 @Async 将耗时操作异步化以提升接口响应速度,并掌握 @Scheduled 实现 Cron 表达式级别的定时任务。最重要的是,我们将配置 自定义线程池 以避免生产环境的内存溢出风险,并探索 Spring Boot 3.2+ 对 JDK 21 虚拟线程 的原生支持,体验高并发 IO 的新解法。

本章学习路径

  1. 异步处理:使用 @Async 实现“火后即焚”的异步调用。
  2. 线程池优化:替换默认的危险线程池,配置生产级 ThreadPoolTaskExecutor
  3. 定时调度:使用 @Scheduled 实现固定频率与 Cron 表达式任务。
  4. 未来已来:在 Spring Boot 中启用 JDK 21 虚拟线程。

20.1. 异步任务:@Async

20.1.1. 痛点:同步执行的阻塞

假设 UserService.register() 方法包含以下步骤:

  1. 插入数据库 (10ms)
  2. 发送欢迎邮件 (2000ms, 依赖外部 SMTP 服务)
  3. 发送注册短信 (500ms, 依赖外部 API)

如果同步执行,用户点击“注册”后需要等待 2.5s 才能收到响应,体验极差。实际上,只要数据库插入成功,就可以立刻告诉用户“注册成功”,邮件和短信可以在后台慢慢发。

20.1.2. 开启异步支持

首先,需要在启动类或配置类上添加 @EnableAsync 注解。

文件路径: src/main/java/com/example/demo/DemoApplication.java

1
2
3
@EnableAsync // 开启异步注解支持
@SpringBootApplication
public class DemoApplication { ... }

20.1.3. 编写异步方法

将耗时逻辑抽取到一个单独的方法,并加上 @Async

文件路径: src/main/java/com/example/demo/service/NotificationService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
@Slf4j
public class NotificationService {

@Async // 关键:告诉 Spring 将此方法放入线程池执行
public void sendWelcomeEmail(String email) {
long start = System.currentTimeMillis();
try {
// 模拟耗时操作
Thread.sleep(2000);
log.info("邮件已发送至: {}", email);
} catch (InterruptedException e) {
log.error("发送邮件被中断", e);
}
log.info("发送邮件耗时: {}ms", System.currentTimeMillis() - start);
}
}

调用方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class UserService {
@Autowired
private NotificationService notificationService;

public void register(User user) {
// 1. 存库
userMapper.insert(user);

// 2. 异步发邮件 (主线程不会在此阻塞,立即往下走)
notificationService.sendWelcomeEmail(user.getEmail());

// 3. 返回
}
}

20.2. [关键] 配置自定义线程池

生产事故预警:Spring 默认使用的 @Async 线程池是 SimpleAsyncTaskExecutor。这个“线程池”非常坑爹,它 不复用线程,每来一个任务就创建一个新线程。如果瞬间涌入 1000 个请求,它就会创建 1000 个线程,直接导致服务器 OOM (内存溢出) 宕机。

我们必须配置一个生产级的线程池。

文件路径: src/main/java/com/example/demo/config/AsyncConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
public class AsyncConfig {

@Bean("taskExecutor") // 指定 Bean 名称,方便 @Async("taskExecutor") 引用
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

// 1. 核心线程数:即使空闲也保留的线程数 (CPU核数 * 2 适合 IO 密集型)
executor.setCorePoolSize(10);

// 2. 最大线程数:队列满时,最多创建多少线程
executor.setMaxPoolSize(20);

// 3. 队列容量:任务堆积的缓冲区
executor.setQueueCapacity(200);

// 4. 线程前缀:方便排查日志 (如: async-1, async-2)
executor.setThreadNamePrefix("async-");

// 5. 拒绝策略:队列满了、线程也满了怎么办?
// CallerRunsPolicy: 由调用者线程(主线程)自己去执行,起到“削峰填谷”和变相限流的作用
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

executor.initialize();
return executor;
}
}

20.3. 定时任务:@Scheduled

后端系统经常需要执行周期性任务。Spring Boot 内置了轻量级的调度器。

20.3.1. 开启调度

在启动类上添加 @EnableScheduling

1
2
3
@EnableScheduling
@SpringBootApplication
public class DemoApplication { ... }

20.3.2. 任务定义

文件路径: src/main/java/com/example/demo/task/DailyTask.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
@Slf4j
public class DailyTask {

/**
* 方式一:fixedRate (固定频率)
* 每隔 5000 毫秒执行一次 (以上一次【开始】时间为准)
*/
@Scheduled(fixedRate = 5000)
public void syncData() {
log.info("同步数据任务开始: {}", LocalDateTime.now());
}

/**
* 方式二:Cron 表达式 (最强大)
* 每天凌晨 03:00:00 执行
* 语法: 秒 分 时 日 月 周
*/
@Scheduled(cron = "0 0 3 * * ?")
public void cleanLog() {
log.info("清理日志任务开始: {}", LocalDateTime.now());
}
}

Cron 技巧:不要死记硬背 Cron 语法,直接使用在线生成器(如 cron.qqe2.com),生成后复制进来即可。


20.4. [前沿] JDK 21 虚拟线程 (Virtual Threads)

如果你的项目使用的是 JDK 21 且 Spring Boot 版本 >= 3.2,恭喜你,你拥有了处理高并发 IO 的核武器。

20.4.1. 什么是虚拟线程?

  • 传统线程 (Platform Thread):与操作系统线程 1:1 绑定。创建昂贵,数量有限(几千个就顶天了)。遇到 IO 阻塞(如查数据库、调 API)时,CPU 只能干等,浪费资源。
  • 虚拟线程 (Virtual Thread):由 JVM 管理的轻量级线程。创建极其廉价(百万级随便开)。遇到 IO 阻塞时,JVM 会自动挂起它,让底层 carrier 线程去干别的事。

20.4.2. 一键开启

在 Spring Boot 3.2+ 中,开启虚拟线程只需要一行配置。

文件路径: src/main/resources/application.yml

1
2
3
4
spring:
threads:
virtual:
enabled: true # 开启虚拟线程支持

开启后:

  1. Tomcat 会自动使用虚拟线程来处理 HTTP 请求(哪怕有 10 万个并发请求,也不会把线程池撑爆)。
  2. @Async 如果没有指定自定义线程池,也会尝试使用虚拟线程执行任务(SimpleAsyncTaskExecutor 在 3.2+ 对虚拟线程做了优化)。

20.4.3. 适用场景

  • 适合:IO 密集型任务(Web 服务、数据库操作、RPC 调用)。虚拟线程能把吞吐量提升数倍。
  • 不适合:CPU 密集型任务(视频转码、复杂数学计算)。虚拟线程并没有增加 CPU 核心数,这时候还是得靠传统线程池。

20.5. 本章总结与并发速查

摘要回顾
本章我们让系统具备了“三头六臂”的能力。我们使用 @Async 将耗时任务剥离主线程,配置了安全的 ThreadPoolTaskExecutor 防止 OOM;使用 @Scheduled 实现了自动化运维任务;最后介绍了 JDK 21 的虚拟线程,为未来高并发架构指明了方向。

遇到以下 3 种并发场景时,请直接参考代码模版:

1. 场景一:发送通知/日志异步入库

需求:不阻塞主流程,后台执行。
方案@Async + 自定义线程池。
代码

1
2
3
4
@Async("taskExecutor")
public void sendLog(LogData data) {
// 写入 ES 或 数据库
}

2. 场景二:定期报表/状态同步

需求:每隔 10 分钟拉取一次支付状态。
方案@Scheduled(cron = "0 0/10 * * * ?")

3. 场景三:极高并发的 I/O 密集型 API

需求:类似于网关或聚合服务,需要同时调用几十个外部 API。
方案:升级 JDK 21 + Spring Boot 3.2,开启 spring.threads.virtual.enabled=true

4. 核心避坑指南

  1. 异步失效 (自调用)

    • 现象this.sendEmail() 是同步执行的。
    • 原因:AOP 代理失效(同 @Transactional)。
    • 对策:注入自己或拆分 Service。
  2. 定时任务单线程阻塞

    • 现象:定义了多个 @Scheduled 任务,如果任务 A 卡住了,任务 B 居然也不执行了。
    • 原因:Spring 默认的 Scheduling 线程池大小只有 1。
    • 对策:在 application.yml 配置 spring.task.scheduling.pool.size=5
  3. ThreadLocal 数据丢失

    • 现象:在 @Async 方法里调用 UserContext.getUserId() 拿不到数据。
    • 原因:ThreadLocal 是线程绑定的,异步方法在 新线程 里执行,自然拿不到主线程的数据。
    • 对策:在调用异步方法前,手动将数据作为参数传过去;或者使用 TransmittableThreadLocal (阿里开源工具) 来解决线程间上下文传递问题。