《Java 编程实战》11.1 ScheduledExecutorService:周期任务与时钟

libraryd 需要每天扫逾期、每月结算罚金。本节用 ScheduledExecutorService 的 fixedRate 与 fixedDelay 实跑对比:200ms 任务配 100ms 周期时,fixedRate 每约 200ms 才启动一次,fixedDelay 每约 300ms 一次;再说明周期任务抛异常会静默停止,以及如何用注入的 Clock 算出下一次整点执行时间。

本节目标:用 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 批处理:游标、分片与罚金结算 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发