Skip to content

一个很常见的架构演进坑:业务里定时任务一堆——每天凌晨备份数据库、每小时同步对账、每分钟刷新报表缓存,最早一个 @Scheduled 就能搞定;可服务从单机扩到多实例后,问题来了——凌晨的备份任务被三个实例同时执行,数据库被锁死,备份文件也错乱

这就是分布式环境里定时任务的第一大坑:多实例重复执行。这篇从 @Scheduled 出发,按演进顺序讲:为什么多实例会重复执行、Redis 锁怎么防重、以及专业调度框架(XXL-Job / SnailJob)解决哪些锁解决不了的问题。代码按 Spring Boot 落地。


一、先看问题:@Scheduled 在多实例下的表现

单机部署时,@Scheduled 的定时任务很老实,到点就执行。但服务一扩到多实例,每个实例都会跑一份

实例A ──┐
实例B ──┼──> 到点同时执行「备份数据库」→ 备份 3 次,文件冲突
实例C ──┘

为什么?因为 @Scheduled 的调度器是 JVM 进程内的,实例之间完全不知道对方的存在,各自按 cron 到点触发。这是"本地定时"的天然局限——它只保证"我这个进程到点执行",不保证"全局只有一份执行"

二、方案 v1:Redis 分布式锁防重(给 @Scheduled 加锁)

最直接的修法:执行前抢一把 Redis 锁,抢到才执行。还是熟悉的 SETNX + 过期时间:

java
@Scheduled(cron = "0 0 2 * * ?")     // 每天凌晨 2 点备份
public void backup() {
    // 抢锁:谁抢到谁执行,防止多实例重复备份
    boolean locked = redisTemplate.opsForValue()
            .setIfAbsent("job:lock:backup", "1", Duration.ofMinutes(30));
    if (!locked) {
        log.info("其他实例正在执行备份,跳过");
        return;
    }
    try {
        doBackup();
    } finally {
        redisTemplate.delete("job:lock:backup");   // 执行完释放锁
    }
}

这版能解决重复执行,但有两个隐患

  1. 锁过期时间怎么定:任务跑超过 30 分钟,锁自动过期,另一个实例又能抢到 → 还是重复执行。锁时间设太短会重复,太长崩溃后任务永远不执行(死锁)
  2. 释放锁的竞态:自己的锁过期了,别的实例抢到锁,自己执行完却把别人的锁删了 → 连锁失控

升级版:用 Redisson 的看门狗(自动续期)+ 锁值校验(只删自己的锁):

java
@Scheduled(cron = "0 0 2 * * ?")
public void backup() {
    RLock lock = redissonClient.getLock("job:lock:backup");
    if (lock.tryLock(0, TimeUnit.SECONDS)) {   // 抢到锁(watchdog 自动续期)
        try {
            doBackup();
        } finally {
            lock.unlock();                     // 只释放自己的锁
        }
    }
}

特点:改动小、不引新组件;但锁方案只解决"防重",解决不了"失败重试、任务监控、分片"——任务挂了没人知道,也不会自动重跑。

三、方案 v2:专业调度框架(XXL-Job / SnailJob)

任务变多、变关键之后,锁方案撑不住了,需要专业调度框架。核心能力对比:

能力Redis 锁XXL-Job / SnailJob
多实例防重✅ 手动加锁✅ 调度器保证单实例执行
失败重试✅ 自动重试(可配置次数)
任务监控/告警✅ 执行日志、失败告警
错过执行补偿✅ 任务错过自动补跑
分片广播✅ 大数据量任务按分片并行
动态调整改代码重发✅ 后台改 cron、启停

架构:调度中心(独立部署,负责任务分发)+ 执行器(业务服务里嵌入,接收任务执行)。调度中心维护任务的 cron、路由策略(轮询/一致性哈希/分片),执行器上报执行状态。

java
// SnailJob 执行器:业务里实现一个任务处理器
@SnailJob(jobName = "backupDatabaseJob")
public class BackupJobHandler implements JobHandler {
    @Override
    public ProcessResult execute(JobExecuteContext context) {
        try {
            doBackup();
            return ProcessResult.processSuccess();
        } catch (Exception e) {
            // 返回失败 → 调度中心按配置自动重试
            return ProcessResult.processFailure("备份失败: " + e.getMessage());
        }
    }
}
java
// XXL-Job 执行器:@XxlJob 注解标记任务方法
@Component
public class BackupJob {
    @XxlJob("backupDatabaseJob")
    public void backup() {
        XxlJobHelper.log("开始备份...");
        doBackup();
        XxlJobHelper.handleSuccess();
    }
}

路由策略怎么选

  • 大多数任务:轮询/一致性哈希(每次由一台实例执行,天然防重)
  • 数据量大的批处理任务:分片广播(每个实例处理一部分数据,并行加速)
java
// 分片广播:任务拆成 N 片,每个实例处理其中一片
@SnailJob(jobName = "syncBigDataJob", routeStrategy = RouteStrategy.SHARDING)
public class SyncBigDataHandler implements JobHandler {
    @Override
    public ProcessResult execute(JobExecuteContext context) {
        int shardIndex = context.getShardIndex();   // 当前实例的分片号
        int shardTotal = context.getShardTotal();   // 总分片数
        // 只处理属于自己分片的数据(如按 id % shardTotal == shardIndex)
        syncDataByShard(shardIndex, shardTotal);
        return ProcessResult.processSuccess();
    }
}

对比一下两个框架(我实际用的 SnailJob):

对比点XXL-JobSnailJob
定位老牌调度框架,社区大国产后起之秀,支持工作流
任务编排单任务为主支持 DAG 工作流(任务编排)
存储MySQLMySQL(支持集群)
适合经典定时任务需要工作流编排的场景

小悟注:我项目里的定时备份、对账任务用的就是 SnailJob,工作流模式还能把"备份 → 校验 → 通知"串成一条流水线。

四、我的选型结论

场景方案
单机部署、任务少@Scheduled 够用
多实例、任务简单@Scheduled + Redisson 锁(防重即可)
多实例、任务多且关键调度框架(XXL-Job / SnailJob)

一个核心认知:分布式锁解决的只是"并发互斥",调度框架解决的是"任务生命周期管理"(触发、执行、重试、监控)。任务从"几个"变"几十个"之后,别在锁方案上硬撑——上调度框架的收益(告警、重试、可视化)远大于迁移成本。

分布式锁的原理和坑见《Redis 分布式锁详解》和《锁的分类与实现原理》;任务失败后的幂等处理见《接口幂等与防重提交》。


验证记录:Redis SETNX 抢锁 + 释放锁的防重逻辑(setIfAbsent + 过期时间)已实跑验证(Redis 容器实测:两个并发 SETNX 同一 key,只有一个成功)。Redisson 看门狗、XXL-Job/SnailJob 的路由策略与分片语义为框架标准行为,标注为静态核对。