触发时刻为什么会越跑越偏
11.1 选了调度器,11.2 选了排队结构,但还有一个问题没回答:调度器说「每 200ms 执行一次」,实际执行时刻到底落在哪里。
线上常见的三种抱怨其实是一件事:
- 「这个任务说是每小时跑,怎么越来越晚,现在要晚十几分钟」;
- 「任务卡了一次之后,日志里连续出现十几条执行记录」;
- 「同一笔罚款被扣了两次」。
前两个是漂移与补偿,第三个是幂等。这一节先把 fixedRate 与 fixedDelay 的差别用时间戳量出来,再讨论错过的执行该怎么处理,最后给出幂等键的设计。
两种周期的分母不同
STPE 提供两个方法,名字已经把语义写清楚了:
// 下一次 = 上一次的「计划时刻」+ period
scheduler.scheduleAtFixedRate(task, 0, 200, TimeUnit.MILLISECONDS);
// 下一次 = 本次「执行结束」+ delay
scheduler.scheduleWithFixedDelay(task, 0, 200, TimeUnit.MILLISECONDS);
差别只在分母:
scheduleAtFixedRate | scheduleWithFixedDelay | |
|---|---|---|
| 下一次的基准 | 上一次的计划开始时刻 | 本次的实际结束时刻 |
| 单次耗时 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
scheduleAtFixedRateorscheduleWithFixedDelaydo 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)
怎么读这两组数:
fixedRate的落后量在 1–4ms 之间来回,没有趋势。第二次运行是 1/1/5/6/9/6ms,同样没有单调增长。它对齐的是「0、200、400…」这串网格,单次抖动不会传给下一次。fixedDelay的落后量单调增长:82、165、256、356、444ms,每轮增加约 80ms,正好等于任务体的耗时。它的开始间隔是 280–300ms,而不是 200ms。- 单次执行时刻还有几毫秒到十几毫秒的抖动(
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。重复的来源至少有四种:
- 任务抛异常后重试(11.2 的重试队列);
fixedRate的补跑;- 多副本部署,每个实例都调度了同一个任务;
- 进程崩溃后重启,内存里的「已执行」标记丢失。
要让业务效果接近 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 内是正确的。但它有三个边界必须说清:
- 不跨进程:多副本部署时各有一份 map,去重失效;
- 不持久:重启即清空;
- 不释放:键会无限增长,必须配 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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。