《Java 编程实战》11.3 任务幂等:重复执行不出错

定时任务可能因为重启、重试或多实例被跑两次。本节先用 job_runs 的唯一键和 on conflict do nothing 抢占执行权,再用真实输出说明第二次抢占返回 0;接着展示一个真实踩坑:用 fine_cents = 0 当未结算标记,第二次运行又处理了 1546 行零罚金记录,最后用 fine_settled_at 修正。

本节目标:让同一个结算任务跑两次、三次,数据库里的最终结果和跑一次一样。
适用版本: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 = 0fine_settled_at is null
邮件通知notified = false 但失败也置 falsenotified_at + 失败状态
数据导出exported_count = 0exported_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 与配置 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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