☰
集群定时任务重复执行问题:Redis分布式锁方案原理与实战
2026/10/24 1:26:32 网站建设 项目流程

集群环境下定时任务重复执行,这问题我前后遇到不下三次。最典型的一次是订单状态同步任务在凌晨跑批,上线前单机部署运行一年多都没事,切到集群后某个夜里同一条订单被两个实例同时捞出来处理,结果用户收到两条模板消息,流水也重复记了一笔,排查到凌晨四点才定位到是定时任务撞车了。

其实问题本可以更早发现:集群化之后,同一个定时任务会被每个实例各执行一次,只要任务里没有互斥机制,重复几乎是必然。这也是为什么“利用Redis分布式锁解决集群服务器定时任务重复执行问题”这个需求在后端团队里一直高频出现。本文不聊理论空谈,直接讲清楚问题根源、原理、落地代码和值得警惕的坑,给出一套能直接照做的方案。

1. 后台定时任务在集群环境下的“重复执行”真相

1.1 单机Cron的正常状态并不等于集群安全

先看单机场景。我们用Spring Boot时最常用的就是@Scheduled(cron = "0 0 2 * * ?"),或者维护一张cron配置表由调度线程触发。单机部署时,进程只有一个,同一时刻只有一个调度线程触发同一个任务,这没问题。

但集群部署后,服务一般会有3到5个副本实例。每个实例都会加载同样的定时任务代码,也都会在相同的时间点触发调度。也就是说,凌晨两点那一次跑批,3个实例会同时启动3次相同的处理流程。如果任务逻辑没有做全局互斥,就会有重复消费、重复写入、重复通知这类连锁问题。

我见过不少团队一开始会用“给任务表加状态字段,处理时先UPDATE成running再继续”的方式,但集群场景下两个实例同时捞到同一条未处理数据时,大家都能读到初始状态,都被判定为“我来处理”,最终照样撞车。这类方案本质上只有单机互斥,没有全局互斥。

1.2 重复执行最常见的三种业务事故现场

我在实际支持过的项目里归纳,重复执行的危害主要集中在三类场景:

  • 重复写入/重复扣减:比如余额扣减任务、库存扣减任务,两个实例并行处理时,会导致同一笔余额被扣两次,这是资金类事故。
  • 重复通知/重复推送:到期的会员续费提醒、工单超时提醒,用户会在短时间内收到两条一模一样的消息,舆情影响大但很难追溯到具体任务。
  • 重复补偿/重复对账:对账任务会将同一周期的账单重新生成一次,导致原本正确对平的账变成不平。这类问题往往过了几天才暴露,排查成本极高。

所以分布式锁的核心目标不是“让任务更快”,而是“在全局范围内保证同一个任务在同一时刻只有一个执行者”。

1.3 靠“幂等设计”兜底是不够的

有团队会说:“我们在业务层做了幂等,重复跑也没关系。”这句话只对了一半。

幂等确实能避免数据被写坏,但它有两个代价:第一,流量被无谓放大,3个实例同时跑一个重任务,数据库连接数、外部接口调用量直接翻倍;第二,幂等逻辑本身也要消耗资源去判断,在数据量大的时候,重复执行会把对账、报表的大查询打满数据库。分布式锁做的是“事前拦截”,幂等属于“事后修复”,两者不是二选一。

2. 互斥方案横评:为什么Redis分布式锁是多数团队的务实选择

2.1 传统数据库锁方案的硬伤

最早做分布式互斥,很多团队先想到的是数据库。

悲观锁用SELECT ... FOR UPDATE实现,能工作但有一个明显问题:锁由数据库连接持有,一旦线程异常中断而连接没释放,极容易导致其他线程长时间阻塞;而且高并发下会额外占用数据库连接,生产库上不敢常用。乐观锁需要业务表有版本号,它只适合“更新同一行数据”的场景,不适合“给定时任务加锁”这种通用场景。

退一步说,定时任务加锁通常要求的是“拿不到锁就直接跳过”,而不是“拿不到锁就阻塞排队等待”,数据库方案天然不适合这个语义。

2.2 ZooKeeper方案的准确性与运维成本

ZooKeeper临时顺序节点做锁,原理上更严谨:客户端创建临时节点,拿到最小序号者获取锁;客户端崩溃时,临时节点自动消失,锁自然释放。这套机制能很好地解决“锁释放失败”的问题,而且不需要设置过期时间。

但很多团队没有深度使用的ZK环境。为了一个定时任务去部署一套ZooKeeper集群,还要维护会话超时、节点监听这些逻辑,本身复杂度不低。如果你们业务里完全没有ZK,为了加锁而引入一个新中间件,性价比并不高。

2.3 Redis锁胜在性能、成本与运维的平衡点

Redis分布式锁在这几个维度上是最中庸也最实用的选择:

  • 性能高,加锁/解锁都是毫秒级操作,不大会成为任务瓶颈。
  • 大多数后端团队已经有Redis环境,不需要为锁功能单独引入新组件。
  • 实现成本低,核心命令就几条。
  • 配合Lua脚本可以保证原子性,满足生产要求。

这也是为什么Redisson、ShedLock这些框架都以Redis为第一支持对象。下面把这些方案的对比整理成一张表,供选型时参考。

方案互斥可靠性性能运维成本适用场景
数据库悲观锁中等差低数据量小的内部系统
数据库乐观锁中等中等低单行数据更新场景
ZooKeeper临时节点高中等高已有ZK,且要求高可靠
Redis分布式锁较高高低绝大多数定时任务互斥场景

Redis方案最大的争议点在于极端情况下锁可能丢失(主从切换时),但这个问题可以通过合理设计来规避或接受,第三节会详细展开。

3. 分布式锁实现原理:三个必须抠死的细节

3.1 加锁必须原子:SET NX EX是一条命令的底线

Redis实现锁,网上一搜能搜到很多旧代码,比如先SETNX再EXPIRE两条命令分开写,这是新手最容易踩的坑。

SETNX key value只能设置键,不会设置过期时间。如果进程在两条命令之间崩溃,锁键会永久留在Redis里,其他实例永远拿不到锁。正确的做法是把“存在性判断+写入+设置过期时间”放在同一条命令完成:

SET lock_key lock_value NX PX 30000

这条命令的含义是:只有lock_key不存在时才能写入成功,并同时设置30秒过期时间。NX保证互斥,PX保证锁不会因为进程崩溃而永久存在。Redis从2.6.12版本开始支持这种写法,现在所有常用版本都满足。

一点补充:SET NX EX与SET NX PX的EX单位是秒,PX单位是毫秒。定时任务场景建议用毫秒,控制更精细。

3.2 释放锁不能直接DEL:这是误删锁的根源

拿到锁之后,业务执行完要释放锁。最直观的思路是DEL lock_key,但这里潜藏一个问题:如果任务执行时间超过了锁的过期时间,Redis自动释放了锁,此时另一个实例B成功加锁并开始执行;然后第一个实例A终于执行完毕,执行了DEL lock_key,这把锁实际上是B的锁,A把B的锁删掉了。

这是典型的“锁误删”,解决办法是在锁的value里放一个唯一标识(比如UUID或机器IP+线程ID组合)。在删除之前,先判断这个key的value是不是自己写入的那个值,确认是自己的锁才删除。

判断和删除必须是一个原子操作,否则判断完还没删的时候锁恰好过期,同样会误删他人锁。Redis官方推荐用Lua脚本保证原子性:

-- release.lua if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

这段脚本的意思是:如果当前key的value等于调用方传入的唯一标识,就删除;否则说明锁已经不是自己的了,不能动。用EVAL命令执行这段脚本,就能在原子层面安全释放锁。

3.3 过期时间与看门狗:锁超时是保护,不是银弹

给锁设置过期时间是出于安全考虑,但同时也引入了一个矛盾:过期时间太短,任务还没跑完锁就自动过期,其他实例又会抢到锁,等于锁失效;过期时间太长,如果进程真的崩了,其他实例要等很久才能重新抢到锁。

Redisson框架给出的解决方案是“看门狗”机制。默认锁的过期时间是30秒,拿到锁的线程会启动一个后台定时任务,每10秒检查一次锁是否还被当前线程持有,如果是就续期到30秒。任务没跑完,锁就一直续期;任务跑完了,主动释放锁时看门狗自动取消。这套机制相当于动态延长了锁的生命周期,既保护了长时间任务,又避免了永久死锁。

如果不用Redisson,而是自己基于SET NX实现,就需要在业务代码里自己处理“是否需要续期”的逻辑。我的建议是,对任务执行时间做评估:如果任务稳定在锁超时时间之内,不续期完全可以;如果任务可能抖动或数据量大时明显超时,优先用Redisson,别把续期逻辑写得过于复杂,容易引入新问题。

4. 实操落地:从Redisson到原生实现的完整步骤

4.1 最少成本的方案:用Redisson封装

如果项目里已经有Spring Boot + Redis,接Redisson是最务实的选择。它把加锁、看门狗、可重入都封装好了,几乎没有自己发挥的空间。

引入依赖:

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.27.2</version> </dependency>

配置一个Client:

@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setDatabase(0); return Redisson.create(config); } }

然后定时任务里这样写:

@Scheduled(cron = "0 0 2 * * ?") public void syncOrderTask() { String lockKey = "job:syncOrderTask"; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { locked = lock.tryLock(0, 30, TimeUnit.SECONDS); if (!locked) { log.info("syncOrderTask 未获取到锁,本次跳过"); return; } // 真正的业务逻辑 doSyncOrder(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (locked) { lock.unlock(); } } }

tryLock(0, 30, TimeUnit.SECONDS)里的两个时间参数含义不同:第一个0表示抢锁时完全不等待,拿不到立即返回false;第二个30表示锁的leaseTime为30秒,也就是最多持有30秒。传入leaseTime后,Redisson不会再启用看门狗续期。如果希望使用看门狗,就不要传leaseTime,直接调lock.tryLock(0, TimeUnit.SECONDS),此时锁默认由看门狗维护到30秒并自动续期。

所以这里有一个重要取舍:如果你想靠看门狗自动续期,就别传leaseTime;如果你能确定任务稳定在30秒内完成,传leaseTime更稳妥。两者思路不同,我是建议大多数定时任务场景用默认看门狗,虽然代码上少传了参数,但可靠性和可控性反而更好。

有一点要专门提一下:Redisson的RLock是可重入的,同一个线程可以多次获取同一把锁。在定时任务里这个能力一般用不上,但如果你的任务内部还有一个嵌套的加锁方法,可重入可以避免自己把自己锁死。

4.2 原生SET NX + Lua实现:适合不想引依赖的团队

如果项目不大,或者不想引入Redisson全家桶,原生实现也完全可控。用Spring Boot自带的StringRedisTemplate就能完成。

先定义一个锁工具类:

@Component public class RedisLockUtil { private static final String LOCK_PREFIX = "distributed_lock:"; private final StringRedisTemplate redisTemplate; public RedisLockUtil(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public boolean tryLock(String key, String requestId, long expireMillis) { String lockKey = LOCK_PREFIX + key; return Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, Duration.ofMillis(expireMillis)) ); } public boolean unlock(String key, String requestId) { String lockKey = LOCK_PREFIX + key; String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Long result = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), requestId ); return result != null && result > 0; } }

定时任务里使用:

@Scheduled(cron = "0 0 2 * * ?") public void syncOrderTask() { String lockKey = "syncOrderTask"; String requestId = UUID.randomUUID().toString(); if (!redisLockUtil.tryLock(lockKey, requestId, 30000)) { log.info("syncOrderTask 未获取到锁,本次跳过"); return; } try { doSyncOrder(); } finally { redisLockUtil.unlock(lockKey, requestId); } }

这个实现里,requestId是关键。每次加锁都生成一个新的UUID,释放锁时通过Lua脚本比对UUID,不是自己的锁不删。这个逻辑和4.1节Redisson的内部机制本质上是一致的,只不过自己实现了一版更轻量的。

4.3 锁的粒度:锁整个Job还是锁分片

写代码前要先想清楚一个维度,锁的作用范围到底应该多大。

一个定时任务如果处理的是几十万条数据,通常实现方式是“查出一批待处理数据,一条条或一批批处理”。这里有两种加锁思路:

  • 锁整个Job:一次性抢锁,抢到的人跑完全部数据,其他人直接跳过。实现简单,但任务耗时太长时锁的持有时间也长,容易撞上过期时间问题。
  • 锁数据分片:不等于整个任务只跑一次,而是按数据维度(比如订单ID、商户ID、用户ID)加锁,每个实例处理不同分片,抢不到某分片锁的跳过该分片。实现复杂,但更灵活。

我见过的大多数场景,采用锁整个Job就够了。只有当任务数据量特别大、单实例处理要跑几十分钟以上,且业务上允许分片并行时,才值得做分片锁。分片锁的复杂度不在于锁本身,而在于分片逻辑的稳定性和分片边界的一致性,这个成本常常被低估。

4.4 拿不到锁是直接丢弃还是等待重试

锁竞争发生时,有两种处理策略:

  • 策略一:抢不到锁直接跳过本次执行,适合周期短、频率高的任务,比如每5分钟跑一次同步、每10分钟扫一次过期单。下一次调度马上会来,本次跳过影响不大。
  • 策略二:抢不到锁,稍作等待后重试N次,适合每天一次且窗口有限的跑批任务,凌晨跑批如果错过一次就得等第二天,重试几次是值得的。

我个人的习惯是:周期短的用策略一,周期长或频率低的用策略二。重试时要设置退避时间,避免所有实例同时高频抢锁打爆Redis。比如抢不到锁等3秒再试,最多试3次,然后放弃。

5. 线上问题实录:锁失效、锁误删、锁叠加的排查过程

5.1 问题一:任务执行时间超过锁过期时间,锁提前失效

这是我自己踩过最深的坑。某个数据补偿任务平时执行只要2秒,某次上游数据量暴增,处理时长拉到了40秒。锁过期时间设置的30秒,于是锁在40秒内自动释放,另一个实例立刻抢到锁,两边同时处理同一批数据。

排查时先从Redis里看锁的TTL和value,再用日志确认同一批次数据在两个实例上都有处理记录,最终定位是过期时间设置不合理。

解决办法有两个方向:一是调大过期时间,这是最简单的,但不能根治,因为任务耗时是不可控的;二是上Redisson看门狗机制,让锁自动续期。从那次之后,凡是我能确定任务可能超时的场景,都优先用看门狗方案。

注意:不管用哪种方案,锁的超时时间至少要大于任务正常耗时的P99。建议基于监控里的执行耗时分布来定,不要拍脑袋填一个数。

5.2 问题二:主从切换瞬间锁丢失,两个实例同时拿到锁

Redis如果采用主从架构,主节点宕机触发切换时,存在一个极小的窗口期:主节点刚写入锁,还没来得及同步到从节点,主节点就挂了,从节点升为主节点后发现根本没有这把锁,于是另一个实例也能成功加锁。

这个问题在Redis官方文档里也有说明,是在分布式锁方案中一个被反复讨论的边界。解决思路有两种:

  • 使用RedLock算法,同时向多个独立Redis节点加锁,过半成功才算拿到锁。但RedLock本身也有一定争议,实现复杂度高。
  • 接受这个极小概率的误差,在业务层做好幂等兜底。大多数定时任务场景,主从切换发生的时间窗口也就是几十上百毫秒,出现概率极低;真正要命的是“锁误删”和“锁不释放”这种高频问题。

我个人建议第二条路,把精力花在更可控的环节上。如果业务对并发重复零容忍且规模足够大,再考虑引入ETCD或ZooKeeper方案。

5.3 问题三:锁冲突导致任务排队,数据库和Redis被打满

某次高峰期,一个任务跑批时间比较长,而另一个每2分钟触发一次的任务也在抢同一把锁,导致大量请求在抢锁时等待重试。重试代码写得比较笨,退避时间太短,瞬间产生了大量的重复请求打到Redis,进一步加剧了延迟。

这种问题的坑不在分布式锁本身,而是多个任务共用了同一个锁key。先把所有任务的锁key整理出来,发现两个任务确实用了同一个key前缀,但这符合预期,因为本来就是要互斥。后来把重试退避从固定时间改成随机退避,现象明显缓解。Redis客户端侧也加了熔断,连续抢锁失败多次后直接放弃并告警。

这提醒我一点:分布式锁的代码本身要足够轻量,但锁的拥塞控制同样重要。尤其在任务并发高峰期,抢锁的请求也要有自我保护。

5.4 分布式锁衰减排查速查表

把我在多种生产问题里沉淀的排查点整理成一张表,遇到类似问题可以按顺序核对。

现象优先检查项最可能原因处理建议
任务偶尔重复执行锁过期时间是否比任务P99耗时短锁提前失效改用看门狗或调大过期时间
某个实例处理完任务,其他实例仍无法执行锁key是否残留释放锁逻辑未走到检查finally块,监控锁key数量
重复执行且发现锁被误删释放锁时是否校验唯一标识value不是唯一值改为UUID+原子Lua释放
所有实例都拿不到锁Redis内锁key是否永远存在死锁或锁未释放检查代码异常路径,排查Redis中的锁TTL
锁key存在但没有业务在执行进程是否刚重启重启前锁未清理监控进程状态,必要时手动清理

5.5 锁之外必配的监控与告警

分布式锁写完之后,一定要在监控上补几个指标,否则锁出问题很难第一时间感知。我建议至少盯三块:

  • 锁获取成功率:每个锁维度统计成功/失败次数,失败率突然升高说明有任务竞争异常。
  • 锁持有时间分布:持有时间超过阈值说明任务处理变慢,需要关注。
  • 锁丢失告警:如果检测到锁key突然消失而业务还没执行完,立即推送告警。

这些指标用Spring Boot Actuator + Prometheus就能轻松暴露出来,成本不高,但能大大缩短定位时间。

6. 分布式锁之外:什么时候应该直接换调度框架

6.1 锁方案在动态分片场景的局限

自己做分布式锁解决的是“多个实例抢同一个任务”的问题,这本质上是静态互斥。但如果任务数据量增长很快,单个实例跑不完,希望多台机器各处理一部分数据,锁方案就很难优雅扩展了。分片逻辑、数据分配、故障转移都要自己维护,复杂度并不比引入调度框架低。

所以一个判断标准是:如果任务只是每天/每小时跑一次,且单实例能扛住,用Redis分布式锁就够了。如果希望任务能横向扩容、按分片并行、支持控制台手动触发,建议直接上分布式任务调度框架,比如XXL-Job、Elastic-Job这类成熟方案。

6.2 框架选型的边界判断

老实讲,定时任务调度框架本身也自带分布式锁能力,它通过注册中心管理执行器,从根源上保证同一个任务只被一个执行器实例领取。如果团队已经有这类框架,根本不需要再单独写Redis锁。

但我仍然见过不少团队先用了框架,又因为框架的权限控制、登录配置太麻烦,最后自己用Redis锁又写了一套简单调度。这个模式其实非常常见——工具选型没有标准答案,更多的取决于团队基建现状和维护成本。

对于中小团队从零起步,我的建议是:先用原生@Scheduled+ Redis分布式锁跑起来,保持最简单的状态;等任务数量多了、逻辑复杂了,再平滑迁移到调度框架也不迟。不要一开始就引入重型框架,因为学习和维护成本可能远高于锁本身带来的收益。

从我的使用经验看,多数项目的定时任务量级根本到不了需要调度框架的地步。真正要养成的习惯是:新写定时任务时,默认先评估是否处于集群环境,如果有多个副本,就顺手把分布式锁加上。这个习惯的价值,比再精通几个框架API都重要。

最后分享一个小技巧:如果你的服务还在单机阶段,但明确知道下个月要水平扩容,提前给定时任务加上Redis锁,成本几乎为零,但能为未来省去一次临时改造。踩过几次坑之后,你会认同这个判断。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询