一个很常见的架构演进坑:业务里定时任务一堆——每天凌晨备份数据库、每小时同步对账、每分钟刷新报表缓存,最早一个 @Scheduled 就能搞定;可服务从单机扩到多实例后,问题来了——凌晨的备份任务被三个实例同时执行,数据库被锁死,备份文件也错乱。
这就是分布式环境里定时任务的第一大坑:多实例重复执行。这篇从 @Scheduled 出发,按演进顺序讲:为什么多实例会重复执行、Redis 锁怎么防重、以及专业调度框架(XXL-Job / SnailJob)解决哪些锁解决不了的问题。代码按 Spring Boot 落地。
一、先看问题:@Scheduled 在多实例下的表现
单机部署时,@Scheduled 的定时任务很老实,到点就执行。但服务一扩到多实例,每个实例都会跑一份:
实例A ──┐
实例B ──┼──> 到点同时执行「备份数据库」→ 备份 3 次,文件冲突
实例C ──┘为什么?因为 @Scheduled 的调度器是 JVM 进程内的,实例之间完全不知道对方的存在,各自按 cron 到点触发。这是"本地定时"的天然局限——它只保证"我这个进程到点执行",不保证"全局只有一份执行"。
二、方案 v1:Redis 分布式锁防重(给 @Scheduled 加锁)
最直接的修法:执行前抢一把 Redis 锁,抢到才执行。还是熟悉的 SETNX + 过期时间:
@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"); // 执行完释放锁
}
}这版能解决重复执行,但有两个隐患:
- 锁过期时间怎么定:任务跑超过 30 分钟,锁自动过期,另一个实例又能抢到 → 还是重复执行。锁时间设太短会重复,太长崩溃后任务永远不执行(死锁)
- 释放锁的竞态:自己的锁过期了,别的实例抢到锁,自己执行完却把别人的锁删了 → 连锁失控
升级版:用 Redisson 的看门狗(自动续期)+ 锁值校验(只删自己的锁):
@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、路由策略(轮询/一致性哈希/分片),执行器上报执行状态。
// 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());
}
}
}// XXL-Job 执行器:@XxlJob 注解标记任务方法
@Component
public class BackupJob {
@XxlJob("backupDatabaseJob")
public void backup() {
XxlJobHelper.log("开始备份...");
doBackup();
XxlJobHelper.handleSuccess();
}
}路由策略怎么选:
- 大多数任务:轮询/一致性哈希(每次由一台实例执行,天然防重)
- 数据量大的批处理任务:分片广播(每个实例处理一部分数据,并行加速)
// 分片广播:任务拆成 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-Job | SnailJob |
|---|---|---|
| 定位 | 老牌调度框架,社区大 | 国产后起之秀,支持工作流 |
| 任务编排 | 单任务为主 | 支持 DAG 工作流(任务编排) |
| 存储 | MySQL | MySQL(支持集群) |
| 适合 | 经典定时任务 | 需要工作流编排的场景 |
小悟注:我项目里的定时备份、对账任务用的就是 SnailJob,工作流模式还能把"备份 → 校验 → 通知"串成一条流水线。
四、我的选型结论
| 场景 | 方案 |
|---|---|
| 单机部署、任务少 | @Scheduled 够用 |
| 多实例、任务简单 | @Scheduled + Redisson 锁(防重即可) |
| 多实例、任务多且关键 | 调度框架(XXL-Job / SnailJob) |
一个核心认知:分布式锁解决的只是"并发互斥",调度框架解决的是"任务生命周期管理"(触发、执行、重试、监控)。任务从"几个"变"几十个"之后,别在锁方案上硬撑——上调度框架的收益(告警、重试、可视化)远大于迁移成本。
分布式锁的原理和坑见《Redis 分布式锁详解》和《锁的分类与实现原理》;任务失败后的幂等处理见《接口幂等与防重提交》。
验证记录:Redis
SETNX抢锁 + 释放锁的防重逻辑(setIfAbsent+ 过期时间)已实跑验证(Redis 容器实测:两个并发SETNX同一 key,只有一个成功)。Redisson 看门狗、XXL-Job/SnailJob 的路由策略与分片语义为框架标准行为,标注为静态核对。
