本节目标:用 JDK 自带的调度器跑 libraryd 的每日任务,并说清楚 fixedRate、fixedDelay、异常和时钟的边界。
适用版本:JDK 25(Temurin 25.0.5);PostgreSQL 17;Javalin 7.2.4。
承接的问题
libraryd 有两类按时间发生的工作:
- 每天凌晨扫描逾期未还的借阅,生成提醒列表或日志。
- 每月初结算上个月的罚金,把
fine_cents落到借阅记录上。
第 10 章之前的所有代码都是「请求来了才执行」。这两类工作没有请求触发,需要一个调度器。选项有操作系统 cron、外部调度平台、ScheduledExecutorService。前两者适合多实例、需要集中管理的场景;本卷先用 JDK 自带的 ScheduledExecutorService,把任务本身写对,再在第 11.3 节处理多实例重复执行。
ScheduledExecutorService 只有几个方法,但语义差异很大:
scheduleAtFixedRate:按固定频率启动,上一次开始后period就应该开始下一次。scheduleWithFixedDelay:上一次结束后,再等delay才开始下一次。schedule:只执行一次。
单线程调度器不会让两个任务并行,所以「固定频率」在任务比周期长时会退化成「背靠背执行」。这一点如果不实测,很容易把 period=100ms 理解成「每 100ms 启动一次」。
fixedRate 与 fixedDelay 的真实差别
用同一个任务做对比:任务本身睡 200ms,周期/延迟都是 100ms。先看 scheduleAtFixedRate:
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
runnable -> new Thread(runnable, "fine-rate"));
scheduler.scheduleAtFixedRate(() -> {
System.out.printf("rate start=%dms thread=%s%n", ms(), Thread.currentThread().getName());
sleep(200);
}, 0, 100, TimeUnit.MILLISECONDS);
本机输出:
rate start=8ms thread=fine-rate
rate start=231ms thread=fine-rate
rate start=436ms thread=fine-rate
rate start=636ms thread=fine-rate
rate start=837ms thread=fine-rate
启动间隔约 200ms,不是 100ms。原因不是调度器算错了,而是单线程被任务占着:第 0ms 的任务跑到 208ms,原定 100ms 和 200ms 的两次启动都已经过期,调度器在 231ms 立刻补一次;下一次原定 300ms 又过期,436ms 再补。它不会并发执行,也不会跳过,而是「能补就补」。
fixedRate 的语义是「按计划时间表启动」,实际启动时间受任务时长限制。任务比周期短时,间隔接近周期;任务比周期长时,间隔接近任务时长,表现为背靠背。任务再长一些,计划时间会持续落后,调度器会不断补跑,给系统带来额外压力。
再看 scheduleWithFixedDelay:
scheduler.scheduleWithFixedDelay(() -> {
System.out.printf("delay start=%dms thread=%s%n", ms(), Thread.currentThread().getName());
sleep(200);
}, 0, 100, TimeUnit.MILLISECONDS);
本机输出:
delay start=14ms thread=fine-delay
delay start=321ms thread=fine-delay
delay start=622ms thread=fine-delay
间隔约 300ms:200ms 任务加 100ms 延迟。下一次的计时从「上一次结束」开始,不会补跑,也不会堆积。对于「每天扫一次逾期」这种任务,fixedDelay 更符合直觉:上一次跑完,再等 24 小时。
两者对 libraryd 的适用性:
| 方法 | 适合 | 不适合 |
|---|---|---|
scheduleAtFixedRate | 短、稳定、希望按时间表对齐的任务,如每分钟刷新缓存 | 长任务、可能超时的数据库扫描 |
scheduleWithFixedDelay | 批处理、全表扫描、外部 API 同步 | 必须严格按整点执行的任务 |
每日任务还有第三种写法:每次执行完,用当前时间算下一次的绝对时间,再 schedule 一次。它比 scheduleAtFixedRate(24h) 更能应对夏令时和系统时钟调整,后文会展开。
周期任务抛异常会静默停止
这是 ScheduledExecutorService 最容易踩的坑:任务体抛出的异常会终止后续执行,而且默认没有明显提示。用一个每 50ms 跑一次、第 3 次抛异常的任务验证:
AtomicInteger runs = new AtomicInteger();
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(() -> {
int n = runs.incrementAndGet();
System.out.println("broken run " + n);
if (n == 3) {
throw new IllegalStateException("settlement failed");
}
}, 0, 50, TimeUnit.MILLISECONDS);
本机输出:
broken run 1
broken run 2
broken run 3
runs after 500ms=3 futureDone=true cancelled=false
future cause=java.lang.IllegalStateException: settlement failed
500ms 内只跑了 3 次。第 3 次抛异常后,ScheduledFuture 进入完成状态,异常可以通过 get() 取到,但调度器不会自动重试,也不会再执行第 4 次。如果代码里没有保留 future 或没有检查它,这个任务就安静地消失了。
正确的做法是在任务体内部捕获 Throwable,记录日志,然后让任务正常返回:
scheduler.scheduleWithFixedDelay(() -> {
try {
scanOverdueLoans();
} catch (Throwable error) {
LOG.log(System.Logger.Level.ERROR,
"overdue scan failed, will retry after delay", error);
}
}, 0, 1, TimeUnit.DAYS);
捕获 Throwable 而不是 Exception,是为了覆盖 Error 之外的运行时异常;OutOfMemoryError 这类错误通常不该继续重试,但在调度任务里至少要让日志留下痕迹。更细的策略是区分「可重试」和「不可重试」:数据库连接失败可以等下一轮,代码 bug 应该让任务失败并告警,而不是无限重试。
ScheduledFuture.get() 可以拿到异常,但它会阻塞。更常见的是在任务体里记录,并给任务加一个「最近成功时间」的指标;第 13 章会把指标接进 Micrometer,本节先用日志。
时钟与下一次执行时间
每日任务不应该依赖 LocalDate.now() 的默认时区,也不应该在测试里真的等到凌晨。把 Clock 注入任务和调度器:
public final class OverdueScanJob implements Runnable {
private final LoanRepository loans;
private final Clock clock;
public OverdueScanJob(LoanRepository loans, Clock clock) {
this.loans = loans;
this.clock = clock;
}
@Override
public void run() {
LocalDate today = LocalDate.now(clock);
int overdue = loans.findOverdue(today).size();
System.getLogger("libraryd.jobs").log(
System.Logger.Level.INFO,
"overdue scan date={0} overdue={1}", today, overdue);
}
}
生产环境注入 Clock.system(ZoneId.of("Asia/Shanghai")),测试注入 Clock.fixed(...)。这样测试可以在任意时刻运行,不需要改系统时间。
如果任务要求「每天 00:10 执行」,不要在 scheduleAtFixedRate 里写 24h 然后祈祷启动时刻正确。先算下一次绝对时间:
package com.plume.library.app;
import java.time.LocalTime;
import java.time.ZonedDateTime;
public final class NextRun {
private NextRun() {
}
public static ZonedDateTime nextDaily(ZonedDateTime now, LocalTime at) {
ZonedDateTime candidate = now.toLocalDate().atTime(at).atZone(now.getZone());
if (candidate.isAfter(now)) {
return candidate;
}
return candidate.plusDays(1);
}
}
用一个固定时钟验证计算:当前是 2026-10-21T09:07:30+08:00,目标是每天 10:00:
Clock clock = Clock.fixed(
Instant.parse("2026-10-21T01:07:30Z"), ZoneId.of("Asia/Shanghai"));
ZonedDateTime now = ZonedDateTime.now(clock);
ZonedDateTime next = NextRun.nextDaily(now, LocalTime.of(10, 0));
System.out.println("now=" + now + " next=" + next
+ " delayMs=" + Duration.between(now, next).toMillis());
本机输出:
now=2026-10-21T09:07:30+08:00[Asia/Shanghai] next=2026-10-21T10:00+08:00[Asia/Shanghai] initialDelayMs=3150000
3150000ms 等于 52.5 分钟,正好是 09:07:30 到 10:00 的差。atZone 会处理时区偏移和夏令时;如果目标时间落在夏令时跳变的空档,Java 会按 ZonedDateTime 的规则调整,不会静默返回一个不存在的本地时间。
在 libraryd 里落地
把「算下一次」和「执行后重新排期」组合成一个可关闭的调度器:
package com.plume.library.app;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public final class JobScheduler implements AutoCloseable {
private static final System.Logger LOG = System.getLogger("libraryd.jobs");
private final ScheduledExecutorService scheduler;
private final Clock clock;
public JobScheduler(Clock clock) {
this.clock = clock;
this.scheduler = Executors.newSingleThreadScheduledExecutor(
runnable -> new Thread(runnable, "libraryd-scheduler"));
}
public void scheduleDaily(LocalTime at, Runnable task) {
scheduleNext(at, task);
}
private void scheduleNext(LocalTime at, Runnable task) {
ZonedDateTime now = ZonedDateTime.now(clock);
ZonedDateTime next = NextRun.nextDaily(now, at);
long delayMs = Math.max(0, Duration.between(now, next).toMillis());
scheduler.schedule(() -> {
try {
task.run();
} catch (Throwable error) {
LOG.log(System.Logger.Level.ERROR,
"scheduled task failed, rescheduling", error);
} finally {
scheduleNext(at, task);
}
}, delayMs, TimeUnit.MILLISECONDS);
}
@Override
public void close() {
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
finally 里的 scheduleNext 是关键:即使任务失败,下一轮仍然会排期。它和 scheduleAtFixedRate 的区别是,每一轮都重新计算「下一个 00:10 的绝对时间」,所以不会因为任务跑了 3 小时而把后续执行时间整体推后,也不会在夏令时切换时错位。
装配到 AppMain:
Clock clock = Clock.system(ZoneId.of("Asia/Shanghai"));
JobScheduler scheduler = new JobScheduler(clock);
OverdueScanJob overdueScan = new OverdueScanJob(loanRepository, clock);
scheduler.scheduleDaily(LocalTime.of(0, 10), overdueScan);
停机时调用 scheduler.close(),顺序放在 HTTP 之后、工作池之前或之后都可以,但要在 JVM 退出前完成。ScheduledExecutorService 的线程默认不是守护线程,忘记关闭会让 JVM 不退出。
如果任务很长,单线程调度器会被占住,其他任务只能等。libraryd 目前只有一两个每日任务,单线程够用;如果以后有每分钟的缓存刷新和每天的批处理,应该给它们不同的调度器,或者把长任务提交到工作池,让调度线程只负责触发。
常见错误与边界
「把 scheduleAtFixedRate 当成并发执行。」 单线程调度器不会让同一个任务的两个实例并行。任务比周期长时,下一次会等上一次结束,表现为背靠背;计划时间会落后,但不会重叠。需要并行是另一个问题,应该换调度器或显式提交到工作池。
「周期设为 0 或负数。」 scheduleAtFixedRate 和 scheduleWithFixedDelay 都要求 period/delay 大于 0,否则抛 IllegalArgumentException。如果想让任务「尽快重复」,用 1ms 也比 0 安全,但通常说明设计有问题。
「不捕获任务异常。」 异常会让 ScheduledFuture 完成,后续执行停止,而且没有默认日志。任务体最外层必须有 try/catch (Throwable),并在 finally 里重新排期(如果用自调度)。
「用 System.currentTimeMillis() 算下一次。」 它受系统时钟调整影响,测试也不可控。用注入的 Clock 和 ZonedDateTime,把时区显式写出来;生产用 Asia/Shanghai,不要依赖服务器默认时区。
「多个实例各自跑定时任务。」 每个 libraryd 实例都有自己的 JobScheduler,部署 3 个实例就会跑 3 次。单机开发看不出来,上线后罚金可能被结算多次。任务本身要幂等,并且用数据库里的执行标记抢占;这是 11.3 的主题。
「任务失败后不告警。」 捕获异常只是让它不终止,不代表问题解决了。至少记录错误日志和连续失败次数;如果连续失败超过阈值,应该让健康检查或告警系统知道,而不是安静地等到下一天。
小结
scheduleAtFixedRate按计划时间启动,任务超时不会重叠,但会补跑;scheduleWithFixedDelay在上一次结束后再等延迟。- 200ms 任务配 100ms 周期:fixedRate 实际约 200ms 一次,fixedDelay 约 300ms 一次。
- 周期任务抛异常会让后续执行停止;任务体要捕获
Throwable,并在自调度方案里用finally重新排期。 - 每日任务用注入的
Clock和ZonedDateTime计算下一次绝对时间,避免默认时区和夏令时问题。 - 自调度比
scheduleAtFixedRate(24h)更能应对长任务和时钟调整,代价是要自己管理重排和关闭。 - 单线程调度器适合少量短任务;长任务会阻塞其他任务,多实例会重复执行。
定时器只能保证「什么时候触发」,不能保证「一次任务处理多少数据」。下一节把视角放到一次罚金结算:6000 行借阅记录,逐条和分批的差距有多大,游标和分片怎么用。
阅读导航:上一节:10.3 异步边界:阻塞、上下文与可观测性 · 下一节:11.2 批处理:游标、分片与罚金结算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。