本节目标:解决
@Scheduled在多实例部署下的重复执行问题,掌握数据库锁与 Redis 锁两种去重手段、ShedLock 的适用边界,并建立一套让任务「重复跑也安全」的幂等与告警设计。
适用版本:Spring Boot 4.1.x(Java 21)
13.3 分布式调度与幂等
13.1 讲 @Scheduled 时有个默认前提:只有一个实例。真实的生产服务几乎都是多实例部署——为了高可用、为了横向扩容。一旦实例数变成 3,@Scheduled 立刻暴露出它最危险的一面:每个实例都会独立触发同一个任务。
借阅服务部署 3 个副本,逾期罚金结算任务每天凌晨 2 点执行。结果就是凌晨 2 点三个实例同时开工,同一笔逾期被算三次罚金。这不是理论风险,是几乎每个从单机扩到多实例的团队都会撞上的一次事故。
本节先解决「怎么只让一个实例跑」,再解决「万一还是跑了两次怎么办」——后者是更根本的一层。
13.3.1 重复执行是怎么发生的
@Scheduled 的调度器完全在本进程内:它不知道也不关心别的实例。三个实例各自启动、各自注册任务、各自按 cron 触发,彼此没有通信。示例输出(三个实例在同一时刻各跑一次):
实例 A 02:00:00.101 [scheduling-1] 开始结算逾期罚金
实例 B 02:00:00.118 [scheduling-1] 开始结算逾期罚金
实例 C 02:00:00.097 [scheduling-1] 开始结算逾期罚金
要消除重复,必须引入一个所有实例都能看到的、可原子抢占的共享状态。这个共享状态可以是数据库,可以是 Redis,也可以是一个外部调度中心——这就是三种解法。
13.3.2 解法一:数据库锁
最不需要额外组件的方式,是直接用业务库做一把锁。建一张锁表:
create table task_lock (
task_name varchar(100) primary key,
locked_until timestamptz not null,
locked_by varchar(100) not null
);
抢占动作必须是原子的——先查后更新会留下竞态窗口。PostgreSQL 可以用带条件的 upsert 一步完成「若已过期则抢占」:
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
@Component
class DbLockScheduler {
private final JdbcTemplate jdbc;
private final String instanceId = UUID.randomUUID().toString();
DbLockScheduler(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
boolean tryLock(String taskName, Duration ttl) {
List<String> owners = jdbc.queryForList("""
insert into task_lock(task_name, locked_until, locked_by)
values (?, now() + (? * interval '1 second'), ?)
on conflict (task_name) do update
set locked_until = excluded.locked_until,
locked_by = excluded.locked_by
where task_lock.locked_until < now()
returning locked_by
""", String.class, taskName, ttl.toSeconds(), instanceId);
return !owners.isEmpty(); // 有返回行 = 抢到锁
}
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Shanghai")
void settleFine() {
if (!tryLock("settleFineJob", Duration.ofMinutes(15))) {
return; // 别的实例在跑,本实例直接退出
}
fineService.settle(LocalDate.now().minusDays(1));
}
}
这段 SQL 的关键在于 on conflict ... where task_lock.locked_until < now():只有当已有锁已过期时才允许覆盖,否则 update 不执行、returning 无行,抢占失败。整个「判断 + 抢占」在一条语句里完成,不存在竞态。
锁必须带过期时间(TTL)。 如果持锁实例在任务中途崩溃,没有 TTL 的锁会永久留在表里,任务再也不会被任何实例执行。TTL 要大于任务的正常最大耗时,留出余量。反过来,TTL 太长又会拖慢故障恢复——权衡点是「任务正常最长多久 + 一点缓冲」。
数据库锁的优点是零新增组件、跟业务事务在同一个库、语义清晰;缺点是每次抢占都要打库,任务本身已经是数据库密集时会更明显。
13.3.3 解法二:Redis 锁
如果服务已经依赖 Redis,用 SET key value NX PX ttl 抢占更轻量——它是一条原子命令,天然满足「不存在才设置」:
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
@Component
class RedisLockScheduler {
private final StringRedisTemplate redis;
private final String instanceId = UUID.randomUUID().toString();
RedisLockScheduler(StringRedisTemplate redis) {
this.redis = redis;
}
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Shanghai")
void settleFine() {
String key = "lock:settleFineJob";
Boolean locked = redis.opsForValue()
.setIfAbsent(key, instanceId, Duration.ofMinutes(15)); // SET NX PX
if (!Boolean.TRUE.equals(locked)) {
return; // 没抢到,说明别的实例在跑
}
try {
fineService.settle(LocalDate.now().minusDays(1));
} finally {
releaseIfOwner(key);
}
}
// 只有值等于自己的 instanceId 才删,避免删掉别人续期后的锁
private void releaseIfOwner(String key) {
String script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
redis.execute(new DefaultRedisScript<>(script, Long.class), List.of(key), instanceId);
}
}
两个必须注意的点:
- 释放锁要校验持有者。 如果 A 的锁因 TTL 到期自动释放、B 抢到并开始执行,此时 A 执行完直接
DEL,会把 B 的锁删掉。所以释放要用 Lua 脚本比对值再删——上面的releaseIfOwner就是这个作用。这一点在缓存章节讲分布式互斥时也强调过。 - 任务耗时可能超过 TTL。 TTL 到期后锁自动失效,别的实例就能抢进来,于是两个实例同时跑。要解决就得续期(看门狗机制),但这会显著增加复杂度。工程上更常见的做法是:把 TTL 设得足够大、同时把任务本身设计成幂等的(见 13.3.4)——锁只负责减少重复,幂等负责兜住漏网的重复。
13.3.4 解法三:交给调度中心
前两种解法都是「触发还在每个实例里,只是执行前抢锁」。第三种思路是从根上消除重复触发:把「什么时候触发」交给一个中心(XXL-JOB、ElasticJob 等),业务实例只注册为执行器。中心按配置触发,天然只触发一次;业务侧不再写 @Scheduled,也就没有「每个实例各触发一次」的问题。
这种解法的价值不止去重——它还附带了可视化、手动重跑、分片、执行日志。代价是引入一个独立中间件与一套运维(见 13.1.6 的对比表)。判断标准:如果你只缺「不重复」,前两种锁就够;如果你还缺「看得见、能干预、能重跑」,才值得上中心。
13.3.5 ShedLock 的定位
ShedLock 是一个很窄的工具,它的定位值得单独说清:它只做一件事——让同一个 @Scheduled 任务在集群里最多执行一次。 它通过一张锁表(或 Redis 等存储)实现,用法是在任务方法上加一个注解:
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component
class ShedLockScheduler {
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Shanghai")
@SchedulerLock(name = "settleFineJob",
lockAtLeastFor = "PT1M", lockAtMostFor = "PT15M")
void settleFine() {
fineService.settle(LocalDate.now().minusDays(1));
}
}
lockAtMostFor 对应「TTL」(防止持锁实例崩溃后死锁),lockAtLeastFor 是「最短持锁时间」(防止任务瞬间跑完、锁立刻释放、时钟误差导致别的实例又跑一次)。注意 name 必须在所有实例间一致,才能互斥。
它不解决什么:不提供触发、不持久化触发记录、没有可视化、不做错过补偿、不做分片。所以 ShedLock 不是调度中心,它只是「给 @Scheduled 加一把分布式锁」的封装。适用场景很明确:你已经在用 @Scheduled、已经有多实例、只差一把锁——这时引入 ShedLock 比自己手写锁表更省事,因为锁的续期、最短持锁这些细节它都处理了。
13.3.6 幂等:重复执行也安全
前面所有锁都在努力「让任务只跑一次」,但锁不能保证百分之百不重复:TTL 到期、时钟漂移、实例崩溃后恢复、手动重跑,任何一种都可能让任务跑第二遍。所以真正的最后一道防线是幂等——让任务「跑一次和跑多次的结果相同」。
幂等有三种常用设计,往往组合使用。
其一,幂等键 + 唯一约束。 给每条处理结果一个业务上的唯一键,靠数据库唯一约束兜底。罚金结算的幂等键就是「哪笔借阅、哪个月」:
create unique index uk_fine_loan_month
on fine_record(loan_id, settle_month);
写入时用「冲突就忽略」而不是先查后插:
insert into fine_record(loan_id, member_id, settle_month, amount)
values (?, ?, ?, ?)
on conflict (loan_id, settle_month) do nothing;
这样即使任务重复执行,第二遍的插入全部被唯一约束挡掉,结果与执行一次完全相同。这是最可靠的一层,因为它把幂等交给了数据库约束,不依赖任何应用层判断。
其二,状态机。 把任务的业务状态显式建模,重复执行时先看状态决定是否跳过。比如罚金单有「待结算 → 已结算 → 已作废」三种状态,结算动作只在「待结算」时执行:
@Transactional
public void settleOne(Long fineId) {
Fine fine = fineRepository.findByIdForUpdate(fineId).orElseThrow();
if (fine.getStatus() != FineStatus.PENDING) {
return; // 已结算或已作废:直接返回,不重复动作
}
fine.markSettled();
}
配合行锁(for update)或乐观锁,保证并发下状态流转只发生一次。
其三,可重入设计。 让任务本身「天生可重复」——比如「先删掉本批次结果再重新生成」,或者「按业务日期整体覆盖」。这类设计里,重复执行只是多算一遍,最终结果一致。
一个原则要记住:幂等是必选项,锁是可选项。 不要因为「加了锁」就认为任务可以不做幂等——锁是概率性防护,幂等是确定性保证。
13.3.7 执行记录与告警
多实例调度还有一个容易被忽略的问题:任务失败了,谁来发现? 13.1 讲过,周期任务抛异常会被记录并吞掉,调度不会停。如果只有日志,一次失败可能几天都没人注意到。
标准做法是给每个任务写一份执行记录:
create table task_execution (
id bigserial primary key,
task_name varchar(100) not null,
started_at timestamptz not null,
finished_at timestamptz,
status varchar(20) not null, -- RUNNING / SUCCESS / FAILED
affected_rows int,
error text
);
任务用统一模板包裹:开始写一条 RUNNING,结束更新为 SUCCESS 或 FAILED,并记录影响行数与异常。有了这张表,告警就有据可依,三类告警缺一不可:
| 告警 | 触发条件 | 为什么重要 |
|---|---|---|
| 失败告警 | status = FAILED | 最直接,但要避免每次失败都刷屏 |
| 超时告警 | 运行时长超过阈值 | 任务「还在跑」但卡住了,往往比失败更隐蔽 |
| 未执行告警 | 到了该跑的时间仍无记录 | 任务根本没触发(如锁没抢到、实例全挂),最危险 |
第三类最容易被漏掉:任务失败你至少有一条失败记录,而任务没跑则连记录都没有,表面上风平浪静。所以未执行告警要按「预期执行时刻 + 容差」来判断——比如结算任务每天 2 点跑,到了 3 点还没有当天记录就告警。这类指标的采集与告警接入,可以和第 16 章的可观测性 体系复用同一套通道。
13.3.8 错过执行与补偿
多实例、重启、发布都会导致触发时刻错过:凌晨 2 点结算时正好在滚动发布,任务没跑成。@Scheduled 对此没有任何补偿机制——错过了就是错过了,下一个周期再说。
补偿要自己设计,核心是把「错过一次触发」和「漏处理一批数据」区分开:前者是调度问题,后者是数据问题。设计目标是「漏触发不会漏数据」。
做法一:按上次成功时间补跑。 任务启动时先查执行记录里上一次成功的业务日期,把中间漏掉的每一天都补上:
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Shanghai")
void settleDaily() {
LocalDate lastSuccess = executionLog.lastSuccessDate("settleDaily");
LocalDate today = LocalDate.now();
for (LocalDate d = lastSuccess.plusDays(1); !d.isAfter(today); d = d.plusDays(1)) {
settleForDate(d); // 逐日补跑,每个日期各自幂等
}
}
做法二:把「要做的活」落成待处理数据。 需要结算的罚金单在业务发生时就已经写进 fine_record(状态 PENDING),任务只负责「把 PENDING 变成 SETTLED」。这样即使某天任务没触发,数据仍在表里,下一次触发会自动把它们带上——触发错过,数据不漏。
两种做法的共同点是:任务处理的是「状态」,而不是「某一次触发」。 只要数据带着「待处理」状态存在,任务晚跑、重跑都能收敛。这与 13.3.6 的状态机是同一套思路的两面。
小结
@Scheduled的调度器在本进程内,多实例部署时每个实例都会触发,导致任务重复执行——这是扩副本时最常见的事故。- 三种解法:数据库锁(零新增组件、原子 upsert 抢占)、Redis 锁(
SET NX PX、Lua 校验持有者释放)、调度中心(从根上消除重复触发)。 - 分布式锁必须带 TTL,否则持锁实例崩溃会死锁;释放锁要校验持有者,否则会误删他人的锁;TTL 小于任务耗时仍会重复,需要幂等兜底。
- ShedLock 的定位很窄:只给
@Scheduled加一把集群锁,不做触发、不做可视化、不做补偿;适合「已有@Scheduled+ 多实例 + 只差一把锁」的场景。 - 幂等是必选项、锁是可选项:幂等键 + 唯一约束(
on conflict do nothing)最可靠,状态机与可重入设计是常用补充。 - 每个任务都要写执行记录,并配三类告警:失败、超时、未执行——未执行告警最容易被漏掉却最危险。
@Scheduled没有错过补偿;要自己按「上次成功时间」补跑,或把任务设计成「消费待处理状态」,做到「漏触发不漏数据」。
调度这一章到此收口:13.1 定方案,13.2 管批量,13.3 保正确。下一章换到文件处理,从上传下载的流式处理与安全边界讲起。
阅读导航:上一节:13.2 Spring Batch 实战 · 下一节:14.1 上传与下载 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。