Java 分布式定时任务重复触发与漏触发生产事故复盘:从 Cron 漂移到“锁+幂等+错过补偿”

Java 分布式定时任务重复触发与漏触发生产事故复盘:从 Cron 漂移到“锁+幂等+错过补偿”

技术主题:Java 编程语言
内容方向:生产环境事故的解决过程(故障现象、根因分析、解决方案、预防措施)

引言

定时任务在单机场景“看起来很简单”,但一旦进入多实例部署与复杂运行环境(容器弹性伸缩、时区/夏令时、下游波动),问题往往迅速复杂化。本次事故复盘,聚焦“同一 Cron 在多实例下重复执行、任务超时导致重入、misfire 导致漏触发”三个常见坑,并给出工程化治理方案与可落地代码骨架。

一、故障现象与影响

  • 每天 00:05 的对账任务,偶发出现两次执行,产生重复对账记录;
  • 在下游接口抖动时,任务执行时长超过一个周期,下一次触发重入,导致并发写入;
  • 业务侧报警显示某些日期缺少对账记录,确认是错过触发(misfire)未补偿;
  • 同一时间多个实例日志均记录“开始执行”,表明任务在集群中被多次调度。

影响:重复对账导致人工复核量上升;漏对账引发账务风险;CPU 与数据库写放大,影响同机业务。

二、排查路径

  1. 复现与定位:
    • 抽取该任务近 30 天的执行日志,按分钟聚类,发现 00:05 同时有 2-3 个实例进入执行;
    • 对比主机系统时间,发现一台实例 NTP 漂移 1.8 秒;
    • 查看线程池与任务注解,使用的是 Spring @Scheduled,未加分布式锁;
    • 检查 misfire 配置,未使用 Quartz,@Scheduled 默认无补偿;
    • 业务库里存在同一账期重复写入,缺少幂等约束。
  2. 影响面评估:梳理所有 @Scheduled 任务,标注“有副作用/长时运行/跨日任务”,初步 7 个存在风险。

三、根因分析

  • 集群唯一执行缺失:@Scheduled 在多实例下会在每个实例各自触发一次;
  • 任务重入:执行超过一个周期时,下一次调度到来,发生并发执行;
  • misfire 策略缺失:容器重启/GC 长停顿后错过触发未补偿;
  • 时间漂移与时区:个别节点 NTP 偏移、时区未统一,造成触发边界差异;
  • 业务幂等缺失:同一账期重复入库未做“幂等去重(唯一键/版本锁)”。

四、总体修复方案

  • 集群唯一执行:
    • 方案 A:Spring + ShedLock + Redis(或 JDBC)为每个任务加“分布式锁”。
    • 方案 B:接入 Quartz 集群或调度平台(如 XXL-JOB),由调度器保证单次派发。
  • 防重入与超时:配置“至多锁定时长”和“至少锁定时长”,避免重入与抖动导致抛锚;
  • misfire 补偿:为关键任务启用 misfire 策略(FireAndProceed),或业务侧按游标补齐缺失账期;
  • 幂等与去重:以业务键(账期 + 业务线)建立唯一约束;接入幂等表或去重缓存;
  • 时间与时区:统一使用 UTC 存储,应用层以固定时区解析 Cron;全节点强制 NTP 同步;
  • 观测与告警:暴露指标 last_run_time、last_success_time、duration、lag、fired_count、misfire_count,并对“超阈/缺失”告警。

五、关键代码与配置

1)Spring + ShedLock:集群唯一执行与防重入

pom.xml 依赖(示意):

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.9.1</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-redis-spring</artifactId>
<version>5.9.1</version>
</dependency>

配置与使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Java
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M", defaultLockAtLeastFor = "PT5S")
public class SchedulerConfig {}

@Component
public class ReconcileJob {

// 每天 00:05 执行;锁定最多 10 分钟,至少持有 5 秒,避免抖动重入
@Scheduled(cron = "0 5 0 * * ?", zone = "UTC")
@SchedulerLock(name = "reconcileJob", lockAtMostFor = "PT10M", lockAtLeastFor = "PT5S")
public void run() {
String period = DateTimeFormatter.ofPattern("yyyy-MM-dd")
.withZone(ZoneOffset.UTC)
.format(Instant.now().minus(1, ChronoUnit.DAYS));
if (!acquireBusinessIdempotency(period)) {
return; // 已处理过,幂等快返
}
// 执行业务逻辑
doReconcile(period);
}

private boolean acquireBusinessIdempotency(String period) {
// 可使用 DB 唯一键或 Redis SETNX(period) + TTL 作为二道防线
return true;
}
}

Redis 连接配置与 ShedLock 的配置略(生产建议设置合适的超时、序列化与连接池)。

2)Quartz:misfire 策略与禁止并发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Java
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

@DisallowConcurrentExecution
public class ReconcileQuartzJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
JobDataMap data = context.getMergedJobDataMap();
String period = data.getString("period");
// 业务幂等校验(唯一键/去重表)
// ...
doReconcile(period);
}
}

public class QuartzSetup {
public static void setup(Scheduler scheduler) throws Exception {
JobDetail job = JobBuilder.newJob(ReconcileQuartzJob.class)
.withIdentity("reconcile", "billing").build();

CronScheduleBuilder cron = CronScheduleBuilder.cronSchedule("0 5 0 * * ?")
.inTimeZone(TimeZone.getTimeZone("UTC"))
// FireAndProceed:错过触发后补一发,并继续后续计划
.withMisfireHandlingInstructionFireAndProceed();

Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("reconcileTrigger", "billing")
.withSchedule(cron)
.build();

scheduler.scheduleJob(job, trigger);
}
}

quartz.properties 关键项(使用 JDBC 集群时):

1
2
3
4
org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.isClustered=true
org.quartz.scheduler.instanceId=AUTO
org.quartz.threadPool.threadCount=5

说明:@DisallowConcurrentExecution 禁止同一 JobDetail 的并发;集群下由 Quartz 选举单实例触发;misfire 策略用于补偿错过的触发。

3)业务幂等:唯一键与去重表

1
2
-- SQL(以账期 period + 业务线 biz 为唯一键)
ALTER TABLE t_reconcile ADD CONSTRAINT uk_period_biz UNIQUE(period, biz);
1
2
3
4
5
6
// Java:写入前检查(或直接依赖唯一键 + 捕获违反异常)
try {
repository.insert(new ReconcileRecord(period, biz));
} catch (DuplicateKeyException e) {
// 幂等:已处理过,忽略
}

4)时间与时区、NTP

  • 应用统一使用 UTC,Cron 显式指定 zone;
  • 容器层启用 NTP/Chrony,偏移超阈告警;
  • 夏令时切换窗口对跨日任务进行灰度与人工观察。

六、验证与观测

  • 回放与对账:对近 60 天账期进行“缺失/重复”扫描,确保 0 漏 0 重;
  • 压测与注故障:人为注入下游慢调用,验证“锁与幂等”在超时场景下生效;
  • 指标看板:
    • last_run_time / last_success_time / duration / fired_count / misfire_count;
    • job_lag(当前时间-上次成功账期),超过阈值告警;
    • lock_contention、idempotent_conflict 次数跟踪。

七、防复发清单

  • 任何有副作用的 @Scheduled 必须加“集群唯一执行 + 幂等去重”;
  • 关键任务必须有 misfire 策略或游标式补偿逻辑;
  • 统一时区与 NTP,偏移超阈告警;
  • 任务线程池与超时设置明确,禁止无限制并发;
  • 上线前做“多实例演练 + 容器重启 + 时钟漂移 + 下游抖动”联合演练;
  • 建立任务台账(负责人、周期、SLO、观测项和回滚方案)。

总结

定时任务的问题本质上是“调度正确性 + 执行有界性 + 结果幂等性”的系统工程。在多实例与不可靠环境下,仅靠 @Scheduled 难以满足生产要求。通过“锁住触发(ShedLock/Quartz 集群)+ 幂等去重 + misfire 补偿 + 统一时钟 + 可观测性”,可以把重复和漏触发的风险降到可控范围内。把这些做成模板化、平台化能力,才能从根上减少此类事故反复发生。