Redisson分布式锁实战:定时任务防重、看门狗调优与踩坑全记录
去年我们有个订单同步任务凌晨两点跑批本来相安无事。给应用扩容到多个节点之后第二天早上业务方就来找了——数据库里冒出一批重复订单。排查到最后原因一点都不神秘两个服务节点在同一秒里同时执行了同一条定时任务没有互斥机制各跑了一遍数据自然就重复了。从那以后我把“分布式锁”和“分布式任务”这两件事绑定在了一起。这篇文章就围绕用Redisson锁实现分布式任务这条主线把设计思路、代码写法、参数调优以及线上踩坑的完整过程梳理出来。无论你是正在做分布式定时任务的后端开发还是在准备分布式锁面试题这份内容都能给你一个能直接参考的落地方案。1. 分布式场景下的定时任务为什么锁会成为刚需1.1 一个让我半夜爬起来的事故复盘先说说那个事故的具体细节。我们的订单状态同步任务用的是Spring的Scheduled注解配置了cron表达式凌晨两点整触发。单机部署的时候没有任何问题因为同一时间只有一个JVM任务天然互斥。但后来为了吞吐量我把服务部署到了两台机器上问题就来了。Spring的Scheduled在每个节点上是独立运行的它不会感知其他节点的存在。两台机器都到两点整就会同时触发syncOrder()方法两个线程同时读未同步订单、同时调第三方接口、同时写数据库。更麻烦的是这类任务通常不是一轮就结束而是循环分页处理一旦撞车就可能整批数据都被处理两遍。那次事故让我意识到一个基本事实在分布式环境里定时任务必须加锁锁是分布式任务的第一道防线。不是可选项是必选项。1.2 分布式任务协同的四种方案对比解决分布式任务互斥业界常见方案无非四种数据库锁、Redis锁SETNX手写、ZooKeeper协调锁、Redisson锁。数据库锁最容易理解一个select for update或者一个乐观锁版本号就能实现。它的优势是简单、不依赖额外组件但问题也很明显性能上限低高并发下数据库会成为瓶颈而且事务和锁的边界很容易搞混稍不注意锁就释放不干净。手写Redis SETNX锁是个过渡方案网上大量教程都在讲。核心思路就是set key value ex 30 nx加锁成功返回OK解锁时用Lua脚本比对value后删除。这个方案能跑但工程心智负担很重过期时间设多少、怎么续期、怎么处理可重入全部要自己实现任何一个环节漏掉都是事故。ZooKeeper锁可靠但引入ZooKeeper本身就是一个大工程对大多数业务团队来说过于重了。Redisson锁是我最终的选择。它把Redis锁的复杂度全部封装掉了看门狗自动续期、可重入、公平锁、读写锁这些能力开箱即用加锁解锁只需要几行代码。对一个以Redis为核心缓存组件的技术栈来说这是最平滑的方案。1.3 为什么我最终把选型定在Redisson选Redisson还有一个实际原因我们团队本身就重度使用Redis加锁不需要新引入任何中间件运维成本和故障面都没有扩大。Redisson是Java生态里最成熟的Redis客户端之一底层对Redis的数据结构封装非常完整而且它的分布式锁实现经过了大规模生产验证不是玩具代码。当然选型也要看前提。如果你的团队Redis是单点部署、没有主从高可用那我建议慎重——这种情况Redis挂了锁就没了光靠锁兜底是兜不住的。我们在生产环境也是等Redis做了主从和哨兵之后才把核心任务全面切到Redisson锁上。2. Redisson锁的底层机制看门狗、可重入与原子性2.1 手写SETNX锁的问题到底出在哪理解了为什么选Redisson接下来必须搞明白它到底是怎么实现的。很多人只停留在“会用”层面结果线上出了诡异问题根本无从下手。手写SETNX锁典型的伪代码是这样加锁用SET key uniqueId EX 30 NX解锁用Lua脚本比对uniqueId再删除。表面上没问题但一深入就有三个坑。第一个坑是过期时间不好设。设短了任务还没执行完锁就过期了另一个节点就能抢到锁两个任务同时跑设长了万一持锁节点宕机锁要很久才能被其他节点拿到影响后续任务。第二个坑是续期难做你需要自己起一个定时任务去给锁续期这个续期守护逻辑写起来并不简单。第三个坑是不可重入同一个线程在嵌套方法里要再次获取同一把锁时直接会被自己阻塞住。Redisson的设计目标就是逐个击破这些坑。2.2 Lua脚本加锁和释放为什么能原子Redisson锁的核心秘密是Lua脚本。分布式锁的基本要求是“判断-加锁”两个动作必须原子不能有并发缝隙。Redis执行Lua脚本时整个脚本是原子的中间不会插入其他命令这就从根源上解决了竞态。Redisson加锁时执行的Lua逻辑可以理解为三步先检查锁的key是否存在不存在就直接写入当前客户端标识并设置过期时间存在则检查是不是同一个客户端标识是就说明是可重入把计数加1否则说明锁被别的线程持有返回剩余过期时间。这个逻辑用Redis的Hash结构存储key是锁名field是“UUID 线程ID”value是重入次数。通过field区分锁持有者通过value支持重入计数这是Redisson锁设计的基石。2.3 可重入的实现细节可重入是分布式任务里特别重要的特性。举个实际场景一个同步任务先加了锁执行中调用了一个内部方法内部方法里又对同一把锁做了一次lock()。如果是SETNX手写锁第二个加锁就把自己堵死了。Redisson的重入靠Hash和HINCRBY实现。第一次加锁写入field和计数1同一线程再次加锁Lua脚本发现field匹配就把计数加1。释放锁时递减计数计数归零才真正删除key。这样就完美模拟了Java的synchronized重入语义。判断“同一线程”的field是解构锁的关键。Redisson加锁时生成一个UUID作为客户端唯一标识再拼上线程ID。同一个JVM里不同的线程拿到的field不同不同的JVM节点拿到的UUID不同所以锁天然隔离不会出现互相解锁的问题。2.4 看门狗锁自动续期的设计逻辑看门狗WatchDog是我认为Redisson锁最出彩的部分也是面试时最容易聊深的地方。默认情况下Redisson的锁有效期是30秒。如果业务执行超过30秒锁会自动失效其他节点就可以抢锁这就会退化成“没有锁”。看门狗机制解决的就是这个问题持锁线程的锁还剩三分之一时间约10秒时后台调度线程会自动把锁的有效期重置为30秒一直续到业务主动释放锁为止。换句话说只要业务JVM还活着锁就不会因为超时被自动释放。这把“任务执行时间不可控”的问题彻底屏蔽掉了。但注意一个细节看门狗只在调用lock()或tryLock()未指定leaseTime时才生效。如果手动传了leaseTimeRedisson认为你自己已经决定了锁的租约时间不会再启动看门狗。这个细节在线上踩坑时至关重要后面我会细说。3. 用Redisson锁包住分布式任务完整代码实战3.1 依赖引入与客户端初始化说再多原理不如直接上手。第一步是引入依赖。我们项目是Spring Boot直接使用redisson-spring-boot-starter最方便dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency如果你不想用starter也可以用原生的redisson依赖然后自己构建RedissonClientConfiguration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() // 这里根据实际Redis地址调整 .setAddress(redis://10.0.0.11:6379) .setPassword(your_password) .setConnectionMinimumIdleSize(4) .setConnectionPoolSize(8); return Redisson.create(config); } }连接池参数我建议至少给到最小空闲4、最大8。分布式锁场景下每个持锁节点都会周期性地发起续期请求虽然量不大但如果连接池太小遇到Redis连接抖动时很容易出现续期请求排队极端情况会影响看门狗续期。3.2 定时任务抢占锁的标准范式项目里最典型的需求就是定时任务防重。以我们那个订单同步任务为例完整写法如下Component public class OrderSyncTask { private static final String SYNC_ORDER_LOCK task:syncOrder; private final RedissonClient redissonClient; public OrderSyncTask(RedissonClient redissonClient) { this.redissonClient redissonClient; } Scheduled(cron 0 0 2 * * ?) public void syncOrder() { RLock lock redissonClient.getLock(SYNC_ORDER_LOCK); // waitTime传0抢不到锁立即放弃让已有任务继续跑 boolean locked lock.tryLock(0, TimeUnit.SECONDS); if (!locked) { log.info([syncOrder] 任务锁被其他节点持有本次跳过); return; } try { log.info([syncOrder] 开始执行订单同步); doSyncOrder(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } log.info([syncOrder] 订单同步结束锁已释放); } } private void doSyncOrder() { // 分页拉取未同步订单调用对端接口更新状态…… } }这段代码有四个关键点值得展开说。第一锁的key我用了task:syncOrder。锁的key命名必须能代表一个业务动作而不是某台机器或某个方法名。这样无论在哪个节点执行抢的都是同一把锁。第二tryLock(0, TimeUnit.SECONDS)只抢一次抢不到就立即返回。定时任务场景下这是最合理的行为——既然别的节点已经在执行了自己这轮直接放弃等下一轮cron触发即可。第三finally块里先判断isHeldByCurrentThread()再unlock()。这个判断是标准的防御性写法。如果不判断在某些并发场景下可能因为锁已经被看门狗判定超时释放、当前线程不再持有锁而抛出异常。第四异常处理。我上面的代码里如果doSyncOrder()抛出运行时异常锁也会在finally里被释放不会死锁。但任务本身失败后是否要通知、是否要重试这是业务层要额外设计的。3.3 批量任务中的读写锁应用除了简单互斥锁Redisson还提供了读写锁这个在分布式任务里也有很好的应用场景。举个例子我们的配置缓存定时刷新任务和前台查询任务之间就存在读写冲突刷新任务写缓存时不允许查询任务去读旧缓存刷新完才允许读。// 刷新任务持写锁 RReadWriteLock rwLock redissonClient.getReadWriteLock(task:configCache); RLock writeLock rwLock.writeLock(); writeLock.lock(); try { refreshConfigCache(); } finally { writeLock.unlock(); } // 查询任务持读锁 RLock readLock rwLock.readLock(); readLock.lock(); try { return getConfigFromCache(); } finally { readLock.unlock(); }读写锁的意义在于提升并发度多个查询任务可以同时读但写任务和读任务互斥。分布式环境下这种锁用得好能显著减少无谓的排队等待。3.4 锁释放的正确姿势与异常兜底关于释放锁我强调一个红线加了锁的代码unlock必须放在finally里。这是个老生常谈但线上真的见过很多次漏写finally的。还有一个小技巧如果业务方法里包含多段逻辑锁的粒度要尽量小不要让锁覆盖无关操作。比如同步订单时还需要拉取配置那就先拉配置再进入加锁区域执行真正需要互斥的同步逻辑。锁的范围越小对系统的吞吐影响越小。另外如果确实因为某种极端情况比如进程被kill -9锁没有被释放Redisson的锁有过期时间兜底不会永远死锁。这就是为什么锁必须有过期时间也是为什么不要随便把leaseTime设置为-1之外的超长值——过期时间是异常兜底的最后一张网。4. 参数配置与调优等待时间、租约时间和看门狗的组合拳4.1 三个时间参数各管什么Redisson的锁设计里有三个时间参数容易混淆waitTime等待时间、leaseTime租约时间和看门狗的lockWatchdogTimeout看门狗超时时间。waitTime是尝试获取锁时的最多等待时长。tryLock(5, TimeUnit.SECONDS)的含义是我等锁最多5秒5秒内如果锁被释放了就返回true如果5秒还没拿到就返回false。这个参数主要用在那些“无法接受快速放弃”的场景比如用户请求处理中需要拿锁保证幂等拿不到锁直接放弃会导致请求失败一般可以给一个较短的等待时间。leaseTime是锁的租约时间也就是锁最长会被当前线程持有多少秒。不传这个参数时默认值为-1表示由看门狗动态续期。lockWatchdogTimeout是看门狗的续期周期基准值默认30秒。4.2 锁等待时间和租约时间的权衡这里我要单独提醒一个容易被我忽视的细节watchDog默认-1自动续期的行为适合的是“持锁时间不可预知”的任务。像凌晨跑批、全量数据同步这种时长不定的任务就该依赖看门狗。但并不是所有任务都适合看门狗比如一个预期最多执行200毫秒的扣库存操作给看门狗反而多了一堆续期调度的开销。我自己总结了一个参考规则任务类型waitTimeleaseTime是否启用看门狗定时批处理任务0不传-1启用推荐高频幂等控制50~200ms1~3秒不启用分布式事务短操作100ms5秒以内不启用不可预知的超长任务0不传-1启用推荐核心思路就是预期执行时间短的任务手动指定leaseTime不依赖看门狗预期执行时间不稳定的长任务交给看门狗。4.3 我在线上环境调整参数的一次记录在一次大促前压测中我们发现秒杀扣库存接口偶尔会抛出RedisException定位后发现是waitTime设得太大。扣库存锁的waitTime原本是300ms预想是给最多300ms等待锁但上游一个慢查询导致大量请求同时积压所有请求都在抢同一把锁锁的等待队列被打满Redis连接池也被占满了。那次我们把waitTime压到0拿不到锁直接返回“繁忙请重试”。从业务上看用户点击一次如果没抢到锁前端做个toast提示重试即可这比让用户请求在服务端傻等300ms要健康得多。压测结果也证明快速失败比长时间等待的平均响应时间更稳定。这是调优里很重要的一条经验锁的等待时间不要靠拍脑袋要看业务容忍度。5. 线上踩过的坑与完整的排查链路5.1 主从切换后的“双活”梦魇前面都是用法层面的内容下面聊真正让人头疼的坑。我们的Redis是主从架构某次主节点宕机触发哨兵切换结果巡检发现任务在切换过程中竟然重复执行了。根因不复杂任务A在主节点写入了锁主节点宕机哨兵把从节点提升为新主节点。但旧主的锁数据还没来得及同步到从节点新主节点上根本没有这把锁的数据。此时任务B来抢锁发现锁不存在加锁成功两个任务同时在跑。这是Redis主从架构下分布式锁的天然弱点。Redisson自身也解决不了这个问题要做相对可靠的方案就得引入RedLock也就是红锁向多个独立Redis节点同时加锁超过半数成功才算加锁成功。但RedLock在分布式系统领域有非常大的争议这个我建议做技术选型时自己判断不要在单个节点的部署上做文章。从实践角度我给三个建议一是核心任务在锁检查通过后再去业务表做一次幂等校验比如查一下任务批次号是否已存在二是给任务处理结果加唯一索引兜底三是如果业务对重复执行零容忍考虑用关系型数据库的原生锁或者引入其他强一致协调组件。5.2 锁过期导致同一个锁被两个节点同时持有第二个坑跟“手动指定leaseTime过短”有关。我们的一个数据归因任务原代码写的是lock.lock(10, TimeUnit.SECONDS)坚信这个任务10秒内一定跑完。结果那天数据量大增任务实际跑了25秒。第10秒锁自动过期另一个节点成功抢锁两个节点同时处理同一批数据最严重的后果是下游统计口径全乱了。排查链路是这样的先看应用日志发现同一个timestamp里两台服务器都在打“开始执行”日志然后看Redisson的锁日志发现锁在10秒整失效再翻代码确认是手动传了leaseTime导致看门狗根本没启动。找到根因后修复方案是把lock(10, TimeUnit.SECONDS)改成lock.lock()让看门狗接管续期。从那以后我们定了一条规矩除非有明确理由否则不在代码里手动传leaseTime。5.3 unlock时的IllegalMonitorStateException还有一个高频异常在非持锁线程里调用了unlock()抛出IllegalMonitorStateException。常见场景是在异步回调线程里释放锁或者在锁已过期自动释放后原持锁线程的finally代码才执行unlock()。这个异常的排查链路相对简单看堆栈就能定位。但如果只是简单把unlock()包在try-catch里吞掉异常是不对的。正确的处理是像3.2节那样在释放前调用lock.isHeldByCurrentThread()判断当前线程是否还持有锁。这里特别要提醒一个容易被忽视的点Redisson的isHeldByCurrentThread()只检查当前JVM本地状态不会去Redis查询。所以在主从切换、锁丢失这种异常场景下本地状态可能和Redis的实际状态不一致。它能防住误释放但防不住锁丢失导致的并发执行当然这也是分布式锁在弱一致存储下的边界。5.4 大任务卡死与看门狗失效的定位过程第四个坑比较隐蔽。有一次我们的全量数据对账任务执行超过半小时正常情况下看门狗会一直续期但实际运行时锁还是在某个时间点失效了另一个节点抢到了锁。一开始我怀疑是看门狗线程出了问题查看线程快照发现业务线程执行过程中发生了长时间的Full GC停顿超过30秒。看门狗续期线程本身也受到GC停顿影响无法及时执行续期锁自然过期。这个坑要解决单纯调Redisson参数没有用。正确的思路是控制业务线程的GC停顿时间避免超长Full GC同时给锁加一层“续期记录”的日志监控一旦出现锁提前失效就报警。另外也可以考虑把大任务拆成多个小任务每个小任务独立加锁执行这样单次持锁时间短即使某个小任务异常影响面也小得多。6. 分布式锁使用场景与面试考点一次讲透6.1 高频使用场景归类说到使用场景我归纳为五类。第一类是分布式定时任务防重这是这篇文章的主线场景。同一时刻只能有一个节点执行某条任务其他节点自动跳过或排队等待。第二类是秒杀与库存扣减。关键在于“判断库存充足”和“扣减库存”必须原子。用Redisson锁可以保证请求串行化但要注意锁只保护了应用层数据库层的库存字段仍要用行锁或版本号保护好。第三类是接口幂等控制。用户重复提交订单、支付回调重复通知在关键入口加锁同一个业务ID只有一次能通过。第四类是分布式缓存重建。缓存过期后大量请求同时回源数据库可以用锁保证只有一个线程去查库重建缓存其他线程等待后读新缓存。第五类是分布式环境下对共享资源的互斥访问比如同一个文件只能被一个节点写同一批消息只能被一个消费者处理。6.2 面试官爱问的几个问题这套东西也是面试高频区我把自己平时被问到的问题以及回答思路整理一下。“Redis分布式锁为什么是线程不安全的Redisson怎么解决的”Redis本身是单线程执行命令的加锁的“判断写入”在单命令层面是原子的所以“线程不安全”这个说法在单机Redis场景下并不成立。真正的风险在于锁过期、主从切换等异常场景。Redisson通过看门狗续期降低锁过期的概率通过Lua脚本保证重入和释放过程的原子性但主从切换导致的锁丢失风险Redisson单机模式是兜不住的。“看门狗的原理是什么”看门狗本质是后台任务。申请锁时不指定leaseTimeRedisson会启动一个TimeoutTask默认锁超时30秒当锁剩余时间到三分之一时自动续期到30秒直到业务lock被释放。这个机制的目的是防止业务执行时间超过固定过期时间导致锁提前释放。“Redis分布式锁和ZooKeeper分布式锁相比有什么优缺点”Redis锁的性能更好实现更轻量但一致性弱于ZooKeeper。ZooKeeper的临时顺序节点天然支持公平锁和会话过期自动释放可靠性更高但要引入一套ZooKeeper集群。“什么是红锁RedLock有什么争议”RedLock是向多个独立Redis实例同时加锁超过半数实例成功就认为加锁成功从而容忍少量实例宕机。它的争议在于分布式系统专家认为网络分区和进程暂停GC可能让RedLock也无法绝对保证互斥。因此我认为在工程上更务实的态度是锁用来防误操作业务层还要自行处理好重复执行带来的幂等问题。6.3 我的选型建议给一个结论性的建议如果你们已经有Redis且业务允许偶尔出现极低概率的“重复执行”配合幂等兜底Redisson锁是性价比最高的方案。如果业务绝对不能容忍重复执行比如资金类操作那请不要指望任何一种Redis锁直接上ZooKeeper协调、数据库事务结合业务幂等甚至消息表的唯一键兜底。在团队落地这套实践时别把所有逻辑堆在业务代码里。我推荐把加锁逻辑封装成注解和切面通过DistributedTask这样的方式统一管理锁的key、waitTime和释放逻辑。这样团队成员不需要理解底层原理也能用对大幅降低犯错概率。最后讲一点个人体会。分布式锁这个东西用起来简单真正用对却很难它最大的价值不是“防住并发”而是“兜住工程底线”。我们团队现在处理分布式任务的标准动作是Redis锁负责任务互斥业务表唯一索引负责数据兜底日志监控负责及时发现异常三层各守一道防线。另外分享一个小技巧每次发布引入新锁的业务前我都会在预发环境故意用两台节点同时触发任务然后插入一个10秒的慢SQL强迫锁进入续期和竞争状态观察日志里是否有重复执行。这种“故意制造故障”的演练比写再多防御性代码都更能暴露问题。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →