【性能篇II】释放主线程:异步任务(@Async)与定时任务(@Scheduled)

发布于:2025-06-11 ⋅ 阅读:(32) ⋅ 点赞:(0)
摘要

本文是《Spring Boot 实战派》系列的第七篇,继续深入性能优化领域。文章将聚焦于解决两类常见的业务场景:耗时操作阻塞用户请求周期性自动化任务

我们将首先学习如何使用 Spring 的 @Async 注解,将耗时操作(如发送邮件、生成报表)从主请求线程中剥离,实现异步执行,从而做到接口的瞬时响应,极大提升用户体验。接着,我们会深入探讨如何配置和优化异步任务的线程池。随后,文章将详细讲解如何使用 @Scheduled 注解,轻松创建强大的定时任务,并详解 cron 表达式的用法,实现如“每天凌晨执行数据清理”等自动化需求。

系列回顾:
在上一篇中,我们通过整合 Redis 缓存,极大地提升了应用“读”的性能,让高频查询接口快如闪电。但是,应用的性能瓶颈不仅仅在“读”。想象一个用户注册的场景:用户点击“注册”按钮后,系统需要创建用户、发送欢迎邮件、初始化积分… 如果这些操作都在一个请求里同步完成,用户可能要盯着加载圈转好几秒,这种糟糕的体验足以劝退大量用户。

欢迎来到性能优化的第二站!

今天,我们要解决的核心问题是:如何优雅地处理那些“慢”操作,不让它们阻塞主流程,影响用户体验。 我们将学习 Spring Boot 提供的两个强大的“多线程”利器:@Async@Scheduled

  1. @Async (异步任务): 就像给耗时任务开了一个“VIP通道”。主线程把任务交给它之后,就可以立即返回,继续处理其他事情,而这个耗时任务则在后台的另一个线程里默默执行。
  2. @Scheduled (定时任务): 就像给应用设置了一个“智能闹钟”。你可以让它在指定的时间(如每晚12点)或按固定的频率(如每5分钟)自动执行某个任务,无需人工干预。

第一部分:异步的魔力 —— 使用 @Async 提升响应速度

场景:模拟用户注册后发送欢迎邮件

我们将创建一个用户注册接口。在用户数据成功存入数据库后,需要调用一个模拟的“邮件发送服务”。这个邮件服务会故意休眠3秒,来模拟网络延迟和SMTP服务器的处理耗时。

1. 开启异步功能

@EnableCaching 类似,我们需要一个注解来告诉 Spring 开启异步方法执行的支持。在主启动类 MyFirstAppApplication.java 或任何一个配置类上,添加 @EnableAsync

@SpringBootApplication
@EnableCaching
@EnableAsync // 开启异步方法执行支持
public class MyFirstAppApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyFirstAppApplication.class, args);
    }
}

2. 创建一个模拟的邮件服务

service 包下,创建一个 EmailService.java

package com.example.myfirstapp.service;

import com.example.myfirstapp.entity.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class EmailService {
    private static final Logger log = LoggerFactory.getLogger(EmailService.class);

    @Async // 核心注解:将此方法标记为异步方法
    public void sendWelcomeEmail(User user) {
        log.info("开始向 {} 发送欢迎邮件...", user.getName());
        try {
            // 模拟耗时 3 秒
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        log.info("欢迎邮件已成功发送至 {} !", user.getName());
    }
}

关键点:

  • @Async 注解被放在了 sendWelcomeEmail 方法上。当其他 Bean 调用这个方法时,Spring 会拦截这个调用,将它提交到一个后台线程池中执行,然后立即返回,调用方不会被阻塞。
  • 重要限制: 异步方法必须是 public 的。并且,在同一个类中的方法调用(this.someAsyncMethod())是不会触发异步的,因为它绕过了 Spring 的代理机制。必须是通过 Spring 注入的 Bean 进行调用。

3. 在注册逻辑中调用异步方法

我们将改造 UserService,在添加用户后,调用 EmailService

package com.example.myfirstapp.service;

import com.example.myfirstapp.entity.User;
// ... 其他 import
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {
    // ...
    @Autowired
    private EmailService emailService;

    public User registerUser(User user) {
        System.out.println("主线程:开始注册用户...");
        User savedUser = userRepository.save(user); // 同步保存用户
        
        emailService.sendWelcomeEmail(savedUser); // 异步发送邮件
        
        System.out.println("主线程:用户注册方法返回,无需等待邮件发送。");
        return savedUser;
    }
    // ... 其他方法
}

注意:为了演示,我们创建了一个新的 registerUser 方法。

4. 测试效果

创建一个新的注册接口,并用 Postman 测试。

  • UserController.java
    @PostMapping("/register")
    public Result<User> register(@RequestBody User user) {
        return Result.success(userService.registerUser(user));
    }
    

测试流程:

  1. 启动应用。
  2. 用 Postman 调用 POST /users/register,Body 中传入用户信息。
  3. 观察响应时间: 你会发现 Postman 几乎是瞬间就收到了响应。
  4. 观察控制台日志:
    主线程:开始注册用户...
    // 日志来自 EmailService,注意线程名不是 main
    [   task-1] c.e.m.service.EmailService      : 开始向 [用户名] 发送欢迎邮件... 
    主线程:用户注册方法返回,无需等待邮件发送。
    // 3秒后...
    [   task-1] c.e.m.service.EmailService      : 欢迎邮件已成功发送至 [用户名] !
    

日志清晰地显示,主线程在调用邮件服务后立即返回,而邮件发送的逻辑则在另一个名为 task-1 的线程中执行。我们成功地释放了主线程!

进阶:自定义异步线程池

Spring Boot 的默认异步线程池核心线程数为8,队列无限大。在生产环境中,这可能导致内存溢出。我们通常需要自定义线程池。

config 包下创建 AsyncConfig.java

package com.example.myfirstapp.config;

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

@Configuration
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5); // 核心线程数
        executor.setMaxPoolSize(10); // 最大线程数
        executor.setQueueCapacity(25); // 任务队列容量
        executor.setThreadNamePrefix("MyAsync-"); // 线程名前缀
        executor.initialize();
        return executor;
    }
}

@Async 注解中,你可以指定使用这个线程池:@Async("taskExecutor")


第二部分:自动化的节拍 —— 使用 @Scheduled 创建定时任务

场景:创建一个每分钟打印一次当前时间的定时任务

这在很多场景都很有用,比如:

  • 每天凌晨1点,进行数据备份和清理。
  • 每小时,同步一次外部数据。
  • 每5分钟,检查一次系统健康状况并发送报告。

1. 开启定时任务功能

在主启动类或任何配置类上,添加 @EnableScheduling 注解。

@SpringBootApplication
@EnableCaching
@EnableAsync
@EnableScheduling // 开启定时任务支持
public class MyFirstAppApplication {
    // ...
}

2. 创建定时任务类

创建一个新的 servicetask 包,在其中创建 ScheduledTasks.java

package com.example.myfirstapp.task;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Component
public class ScheduledTasks {

    private static final Logger log = LoggerFactory.getLogger(ScheduledTasks.class);
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

    /**
     * 使用 fixedRate,表示每隔 60 秒执行一次。
     * 从上一次任务开始时计时。
     */
    @Scheduled(fixedRate = 60000)
    public void reportCurrentTime() {
        log.info("现在时间是 (fixedRate): {}", formatter.format(LocalDateTime.now()));
    }
    
    /**
     * 使用 cron 表达式,表示每分钟的第 0 秒执行(即每分钟的开始)。
     * 这是最常用和最强大的方式。
     */
    @Scheduled(cron = "0 * * * * ?")
    public void reportCurrentTimeWithCron() {
        log.info("现在时间是 (cron): {}", formatter.format(LocalDateTime.now()));
    }
}

注解解读:

  • @Scheduled: 核心注解,标记这是一个定时任务。
  • fixedRate = 60000: 表示任务执行的固定频率,单位是毫秒。无论上一次任务执行了多久,下一次任务都会在上一次任务开始后的60秒后启动。
  • fixedDelay = 60000: 与 fixedRate 类似,但它是从上一次任务结束后开始计时。如果任务执行耗时5秒,那么下一次任务将在65秒后启动。
  • cron = "0 * * * * ?": 使用 Cron 表达式,提供了极高的灵活性。

Cron 表达式详解 (从左到右):
秒 分 时 日 月 周

  • * : 匹配任意值。
  • ? : 只能用在“日”和“周”字段,表示不指定值。
  • / : 表示步长。0/15 在“秒”字段表示每15秒执行一次(0, 15, 30, 45)。
  • , : 列出枚举值。MON,WED,FRI 在“周”字段表示周一、周三、周五。
  • - : 表示范围。9-17 在“时”字段表示从9点到17点。

常用 Cron 表达式示例:

  • 0 0 1 * * ? : 每天凌晨1点执行。
  • 0 0/30 9-17 * * ? : 每天9点到17点之间,每半小时执行一次。
  • 0 15 10 ? * MON-FRI : 每周一至周五的上午10点15分执行。

3. 运行并观察日志

重启应用,你不需要做任何操作。静静地观察控制台日志,你会发现每隔一分钟,ScheduledTasks 里的方法就会被自动执行,并打印出当前时间。

注意: 默认情况下,所有 @Scheduled 任务共享同一个单线程。如果一个任务执行时间过长,会阻塞其他任务。如果你的定时任务很多或很耗时,建议像配置异步任务一样,配置一个专门的定时任务线程池。


总结与展望

今天,我们为应用赋予了“分身”和“自律”的能力,学会了:

  • 使用 @Async 将耗时操作异步化,实现了接口的快速响应,极大地提升了用户体验。
  • 如何自定义异步任务线程池,以适应生产环境的需求。
  • 使用 @Scheduled 和强大的 Cron 表达式,创建了灵活可靠的定时任务,实现了应用的自动化运维。

至此,我们的应用不仅在功能、安全、配置上趋于完善,在性能表现和架构合理性上也迈上了一个新台阶。

接下来,我们将进入微服务的前哨站。一个现代化的应用,很少是孤立存在的,它需要与系统中的其他服务进行通信。在下一篇 《【微服务基石篇】服务间的对话:RestTemplate、WebClient 与 OpenFeign 对比与实战》 中,我们将学习 Spring Boot 中进行服务间 HTTP 调用的三种主流方式,为你踏入微服务世界做好最充分的准备。我们下期见!


网站公告

今日签到

点亮在社区的每一天
去签到