定时任务重叠执行防护:上一轮没跑完下一轮开始了?分布式锁+单机串行双重保障!

凌晨 2 点,运维群炸了:结算任务超时,数据库连接池打满,CPU 飙到 95%。查了半天发现 —— 上一轮的账单还没算完,下一轮又启动了,数据重复处理、死锁、OOM,一波带走。

这不是段子。只要你的系统有定时任务,这个坑迟早会踩进去。

今天这篇文章,从根因到方案,从单机到分布式,把定时任务重叠执行这件事讲透。


一、问题到底出在哪?

先看一个最简单的场景。

// 每 5 分钟执行一次数据同步
@Scheduled(cron = "0 */5 * * * ?")
public void syncData() {
    // 从上游拉数据、清洗、入库
    pullData();   // 耗时:不固定,2~8 分钟
    cleanData();
    saveToDB();
}

调度周期是 5 分钟,但 pullData() 本身就要 2~8 分钟。问题来了:

  • 8:05 第一轮启动,预计 8:13 结束
  • 8:10 第二轮启动,此时第一轮还没跑完
  • 两轮并行执行,同时操作同一批数据

后果你懂的:数据重复、死锁、连接池耗尽、OOM,一条龙服务。

根因一句话:调度频率 > 任务执行耗时,且没有任何防护机制。

那么,怎么防?


二、方案一:单机串行保障

如果你的服务只有 单实例部署,最简单的方案是加一把 JVM 级别的锁。

2.1 核心思路

任务启动时尝试获取锁,拿到了就执行,拿不到就跳过本次。

定时触发
  │
  ├─ tryLock() → 成功 → 执行业务 → 释放锁
  │
  └─ tryLock() → 失败 → 记录日志,直接返回(上一轮还没跑完)

2.2 伪代码

class SchedulerGuard:
    AtomicBoolean locked = false

    guard():
        if locked.compareAndSet(false, true):
            try:
                doBusiness()
            finally:
                locked = false
        else:
            log("上一轮还在执行,本次跳过")

核心就一个 AtomicBoolean —— 无阻塞、不等待、不排队。如果上一轮没执行完,本轮直接放弃。不积压,不连锁雪崩。

2.3 优点

  • 零外部依赖,纯内存操作,毫秒级判断
  • 不会因为等锁而积压任务线程
  • 配合 @Scheduled(fixedDelay) 效果更佳

2.4 局限

  • 只防单机,多实例部署时完全无效(每个实例各自有一把锁,互不感知)
  • 服务重启后锁状态丢失(但在单机场景下这不是问题,重启后任务本身就是全新的)

三、方案二:分布式锁保障

多实例部署时,锁的粒度必须从"进程级"升级到"集群级"。这就需要一把所有实例都能看到的锁

3.1 核心思路

用 Redis 的 SET NX EX 实现一个带超时的分布式锁:

定时触发(任意实例)
  │
  ├─ Redis SET lock:task NX EX 300 → 成功 → 执行业务 → DEL lock:task
  │                                                │
  │                                          └─ 续期线程(防业务超时锁释放)
  │
  └─ Redis SET lock:task NX EX 300 → 失败 → 记录日志,直接返回

3.2 两个关键细节

细节一:锁必须设置过期时间。

否则某个实例拿了锁后宕机,锁永远不释放,整个集群的定时任务全部停摆。过期时间一般设为任务最大执行时长的 1.5~2 倍,留足余量。

细节二:需要续期机制。

假设任务预估最长 10 分钟,锁过期设了 15 分钟。但某次因为上游慢,任务跑了 20 分钟,锁在第 15 分钟自动释放了。第 16 分钟另一个实例抢到锁,又开始执行 —— 又重叠了。

所以需要一个 watchdog 续期线程:每间隔一段时间(比如锁过期时间的 1/3),检查任务是否还在执行,在的话就把锁续上。

Watchdog 续期逻辑:

每隔 interval:
    if 任务仍在执行:
        Redis EXPIRE lock:task 300   // 续期
    else:
        退出续期线程

这里直接用 Redisson 即可,它的 RLock 内置了 watchdog 机制,不需要自己写续期逻辑。

3.3 伪代码

class DistributedSchedulerGuard:
    redis = RedisClient

    guard(taskKey):
        locked = redis.set(taskKey, "1", NX=True, EX=600)
        if locked:
            taskId = startWatchdog(taskKey)    // 启动续期
            try:
                doBusiness()
            finally:
                stopWatchdog(taskId)
                redis.del(taskKey)
        else:
            log("其他实例正在执行,本次跳过")

3.4 优点

  • 多实例互斥,不论部署几个节点,同一时刻只有一个在执行
  • Redisson 等成熟库开箱即用,Redis 也是基础设施标配

3.5 局限

  • 依赖 Redis 的可用性 —— Redis 挂了,锁服务跟着挂
  • 极端情况下(Redis 主从切换、网络分区),仍有极低概率出现锁失效

四、深度对比:两种方案各防什么?

很多同学问:有了分布式锁,还需要单机串行吗?

这个问题得先搞清楚:两种方案防护的对象不同。

维度单机串行锁分布式锁
防什么同一实例内,前一轮没跑完下一轮又启动多个实例同时执行同一任务
锁范围JVM 进程级集群级
典型故障数据处理速度波动,出现堆积执行K8s 多副本、滚动发布期间实例交替
单实例场景✅ 完全够用🔺 杀鸡用牛刀,还多了 Redis 依赖
多实例场景❌ 无效✅ 必须

所以答案是:看部署架构。

  • 单实例:单机串行锁就够了
  • 多实例:分布式锁是刚需

五、方案三:双重保障组合

既然各有侧重,那能不能两层都加上

当然可以。而且生产环境建议这么做。

5.1 执行流程

定时触发
  │
  ├─ 第一层:单机串行锁(AtomicBoolean)
  │    └─ 失败 → return(本实例上一轮还没跑完)
  │
  ├─ 第二层:分布式锁(Redis SET NX)
  │    └─ 失败 → return(其他实例正在执行)
  │
  └─ 两层都通过 → 执行业务 → 释放锁(先分布式、再单机)

5.2 为什么两层都要

  • 单机串行锁 是"快速失败"通道:纯内存判断,零网络开销,毫秒级拦截。即使 Redis 短暂不可用,单机层面仍然能防止同一实例内的重叠执行。
  • 分布式锁 是"集群互斥"通道:多实例场景下的最终保障。

两层缺一不可,各司其职。

5.3 伪代码

class DoubleGuardScheduler:
    localLock = AtomicBoolean(false)
    redis = RedisClient

    guard(taskKey):
        // 第一层:毫秒级本地拦截
        if not localLock.compareAndSet(false, true):
            log("本实例上一轮还在执行")
            return

        // 第二层:集群级互斥
        try:
            if not redis.set(taskKey, "1", NX=True, EX=600):
                log("其他实例正在执行")
                return

            taskId = startWatchdog(taskKey)
            doBusiness()
        finally:
            stopWatchdog(taskId)
            redis.del(taskKey)
            localLock = false

注意释放顺序:先释放分布式锁,再释放本地锁。避免本地锁释放后、分布式锁还在时,同一实例的下一轮任务被 Redis 层挡在外面 —— 这会导致不必要的跳过。


六、踩坑清单:三个反直觉的坑

6.1 synchronized + @Scheduled —— 你以为串行了,其实没有

@Scheduled(fixedRate = 5000)
public synchronized void task() {
    // ...
}

synchronized 只保证同一个实例内不会并发执行同一个方法。但 @Scheduled(fixedRate) 默认是单线程调度,任务耗时超过间隔时,下一轮会排队等待,而不是直接跳过。排队的后果是任务越积越多,线程池打满。

这和我们的目标完全背离。我们要的是"跳过",不是"排队"。

6.2 Spring @Scheduled 配合 fixedDelay 才是正确姿势

fixedRate  = 5000   → 每隔 5 秒触发,不管上一次结束没
fixedDelay = 5000   → 上一次结束后再等 5 秒触发

fixedDelay 可以从调度层面减少重叠概率,但不能完全杜绝(多实例问题依然存在)。它和加锁是互补关系,不是替代关系。

6.3 锁的 Key 设计要有讲究

❌ key = "lock"                      // 所有任务共用一把锁
✅ key = "lock:task:billing:sync"    // 按任务粒度隔离

Key 里带上任务标识,确保不同任务之间的锁互不干扰。另外,如果你的任务是按租户分片执行的,Key 里还应带上分片标识:

key = "lock:task:billing:tenant_" + tenantId

七、生产级落地方案总结

把上面的内容串起来,一个生产级的定时任务防护方案包含以下要素:

1. 调度层:@Scheduled(fixedDelay)

从调度策略上就避免积压。

2. 单机层:AtomicBoolean 快速失败

毫秒级拦截同一实例内的重叠执行,不依赖任何外部组件。

3. 集群层:Redis 分布式锁 + Watchdog 续期

多实例环境下的互斥保障,推荐使用 Redisson 成熟方案。

4. 监控告警:跳过次数上报

if (!locked) {
    metrics.increment("scheduler.skip.count", taskKey);
    log.warn("任务被跳过,上一轮可能还在执行或存在异常");
}

如果某个任务频繁被跳过,说明执行耗时已经压到了调度周期的边界,需要优化业务逻辑或调整调度频率。跳过本身不是 Bug,但高频跳过一定是信号。

5. 兜底:任务超时告警

给任务加一个最大执行时间阈值,超过后主动告警:

if task.elapsed > maxAllowedTime:
    alert("任务执行超时!可能死循环或死锁")

八、一句话总结

单机串行锁防自己,分布式锁防别人。两层都加上,定时任务稳如老狗。

定时任务重叠执行不是什么高深问题,但几乎每个团队都踩过这个坑。关键不在于技术有多复杂,而在于:你是否意识到这是一个必须解决的问题。

下次凌晨 2 点被运维电话叫醒的时候,希望不是因为你的定时任务。


如果这篇文章对你有帮助,欢迎转发给团队里还在用裸 @Scheduled 的同事。


标题:定时任务重叠执行防护:上一轮没跑完下一轮开始了?分布式锁+单机串行双重保障!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/01/1780129826002.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消