上一章已经能够调用一个被监控服务,但检查仍要由 HTTP 请求触发。监控系统通常还需要主动执行:应用启动后按照固定间隔调用目标接口,再把结果保存到数据库。
Spring 提供任务调度和异步执行两套能力。@Scheduled 决定任务何时触发,@Async 决定一次方法调用是否交给其他线程执行。两者可以组合,但解决的问题并不相同。本章先实现可靠的同步定时任务,再逐步引入异步执行。
1. 调度与异步不是一回事
先区分三个角色:
| 概念 | 回答的问题 | 当前案例中的作用 |
|---|---|---|
| 调度器 | 什么时候开始 | 每隔 30 秒触发一次检查 |
| 执行器 | 在哪个线程执行 | 把异步检查交给任务线程池 |
| 业务服务 | 实际做什么 | 调用目标接口并保存状态 |
只有一个目标时,定时线程直接调用 HealthCheckService 往往已经足够。远程调用设置了超时,fixedDelay 又会等待本次执行结束后再计算下一次时间,因此同一个任务不会在单实例内自行重叠。
异步适合一次需要并发检查多个目标,或者调用方不需要等待结果的场景。它不会让单次 HTTP 调用本身更快,也不会自动提供重试、限流或分布式协调。
2. 启用任务基础设施
把调度和异步能力显式放在配置类中:
01package cn.usehook.spring.config;0203import org.springframework.context.annotation.Configuration;04import org.springframework.scheduling.annotation.EnableAsync;05import org.springframework.scheduling.annotation.EnableScheduling;0607@Configuration(proxyBeanMethods = false)08@EnableScheduling09@EnableAsync10public class TaskConfiguration {}
@EnableScheduling 让 Spring 查找 Bean 上的 @Scheduled 方法,@EnableAsync 让 @Async 方法可以通过代理提交给执行器。项目如果暂时只使用定时任务,可以先不添加 @EnableAsync。
在配置文件中给业务间隔和两类线程设置明确名称:
01app:02monitoring:03interval: 30s04initial-delay: 5s0506spring:07task:08scheduling:09thread-name-prefix: scheduling-10pool:11size: 212execution:13thread-name-prefix: monitoring-14pool:15core-size: 416max-size: 817queue-capacity: 100
spring.task.scheduling 配置调度器,spring.task.execution 配置普通异步任务使用的执行器。两个命名空间不能互换。线程数量也不应该盲目放大:同步 HTTP 调用主要受目标容量、超时和本机连接资源限制,线程越多不等于吞吐越稳定。
3. 创建定时检查
创建一个只负责触发业务的 Bean:
01package cn.usehook.spring.monitoring;0203import org.slf4j.Logger;04import org.slf4j.LoggerFactory;05import org.springframework.scheduling.annotation.Scheduled;06import org.springframework.stereotype.Component;0708@Component09public class HealthCheckScheduler {1011private static final Logger log = LoggerFactory.getLogger(12HealthCheckScheduler.class13);1415private final HealthCheckService healthCheckService;1617public HealthCheckScheduler(HealthCheckService healthCheckService) {18this.healthCheckService = healthCheckService;19}2021@Scheduled(22fixedDelayString = "${app.monitoring.interval}",23initialDelayString = "${app.monitoring.initial-delay}"24)25void check() {26try {27healthCheckService.checkConfiguredTarget();28} catch (RuntimeException exception) {29log.error("Scheduled health check failed", exception);30}31}32}
@Scheduled 方法属于容器管理的 Bean,不需要 Controller 调用。它不能接收方法参数;需要的数据应该通过构造器依赖或配置对象取得。同步定时方法的返回值也没有调用方,因此通常声明为 void。
这里捕获异常是为了给失败留下明确日志。若异常直接离开方法,Spring 的默认错误处理仍会记录错误并继续后续调度,但任务本身无法补充目标名称等业务上下文。只捕获能够记录或转换的异常,不要留下空的 catch。
4. 选择触发规则
Spring 常用三种重复触发方式:
| 方式 | 下一次时间如何计算 | 适用情况 |
|---|---|---|
fixedDelay | 从上一次完成时间开始计算间隔 | 避免同一任务连续挤压,适合远程检查 |
fixedRate | 按相邻计划开始时间计算 | 希望接近固定频率,任务耗时稳定 |
cron | 按日历时间匹配 | 每天、每周或工作日等计划 |
假设间隔为 30 秒,检查本身耗时 8 秒:
1fixedDelay:0 秒开始 -> 8 秒完成 -> 38 秒再次开始2fixedRate: 0 秒开始 -> 8 秒完成 -> 30 秒再次开始
默认调度线程池并不会让 fixedRate 的同一方法自动并行,但耗时超过周期时,后续执行可能紧接着排队运行。增加调度线程数以后,不同任务可以并发,重复注解或其他触发方式也可能重叠,因此仍要按业务语义控制并发。
日历计划使用 Spring 的六段 cron 表达式,第一段是秒:
1@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Shanghai")2void checkOnWeekdayMorning() {3healthCheckService.checkConfiguredTarget();4}
这个表达式表示上海时区每个工作日上午 9 点执行。涉及日历时间时应显式声明 zone,否则运行机器默认时区变化会改变实际执行时刻。不要同时保留这个方法和前面的固定间隔方法,除非业务确实需要两套独立触发器。
5. 按需异步执行
当调度器需要尽快释放自己的线程时,可以把工作交给异步 Bean。异步方法必须通过另一个 Bean 的代理调用;同一个类里使用 this.checkAsync() 不会经过默认代理,@Async 也就不会生效。
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.health.HealthStatus;04import java.util.concurrent.CompletableFuture;05import org.springframework.scheduling.annotation.Async;06import org.springframework.stereotype.Component;0708@Component09public class AsyncHealthCheckWorker {1011private final HealthCheckService healthCheckService;1213public AsyncHealthCheckWorker(HealthCheckService healthCheckService) {14this.healthCheckService = healthCheckService;15}1617@Async18public CompletableFuture<HealthStatus> checkConfiguredTarget() {19HealthStatus result = healthCheckService.checkConfiguredTarget();20return CompletableFuture.completedFuture(result);21}22}
调用方得到的 CompletableFuture 会在任务完成时携带结果;业务异常则让 Future 异常完成。与返回 void 相比,它让调用方可以观察成功或失败,也便于组合多个结果。
异步版本的调度器需要防止上一轮尚未完成时再次提交:
01package cn.usehook.spring.monitoring;0203import java.util.concurrent.atomic.AtomicBoolean;04import org.slf4j.Logger;05import org.slf4j.LoggerFactory;06import org.springframework.scheduling.annotation.Scheduled;07import org.springframework.stereotype.Component;0809@Component10public class AsyncHealthCheckScheduler {1112private static final Logger log = LoggerFactory.getLogger(13AsyncHealthCheckScheduler.class14);1516private final AtomicBoolean running = new AtomicBoolean();17private final AsyncHealthCheckWorker worker;1819public AsyncHealthCheckScheduler(AsyncHealthCheckWorker worker) {20this.worker = worker;21}2223@Scheduled(24fixedDelayString = "${app.monitoring.interval}",25initialDelayString = "${app.monitoring.initial-delay}"26)27void submit() {28if (!running.compareAndSet(false, true)) {29return;30}3132try {33worker.checkConfiguredTarget().whenComplete((result, error) -> {34running.set(false);35if (error != null) {36log.error("Async health check failed", error);37}38});39} catch (RuntimeException exception) {40running.set(false);41throw exception;42}43}44}
这个类是上一节同步 HealthCheckScheduler 的替代方案,不能让两者同时注册,否则每个周期会执行两次检查。异步方法返回后,调度器已经认为本轮触发结束,因此这里用 AtomicBoolean 一直标记到异步工作真正完成。外层 catch 还处理任务在线程池提交阶段就被拒绝的情况,保证标记能够恢复。这个标记只防止当前进程内重叠,不是分布式锁。
6. 并发、异常与多实例
启用异步以后,需要同时考虑提交速度、执行速度和队列容量:
1调度器产生任务2-> 执行器队列暂存3-> 最多 max-size 个工作线程执行4-> RestClient 等待远程响应
目标持续变慢时,任务会先占用线程,再积压到队列。队列满后,新任务会被拒绝。有限线程、有限队列和有限 HTTP 超时共同形成容量边界;无界队列会把过载延迟成内存和长时间排队问题。
CompletableFuture 的异常必须通过 whenComplete()、exceptionally() 或最终 join() 等方式观察。返回 void 的 @Async 方法无法把异常交还调用方,只能依赖 AsyncUncaughtExceptionHandler,因此有结果或失败语义的任务优先返回 Future。
部署多个应用实例后,每个实例都会注册自己的 @Scheduled 任务。若运行三个实例,同一目标通常会被检查三次。只允许全局执行一次时,需要使用数据库锁、专用调度平台或选主机制;进程内的 AtomicBoolean 无法协调其他实例。若重复检查本来就是为了从多个实例观察目标,则应为每次结果记录执行实例,并确保保存操作能够处理并发。
应用关闭时还要决定是否等待正在执行的任务。后面的优雅停机章节会统一配置等待时间;这里先确保每个远程调用都有超时,否则停机也可能长期等待无法结束的调用。
7. 测试任务边界
定时方法中的业务很薄,可以直接调用方法测试,不需要真的等待 30 秒:
01package cn.usehook.spring.monitoring;0203import org.junit.jupiter.api.Test;0405import static org.mockito.Mockito.mock;06import static org.mockito.Mockito.verify;0708class HealthCheckSchedulerTests {0910@Test11void delegatesScheduledCheckToService() {12HealthCheckService service = mock(HealthCheckService.class);13HealthCheckScheduler scheduler = new HealthCheckScheduler(service);1415scheduler.check();1617verify(service).checkConfiguredTarget();18}19}
该测试验证「触发后调用业务服务」,不验证 Spring 是否能读懂 @Scheduled 注解。调度表达式应尽量保持配置化且简单;确实需要验证容器调度时,可以使用很短的测试间隔、计数器和有上限的等待,但不要让测试依赖固定的 Thread.sleep() 后碰运气。
客户端测试已经覆盖远程协议,Repository 测试已经覆盖持久化,定时任务测试又覆盖触发边界。下一篇会把这些层放进同一个 Spring Boot 测试中,从真实 HTTP 请求开始,经过 Service 和数据库,再验证最终响应与持久化结果。