《Java 并发编程·线程与内存模型》11.3 调度漂移、幂等与补偿

固定频率和固定延迟的分母不同:前者对齐计划时刻,后者从执行结束开始算。本机实测同一个 200ms 周期、80ms 耗时的任务,fixedRate 六次的落后量稳定在 1–9ms,fixedDelay 则从 82ms 一路涨到 444ms;单次执行超过周期时,fixedRate 不丢弃欠账而是背靠背补跑。文末给出幂等键与补偿的落地写法。

触发时刻为什么会越跑越偏

11.1 选了调度器,11.2 选了排队结构,但还有一个问题没回答:调度器说「每 200ms 执行一次」,实际执行时刻到底落在哪里。

线上常见的三种抱怨其实是一件事:

  • 「这个任务说是每小时跑,怎么越来越晚,现在要晚十几分钟」;
  • 「任务卡了一次之后,日志里连续出现十几条执行记录」;
  • 「同一笔罚款被扣了两次」。

前两个是漂移与补偿,第三个是幂等。这一节先把 fixedRate 与 fixedDelay 的差别用时间戳量出来,再讨论错过的执行该怎么处理,最后给出幂等键的设计。

两种周期的分母不同

STPE 提供两个方法,名字已经把语义写清楚了:

// 下一次 = 上一次的「计划时刻」+ period
scheduler.scheduleAtFixedRate(task, 0, 200, TimeUnit.MILLISECONDS);

// 下一次 = 本次「执行结束」+ delay
scheduler.scheduleWithFixedDelay(task, 0, 200, TimeUnit.MILLISECONDS);

差别只在分母:

scheduleAtFixedRatescheduleWithFixedDelay
下一次的基准上一次的计划开始时刻本次的实际结束时刻
单次耗时 80ms、周期 200ms开始间隔 ≈ 200ms开始间隔 ≈ 280ms
漂移是否累积不累积,只随单次抖动浮动每轮累积一个「执行耗时」
适合对齐整点/整分的采样、聚合两轮之间需要固定休息时间的扫描

漂移的定义:实际开始时刻减去理想网格时刻。

理想网格:0, 200, 400, 600, ...        (第 k 次 = k × period)
漂移(k) = 实际开始(k) - k × period

fixedRate 的目标是让 漂移(k) 始终在 0 附近;fixedDelay 根本不追求这个目标,它保证的是「两次执行之间有 delay 的空隙」,漂移(k) 会随 k 线性增长。

STPE 内部对周期任务的重排只有一行逻辑:固定频率是 time += period,固定延迟是 time = now + delay。所以 fixedRate 的「计划时刻」是一串与执行无关的等差数列,fixedDelay 的计划时刻每次都从「现在」重新起算。

还有一条硬约束,Javadoc 写得很明确:

Successive executions of a periodic task scheduled via scheduleAtFixedRate or scheduleWithFixedDelay do not overlap.

同一周期任务永远串行,corePoolSize 再大也不会并发执行它。这条约束是后面所有「补跑」行为的前提。

本机实测:两种调度的漂移

测法很直接:任务开始时记录 System.nanoTime() 相对 t0 的毫秒数,跑 6 次,周期 200ms,任务体 Thread.sleep(80)。

Runnable task = () -> {
    long at = (System.nanoTime() - t0) / 1_000_000;
    starts.add(at);
    try {
        Thread.sleep(80);                 // 模拟执行耗时
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    done.countDown();
};

本机 JDK 25(load averages 150 左右,两次运行):

== fixedRate  period=200ms work=80ms ==
  第 1 次 开始 +   1ms(理想 +   0ms,落后 1ms)
  第 2 次 开始 + 203ms(理想 + 200ms,落后 3ms,距上次 202ms)
  第 3 次 开始 + 401ms(理想 + 400ms,落后 1ms,距上次 198ms)
  第 4 次 开始 + 604ms(理想 + 600ms,落后 4ms,距上次 203ms)
  第 5 次 开始 + 801ms(理想 + 800ms,落后 1ms,距上次 197ms)
  第 6 次 开始 +1003ms(理想 +1000ms,落后 3ms,距上次 202ms)
== fixedDelay period=200ms work=80ms ==
  第 1 次 开始 +   0ms(理想 +   0ms,落后 0ms)
  第 2 次 开始 + 282ms(理想 + 200ms,落后 82ms,距上次 282ms)
  第 3 次 开始 + 565ms(理想 + 400ms,落后 165ms,距上次 283ms)
  第 4 次 开始 + 856ms(理想 + 600ms,落后 256ms,距上次 291ms)
  第 5 次 开始 +1156ms(理想 + 800ms,落后 356ms,距上次 300ms)
  第 6 次 开始 +1444ms(理想 +1000ms,落后 444ms,距上次 288ms)

怎么读这两组数:

  1. fixedRate 的落后量在 1–4ms 之间来回,没有趋势。第二次运行是 1/1/5/6/9/6ms,同样没有单调增长。它对齐的是「0、200、400…」这串网格,单次抖动不会传给下一次。
  2. fixedDelay 的落后量单调增长:82、165、256、356、444ms,每轮增加约 80ms,正好等于任务体的耗时。它的开始间隔是 280–300ms,而不是 200ms。
  3. 单次执行时刻还有几毫秒到十几毫秒的抖动(fixedDelay 的间隔在 282–300ms 之间摆动),来自本机的高负载(跑实验时 load averages 在 150 左右)。抖动是噪声,趋势才是结构:一个没有趋势,一个线性增长。

这也是为什么「任务越来越晚」的排查第一步是看代码用的是哪个方法。用 fixedDelay 却期望整点对齐,是配置错误,不是调度器不准。

单次执行超过周期

把任务体改成 sleep(300),周期 100ms,fixedRate:

== fixedRate period=100ms work=300ms ==
  第 1 次 开始 +    1ms(理想网格 +   0ms,落后    1ms)
  第 2 次 开始 +  301ms(理想网格 + 100ms,落后  201ms)
  第 3 次 开始 +  601ms(理想网格 + 200ms,落后  401ms)
  第 4 次 开始 +  901ms(理想网格 + 300ms,落后  601ms)
  第 5 次 开始 + 1210ms(理想网格 + 400ms,落后  810ms)

三个结构性事实:

  • 没有并发:五次执行严格串行,间隔约 300ms,等于任务体耗时。
  • 没有丢弃:每次执行都发生了,没有因为「已经错过 3 个周期」而跳过。fixedRate 的语义是「每个周期都要有一次执行」,不是「每个周期最多一次」。
  • 欠账不会消失:落后量每轮增加约 200ms(= 300ms 耗时 − 100ms 周期),5 轮后落后 810ms。只要任务体一直比周期长,它就永远追不上,只是背靠背地跑。

这是「补偿风暴」的来源:一次 10 分钟的数据库卡顿,会让一个每秒执行的任务在恢复后连续补跑 600 次。如果每次都要发通知、写审计,下游会被瞬间打满。

错过的执行怎么处理

STPE 的默认行为是「全部补跑」。业务上通常有三种期望,需要自己实现:

策略语义实现方式适用
全部补跑每个周期一次,一次不落默认行为,无需额外代码计数、对账等可累加的任务
跳过欠账只保证「现在这一次」,历史周期不补任务开头判断落后量,超阈值直接返回采样、报表、缓存刷新
合并为一次把 N 次欠账合并成一次全量处理幂等键 + 只处理最新位格全量扫描、对账

跳过欠账:让补跑变便宜

最省事的做法不是取消任务再重建,而是让补跑的那几次执行立刻返回:

private volatile long lastSlot = -1;

private void guardedTick() {
    long slot = System.nanoTime() / TimeUnit.MILLISECONDS.toNanos(PERIOD_MS);
    long behind = slot - lastSlot;
    if (lastSlot >= 0 && behind > MAX_CATCH_UP) {
        // 落后超过 3 个周期:只做一次,把基准拉回现在
        lastSlot = slot;
        doWork();
        return;
    }
    if (slot <= lastSlot) {
        return;                      // 同一周期的重复触发,直接丢弃
    }
    lastSlot = slot;
    doWork();
}

关键是理解:取消重建会丢任务、会引入竞态;让补跑空转只是浪费几微秒。由于同一周期任务不会并发执行,lastSlot 的读写都在同一条调度线程上,严格说不加 volatile 也没有可见性问题;这里保留它,是为了防止将来把任务挪到多个调度线程时留下隐患。

MAX_CATCH_UP 的选择取决于任务本身:耗时 10ms 的任务,补 100 次也才 1 秒,阈值可以放宽;耗时 1 秒的任务,补 10 次就是 10 秒,阈值要收紧到 2–3。

合并为一次:幂等键 + 位格

「合并」的本质是让同一时间位格的多次执行产生同样的结果。做法是给每次执行算一个位格编号,只处理比已处理位格更新的那次:

private final AtomicLong processedSlot = new AtomicLong(-1);

private void mergedTick() {
    long slot = System.currentTimeMillis() / PERIOD_MS;
    long prev = processedSlot.get();
    if (slot <= prev) {
        return;                                  // 这个位格已经处理过
    }
    if (!processedSlot.compareAndSet(prev, slot)) {
        return;                                  // 并发触发时只有一个赢家
    }
    doWorkForSlot(slot);
}

这里的 compareAndSet 不只是防并发,它同时保证了「位格单调递增」:即使两次触发乱序到达,落后的位格也会被拒绝。

注意位格编号用的是 currentTimeMillis(),这引入了 11.1 提过的时钟问题:时钟回拨会让位格回退,导致重复处理。生产上要么用单调递增的持久化序列,要么在幂等层再兜一次(下一小节)。

幂等键:调度器只能保证至少一次

先把责任划清:STPE、Timer、任何调度器都只能保证 at-least-once。重复的来源至少有四种:

  1. 任务抛异常后重试(11.2 的重试队列);
  2. fixedRate 的补跑;
  3. 多副本部署,每个实例都调度了同一个任务;
  4. 进程崩溃后重启,内存里的「已执行」标记丢失。

要让业务效果接近 exactly-once,只有一条路:给每次执行一个稳定的业务键,在执行侧去重。

键怎么设计

组成例子说明
任务名overdue-fine区分不同任务
业务维度会员 ID / ISBN同一任务对不同对象的执行互相独立
时间位格2026-10-28 或 epochSecond / 3600必须带,否则只能执行一次

最常见的错误是只用「任务名 + 业务 ID」:第一天的罚款记上了,第二天用同样的键就被当成重复,永远不再执行。位格是把「幂等」限制在「同一周期内」的关键,跨周期必须视为新事件。

位格的选择要和调度周期对齐:每小时任务用 epochSecond / 3600,每天任务用 LocalDate。用「任务开始时间」做位格是错的——补跑时开始时间每次都不同,键就失去意义。

存储:本地只能演示

public final class IdempotentJob {
    private final ConcurrentHashMap<String, Boolean> seen = new ConcurrentHashMap<>();

    public boolean runOnce(String key, Runnable work) {
        if (seen.putIfAbsent(key, Boolean.TRUE) != null) {
            return false;
        }
        work.run();
        return true;
    }
}

本机实测(同一个位格投递三次,另一个位格投递两次):

== 幂等键去重 ==
  key=daily-fine@12 -> 执行
  key=daily-fine@12 -> 跳过(重复投递)
  key=daily-fine@12 -> 跳过(重复投递)
  key=daily-fine@13 -> 执行
  key=daily-fine@13 -> 跳过(重复投递)
  真实执行 2 次,抑制重复 3 次

ConcurrentHashMap.putIfAbsent 的原子性保证了「只有一个赢家」,这段代码在单 JVM 内是正确的。但它有三个边界必须说清:

  1. 不跨进程:多副本部署时各有一份 map,去重失效;
  2. 不持久:重启即清空;
  3. 不释放:键会无限增长,必须配 TTL 或按位格定期清理。

生产做法是「数据库唯一索引」或「带 TTL 的分布式 KV」:

CREATE UNIQUE INDEX uk_job_key ON job_execution (job_key);

先 INSERT 幂等键,插入成功才执行业务;插入冲突说明已经执行过。关键是幂等键的写入和业务副作用要在同一个事务里,否则会出现「键写了、业务没做」或反过来。

在 libraryd-conc 里落地

借阅系统有一个罚款任务:每天 02:00 扫描逾期借阅,按天计罚款。它同时需要幂等与补偿。

public final class OverdueFineJob {
    private final LoanRepository loans;
    private final FineLedger ledger;                    // 罚款台账端口,不属于领域契约
    private final Function<LocalDate, List<String>> overdueIds;

    public OverdueFineJob(LoanRepository loans, FineLedger ledger,
                          Function<LocalDate, List<String>> overdueIds) {
        this.loans = loans;
        this.ledger = ledger;
        this.overdueIds = overdueIds;
    }

    public void runFor(LocalDate day) {
        String key = "overdue-fine:" + day;
        if (!ledger.tryAcquire(key)) {                  // 唯一索引插入,冲突即已执行
            return;
        }
        for (String loanId : overdueIds.apply(day)) {
            Loan loan = loans.findById(loanId).orElse(null);
            if (loan == null) {
                continue;
            }
            try {
                Instant deadline = day.atStartOfDay(ZoneId.systemDefault()).toInstant();
                long amount = ChronoUnit.DAYS.between(loan.dueAt(), deadline) * 1L;
                ledger.charge(loan.loanId(), day, amount);       // 每天 1 元
            } catch (RuntimeException e) {
                ledger.recordPending(loan.loanId(), day);        // 失败留下补偿记录
            }
        }
    }

    /** 补偿:下一轮先处理上一轮留下的 pending,再跑当天 */
    public void compensate(LocalDate day) {
        for (PendingFine p : ledger.pendingBefore(day)) {
            try {
                ledger.charge(p.loanId(), p.day(), p.amount());
                ledger.clearPending(p);
            } catch (RuntimeException e) {
                // 仍然失败,留到下一轮;pending 表本身就是重试队列
            }
        }
    }
}

三个设计点:

  • 幂等键的粒度是「任务 + 天」,不是「任务 + 借阅」。charge 内部按 loanId + day 还有一层唯一约束:任务级防止整天重复,记录级防止单条重复。
  • 失败不抛出去,落 pending 表。定时任务抛异常只会让本轮中止,剩下的借阅永远处理不到;逐条捕获才能让「一条失败不影响其余 999 条」。
  • 补偿在下一轮开头做,而不是立刻重试。立刻重试在数据库故障时会放大压力,隔一轮重试给了下游恢复时间(和 11.2 的退避是同一个思路,只是用持久化表代替了内存队列)。

这版也有一个必须承认的缺口:任务键先落库、再逐条处理,进程如果在中途崩溃,重跑会因为键已存在而跳过剩下的记录。所以单条记录也要有自己的幂等约束(loanId + day 的唯一索引),或者把任务键的粒度拆到分片级——幂等键的粒度决定了故障时最多丢多少。

FineLedger、PendingFine 和 overdueIds 是查询与台账端口,不在 COMMON 的领域契约内,这里只用它们表达职责;对领域类型的操作仍然只有 LoanRepository.findById,Loan 的字段也只有 loanId/isbn/memberId/dueAt。

常见错误与边界

错误后果正确做法
用 fixedDelay 却期望整点对齐漂移每轮累积一个执行耗时需要网格对齐就用 fixedRate
用 fixedRate 跑长任务背靠背补跑,落后量线性增长缩短单次耗时,或改 fixedDelay
认为补跑会跳过卡顿恢复后瞬间打满下游显式实现跳过或合并策略
幂等键不带时间位格只能执行一次,后续周期全部被吞键 = 任务 + 业务维度 + 位格
位格用任务开始时间补跑时键每次都变,去重失效用周期网格(日期 / epochSecond ÷ period)
只在本地 Map 去重多副本、重启后重复执行数据库唯一索引或分布式 KV + TTL
幂等键与业务不在同一事务键写了业务没做,或反过来同事务提交,或先键后业务 + 补偿
补偿无上限、无退避故障期间补偿表暴涨最大次数 + 退避 + 死信

什么时候不要追求「一次不落」。 通知、报表、缓存刷新这类任务,补跑 600 次没有业务价值,只会打满下游;用「跳过欠账」或「合并为一次」。反过来,计费、扣库存、记账必须一次不落,那就必须承受补跑,并用幂等键保证补跑不产生重复副作用。

什么时候不该用本地调度。 多副本部署、需要跨实例唯一执行、需要可视化与手动触发时,本地 STPE 都不是合适的选择;应该用外部调度中心 + 分布式锁。这不是「STPE 不够好」,而是「单进程组件不该承担集群语义」。

小结

  • fixedRate 与 fixedDelay 的差别在分母:前者对齐计划时刻,后者从执行结束起算。实测同一个 200ms 周期、80ms 耗时的任务,fixedRate 落后量稳定在 1–9ms 且无趋势,fixedDelay 从 82ms 单调增长到 444ms,每轮约增加一个执行耗时。
  • 单次执行超过周期时,fixedRate 不并发、不丢弃、不追赶:串行背靠背补跑,落后量每轮增加「耗时 − 周期」,实测 5 轮后落后 810ms。
  • 补跑是「补偿风暴」的根源。跳过欠账的实现要点是让补跑的执行立刻返回,而不是取消重建任务;合并的实现要点是位格单调 + CAS。
  • 调度器只保证 at-least-once,重复来自重试、补跑、多副本、重启。幂等键必须包含任务名、业务维度和时间位格,本地 Map 只能演示,生产要用唯一索引或分布式 KV。
  • 失败不要抛出一轮了之,逐条捕获并落补偿记录;补偿放在下一轮开头,天然带退避。

第 11 章到此结束。下一章换一种执行模型:不再由外部提交任务,而是让任务自己分裂、自己找活干——工作窃取与 ForkJoinPool。

阅读导航:上一节 11.2 延迟队列与时间轮 | 下一节 12.1 工作窃取与 ForkJoinPool

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收