跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

上一章已经能够调用一个被监控服务,但检查仍要由 HTTP 请求触发。监控系统通常还需要主动执行:应用启动后按照固定间隔调用目标接口,再把结果保存到数据库。

Spring 提供任务调度和异步执行两套能力。@Scheduled 决定任务何时触发,@Async 决定一次方法调用是否交给其他线程执行。两者可以组合,但解决的问题并不相同。本章先实现可靠的同步定时任务,再逐步引入异步执行。

1. 调度与异步不是一回事

先区分三个角色:

概念回答的问题当前案例中的作用
调度器什么时候开始每隔 30 秒触发一次检查
执行器在哪个线程执行把异步检查交给任务线程池
业务服务实际做什么调用目标接口并保存状态

只有一个目标时,定时线程直接调用 HealthCheckService 往往已经足够。远程调用设置了超时,fixedDelay 又会等待本次执行结束后再计算下一次时间,因此同一个任务不会在单实例内自行重叠。

异步适合一次需要并发检查多个目标,或者调用方不需要等待结果的场景。它不会让单次 HTTP 调用本身更快,也不会自动提供重试、限流或分布式协调。

2. 启用任务基础设施

把调度和异步能力显式放在配置类中:

src/main/java/cn/usehook/spring/config/TaskConfiguration.java
01
package cn.usehook.spring.config;
02
03
import org.springframework.context.annotation.Configuration;
04
import org.springframework.scheduling.annotation.EnableAsync;
05
import org.springframework.scheduling.annotation.EnableScheduling;
06
07
@Configuration(proxyBeanMethods = false)
08
@EnableScheduling
09
@EnableAsync
10
public class TaskConfiguration {}

@EnableScheduling 让 Spring 查找 Bean 上的 @Scheduled 方法,@EnableAsync 让 @Async 方法可以通过代理提交给执行器。项目如果暂时只使用定时任务,可以先不添加 @EnableAsync。

在配置文件中给业务间隔和两类线程设置明确名称:

src/main/resources/application.yaml
01
app:
02
monitoring:
03
interval: 30s
04
initial-delay: 5s
05
06
spring:
07
task:
08
scheduling:
09
thread-name-prefix: scheduling-
10
pool:
11
size: 2
12
execution:
13
thread-name-prefix: monitoring-
14
pool:
15
core-size: 4
16
max-size: 8
17
queue-capacity: 100

spring.task.scheduling 配置调度器,spring.task.execution 配置普通异步任务使用的执行器。两个命名空间不能互换。线程数量也不应该盲目放大:同步 HTTP 调用主要受目标容量、超时和本机连接资源限制,线程越多不等于吞吐越稳定。

3. 创建定时检查

创建一个只负责触发业务的 Bean:

src/main/java/cn/usehook/spring/monitoring/HealthCheckScheduler.java
01
package cn.usehook.spring.monitoring;
02
03
import org.slf4j.Logger;
04
import org.slf4j.LoggerFactory;
05
import org.springframework.scheduling.annotation.Scheduled;
06
import org.springframework.stereotype.Component;
07
08
@Component
09
public class HealthCheckScheduler {
10
11
private static final Logger log = LoggerFactory.getLogger(
12
HealthCheckScheduler.class
13
);
14
15
private final HealthCheckService healthCheckService;
16
17
public HealthCheckScheduler(HealthCheckService healthCheckService) {
18
this.healthCheckService = healthCheckService;
19
}
20
21
@Scheduled(
22
fixedDelayString = "${app.monitoring.interval}",
23
initialDelayString = "${app.monitoring.initial-delay}"
24
)
25
void check() {
26
try {
27
healthCheckService.checkConfiguredTarget();
28
} catch (RuntimeException exception) {
29
log.error("Scheduled health check failed", exception);
30
}
31
}
32
}

@Scheduled 方法属于容器管理的 Bean,不需要 Controller 调用。它不能接收方法参数;需要的数据应该通过构造器依赖或配置对象取得。同步定时方法的返回值也没有调用方,因此通常声明为 void。

这里捕获异常是为了给失败留下明确日志。若异常直接离开方法,Spring 的默认错误处理仍会记录错误并继续后续调度,但任务本身无法补充目标名称等业务上下文。只捕获能够记录或转换的异常,不要留下空的 catch。

4. 选择触发规则

Spring 常用三种重复触发方式:

方式下一次时间如何计算适用情况
fixedDelay从上一次完成时间开始计算间隔避免同一任务连续挤压,适合远程检查
fixedRate按相邻计划开始时间计算希望接近固定频率,任务耗时稳定
cron按日历时间匹配每天、每周或工作日等计划

假设间隔为 30 秒,检查本身耗时 8 秒:

fixed-delay-rate.txt
1
fixedDelay:0 秒开始 -> 8 秒完成 -> 38 秒再次开始
2
fixedRate: 0 秒开始 -> 8 秒完成 -> 30 秒再次开始

默认调度线程池并不会让 fixedRate 的同一方法自动并行,但耗时超过周期时,后续执行可能紧接着排队运行。增加调度线程数以后,不同任务可以并发,重复注解或其他触发方式也可能重叠,因此仍要按业务语义控制并发。

日历计划使用 Spring 的六段 cron 表达式,第一段是秒:

HealthCheckScheduler.java
1
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Shanghai")
2
void checkOnWeekdayMorning() {
3
healthCheckService.checkConfiguredTarget();
4
}

这个表达式表示上海时区每个工作日上午 9 点执行。涉及日历时间时应显式声明 zone,否则运行机器默认时区变化会改变实际执行时刻。不要同时保留这个方法和前面的固定间隔方法,除非业务确实需要两套独立触发器。

5. 按需异步执行

当调度器需要尽快释放自己的线程时,可以把工作交给异步 Bean。异步方法必须通过另一个 Bean 的代理调用;同一个类里使用 this.checkAsync() 不会经过默认代理,@Async 也就不会生效。

src/main/java/cn/usehook/spring/monitoring/AsyncHealthCheckWorker.java
01
package cn.usehook.spring.monitoring;
02
03
import cn.usehook.spring.health.HealthStatus;
04
import java.util.concurrent.CompletableFuture;
05
import org.springframework.scheduling.annotation.Async;
06
import org.springframework.stereotype.Component;
07
08
@Component
09
public class AsyncHealthCheckWorker {
10
11
private final HealthCheckService healthCheckService;
12
13
public AsyncHealthCheckWorker(HealthCheckService healthCheckService) {
14
this.healthCheckService = healthCheckService;
15
}
16
17
@Async
18
public CompletableFuture<HealthStatus> checkConfiguredTarget() {
19
HealthStatus result = healthCheckService.checkConfiguredTarget();
20
return CompletableFuture.completedFuture(result);
21
}
22
}

调用方得到的 CompletableFuture 会在任务完成时携带结果;业务异常则让 Future 异常完成。与返回 void 相比,它让调用方可以观察成功或失败,也便于组合多个结果。

异步版本的调度器需要防止上一轮尚未完成时再次提交:

src/main/java/cn/usehook/spring/monitoring/AsyncHealthCheckScheduler.java
01
package cn.usehook.spring.monitoring;
02
03
import java.util.concurrent.atomic.AtomicBoolean;
04
import org.slf4j.Logger;
05
import org.slf4j.LoggerFactory;
06
import org.springframework.scheduling.annotation.Scheduled;
07
import org.springframework.stereotype.Component;
08
09
@Component
10
public class AsyncHealthCheckScheduler {
11
12
private static final Logger log = LoggerFactory.getLogger(
13
AsyncHealthCheckScheduler.class
14
);
15
16
private final AtomicBoolean running = new AtomicBoolean();
17
private final AsyncHealthCheckWorker worker;
18
19
public AsyncHealthCheckScheduler(AsyncHealthCheckWorker worker) {
20
this.worker = worker;
21
}
22
23
@Scheduled(
24
fixedDelayString = "${app.monitoring.interval}",
25
initialDelayString = "${app.monitoring.initial-delay}"
26
)
27
void submit() {
28
if (!running.compareAndSet(false, true)) {
29
return;
30
}
31
32
try {
33
worker.checkConfiguredTarget().whenComplete((result, error) -> {
34
running.set(false);
35
if (error != null) {
36
log.error("Async health check failed", error);
37
}
38
});
39
} catch (RuntimeException exception) {
40
running.set(false);
41
throw exception;
42
}
43
}
44
}

这个类是上一节同步 HealthCheckScheduler 的替代方案,不能让两者同时注册,否则每个周期会执行两次检查。异步方法返回后,调度器已经认为本轮触发结束,因此这里用 AtomicBoolean 一直标记到异步工作真正完成。外层 catch 还处理任务在线程池提交阶段就被拒绝的情况,保证标记能够恢复。这个标记只防止当前进程内重叠,不是分布式锁。

6. 并发、异常与多实例

启用异步以后,需要同时考虑提交速度、执行速度和队列容量:

async-capacity.txt
1
调度器产生任务
2
-> 执行器队列暂存
3
-> 最多 max-size 个工作线程执行
4
-> RestClient 等待远程响应

目标持续变慢时,任务会先占用线程,再积压到队列。队列满后,新任务会被拒绝。有限线程、有限队列和有限 HTTP 超时共同形成容量边界;无界队列会把过载延迟成内存和长时间排队问题。

CompletableFuture 的异常必须通过 whenComplete()、exceptionally() 或最终 join() 等方式观察。返回 void 的 @Async 方法无法把异常交还调用方,只能依赖 AsyncUncaughtExceptionHandler,因此有结果或失败语义的任务优先返回 Future。

部署多个应用实例后,每个实例都会注册自己的 @Scheduled 任务。若运行三个实例,同一目标通常会被检查三次。只允许全局执行一次时,需要使用数据库锁、专用调度平台或选主机制;进程内的 AtomicBoolean 无法协调其他实例。若重复检查本来就是为了从多个实例观察目标,则应为每次结果记录执行实例,并确保保存操作能够处理并发。

应用关闭时还要决定是否等待正在执行的任务。后面的优雅停机章节会统一配置等待时间;这里先确保每个远程调用都有超时,否则停机也可能长期等待无法结束的调用。

7. 测试任务边界

定时方法中的业务很薄,可以直接调用方法测试,不需要真的等待 30 秒:

src/test/java/cn/usehook/spring/monitoring/HealthCheckSchedulerTests.java
01
package cn.usehook.spring.monitoring;
02
03
import org.junit.jupiter.api.Test;
04
05
import static org.mockito.Mockito.mock;
06
import static org.mockito.Mockito.verify;
07
08
class HealthCheckSchedulerTests {
09
10
@Test
11
void delegatesScheduledCheckToService() {
12
HealthCheckService service = mock(HealthCheckService.class);
13
HealthCheckScheduler scheduler = new HealthCheckScheduler(service);
14
15
scheduler.check();
16
17
verify(service).checkConfiguredTarget();
18
}
19
}

该测试验证「触发后调用业务服务」,不验证 Spring 是否能读懂 @Scheduled 注解。调度表达式应尽量保持配置化且简单;确实需要验证容器调度时,可以使用很短的测试间隔、计数器和有上限的等待,但不要让测试依赖固定的 Thread.sleep() 后碰运气。

客户端测试已经覆盖远程协议,Repository 测试已经覆盖持久化,定时任务测试又覆盖触发边界。下一篇会把这些层放进同一个 Spring Boot 测试中,从真实 HTTP 请求开始,经过 Service 和数据库,再验证最终响应与持久化结果。