Skip to content

第 9 章 任务调度和邮件发送 ​

写给新手的话:

前面我们学了怎么写接口、怎么操作数据库、怎么用消息队列。

这一章我们学两个非常实用的功能:异步任务和定时任务,还有邮件发送。

这些都是项目里经常用到的:

  • 用户注册,发邮件发短信,不用让用户等 → 异步任务
  • 每天凌晨统计报表、清理日志 → 定时任务
  • 注册验证、找回密码、通知提醒 → 邮件发送

这一章内容不难,但是非常实用,一定要掌握。

学习建议:

  • 异步任务很简单,加两个注解就搞定
  • 定时任务重点是 cron 表达式,多练几个例子就会了
  • Quartz 更强大,适合复杂的调度需求
  • 邮件发送注意配置和各种类型的邮件

准备好了吗?我们开始吧!


9.1 异步任务 ​

9.1.1 什么是异步任务 ​

先回忆一下同步和异步的区别:

同步:

  • 调用方法,要等方法执行完才返回
  • 前面的事没做完,后面的事等着
  • 用户要等很久,体验不好

异步:

  • 调用方法,不用等,直接返回
  • 方法在后台慢慢执行
  • 用户不用等,体验好

打个比方:

  • 同步:你去饭馆点菜,站在厨房门口等,菜做好了端走
  • 异步:你点完菜找个位置坐着玩手机,菜做好了服务员给你端过来

显然异步体验更好。

什么时候用异步?

  • 发邮件、发短信
  • 生成报表、导出文件
  • 日志记录
  • 数据同步
  • 任何耗时的、不需要立即返回结果的操作

9.1.2 Spring Boot 实现异步任务 ​

Spring Boot 实现异步任务非常简单,两个注解搞定:

  • @EnableAsync:开启异步支持(加在启动类上)
  • @Async:标记异步方法(加在方法上)

就这么简单!

9.1.3 快速入门 ​

步骤 1:开启异步支持

java
@SpringBootApplication
@EnableAsync  // 开启异步支持
public class Chapter09Application {
    public static void main(String[] args) {
        SpringApplication.run(Chapter09Application.class, args);
    }
}

步骤 2:写异步方法

java
@Service
public class AsyncTaskService {

    /**
     * 无返回值的异步方法
     */
    @Async
    public void sendSMS() throws InterruptedException {
        System.out.println("开始发送短信...");
        // 模拟发短信耗时 5 秒
        Thread.sleep(5000);
        System.out.println("短信发送完成");
    }
}

步骤 3:调用测试

java
@RestController
public class TaskController {

    @Autowired
    private AsyncTaskService taskService;

    @RequestMapping("/sendSMS")
    public String sendSMS() throws InterruptedException {
        // 调用异步方法,立即返回,不用等 5 秒
        taskService.sendSMS();
        return "短信发送中,请稍候...";
    }
}

测试一下:

启动项目,访问 http://localhost:8080/sendSMS

你会发现:

  • 浏览器立即返回结果,不用等 5 秒
  • 控制台在后台慢慢输出"短信发送完成"

就这么简单!加两个注解就实现了异步。 是不是很方便?

9.1.4 有返回值的异步任务 ​

有时候异步任务执行完,我们需要拿到结果。

这时候用 Future<T> 作为返回值。

java
@Service
public class AsyncTaskService {

    /**
     * 有返回值的异步方法
     */
    @Async
    public Future<Integer> salesStatistics(String area) throws InterruptedException {
        System.out.println("开始统计【" + area + "】的销售额...");
        
        // 模拟统计耗时(1-5秒随机)
        int time = new Random().nextInt(5) + 1;
        Thread.sleep(time * 1000);
        
        int result = time + 1000;  // 模拟统计结果
        System.out.println(area + " 统计完成:" + result + " 元");
        
        return new AsyncResult<>(result);
    }
}

调用有返回值的异步方法:

java
@RequestMapping("/statistics")
public String salesStatistics() throws Exception {
    long start = System.currentTimeMillis();
    
    // 同时启动两个异步任务
    Future<Integer> f1 = taskService.salesStatistics("北京");
    Future<Integer> f2 = taskService.salesStatistics("上海");
    
    // 获取结果(会阻塞,等任务完成)
    Integer total = f1.get() + f2.get();
    
    long end = System.currentTimeMillis();
    System.out.println("总计:" + total + " 元,总耗时:" + (end - start) + " 毫秒");
    
    return "统计完成,总计:" + total + " 元";
}

Future 常用方法:

方法说明
get()获取结果,阻塞等待
get(timeout, unit)获取结果,最多等多久
isDone()任务是否完成
cancel(mayInterruptIfRunning)取消任务
isCancelled()是否被取消了

有返回值的异步任务,可以并行执行多个任务,最后汇总结果。 比如同时查多个接口、统计多个地区,比串行快很多。

9.1.5 自定义线程池 ​

默认情况下,Spring 用的是 SimpleAsyncTaskExecutor,每次都新建线程,不太好。

实际项目中,我们一般自定义线程池。

配置类:

java
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        
        // 核心线程数
        executor.setCorePoolSize(10);
        
        // 最大线程数
        executor.setMaxPoolSize(20);
        
        // 队列容量
        executor.setQueueCapacity(200);
        
        // 线程名前缀
        executor.setThreadNamePrefix("async-task-");
        
        // 拒绝策略:由调用线程处理
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        
        // 等待所有任务完成后再关闭
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        
        executor.initialize();
        return executor;
    }
}

使用指定的线程池:

java
@Async("taskExecutor")  // 指定用哪个线程池
public void sendSMS() {
    // ...
}

线程池参数怎么设?

简单的估算方法:

  • CPU 密集型(计算多):核心线程数 = CPU 核心数 + 1
  • IO 密集型(读写多、等待多):核心线程数 = CPU 核心数 * 2

实际项目中,根据压测结果调整。 不要拍脑袋设,也不是越大越好。

9.1.6 异步任务的异常处理 ​

无返回值的异步方法,如果抛异常了,默认是没人管的。

我们可以配置全局的异常处理器。

java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncUncaughtExceptionHandler() {
            @Override
            public void handleUncaughtException(Throwable ex, Method method, Object... params) {
                System.out.println("异步任务异常:" + method.getName());
                System.out.println("异常信息:" + ex.getMessage());
                // 可以做告警、记录日志等
            }
        };
    }
}

重要的异步任务,一定要处理异常,不然出了问题都不知道。


9.2 定时任务(Spring Schedule) ​

9.2.1 什么是定时任务 ​

定时任务就是:到了指定时间,自动执行某个任务。

常见场景:

  • 每天凌晨统计前一天的数据
  • 每隔 5 分钟检查一次系统状态
  • 每个月 1 号生成月报
  • 每周一早上发周报
  • 定时清理临时文件、过期日志

Spring Boot 自带了定时任务支持,非常方便。

9.2.2 快速入门 ​

两个注解搞定:

  • @EnableScheduling:开启定时任务(加在启动类上)
  • @Scheduled:标记定时方法(加在方法上)

步骤 1:开启定时任务

java
@SpringBootApplication
@EnableScheduling  // 开启定时任务
public class Chapter09Application {
    public static void main(String[] args) {
        SpringApplication.run(Chapter09Application.class, args);
    }
}

步骤 2:写定时方法

java
@Service
public class ScheduledTaskService {

    /**
     * 每隔 2 秒执行一次
     */
    @Scheduled(fixedRate = 2000)
    public void fixedRateTask() {
        System.out.println("fixedRate 执行:" + LocalDateTime.now());
    }
}

启动项目,你会看到控制台每隔 2 秒输出一次。

就这么简单!两个注解搞定定时任务。

9.2.3 @Scheduled 的三种方式 ​

@Scheduled 有三种常用的配置方式:

1. fixedRate:固定频率 ​

从上一次开始时间算起,每隔多久执行一次。

java
// 每隔 2 秒执行一次(从上一次开始时间算)
@Scheduled(fixedRate = 2000)
public void fixedRateTask() {
    System.out.println("fixedRate 执行");
}

特点:

  • 固定频率,不管上次执行多久
  • 如果任务执行时间超过间隔,会等上次执行完立即执行下一次
  • 不会并发执行(同一个任务)

2. fixedDelay:固定延迟 ​

从上一次结束时间算起,隔多久执行下一次。

java
// 上一次执行完后,隔 1 秒再执行下一次
@Scheduled(fixedDelay = 1000)
public void fixedDelayTask() throws InterruptedException {
    System.out.println("fixedDelay 执行");
    Thread.sleep(2000);  // 模拟执行 2 秒
}

特点:

  • 两次执行之间有固定的间隔
  • 间隔是从上一次结束开始算
  • 任务执行时间不固定的话,总周期也不固定

3. initialDelay:初始延迟 ​

第一次执行前,先等多久。

java
// 启动后先等 5 秒,然后每隔 2 秒执行一次
@Scheduled(fixedRate = 2000, initialDelay = 5000)
public void taskWithInitialDelay() {
    System.out.println("执行");
}

用途:

  • 项目启动后,等初始化完成了再执行定时任务
  • 避免启动时还没准备好就执行

4. cron:Cron 表达式 ​

最灵活的方式,用 cron 表达式指定执行时间。

java
// 每秒执行一次
@Scheduled(cron = "* * * * * ?")
public void cronTask() {
    System.out.println("cron 执行:" + LocalDateTime.now());
}

cron 表达式最强大,也最常用,下一节详细讲。

9.2.4 fixedRate vs fixedDelay ​

很多人搞不清这两个的区别,我们来对比一下:

方式计算起点特点适用场景
fixedRate上一次开始时间固定频率需要固定频率执行的
fixedDelay上一次结束时间固定间隔两次执行之间需要休息的

举个例子:

假设间隔 2 秒,任务执行 1 秒:

fixedRate:
0s 开始执行 → 1s 结束 → 等 1s → 2s 开始执行 → 3s 结束...
(每 2 秒开始一次)

fixedDelay:
0s 开始执行 → 1s 结束 → 等 2s → 3s 开始执行 → 4s 结束...
(两次结束之间隔 2 秒)

如果任务执行时间超过间隔(比如任务执行 3 秒,间隔 2 秒):

fixedRate:
0s 开始 → 3s 结束 → 立即开始下一次(因为 2s 的点已经过了)
(不会并发,会排队)

fixedDelay:
0s 开始 → 3s 结束 → 等 2s → 5s 开始...
(还是结束后等 2 秒)

简单记:

  • fixedRate = 固定频率(按开始时间算)
  • fixedDelay = 固定延迟(按结束时间算)

9.2.5 Cron 表达式详解 ​

Cron 表达式是定时任务里最重要的,也是面试常问的。

Cron 表达式格式:

秒 分 时 日 月 周 年

共 7 个位置,最后一个"年"可以省略。

各位置的取值范围:

位置字段取值范围特殊字符
1秒0-59, - * /
2分0-59, - * /
3时0-23, - * /
4日1-31, - * / ? L W
5月1-12, - * /
6周1-7(1=周日), - * / ? L #
7年(可选)1970-2099, - * /

特殊字符说明:

字符含义例子
*任意值* 表示每秒/每分/每时
?不指定(日和周互斥,一个设了另一个用 ?)日设了,周就用 ?
,列表1,3,5 表示 1、3、5
-范围1-5 表示 1 到 5
/步长0/5 表示从 0 开始,每 5 个
L最后(Last)日的 L = 当月最后一天
W工作日(Weekday)15W 表示离 15 号最近的工作日
#第几个3#2 表示第 2 个周三

注意:日和周是互斥的,一个指定了具体值,另一个就要用 ?。 不然会冲突,比如 15 号不一定是周一。

9.2.6 常用 Cron 例子 ​

表达式含义
0 0 0 * * ?每天凌晨 0 点
0 0 2 * * ?每天凌晨 2 点
0 0 12 * * ?每天中午 12 点
0 0/5 * * * ?每隔 5 分钟
0 */10 * * * ?每隔 10 分钟
0 0 9-18 * * ?每天 9 点到 18 点,整点执行
0 0 9 ? * MON-FRI周一到周五早上 9 点
0 0 0 1 * ?每月 1 号凌晨
0 0 0 L * ?每月最后一天凌晨
0 0 0 ? * 1每周日凌晨
0 0 0 ? * 2每周一凌晨
0 0 0 ? * 6L每月最后一个周五凌晨
0 0 0 15W * ?每月 15 号最近的工作日
0 0 0 ? * 3#2每月第 2 个周三

不用死记,用的时候查一下就行。 网上有很多 cron 表达式生成器,直接搜"cron 在线生成"。

9.2.7 定时任务的注意事项 ​

  1. 默认单线程

    • Spring Schedule 默认是单线程的
    • 多个定时任务会排队执行
    • 如果一个任务卡住了,其他任务都受影响
  2. 自定义线程池

    • 可以配置线程池,让多个任务并行执行
java
@Configuration
@EnableScheduling
public class ScheduleConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(10);
        scheduler.setThreadNamePrefix("scheduled-");
        scheduler.initialize();
        taskRegistrar.setTaskScheduler(scheduler);
    }
}
  1. 任务不要抛异常

    • 定时任务抛异常,下一次还会继续执行
    • 但最好自己 try-catch,记录日志
  2. 任务不要太长

    • 定时任务不要执行太久
    • 不然影响下一次执行
    • 耗时的任务考虑用异步 + 定时触发
  3. 集群部署要注意

    • 多个实例的话,定时任务会执行多次
    • 解决:分布式锁、只在一个节点执行、用 Quartz 集群模式

Spring Schedule 适合简单的定时任务。 复杂的、需要持久化、需要集群的,用 Quartz。


9.3 Quartz 任务调度 ​

9.3.1 Quartz 是什么 ​

Quartz 是一个功能强大的任务调度框架。

跟 Spring Schedule 比:

特性Spring ScheduleQuartz
上手难度简单稍复杂
功能基础强大
持久化不支持支持
集群不支持支持
动态创建不方便方便
任务管理弱强(暂停、恢复、删除)

什么时候用 Quartz?

  • 任务比较多,需要管理
  • 需要动态创建、修改、删除任务
  • 需要持久化(重启不丢)
  • 需要集群部署
  • 需要复杂的调度规则

简单的定时任务用 Spring Schedule 就行。 复杂的、企业级的用 Quartz。

9.3.2 Quartz 核心概念 ​

Quartz 有几个核心概念,先搞清楚:

概念说明
Job任务,要做什么事
JobDetail任务详情,定义任务的信息
Trigger触发器,什么时候触发
Scheduler调度器,负责调度任务

简单理解:

  • Job = 做什么
  • Trigger = 什么时候做
  • Scheduler = 负责安排什么时候做什么

9.3.3 Spring Boot 整合 Quartz ​

步骤 1:加依赖

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

步骤 2:定义任务类

继承 QuartzJobBean,实现 executeInternal 方法。

java
public class MyTask extends QuartzJobBean {

    @Override
    protected void executeInternal(JobExecutionContext context) {
        System.out.println("MyTask 执行:" + LocalDateTime.now());
    }
}

步骤 3:配置 JobDetail 和 Trigger

java
@Configuration
public class QuartzConfig {

    /**
     * 定义 JobDetail(任务详情)
     */
    @Bean
    public JobDetail jobDetail() {
        return JobBuilder.newJob(MyTask.class)
                .withIdentity("myTask")        // 任务名称(唯一标识)
                .storeDurably()                 // 任务执行完保留
                .build();
    }

    /**
     * 定义 Trigger(触发器)
     */
    @Bean
    public Trigger myTaskTrigger() {
        // Cron 表达式:每秒执行一次
        CronScheduleBuilder schedule = 
            CronScheduleBuilder.cronSchedule("* * * * * ?");
        
        return TriggerBuilder.newTrigger()
                .forJob(jobDetail())          // 绑定哪个任务
                .withIdentity("myTaskTrigger") // 触发器名称
                .withSchedule(schedule)       // 调度规则
                .build();
    }
}

步骤 4:启动测试

启动项目,看看任务是不是每秒执行一次。

就这么简单,Spring Boot 整合 Quartz 很方便。

9.3.4 两种触发器 ​

Quartz 有两种常用的触发器:

1. SimpleTrigger(简单触发器) ​

类似 fixedRate,固定间隔执行。

java
@Bean
public Trigger simpleTrigger() {
    SimpleScheduleBuilder schedule = SimpleScheduleBuilder
            .simpleSchedule()
            .withIntervalInSeconds(5)  // 每隔 5 秒
            .repeatForever();          // 永远重复
    
    return TriggerBuilder.newTrigger()
            .forJob(jobDetail())
            .withIdentity("simpleTrigger")
            .withSchedule(schedule)
            .build();
}

2. CronTrigger(Cron 触发器) ​

用 cron 表达式,更灵活。

java
@Bean
public Trigger cronTrigger() {
    CronScheduleBuilder schedule = CronScheduleBuilder
            .cronSchedule("0 0 2 * * ?");  // 每天凌晨 2 点
    
    return TriggerBuilder.newTrigger()
            .forJob(jobDetail())
            .withIdentity("cronTrigger")
            .withSchedule(schedule)
            .build();
}

一般用 CronTrigger 更多,更灵活。

9.3.5 给任务传参数 ​

有时候任务需要一些参数,可以通过 JobDataMap 传递。

设置参数:

java
@Bean
public JobDetail jobDetail() {
    JobDataMap jobDataMap = new JobDataMap();
    jobDataMap.put("name", "张三");
    jobDataMap.put("age", 18);
    
    return JobBuilder.newJob(MyTask.class)
            .withIdentity("myTask")
            .setJobData(jobDataMap)  // 设置参数
            .storeDurably()
            .build();
}

获取参数:

java
public class MyTask extends QuartzJobBean {

    @Override
    protected void executeInternal(JobExecutionContext context) {
        JobDataMap dataMap = context.getJobDetail().getJobDataMap();
        
        String name = dataMap.getString("name");
        Integer age = dataMap.getInt("age");
        
        System.out.println("name: " + name + ", age: " + age);
    }
}

9.3.6 动态管理任务 ​

Quartz 的一大优势是可以动态管理任务:创建、暂停、恢复、删除。

写一个工具类:

java
@Service
public class QuartzService {

    @Autowired
    private Scheduler scheduler;

    /**
     * 新增任务
     */
    public void addJob(String jobName, Class<? extends Job> jobClass, String cron) 
            throws SchedulerException {
        
        JobDetail jobDetail = JobBuilder.newJob(jobClass)
                .withIdentity(jobName)
                .storeDurably()
                .build();
        
        Trigger trigger = TriggerBuilder.newTrigger()
                .withIdentity(jobName + "Trigger")
                .withSchedule(CronScheduleBuilder.cronSchedule(cron))
                .build();
        
        scheduler.scheduleJob(jobDetail, trigger);
    }

    /**
     * 暂停任务
     */
    public void pauseJob(String jobName) throws SchedulerException {
        scheduler.pauseJob(JobKey.jobKey(jobName));
    }

    /**
     * 恢复任务
     */
    public void resumeJob(String jobName) throws SchedulerException {
        scheduler.resumeJob(JobKey.jobKey(jobName));
    }

    /**
     * 删除任务
     */
    public void deleteJob(String jobName) throws SchedulerException {
        scheduler.deleteJob(JobKey.jobKey(jobName));
    }

    /**
     * 立即执行一次
     */
    public void triggerJob(String jobName) throws SchedulerException {
        scheduler.triggerJob(JobKey.jobKey(jobName));
    }
}

这样就可以动态管理任务了,比如通过后台界面来配置定时任务。 这是 Quartz 比 Spring Schedule 强大的地方。

9.3.7 Quartz 持久化 ​

默认情况下,Quartz 的任务存在内存里,重启就没了。

生产环境一般用 JDBC 持久化,任务存在数据库里,重启不丢。

配置:

yaml
spring:
  quartz:
    job-store-type: jdbc  # 持久化方式:jdbc
    properties:
      org:
        quartz:
          scheduler:
            instanceName: MyScheduler
            instanceId: AUTO
          jobStore:
            class: org.springframework.scheduling.quartz.LocalDataSourceJobStore
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
            tablePrefix: QRTZ_
            isClustered: true  # 开启集群
          threadPool:
            class: org.quartz.simpl.SimpleThreadPool
            threadCount: 10

还要初始化数据库表,Quartz 有官方的 SQL 脚本,执行一下就行。

持久化 + 集群,是企业级应用的标配。 这部分内容比较多,知道有这么回事就行,用到的时候再深入。


9.4 邮件发送 ​

9.4.1 邮件相关协议 ​

先了解几个邮件相关的协议:

协议作用端口
SMTP发送邮件25(普通)/ 465(SSL)
POP3接收邮件,下载到本地110 / 995(SSL)
IMAP接收邮件,服务器上操作143 / 993(SSL)

简单理解:

  • SMTP:发邮件用的
  • POP3/IMAP:收邮件用的

我们这一章讲的是发送邮件,用的是 SMTP 协议。

9.4.2 Spring Boot 整合邮件 ​

Spring Boot 对邮件发送有很好的支持,用 spring-boot-starter-mail。

步骤 1:加依赖

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>

步骤 2:配置邮件

以 QQ 邮箱为例(其他邮箱类似):

yaml
spring:
  mail:
    host: smtp.qq.com          # SMTP 服务器地址
    port: 465                  # 端口(QQ 用 465,SSL)
    username: your@qq.com      # 邮箱地址
    password: your-auth-code   # 授权码(不是密码!)
    default-encoding: UTF-8    # 编码
    properties:
      mail:
        smtp:
          ssl:
            enable: true       # 开启 SSL
          auth: true           # 开启认证

注意:这里的 password 不是邮箱密码,是授权码。 QQ 邮箱、163 邮箱都要开 SMTP 服务,获取授权码。

怎么获取授权码(QQ 邮箱):

  1. 登录 QQ 邮箱 → 设置 → 账户
  2. 找到 POP3/IMAP/SMTP 服务
  3. 开启 SMTP 服务
  4. 生成授权码

9.4.3 发送纯文本邮件 ​

最简单的邮件,纯文本内容。

java
@Service
public class SendEmailService {

    @Autowired
    private JavaMailSender mailSender;

    @Value("${spring.mail.username}")
    private String from;  // 发件人

    /**
     * 发送纯文本邮件
     */
    public void sendSimpleEmail(String to, String subject, String text) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setFrom(from);       // 发件人
        message.setTo(to);           // 收件人
        message.setSubject(subject); // 主题
        message.setText(text);       // 内容
        
        try {
            mailSender.send(message);
            System.out.println("纯文本邮件发送成功");
        } catch (MailException e) {
            System.out.println("纯文本邮件发送失败:" + e.getMessage());
        }
    }
}

测试:

java
@Test
public void testSimpleEmail() {
    sendEmailService.sendSimpleEmail(
        "xxx@qq.com", 
        "测试邮件", 
        "这是一封测试邮件,来自 Spring Boot。"
    );
}

纯文本邮件最简单,但是功能有限,不能发图片、附件、HTML。

9.4.4 发送 HTML 邮件 ​

邮件内容可以是 HTML,支持各种样式。

java
/**
 * 发送 HTML 邮件
 */
public void sendHtmlEmail(String to, String subject, String htmlContent) 
        throws MessagingException {
    
    MimeMessage message = mailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(message, true);
    
    helper.setFrom(from);
    helper.setTo(to);
    helper.setSubject(subject);
    helper.setText(htmlContent, true);  // true 表示是 HTML
    
    mailSender.send(message);
    System.out.println("HTML 邮件发送成功");
}

测试:

java
@Test
public void testHtmlEmail() throws MessagingException {
    String html = "<h1>这是 HTML 邮件</h1>" +
                  "<p style='color: red'>红色文字</p>" +
                  "<a href='https://www.baidu.com'>百度一下</a>";
    
    sendEmailService.sendHtmlEmail("xxx@qq.com", "HTML 邮件测试", html);
}

HTML 邮件可以做得很漂亮,营销邮件一般都是 HTML 的。

9.4.5 发送带附件的邮件 ​

邮件可以带附件,比如文件、图片等。

java
/**
 * 发送带附件的邮件
 */
public void sendEmailWithAttachment(String to, String subject, String text,
                                     String filePath) throws MessagingException {
    
    MimeMessage message = mailSender.createMimeMessage();
    // multipart=true 表示支持多部件(附件、图片等)
    MimeMessageHelper helper = new MimeMessageHelper(message, true);
    
    helper.setFrom(from);
    helper.setTo(to);
    helper.setSubject(subject);
    helper.setText(text);
    
    // 添加附件
    FileSystemResource file = new FileSystemResource(new File(filePath));
    String fileName = filePath.substring(filePath.lastIndexOf(File.separator) + 1);
    helper.addAttachment(fileName, file);
    
    mailSender.send(message);
    System.out.println("带附件的邮件发送成功");
}

测试:

java
@Test
public void testAttachmentEmail() throws MessagingException {
    sendEmailService.sendEmailWithAttachment(
        "xxx@qq.com",
        "带附件的邮件",
        "请看附件",
        "C:/test.pdf"
    );
}

可以添加多个附件,调用多次 addAttachment 就行。

9.4.6 发送带图片的邮件 ​

图片可以直接在邮件内容里显示,不用下载附件。

java
/**
 * 发送带静态资源(图片)的邮件
 */
public void sendEmailWithInline(String to, String subject, String text,
                                 String rscId, String rscPath) throws MessagingException {
    
    MimeMessage message = mailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(message, true);
    
    helper.setFrom(from);
    helper.setTo(to);
    helper.setSubject(subject);
    // HTML 里用 cid:xxx 引用图片
    helper.setText(text, true);
    
    // 添加内联图片
    FileSystemResource res = new FileSystemResource(new File(rscPath));
    helper.addInline(rscId, res);
    
    mailSender.send(message);
    System.out.println("带图片的邮件发送成功");
}

测试:

java
@Test
public void testInlineEmail() throws MessagingException {
    String html = "<h1>这是带图片的邮件</h1>" +
                  "<img src='cid:logo'/>";  // cid: 对应 rscId
    
    sendEmailService.sendEmailWithInline(
        "xxx@qq.com",
        "带图片的邮件",
        html,
        "logo",
        "C:/logo.png"
    );
}

注意:rscId 要跟 HTML 里的 cid:xxx 对应上。

9.4.7 发送模板邮件(重点) ​

实际项目中,邮件内容一般是动态的,比如注册成功邮件,用户名是动态的。

这时候用模板引擎(Thymeleaf)来生成邮件内容。

步骤 1:加 Thymeleaf 依赖

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

步骤 2:写邮件模板

在 resources/templates/ 下创建 emailTemplate.html:

html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>注册成功</title>
</head>
<body>
    <h2>尊敬的 <span th:text="${username}"></span>,您好!</h2>
    
    <p>恭喜您注册成功!</p>
    
    <p>您的账号信息:</p>
    <ul>
        <li>用户名:<span th:text="${username}"></span></li>
        <li>注册时间:<span th:text="${registerTime}"></span></li>
    </ul>
    
    <p>感谢您的加入!</p>
    
    <hr>
    <p style="color: #999; font-size: 12px;">
        此邮件由系统自动发送,请勿回复。
    </p>
</body>
</html>

步骤 3:发送模板邮件

java
@Service
public class SendEmailService {

    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    private TemplateEngine templateEngine;

    @Value("${spring.mail.username}")
    private String from;

    /**
     * 发送模板邮件
     */
    public void sendTemplateEmail(String to, String subject, 
                                   String templateName, Map<String, Object> variables) 
            throws MessagingException {
        
        // 用模板引擎生成 HTML 内容
        Context context = new Context();
        context.setVariables(variables);
        String htmlContent = templateEngine.process(templateName, context);
        
        // 发送 HTML 邮件
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true);
        
        helper.setFrom(from);
        helper.setTo(to);
        helper.setSubject(subject);
        helper.setText(htmlContent, true);
        
        mailSender.send(message);
        System.out.println("模板邮件发送成功");
    }
}

测试:

java
@Test
public void testTemplateEmail() throws MessagingException {
    Map<String, Object> variables = new HashMap<>();
    variables.put("username", "张三");
    variables.put("registerTime", LocalDateTime.now().toString());
    
    sendEmailService.sendTemplateEmail(
        "xxx@qq.com",
        "注册成功",
        "emailTemplate",  // 模板名
        variables
    );
}

模板邮件是实际项目中最常用的方式。 内容和代码分离,改模板不用改代码。


9.5 企业最佳实践 ​

9.5.1 异步任务最佳实践 ​

  1. 一定要自定义线程池

    • 不要用默认的,默认每次新建线程
    • 根据业务设置合理的线程数
  2. 处理异常

    • 无返回值的异步任务,异常要自己处理
    • 记录日志,必要时告警
  3. 监控线程池状态

    • 活跃线程数、队列大小、完成任务数
    • 防止线程池满了任务丢了
  4. 合理使用有返回值的异步

    • 不要为了异步而异步
    • 真正能并行的才用

9.5.2 定时任务最佳实践 ​

  1. 任务幂等性

    • 定时任务可能重复执行(重启、重试)
    • 要保证执行一次和执行多次结果一样
  2. 任务不要太长

    • 单次执行时间不要太长
    • 大数据量的任务考虑分片、分页处理
  3. 集群部署要去重

    • 多个实例的话,定时任务会执行多次
    • 用分布式锁(Redis、ZooKeeper)
    • 或者用 Quartz 集群模式
  4. 失败重试

    • 任务失败了要不要重试
    • 重试几次?间隔多久?
  5. 监控告警

    • 任务是否正常执行
    • 执行时间是否异常
    • 失败了要告警

9.5.3 邮件发送最佳实践 ​

  1. 异步发送

    • 发邮件比较慢,不要同步发
    • 用异步或者消息队列
  2. 失败重试

    • 网络问题可能导致发送失败
    • 要有重试机制
  3. 频率控制

    • 不要发太频繁,不然会被当成垃圾邮件
    • 同一收件人限制发送频率
  4. 模板管理

    • 邮件内容用模板
    • 模板可以存在数据库,支持动态修改
  5. 邮件日志

    • 记录每封邮件的发送情况
    • 发送时间、收件人、内容、状态
  6. 退订机制

    • 营销邮件要有退订链接
    • 合规要求

9.6 新手常见问题排查 ​

问题 1:异步任务不生效 ​

可能原因:

  1. 没加 @EnableAsync
  2. 方法不是 public 的
  3. 方法在同一个类里调用(AOP 不生效)
  4. 类没被 Spring 管理

排查:

  • 检查启动类有没有 @EnableAsync
  • 方法要是 public 的
  • 异步方法要在不同的类里调用(自己调自己不行)
  • 类上要有 @Service / @Component

重点:同一个类里调用异步方法不生效! 因为 AOP 代理的问题,要从外部类调用。

问题 2:定时任务不执行 ​

可能原因:

  1. 没加 @EnableScheduling
  2. 方法不是 public 的
  3. 方法有参数
  4. 类没被 Spring 管理
  5. cron 表达式写错了

排查:

  • 检查启动类有没有 @EnableScheduling
  • 方法要是 public 的,不能有参数
  • 类上要有 @Service / @Component
  • 检查 cron 表达式对不对

问题 3:邮件发送失败 ​

可能原因:

  1. 配置不对(host、port、username、password)
  2. 授权码不对(不是邮箱密码)
  3. 没开 SMTP 服务
  4. 网络问题
  5. 被当成垃圾邮件了

排查:

  • 检查配置是否正确
  • 确认开启了 SMTP 服务,用的是授权码
  • 检查端口和 SSL 配置
  • 检查网络能不能连通 SMTP 服务器
  • 看看垃圾箱里有没有

问题 4:Quartz 任务不执行 ​

可能原因:

  1. JobDetail 或 Trigger 没配置对
  2. cron 表达式错了
  3. 任务类没被 Spring 管理
  4. 任务抛异常了

排查:

  • 检查 JobDetail 和 Trigger 的 Bean 配置
  • 检查 cron 表达式
  • 任务类继承 QuartzJobBean
  • 看看日志有没有异常

问题 5:定时任务在集群里重复执行 ​

原因: 每个实例都执行了一遍

解决:

  • 用分布式锁(Redis 锁)
  • 用 Quartz 集群模式
  • 只在一个节点上跑定时任务

9.7 本章小结 ​

恭喜你!任务调度和邮件发送这一章学完了!

这一章内容比较实用,都是项目里经常用到的。

你都学了什么 ​

异步任务:

  • 什么是异步任务
  • @EnableAsync + @Async
  • 无返回值异步
  • 有返回值异步(Future)
  • 自定义线程池
  • 异常处理

定时任务(Spring Schedule):

  • @EnableScheduling + @Scheduled
  • fixedRate / fixedDelay / initialDelay
  • Cron 表达式详解
  • 常用 cron 例子
  • 注意事项

Quartz 任务调度:

  • Quartz 简介
  • 核心概念(Job、JobDetail、Trigger、Scheduler)
  • Spring Boot 整合
  • 两种触发器(Simple、Cron)
  • 任务传参
  • 动态管理任务
  • 持久化简介

邮件发送:

  • 邮件协议简介
  • Spring Boot 整合邮件
  • 纯文本邮件
  • HTML 邮件
  • 带附件的邮件
  • 带图片的邮件
  • 模板邮件(Thymeleaf)

企业最佳实践:

  • 异步任务最佳实践
  • 定时任务最佳实践
  • 邮件发送最佳实践

动手实践清单 ​

实践项做完打勾
能实现简单的异步任务☐
理解异步方法调用的注意事项(同类调用不生效)☐
会用有返回值的异步任务(Future)☐
能自定义线程池☐
能实现简单的定时任务☐
理解 fixedRate 和 fixedDelay 的区别☐
会写常用的 cron 表达式☐
知道定时任务的注意事项(幂等、集群问题)☐
了解 Quartz 的核心概念☐
  • 能用 Quartz 实现定时任务 | ☐ | | 知道 Quartz 和 Spring Schedule 的区别 | ☐ | | 能发送纯文本邮件 | ☐ | | 能发送 HTML 邮件 | ☐ | | 能发送带附件的邮件 | ☐ | | 能发送模板邮件 | ☐ |

面试常问 ​

  1. Spring Boot 怎么实现异步任务?

    • @EnableAsync 开启异步
    • @Async 标记异步方法
    • 自定义线程池
    • 注意同类调用不生效
  2. fixedRate 和 fixedDelay 的区别?

    • fixedRate:从上一次开始时间算,固定频率
    • fixedDelay:从上一次结束时间算,固定间隔
    • 任务执行时间超过间隔时表现不同
  3. Cron 表达式格式?常用的有哪些?

    • 秒 分 时 日 月 周 年
    • 每天凌晨、每隔 5 分钟、周一到周五等
    • 日和周互斥,一个用 ?
  4. Spring Schedule 和 Quartz 的区别?

    • Spring Schedule:简单、轻量、适合简单任务
    • Quartz:功能强大、支持持久化、集群、动态管理
    • 根据需求选择
  5. Quartz 的核心概念?

    • Job:任务
    • JobDetail:任务详情
    • Trigger:触发器
    • Scheduler:调度器
  6. 集群环境下定时任务怎么避免重复执行?

    • 分布式锁(Redis、ZooKeeper)
    • Quartz 集群模式
    • 只在一个节点执行
  7. Spring Boot 怎么发送邮件?

    • 引入 spring-boot-starter-mail
    • 配置 SMTP 信息
    • 用 JavaMailSender 发送
    • 支持纯文本、HTML、附件、模板
  8. 异步任务和消息队列怎么选?

    • 简单的、同一个应用内的:用异步任务
    • 跨系统的、需要解耦的、需要削峰的:用消息队列
    • 需要持久化、需要可靠投递的:用消息队列

给实习生的建议 ​

  1. 异步任务很常用:发邮件、发短信、记录日志,这些耗时的操作都应该异步。

  2. Cron 表达式不用死记:用到的时候查一下,或者用在线生成器。常用的几个记住就行。

  3. 简单任务用 Spring Schedule:不要上来就用 Quartz,简单的定时任务 Spring Schedule 就够了。

  4. 复杂调度用 Quartz:需要动态管理、持久化、集群的,再用 Quartz。

  5. 邮件发送注意异步:发邮件比较慢,不要同步发,影响用户体验。

  6. 幂等性很重要:定时任务、异步任务、消息队列,都要考虑幂等性。不要假设消息/任务只会来一次。

  7. 监控和告警:定时任务失败了、邮件发不出去了,要能及时发现。生产环境监控很重要。

下一章我们学习 Spring Boot 综合项目实战(瑞吉外卖),把前面学的知识综合运用起来。加油!


基于 Vite 强力驱动 | 纯静态轻量托管