深入浅出的理解SpringBoot异步处理框架 - 指南

目录

同步 VS 异步

快速上手

① 开启异步支持

② 标记异步方法

③ 异步方法调用(使用)

默认配置的陷阱

线程池的配置

线程池工作流程 

核心配置代码解释

配置之后的声明使用

建议声明

代码读取到 @Async 会发生什么?

核心线程 VS 最大线程

原理解析(Proxy)

拒绝策略

1. AbortPolicy (默认) 

2.CallerRunsPolicy(推荐使用)

3.DiscardPolicy

4.DiscardOldestPolicy

异常处理和返回值

返回值(CompletableFuture)

异常处理

有返回值的方法

无返回值的方法

上下文丢失问题


同步 VS 异步

理解"为什么我们需要异步"是掌握该技术的第一步

同步是我们的惯性思维,异步才是打开效率之门的钥匙。 

以咖啡店点餐实景举例:

同步 (Synchronous) - "服务员在此等待"

就像你在咖啡店点单,服务员点完单后,一直站在那里盯着咖啡机做咖啡,直到咖啡做好才递给你,然后再去服务下一个客人

异步 (Asynchronous) - "拿好号牌,下一位"

服务员点完单,给你一张号牌 (Future),然后立刻转身服务下一位客人。后厨(线程池)在后台慢慢做咖啡。咖啡好了会叫号(Callback/Complete)

Spring Boot 中的应用场景:

  • 发送邮件/短信: 用户注册后不需要等待"欢迎邮件"发送成功才看到"注册成功"页面。
  • 文件处理: 上传大文件后,后台慢慢解析(另起线程),不必让 HTTP 连接一直超时等待。
  • 第三方 API 调用: 某些非核心业务的 API 调用可以异步执行,不影响主流程响应速度。

快速上手

只需两个注解即可开启异步世界

① 开启异步支持

启动类任意配置类上添加 @EnableAsync注解

@SpringBootApplication
@EnableAsync // 关键!开启异步功能的开关
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

② 标记/定义异步方法

Service 层的方法上添加 @Async 注解

@Service
public class EmailService {
    @Async
    public void sendWelcomeEmail(String user) {
        // 模拟耗时操作
        try { Thread.sleep(3000); } catch (InterruptedException e) {}
        System.out.println("Thread: " + Thread.currentThread().getName());
        // 输出类似于: Thread: task-1
    }
}

③ 异步方法调用(使用)

@RequestMapping("emails")
@RequiredArgsConstructor
public class EmailController{
    private final EmailService emailService;
    @PostMapping
    public Boolean sendEmail(@RequestParam String user){
        // 调用异步方法,执行异步任务
        emailService.sendWelcomeEmail(user);
        // 直接返回
        return true;
    }
}

发生了什么?

当 Controller 中调用 sendWelcomeEmail() 时,Spring 不会等待该方法执行完毕。它会立即返回给 Controller,而邮件发送逻辑会在另一个线程中独立运行

无需等待发送邮件这个耗时代码逻辑,直接响应。


默认配置的陷阱

上面的【快速上手】虽然实现起来快速简单,但是其中存在一定的风险问题

原因:没有配置线程池

如果我们没有配置线程池,那么 SpringBoot 就会使用默认的代码配置SimpleAsyncTaskExecutor(生产环境的恶梦)

Spring Boot 默认使用的 SimpleAsyncTaskExecutor, 这名字听起来"简单",但它的行为非常激进:

  • 不复用线程: 每个任务都创建一个全新的线程。
  • 无上限: 如果并发请求激增(例如 1000 QPS),它会尝试创建 1000 个线程。

结果:导致 CPU 频繁切换上下文损耗系统资源,甚至 OutOfMemoryError

不使用线程池可视化:

使用线程池可视化:


线程池的配置

使用 ThreadPoolTaskExecutor掌控全局

线程池工作流程 

核心配置代码解释

@Configuration
@EnableAsync // 也可以在配置类上开启异步支持
public class AsyncConfig {
    @Bean("taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 1. 核心线程数:线程池创建时候初始化的线程数
        executor.setCorePoolSize(10);
        // 2. 最大线程数:线程池最大的线程数,只有在缓冲队列满了之后才会申请超过核心线程数的线程
        executor.setMaxPoolSize(20);
        // 3. 缓冲队列:用来缓冲执行任务的队列
        executor.setQueueCapacity(200);
        // 4. 允许线程的空闲时间60秒:当超过了核心线程出之外的线程在空闲时间到达之后会被销毁
        executor.setKeepAliveSeconds(60);
        // 5. 线程池名的前缀:设置好了之后可以方便我们定位处理任务所在的线程池
        executor.setThreadNamePrefix("async-task-");
        // 6. 拒绝策略:当线程池没了,队列也满了,怎么处理?
        // CallerRunsPolicy: 由调用者所在的线程来执行,既不丢弃任务,也能减缓提交速度
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

最佳实践: 使用 CallerRunsPolicy 作为拒绝策略通常是比较安全的选择,因为它通过迫使主线程执行任务来自然地实现"背压"(Backpressure),防止系统崩溃。

配置之后的声明使用

建议声明

在配置好线程池之后,强烈建议在异步方法声明时显示添加上配置好的Executor的Bean名称。

@Async("taskExecutor") // 使用配置好的线程池配置
public void sendWelcomeEmail(String user) {
    // 模拟耗时操作
    try { Thread.sleep(3000); } catch (InterruptedException e) {}
    System.out.println("Thread: " + Thread.currentThread().getName());
    // 输出类似于: Thread: task-1
}

代码读取到 @Async 会发生什么?

Spring 查找 Executor 的逻辑:

1.显示指定查找 @Async 注解是否提供了值,提供了就去Spring容器中找对应名称的Bean,找不到,抛出异常:NoSuchBeanDefinitionException

2. 找约定名称的Bean@Async没有提供值(只写了@Async),Spring 会尝试寻找一个名为 taskExecutor 的 Executor 类型的 Bean

3. 找唯一的Executor的Bean在容器中找不到名为 taskExecutor 的Bean后,Spring 会继续查找容器中是否存在且仅存在一个 Executor 类型的 Bean。如果只有一个,就会使用它。如果有多个,报错:NoUniqueBeanDefinitionException(无法选择)

4. 使用默认的Executor:如果以上条件都不满足,Spring 会使用默认的SimpleAsyncTaskExecutor上面提到使用风险


核心线程 VS 最大线程

很多开发者误以为任务多了就会立即创建最大线程。
这是错误的!中间还有一个关键角色:Queue (队列)。

看下两者的区别:

随着任务的增加,内部如何判断?

关键误区:由于队列的存在,执行顺序并非线性增加

可视化模拟线程池扩容

Core: 2 | Queue: 3 | Max: 5

对于临时工(最大线程-核心线程)线程的临时时间(空闲时间)

可以通过 executor.setKeepAliveSeconds(60) 进行设置


原理解析(Proxy)

SpringBoot 的异步处理框架主要依托于 Spring AOP 代理构建的代理机制

当你注入一个 @Async Bean 时,你拿到的不是原来的对象,而是一个 Proxy (代理对象)。

代理对象拦截方法调用,判断是否需要异步执行。

如果需要,它将其提交给线程池,如果不需要,才调用原始对象。

就是说我们在【快速上手】中:Controller 调用 Service 的异步方法时,获取到的 Service Bean 是一个代理对象(执行异步任务时)。

失效高频场景:
如果在同一个类中方法A调用异步方法B异步会失效

原因:this.methodB() 直接调用了原始对象的方法,绕过了 Spring 的代理拦截器

因此:在开发时要将异步方法单独存放,在其他类中进行异步调用,使用代理(异步)生效。


拒绝策略

当 CorePool 忙,Queue 满,且 Max Pool 也达到上限时,JDK 线程池会触发拒绝策略。

Spring Boot 提供了 4 种标准策略:

1. AbortPolicy (默认) 

行为: 直接抛出 RejectedExecutionException 异常。

优点:即使发现问题,方便上层监控报警。

缺点:用户体验差,直接收到 500 错误。

2.CallerRunsPolicy(推荐使用)

行为: "谁调用的谁去执行"。既然线程池忙不过来,就让调用者(比如 Controller 的主线程)自己去运行这个任务。

优点:自带"背压" (Backpressure) 机制。因为主线程去干活了,就没空提交新任务了,从而降低了提交速度,给线程池喘息时间。不丢数据。

3.DiscardPolicy

行为: 默默丢弃当前提交的任务,不抛出异常,也不执行。

缺点:数据丢失且无感知,仅适用于无关紧要的日志记录。

4.DiscardOldestPolicy

行为: 丢弃队列中等待最久的任务,然后把当前任务加入队列。

场景:适合传感器数据更新(新的数据比老数据更有价值)。


异常处理和返回值

返回值(CompletableFuture)

讲解:我们在进行异步调用时需要获取异步调用的返回值

异步方法不能直接返回普通对象(如 String, User ),否则调用方拿到的结果是 null。你必须返回一个Future类型的包装对象。

推荐使用 Java 8 引入的 CompletableFuture,它比旧的 Future 更加强大,支持非阻塞的流式调用和异常回调。

示例代码

// Service 层(定义异步方法)
@Service
public class AsyncService {
    @Async
    public CompletableFuture doTaskWithResult() {
        try {
            // 模拟耗时操作
            Thread.sleep(2000);
            System.out.println("任务执行中 - " + Thread.currentThread().getName());
            return CompletableFuture.completedFuture("任务执行成功");
        } catch (InterruptedException e) {
            return CompletableFuture.failedFuture(e);
        }
    }
}
// Controller/调用层(获取结果)
@GetMapping("/task")
public String triggerTask() {
    CompletableFuture future = asyncService.doTaskWithResult();
    // 方式一:阻塞等待结果(不推荐在主线程大量使用)("同步")
    // String result = future.get();
    // 方式二:非阻塞处理(推荐)("异步")
    future.thenAccept(result -> {
        System.out.println("收到异步结果: " + result);
    });
    return "请求已发送";
}

异常处理

异步任务的异常处理分为两种情况:有返回值的方法和无返回值 (void) 

有返回值的方法

返回 Future / CompletableFuture

如果异步方法返回 CompletableFuture,异常会自动被封装在 Future 对象中。

异常不会直接抛出到主线程,而是在你调用 .get() 或 .join() 时抛出,或者在回调中处理。

处理方式:

CompletableFuture future = asyncService.doTaskWithResult();
// 使用 exceptionally 优雅降级
future.exceptionally(ex -> {
    System.err.println("异步任务出错: " + ex.getMessage());
    return "默认失败返回值";
}).thenAccept(result -> {
    System.out.println("最终结果: " + result);
});

无返回值的方法

如果异步方法签名是 public void task(),此时如果在异步线程中抛出了异常,调用方是完全无感知的,异常日志甚至可能不会打印,直接被吞掉(取决于线程池配置)。

解决方案: 必须配置全局的 AsyncUncaughtExceptionHandler

第一步:自定义异常处理器

import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import java.lang.reflect.Method;
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        System.err.println("=== 捕获到异步线程异常 ===");
        System.err.println("异常信息: " + ex.getMessage());
        System.err.println("出错方法: " + method.getName());
        // 可以在这里进行监控报警、发送邮件、记录数据库等操作
    }
}

第二步:配置类中注册处理器

需要实现 AsyncConfigurer 接口来注册自定义异常处理器

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new CustomAsyncExceptionHandler();
    }
    // 这里通常也建议同时配置自定义线程池 (getAsyncExecutor),避免使用默认线程池
}

上下文丢失问题

描述:异步方法在新线程中运行,这意味着 ThreadLocal 变量(如 SecurityContext (当前登录用户信息)默认不会传过去。

解决:需手动传递数据,或配置 TaskDecorator 来在线程切换时复制上下文。

posted @ 2026-02-02 12:02  yangykaifa  阅读(0)  评论(0)    收藏  举报