本节目标:让同一个结算任务跑两次、三次,数据库里的最终结果和跑一次一样。
适用版本:JDK 25(Temurin 25.0.5);PostgreSQL 17.11;PostgreSQL JDBC 42.7.14。
承接的问题
11.2 的 FineSettlementJob 已经可以按批提交、失败重跑。但「可以重跑」和「重跑不会出错」是两件事。
任务重复执行的来源很多:
- 应用重启,调度器在启动时又触发了一次。
- 任务超时,调度平台重试。
- 部署了三个实例,每个实例都有自己的
JobScheduler。 - 运维手动补跑,和定时任务撞在一起。
- 批处理中途失败,下一轮从头上重来。
如果结算逻辑是「把 fine_cents 加上本次罚金」,跑两次就会翻倍;如果是「按 id 更新为计算值」,跑两次结果一样,但第二次仍然会做无用的更新和提交。前者是错误,后者是浪费。幂等要解决的是前者,同时尽量减少后者。
幂等不是「任务只执行一次」。 分布式系统里,跨进程、跨数据库的「恰好一次」很难保证;工程上更现实的目标是「至少执行一次,但重复执行不改变最终状态」。这一节用两个层次实现它:数据库里的执行标记,和业务行的条件更新。
幂等的三个层次
先把「幂等」拆成三个可以分别实现的部分:
| 层次 | 问题 | 手段 |
|---|---|---|
| 请求层 | 同一个 HTTP 请求被客户端重试 | 幂等键、条件更新、唯一约束 |
| 任务层 | 同一个任务被多个实例或多次调度触发 | job_runs 唯一键抢占 |
| 数据层 | 同一行被处理两次 | where ... and settled_at is null 条件更新 |
三层不是互相替代。请求层的幂等键挡不住定时任务;任务层的抢占挡不住两个 worker 抢同一行;数据层的条件更新是最后一道保险,即使前两层都失效,重复更新也不会把结果改坏。
libraryd 的罚金结算是「按行设置计算值」,天然比「累加」更容易幂等。但「按行设置」仍然要防止两种浪费:已经结算过的行被再次扫描,以及零罚金的行被当成「未结算」。后者是本节要展示的真实坑。
用唯一键抢占执行权
先建一张任务执行表:
create table job_runs (
job_name varchar(80) not null,
business_date date not null,
shard int not null default 0,
status varchar(16) not null,
started_at timestamptz not null default now(),
finished_at timestamptz,
primary key (job_name, business_date, shard)
);
主键是 (job_name, business_date, shard),不是自增 id。它表达的是「这个任务的这个业务日期的这个分片,只能有一条执行记录」。同一时刻插入两条相同主键,数据库只会让一条成功。
抢占用 insert ... on conflict do nothing:
public boolean claim(String jobName, LocalDate businessDate, int shard) throws SQLException {
String sql = """
insert into job_runs (job_name, business_date, shard, status)
values (?, ?, ?, 'RUNNING')
on conflict (job_name, business_date, shard) do nothing
""";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, jobName);
ps.setObject(2, businessDate);
ps.setInt(3, shard);
return ps.executeUpdate() == 1;
}
}
executeUpdate() 返回 1 表示插入成功,当前实例抢到了;返回 0 表示主键冲突,已经有别的实例在跑或跑过了。本机实验:
--- idempotency: job_runs marker ---
first acquire=1
second acquire=0
第一次抢占返回 1,第二次返回 0。返回值必须检查,如果调用方忽略它,两个实例都会继续执行,on conflict do nothing 就白写了。
抢占成功只是开始。任务可能在中途崩溃,留下一条 RUNNING 记录;如果下一轮看到 RUNNING 就直接跳过,任务永远不会再跑。需要给执行记录加状态和租约:
public boolean claim(String jobName, LocalDate businessDate, int shard)
throws SQLException {
if (tryInsert(jobName, businessDate, shard)) {
return true;
}
return tryTakeOver(jobName, businessDate, shard);
}
private boolean tryTakeOver(String jobName, LocalDate businessDate, int shard)
throws SQLException {
String sql = """
update job_runs
set status = 'RUNNING', started_at = now(), finished_at = null
where job_name = ? and business_date = ? and shard = ?
and (status = 'FAILED'
or (status = 'RUNNING'
and started_at < now() - interval '30 minutes'))
""";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, jobName);
ps.setObject(2, businessDate);
ps.setInt(3, shard);
return ps.executeUpdate() == 1;
}
}
30 分钟是租约,不是任务超时。任务正常跑完会 markSuccess,状态变成 SUCCESS;失败会 markFailed,下一轮可以直接重试;只有「进程崩溃、没来得及写状态」才会留下过期的 RUNNING,由租约接管。
租约时间要大于任务的最长正常执行时间,否则一个还在跑的任务会被另一个实例抢走,两个实例同时结算。反过来,租约太长会让崩溃后的恢复变慢。libraryd 的结算任务按 6000 行几百毫秒估算,30 分钟足够;数据量更大时要重新评估。
任务完成后写状态:
public void markSuccess(String jobName, LocalDate businessDate, int shard)
throws SQLException {
updateStatus(jobName, businessDate, shard, "SUCCESS");
}
public void markFailed(String jobName, LocalDate businessDate, int shard)
throws SQLException {
updateStatus(jobName, businessDate, shard, "FAILED");
}
SUCCESS 的记录会阻止后续抢占;FAILED 的记录主键还在,on conflict do nothing 仍然返回 0,所以要由上面的接管逻辑把 FAILED 改回 RUNNING。这样失败可重试,成功不可重跑。
业务键幂等:条件更新
任务层保证「同一时间只有一个实例在跑」,数据层保证「同一行不会被重复改坏」。罚金结算的更新语句带两个条件:
update loans
set fine_cents = ?, fine_settled_at = now()
where id = ? and fine_settled_at is null
如果这一行已经被结算,fine_settled_at 不为空,executeUpdate() 返回 0。批量执行时,executeBatch() 返回的计数数组里对应位置是 0,统计时要按 > 0 累加。
这里有一个真实的坑。最初为了简单,结算条件写成了 fine_cents = 0:
select id, due_on, returned_on
from loans
where returned_on is not null and fine_cents = 0
看起来合理:未结算的罚金是 0。但「按时归还」的罚金也是 0。第一次结算把 5400 行都处理了,其中 1546 行算出来是 0;第二次强制运行,这 1546 行仍然满足 fine_cents = 0,又被处理了一遍。本机输出:
forced rerun first=5400 second=1546 totalFirst=1540250 totalSecond=1540250
总额没变,因为更新的是同一个值,但工作量多了 1546 行。如果结算逻辑不是「设置值」而是「累加」,这 1546 行就会被重复计费。不能用业务值本身当「是否处理过」的标记,必须用独立的标记列。
改成 fine_settled_at 后,同样的强制重跑输出:
forced rerun first=5400 second=0 totalFirst=1540250 totalSecond=1540250
第二次扫描 0 行,总额不变。fine_settled_at 为 null 表示未结算,非 null 表示已结算;0 罚金的行也会被标记,不会被再次处理。
这个模式可以推广到其他任务:
| 任务 | 错误标记 | 正确标记 |
|---|---|---|
| 罚金结算 | fine_cents = 0 | fine_settled_at is null |
| 邮件通知 | notified = false 但失败也置 false | notified_at + 失败状态 |
| 数据导出 | exported_count = 0 | exported_at |
| 缓存刷新 | 值为空 | refreshed_at |
核心是:标记要能区分「没做过」和「做过了,结果恰好是零/空」。一个可空的时间戳通常比一个布尔值或业务值更可靠,因为它同时记录了「什么时候做的」。
在 libraryd 里落地
把任务层和数据层合到 FineSettlementJob:
package com.plume.library.app;
import java.sql.SQLException;
import java.time.LocalDate;
public final class FineSettlementJob {
public record SettlementReport(int scanned, int updated, int shard,
int shards, boolean skipped, long elapsedMs) {
}
private static final String JOB_NAME = "fine-settlement";
private final FineSettlementBatch batch;
private final JobRunRepository jobRuns;
public FineSettlementJob(FineSettlementBatch batch, JobRunRepository jobRuns) {
this.batch = batch;
this.jobRuns = jobRuns;
}
public SettlementReport run(LocalDate businessDate, int shard, int shards) {
long start = System.nanoTime();
try {
if (!jobRuns.claim(JOB_NAME, businessDate, shard)) {
return new SettlementReport(0, 0, shard, shards, true,
(System.nanoTime() - start) / 1_000_000);
}
SettlementReport report = batch.settle(businessDate, shard, shards);
jobRuns.markSuccess(JOB_NAME, businessDate, shard);
return new SettlementReport(report.scanned(), report.updated(),
shard, shards, false,
(System.nanoTime() - start) / 1_000_000);
} catch (RuntimeException e) {
try {
jobRuns.markFailed(JOB_NAME, businessDate, shard);
} catch (SQLException stateError) {
e.addSuppressed(stateError);
}
throw e;
} catch (SQLException e) {
throw new IllegalStateException("job state update failed", e);
}
}
}
batch.settle 是 11.2 的按批扫描和更新,businessDate 用于筛选归还日期在业务月份内的记录。claim 返回 false 时直接返回 skipped=true 的报告,不执行任何更新。
业务日期不要用 LocalDate.now()。月末结算应该算「上一个自然月」,并且用注入的 Clock:
YearMonth lastMonth = YearMonth.now(clock).minusMonths(1);
LocalDate businessDate = lastMonth.atEndOfMonth();
如果任务在 1 月 1 日凌晨跑,minusMonths(1) 得到上一年 12 月,不会把日期算错。时区用 Asia/Shanghai,和 11.1 的调度器保持一致。
多实例部署时,每个实例的 shard 不同,job_runs 的主键包含 shard,所以它们可以并行跑不同分片;同一个分片的两个实例只有一个能抢到。实例数变化时,分片数也要跟着变,否则有的分片没人跑。更稳妥的做法是把分片配置写进部署配置,启动时校验 0 <= shard < shards。
JobRunRepository 的连接和事务要独立于批处理连接。抢占是一条短事务,提交后再开始长批处理;不要把抢占和整个结算放在同一个事务里,否则 job_runs 的行锁会一直持有到结算结束,其他实例只能等。
常见错误与边界
「on conflict do nothing 之后不检查返回值。」 两个实例都执行 claim,一个返回 1,一个返回 0;如果都继续跑,抢占就失效了。返回值必须进入 if,返回 0 的分支要直接跳过。
「先写 SUCCESS 再执行任务。」 如果进程在写 SUCCESS 之后崩溃,任务永远不会再跑。状态应该先 RUNNING,执行完再 SUCCESS;失败写 FAILED,崩溃留下的 RUNNING 由租约接管。
「用请求 ID 或 UUID 当幂等键。」 每次重试生成新的 UUID,数据库认为是不同的键,去重失效。幂等键必须来自业务:任务名 + 业务日期 + 分片,或者订单号 + 操作类型。
「用 fine_cents = 0 当未结算标记。」 本机实测第二次会多处理 1546 行零罚金记录。零是合法结果,不是「没做过」。用独立的 fine_settled_at。
「假设 executeBatch 是原子的。」 一批里可能部分成功、部分失败,BatchUpdateException 会给出部分计数。任务要能按 lastId 或业务键重跑,不能假设「这批要么全成要么全败」。
「多实例只靠调度器去重。」 每个实例的 ScheduledExecutorService 是独立的,进程内锁管不到别的进程。必须用数据库唯一键、 advisory lock 或外部调度平台。libraryd 选数据库唯一键,因为它和业务数据在同一个事务边界里。
「任务成功后不保留记录。」 删除 job_runs 会让下一轮重新执行。记录要保留至少一个业务周期,或者归档到历史表;清理策略要明确,不能为了省空间把幂等依据删掉。
小结
- 幂等是「重复执行不改变最终状态」,不是「任务只执行一次」;工程上用至少一次加幂等实现。
- 任务层用
job_runs(job_name, business_date, shard)唯一键和on conflict do nothing抢占,返回值 1/0 决定是否执行。 RUNNING状态要配租约,崩溃后能接管;FAILED允许重试,SUCCESS阻止重跑。- 数据层用
where id = ? and fine_settled_at is null条件更新,重复执行返回 0 行。 fine_cents = 0不能当未结算标记;本机第二次多处理了 1546 行零罚金记录,改用fine_settled_at后为 0。- 幂等键必须来自业务(任务名 + 业务日期 + 分片),不能用每次重试都变化的 UUID。
- 抢占和批处理不要放在同一个长事务里;短事务抢锁,批处理按批提交。
到这里,第 11 章的定时、批处理和幂等已经闭环。下一章开始处理日志:把 System.Logger 和 MDC 换成 SLF4J 与 Logback,统一级别、Appender 和输出格式。
阅读导航:上一节:11.2 批处理:游标、分片与罚金结算 · 下一节:12.1 SLF4J 与 Logback:级别、Appender 与配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。