首页 / 资讯中心 / 文章详情

集群定时任务重复执行问题:Redis分布式锁方案原理与实战

集群定时任务重复执行问题:Redis分布式锁方案原理与实战 ★ FEATURED ARTICLE
集群环境下定时任务重复执行这问题我前后遇到不下三次。最典型的一次是订单状态同步任务在凌晨跑批上线前单机部署运行一年多都没事切到集群后某个夜里同一条订单被两个实例同时捞出来处理结果用户收到两条模板消息流水也重复记了一笔排查到凌晨四点才定位到是定时任务撞车了。其实问题本可以更早发现集群化之后同一个定时任务会被每个实例各执行一次只要任务里没有互斥机制重复几乎是必然。这也是为什么“利用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 groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency配置一个ClientConfiguration 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锁成本几乎为零但能为未来省去一次临时改造。踩过几次坑之后你会认同这个判断。
阅读完成 · 觉得有帮助?
咨询建站