定时任务重叠执行防护:上一轮没跑完下一轮开始了?分布式锁+单机串行双重保障!
凌晨 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
公众号:服务端技术精选
- 一、问题到底出在哪?
- 二、方案一:单机串行保障
- 2.1 核心思路
- 2.2 伪代码
- 2.3 优点
- 2.4 局限
- 三、方案二:分布式锁保障
- 3.1 核心思路
- 3.2 两个关键细节
- 3.3 伪代码
- 3.4 优点
- 3.5 局限
- 四、深度对比:两种方案各防什么?
- 五、方案三:双重保障组合
- 5.1 执行流程
- 5.2 为什么两层都要
- 5.3 伪代码
- 六、踩坑清单:三个反直觉的坑
- 6.1 synchronized + @Scheduled —— 你以为串行了,其实没有
- 6.2 Spring @Scheduled 配合 fixedDelay 才是正确姿势
- 6.3 锁的 Key 设计要有讲究
- 七、生产级落地方案总结
- 八、一句话总结
评论