Skip to content

分布式定时调度 ​

单机部署时 @Scheduled 就能跑定时任务,拆成多实例后同一个任务会被每台机器各执行一遍。本章回答一个问题,怎么让一个定时任务在集群里只跑一次,以及什么时候才值得引入调度框架。

1. 多实例下为什么重复执行 ​

@Scheduled 是进程内的定时器。Spring 在每个实例里各起一个调度线程池,到了触发时间点,每个实例都唤醒自己的调度器,于是同一个任务被多个实例同时执行。

java
@Scheduled(cron = "0 0 3 * * ?")  // 每天凌晨 3 点
public void settleOrders() {
    // 3 个实例部署,这里会被进入 3 次
}
txt
实例 A ──凌晨3点──▶ settleOrders()
实例 B ──凌晨3点──▶ settleOrders()   ← 重复
实例 C ──凌晨3点──▶ settleOrders()   ← 重复

根因不是配置写错,是 @Scheduled 没有任何跨进程协调。每个实例的调度器只认识自己,不知道别的实例也在跑同一个任务。如果任务是结算订单、发对账单,重复执行就是重复扣款、重复通知。

2. 三种解法 ​

目标只有一个,同一时刻只有一个实例执行。三条路线的差别在「谁来决定谁执行」。

2.1 分布式锁抢占 ​

最轻量的解法。任务触发后先抢一把分布式锁,抢到的执行,抢不到的直接跳过。同一时刻只有一个实例能拿到锁,自然只有一个实例执行。

java
@Scheduled(cron = "0 0 3 * * ?")
public void settleOrders() {
    RLock lock = redisson.getLock("task:settle-orders");
    if (!lock.tryLock()) {  // 不等待,拿不到立即返回
        return;
    }
    try {
        // 结算逻辑
    } finally {
        lock.unlock();
    }
}

锁的原子加锁、释放校验、看门狗这些细节见 分布式锁,这里只讲定时任务场景特有的两个坑。

坑一是锁的过期时间短于任务执行时长。锁 30 秒过期,任务跑了 40 秒,锁在 30 秒时被自动释放,另一个实例在下一个触发点拿到锁,任务再次执行。解法是让锁的持有时间覆盖任务最长执行时间,或者用看门狗自动续期。

坑二是续期不等于任务还活着。看门狗按固定间隔续期,它只判断「锁还在被持有」,不判断「任务还在跑」。任务卡死但线程没释放锁,看门狗会一直续期,锁永远不释放。要破解,得让任务自己上报心跳,续期前先校验心跳,而不是只看锁是否持有。

2.2 单机调度加主备切换 ​

把调度收敛到一台机器上,其余机器只做业务,调度器只剩一个,自然不重复。代价是这台机器挂了调度就停。要补一个主备机制,主挂了从节点顶上,这个切换逻辑的实现成本不低,本质是在自己造调度框架的轮子。

2.3 引入调度框架 ​

XXL-Job、PowerJob 这类框架把调度中心独立出来,任务注册到调度中心,由中心决定分发给哪个执行器。它顺带解决了单机方案解决不了的三件事,失败重试、任务分片、依赖编排。

2.4 怎么选 ​

方案能解决什么代价
分布式锁加 @Scheduled多实例只跑一次锁过期、续期、心跳都要自己处理
单机调度加主备多实例只跑一次要自己实现主备切换
调度框架重试、分片、编排引入一个独立组件

判断依据是任务的数量和复杂度。任务只有一两个、执行简单、失败能靠下次调度兜底,用分布式锁加 @Scheduled 足够。任务要失败重试、要分片并行处理、要编排依赖,才上调度框架。多数项目停在分布式锁这一层就够了,一上来就引调度框架是过度设计。

3. 任务本身要幂等 ​

锁解决的是「同时只有一个实例执行」,解决不了「同一个任务被执行多次」。锁失效、框架重试、运维手动补跑,都会让同一个任务被执行多次。所以任务逻辑本身要幂等,重复执行结果不变。

结算任务这条链路上,锁保证不并发,幂等保证即使重复也不产生脏数据,两者缺一不可。幂等的手段和选型见 幂等性设计。

4. 相关章节 ​

分布式锁的原子加锁、看门狗与选型见 分布式锁。任务重复执行的兜底见 幂等性设计。延迟任务的另一种实现,见 延迟任务。

Released under the MIT License.