拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redisson分布式锁三大机制:可重入、可重试与看门狗续约的源码实战

Redisson分布式锁三大机制:可重入、可重试与看门狗续约的源码实战

我接手过不止一个这样的技术咨询:线上定时任务明明加了分布式锁,某个凌晨还是出现了两个实例同时执行同一份报表;更诡异的是,日志里两个节点拿到的锁key完全一致,执行时间还重叠了五十多秒。查到最后,往往不是锁失效,而是使用方把可重入、可重试、超时续约这三个默认行为混在一起用错了。Redisson的分布式锁在日常开发里之所以能“开箱即用”,靠的正是这三套机制一起把“锁我拿到了”这件事在分布式环境下变得可靠。可一旦理解不到位,它们也会变成最隐蔽的定时炸弹。

这篇东西我按源码机制加实战踩坑的方式来写,以Redisson 3.17到3.20之间的代码组织为基准,不同小版本可能有类名或方法结构上的差异,但核心机制没有变过。适合正在排查锁问题的同学,也适合准备把Redisson锁从“能用”提升到“用得明白”的同学。

1. 从三张Redis指令看这三个机制为什么必须绑在一起

1.1 分布式锁的天然短板:状态、通知、存活三件事缺一不可

单机版的ReentrantLock只需要在JVM内存里记录持有线程和重入次数,但分布式锁面对的是多个进程,状态必须落在所有人共同能看到的Redis里。问题随之而来:一个Redis Key只能告诉所有人“锁在不在”,但没法直接回答“谁拿着锁”“同一线程能不能再进一次”“下一个等待者什么时候被通知”“业务跑了很久锁会不会提前消失”这类问题。

这就是Redisson把锁设计成Hash结构而不是简单String的原因。Hash天然适合存储两个维度的信息:field存持有者标识,value存重入次数。光有状态还不够,等待方不能靠死循环轮询Redis来判断锁是否释放,那样既浪费连接又制造无谓的QPS。Redisson的办法是借用Redis的发布订阅能力,让解锁方主动广播“锁释放了”,等待方收到信号后再尝试获取。

存活问题则更隐蔽。如果锁只有固定过期时间,业务执行时间一旦超过这个时间,锁会提前失效,另一个线程就能进来造成并发冲突;如果锁不设过期时间,进程又可能崩溃导致锁永远打不开。两者都不可取,所以Redisson引入了看门狗机制:默认给锁30秒有效期,持有锁的线程在业务执行期间每隔10秒续约一次,既防止永久死锁,又尽量避免业务未完成锁已释放的尴尬。

一句话概括:可重入解决“怎么证明这个锁是我拿的”,可重试解决“没抢到锁的线程该怎么等、等多久”,超时续约解决“业务还没跑完,锁凭什么不能消失”。这三件事缺了任何一件,分布式锁在真实业务里都会出乱子。

1.2 三个默认行为的第一视角:lock、tryLock、watchdog是怎么协同的

以一个最简单的调用为例:

RLock lock = redissonClient.getLock("order:pay:12345"); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }

这段代码背后至少发生了四件事:

  1. 线程T1第一次调用lock(),Redisson向Redis执行一段Lua脚本,脚本里判断锁key不存在,于是创建Hash并写入field=UUID:T1,value=1,同时设置30秒过期时间。
  2. 同一线程T1在锁内再次调用lock(),脚本发现field=UUID:T1已经存在,就把value从1加到2,同时再次刷新过期时间。这就是可重入的完整动作。
  3. 假设线程T2在T1持有锁期间也来lock(),Lua脚本发现锁key存在且field不是UUID:T2,于是返回当前锁剩余TTL。T2拿不到锁,就进入订阅等待流程,直到T1解锁发布消息后再竞争。
  4. T1的业务逻辑执行超过30秒,看门狗每隔10秒把锁的有效期重新reset到30秒。T1最后一次调用unlock()时,Redisson会把Hash删除并向等待方广播释放消息。

这些步骤听起来简单,但每一步都关系到线上稳定。接下来逐个机制拆开讲。

2. 可重入:hash结构加Lua脚本,把“谁拿着锁”嵌入原子操作

2.1 为什么Redis里的锁必须用Hash而不是String

如果你只想实现“互斥”,用SET key value NX EX seconds就够了。这个问题很多人都能答出来。但如果要支持可重入,String结构就不够用了:重入意味着同一个线程再次获取锁时,不能拒绝它,要给它加一个计数;解锁时又要把计数减回来,减到0才真正删除锁。

这个“计数”必须和“持有者标识”绑定在一起。String结构只能存一份value,就算value本身编码成JSON,每次更新也要先GET再SET,两步之间插入了网络延迟或并发竞争,计数就可能丢失。Hash结构天然就是{field: value}的映射,持有者标识放field,重入次数放value,一次脚本调用就能完成判断加更新,这也是Redisson选择Hash的根本原因。

// Redisson中加锁Key的命名规则,一个典型结构 // key: myLock // field: 84d96a12-0c5e-4dfe-9b5a-0b74c74e8d1a:1 // value: 1 // 过期时间: 30秒(默认,由配置lockWatchdogTimeout决定)

其中field是UUID:线程ID,UUID区分了不同Redis客户端实例,线程ID区分了同一JVM里的不同线程,二者组合起来才是一个全局唯一的“持有者身份”。

2.2 lock.lua逐行拆解:获取锁与重入只在一段脚本里

Redisson加锁的核心Lua脚本(不同版本略有变化,以下结构是3.x的稳定版)大致长这样:

-- KEYS[1] : 锁名,例如 myLock -- ARGV[1] : 锁的过期时间,默认30000(毫秒) -- ARGV[2] : 持有者标识,例如 UUID:1 if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);

这个脚本的工作路径非常清晰:

  • 第一个分支:锁key压根不存在,说明当前没有其他人持锁。直接创建Hash,field放持有者标识,value初始化为1,并设置过期时间。返回nil表示加锁成功。
  • 第二个分支:锁key存在,而且当前线程标识已经在这个Hash里。说明这是同一个线程重入,于是把计数加1,同时刷新过期时间。返回nil也表示加锁成功。
  • 如果锁存在但持有者不是当前线程,直接返回当前锁的剩余过期时间。客户端拿到这个TTL后就知道“锁还差多久才可能释放”,后续等待逻辑会利用这个数字。

注意第二个分支里的pexpire容易被忽略。可重入不仅仅是计数加一,它同时会刷新锁的过期时间。也就是说,一次重入操作本身就带“续约”的副作用。很多人以为只有看门狗会续约,其实每次重入时Lua脚本已经把有效期往后推了。

Redis执行Lua脚本的原子性保证了这段逻辑在并发下不会被穿插。客户端之间判断exists和hset永远不会被拆开执行,这是分布式锁的最底层保障。

2.3 unlock.lua的秘密:先做减法,再做销毁

解锁脚本是加锁脚本的镜像对称:

-- KEYS[1] : 锁名 -- KEYS[2] : 用于发布释放消息的channel名 -- ARGV[1] : 释放消息内容 -- ARGV[2] : 锁的过期时间 -- ARGV[3] : 持有者标识 if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil; end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end

几个关键点:

  • 第一行判断Hash里是否存在当前线程的field。如果不存在,说明这个线程压根没持有锁,此时Redisson客户端会在Java层面抛出IllegalMonitorStateException。这是很多人遇到过“unlock的时候报错”的直接原因:要么跨线程操作了锁,要么锁已经因为过期被别人释放了。
  • hincrby value -1是先把重入次数减一,再根据减完后的结果决定下一步。这里必须用hincrby完成“减法”,因为计数更新必须原子进行。
  • 如果减完后计数还大于0,说明外层还有重入没释放,不能删key,但需要继续刷新过期时间。返回0表示“锁还在,只是重入次数少了1”。
  • 如果减完后计数等于0,说明所有重入都退出了,此时删除锁key,并向channel发布一条释放消息。返回1告诉客户端“锁真正释放了”。

这里我特别想提醒一句:开发同学容易在finally里无条件unlock,但Redisson的unlock实际会校验线程身份。如果你在A线程加锁,却因为代码结构问题把解锁放到了B线程,就等着收IllegalMonitorStateException吧。排查这个问题的时间往往比想象中长。

2.4 可重入在分布式环境下的边界条件

可重入并不是“同JVM内只要拿到引用就算重入”。Redisson的判定依据是UUID:threadId,UUID对应一个RedissonClient实例。如果你在同一个进程里创建了两个RedissonClient,或者公司封装了一层导致每次getLock都走不同客户端,那么即使业务上“同一个线程”,也会被识别成两个持有者。

跨进程的重入天然不可能,因为不同进程的UUID不同。这个设计是合理的,否则锁就失去排他性了。但要注意场景:如果你的业务在锁内通过RPC调用了另一个服务,而那个服务又尝试拿同一把锁,它拿到的一定不是同一份锁状态,因为线程ID和UUID都是自己的,和调用方完全无关。也就是说,分布式锁的重入只对“同一客户端实例内的同一线程”成立,跨服务、跨线程、跨实例的重入都无从谈起。

还有一点容易被忽略:可重入次数没有上限也没有超时约束。如果你在递归场景里每层都lock不unlock,value会一路加下去直到业务逻辑跑完才归零。这不会撑爆Redis hash,但会让锁的有效期被不断刷新,相当于隐式续约。反过来,如果释放次数大于获取次数,第二次unlock会触发IllegalMonitorStateException,因为第一次已经删key了。

3. 可重试:tryLock的等待队列是利用Redis的PubSub搭出来的

3.1 lock、tryLock(0)、tryLock(waitTime)三种入口的本质差别

Redisson提供了多个获取锁的入口,它们的区别本质上是“抢不到锁之后怎么办”:

方法等待行为超时行为适用场景
lock()无限等待,直到拿到锁,或线程被中断不返回,线程会一直阻塞定时任务、Job类任务,错过本轮就等下一轮
tryLock(0, TimeUnit.SECONDS)立即尝试,不等待拿不到直接返回false防重复点击、幂等处理这类一锤子买卖
tryLock(waitTime, TimeUnit.SECONDS)在waitTime内持续尝试超过waitTime仍拿不到,返回false请求生命周期有限,不能无限阻塞的场景

注意,lock()方法默认是不限等待时长的。如果你的接口里用了lock(),而拿锁的线程正好排在一串长任务的后面,这个线程会一直阻塞,请求超时之后线程继续占着池子,最终可能拖垮服务。所以接口层我基本不用裸的lock(),而是用tryLock(waitTime, TimeUnit.SECONDS)给等待设置一个上限。

tryLock(waitTime, TimeUnit.SECONDS)实际上是tryLock(waitTime, -1, unit)的重载,也就是说它不指定固定leaseTime,会走看门狗续约机制。这一点不少人也弄反了,以为tryLock一定是不续约的,其实只有当你主动传了leaseTime大于0,才会关闭看门狗。

3.2 锁释放一方的publish:unlock.lua里那行容易被忽略的代码

可重试最核心的等待唤醒机制,藏在前面unlock.lua脚本里那行容易被忽略的redis.call('publish', KEYS[2], ARGV[1])。

当锁真正释放(重入计数归零、删除了lockkey)时,Redis会向一个channel发布消息。这个channel是由锁名派生出来的,通常形如redisson_lock__channel:{lockName},并不是随意的广播频道。消息的内容则是解锁线程的持有者标识。

为什么要发布消息?因为等待方需要被“叫醒”。如果没有这个channel,等待中的线程就只能靠轮询锁状态来判断能否获取,最粗暴的轮询是每秒查一次pttl,锁一多Redis压力立刻上来。有了PubSub,等待线程可以挂在一个计数信号量上,收到消息后再做一次获取尝试。虽然最终还是要回到Lua脚本去抢锁,但网络开销从“周期性轮询”降到了“事件驱动唤醒”,效率完全不是一个量级。

还有一个细节:只有锁真正释放时才publish,可重入次数从2变1时并不会发布消息。因为外层还没退出,等待线程就算收到通知也抢不到锁,发了反而制造无意义的竞争。

3.3 锁等待一方的latch:订阅、阻塞、再唤醒

Redisson的等待方逻辑可以简化为以下伪代码:

// 伪代码,解释tryLock的等待循环结构 long time = unit.toMillis(waitTime); long start = System.currentTimeMillis(); // 1. 先直接尝试一次获取 Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId); if (ttl == null) { return true; // 直接拿到了 } time -= System.currentTimeMillis() - start; if (time <= 0) { return false; // 等待时间已经不够了 } // 2. 订阅锁释放的channel RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId); // 等待订阅完成,同时计入waitTime // 3. 循环尝试 while (true) { long begin = System.currentTimeMillis(); ttl = tryAcquire(waitTime, leaseTime, unit, threadId); if (ttl == null) { return true; } time -= System.currentTimeMillis() - begin; if (time <= 0) { return false; } // 4. 阻塞等待“锁释放”信号或者锁自动过期 if (ttl >= 0) { getEntry().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } else { getEntry().getLatch().acquire(); } time -= System.currentTimeMillis() - begin; }

核心状态全在getEntry().getLatch()这个信号量上。锁释放消息到达后,PubSub监听器会让latch释放一个许可,阻塞中的线程恢复执行,再次进入Lua脚本抢锁。

这里有一个非常重要的设计:当tryAcquire返回的ttl >= 0时,等待线程并不是收到一个通知就立刻抢,而是先调latch.tryAcquire(ttl, MILLISECONDS)。它把“当前锁剩余过期时间”当作一次等待的预算。锁反正还有ttl毫秒才到期,等待线程没必要在锁自然过期前反复去Redis做无意义的尝试。等到ttl毫秒后,锁可能刚好过期,此时再走一轮完整获取流程,成功率反而更高。

3.4 为什么收到释放通知依然可能抢不到锁

这是很多人对可重试机制理解偏差最大的一点:以为订阅channel就像排队取号,锁释放后会按顺序发给自己。事实并非如此。

Redisson普通锁的PubSub是“广播式唤醒”。线程T2、T3、T4都在等待同一把锁的channel,当T1释放锁并publish消息时,T2、T3、T4全都被唤醒,然后一起冲进tryAcquire的Lua脚本。但只有一个线程能从exists=0分支成功创建锁,其余线程拿到的是剩余TTL,只好回到latch上继续等。

所以收到释放通知不代表你一定能拿到锁,只代表“竞争窗口打开了”。这也是为什么等待循环里要反复tryAcquire,因为每次被唤醒都只是新一轮竞争的开始。如果业务对锁的获取顺序有要求,普通锁无法满足,得用FairLock。

3.5 FairLock排队和普通锁抢跑的区别

Redisson提供了RedissonFairLock,内部的等待结构不再是单纯的PubSub信号量,而是维护了一个基于Redis有序集合和链表的FIFO等待队列。

FairLock的逻辑粗略说是:当锁被占用时,后续线程会按到达顺序排队;锁释放后只有队列头部节点有资格获取锁,头部拿到锁后才依次向后推进。公平锁让等待方有了明确次序,避免了“晚到先得”和“唤醒风暴”的问题,但代价是每次加解锁的Lua脚本逻辑更重,Redis端的计算更多,且多了一个队列结构的维护成本。

大多数业务其实不需要严格公平,只要互斥成立就够了。如果你是拿锁做计数器扣减、幂等校验这类场景,普通锁完全够用,用FairLock反而会放大Redis压力。只有当你需要“谁先来谁先处理”的业务顺序时,才值得为公平性买单。

4. 超时续约:看门狗那10秒一次的续约背后藏着什么

4.1 默认30秒的锁有效期,为什么要设成30秒

Redisson的lockWatchdogTimeout配置默认是30000毫秒,也就是锁最长存活30秒。这个值既不能太长也不能太短。

太长意味着如果客户端进程真的崩溃了,其他线程要干等一份基础TTL才能接管锁。比如你把watchdogTimeout调到10分钟,某个节点突然宕机,其他节点要等10分钟才能重新进来,这在故障恢复场景里是灾难。

太短则意味着业务代码稍微慢一点,锁就可能在执行中失效。30秒是一个在大多数场景下都够用的折中值:绝大多数数据库操作、缓存更新、接口调用的单次耗时都不会超过30秒;即便超过,看门狗也能在每10秒的续约节点把它续回来。

配置是全局生效的:

Config config = new Config(); config.setLockWatchdogTimeout(20000); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); RedissonClient redisson = Redisson.create(config);

改任何一个锁的看门狗时间都会影响整个客户端创建的所有锁。如果只是想单把锁的leaseTime更短,不要改全局配置,而是用lock(leaseTime, unit)重载,或者在tryLock里显式传leaseTime。

4.2 看门狗从启动到退休:EXPIRATION_RENEWAL_MAP与TimeTask

看门狗并没有一个独立的“无限循环线程”在跑,它是由Redisson底层Netty的Timer一颗颗任务串起来的。

加锁时,如果当前获取锁的leaseTime是-1(表示不手动指定过期时间),Redisson会在锁拿到后调用scheduleExpirationRenewal(threadId)。核心数据结构是一个名为EXPIRATION_RENEWAL_MAP的ConcurrentHashMap,key是锁名,value是一个ExpirationEntry,ExpirationEntry里记录了一组持有该锁的线程ID以及当前的定时任务。

关键逻辑在于:只有当某个锁的ExpirationEntry里加入的threadId是“第一个”时,才真正启动定时续约任务;后续线程重入时只是把threadId加进集合,不会重复创建Timer。这样避免了重入N次就产生N个看门狗任务的资源浪费。

定时任务本身是这样调度的:

// 伪代码,看门狗定时任务的结构 Timeout task = serviceManager.newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { // 每隔internalLockLeaseTime / 3执行一次续约 renewExpiration(); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

默认internalLockLeaseTime是30000ms,除以3就是10000ms,所以每10秒续约一次。为什么是3等分而不是“每30秒直接续约30秒”?因为网络传输、Redis执行、客户端处理都有延迟,如果锁还剩10毫秒时才发起续约,一个网络抖动就可能让锁过期。每10秒续一次约,能让锁的剩余时间长期稳定在20到30秒之间,留足了抖动空间。

续约动作最终会请求一段Lua脚本:

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;

这个脚本只做一件事:检查持有者标识还在不在,在就把过期时间重置为30秒;如果锁已经被删除或锁的field被清除,则返回0,上层收到0后会终止后续续约调度并清理EXPIRATION_RENEWAL_MAP里的记录。

4.3 续约不是永动的:STW、主从切换和自定义leaseTime

看门狗看起来很聪明,但绝不是无限续约。它的生命有以下几个终止条件:

第一个终止条件是解锁。线程调用unlock时,Redisson在锁成功释放后会调用cancelExpirationRenewal(threadId),把定时任务清掉。《嗯这里要注意,无论锁是由线程A正常释放还是因为过期被自动回收,续约任务都要有一个出口。正常释放会取消定时任务,如果锁因异常情况提前没了,下一次续约脚本会因hexists返回0而终止调度,这是双重兜底。

第二个终止条件是客户端进程崩溃或与Redis连接断开。看门狗调度依赖Redis客户端的EventLoop。进程没了,Timer当然不再执行;锁本身会在30秒后过期释放,不会造成永久死锁,这是“默认有效期”最大的意义。

第三个是执行环境发生严重的STW停顿。这是实际生产里最容易忽视的问题。一次Full GC引起的长停顿,如果超过了30秒,锁的过期时间早就到了,而看门狗由于事件循环也停住了根本没机会续约。等GC恢复,业务代码继续往下跑,但它手里那把锁已经失效了,另一个线程可能已经同时持锁执行。这个问题靠Redisson本身无法解决,只能从业务角度缩短临界区、控制JVM堆大小和GC策略,或者接受极小概率的双执行风险。

第四个是手动指定了leaseTime的情况。只要调用方传了leaseTime大于0,Redisson就认为“锁能活多久由你决定”,不会启动看门狗。这在某些短租约场景下是合理的,但前提是你对业务耗时上限有严格把握。许多人踩坑就是这个:锁内业务平时只要200毫秒,某次触发了一条慢SQL跑了8秒,而leaseTime只给了5秒,锁提前消失,另一个请求趁虚而入,数据就出了问题。

5. 组合使用中的真实踩坑记录(附排查过程)

5.1 手动leaseTime导致的“提前释放”排查链路

先讲一个线上事故:订单服务的库存扣减接口,A和B两台机器同时处理同一个订单的退款和改价,两股流程都会改库存快照。代码里明明加了锁,还是出现了两次扣减重叠。

排查的第一步是去看锁的入参。发现调用方式是tryLock(3, 8, TimeUnit.SECONDS),第一个参数是等待3秒,第二个参数是锁租约8秒。开发同学当时认为“库存扣减最多2秒,8秒足够”。但忽略了一个事实:这个接口除了扣库存,还会调一次会员系统的RPC查询,RPC的超时时间设了10秒。

当一次会员系统调用网络抖动到8秒时,锁在第八秒到期,而业务代码还在等待RPC返回。同一时刻,另一个流程进来拿到锁,也走了一遍“查询会员+扣库存”,两边基于不同的快照版本写数据,自然产生了覆盖。

修复方案不是把leaseTime拉到50秒,而是先拆掉锁内无关的RPC调用,让临界区只保留真正的“检查和更新库存”两步。锁的租约只有当业务必须整体串行时才有意义,如果锁内塞了过多外部依赖,再长的租约都只是概率性兜底。原则上我建议先用不传leaseTime的tryLock(waitTime, TimeUnit.SECONDS),让看门狗接管续约;如果业务对“锁不能太晚自动释放”有强约束,再显式传leaseTime,但leaseTime必须大于历史业务耗时的P99。

5.2 锁内跨越线程,重入计数不认账

第二个坑来自一个批处理任务:主线程拿到锁后,把一批数据交给线程池并行处理,最后主线程统一unlock。结果一跑起来,某个子线程在处理过程中也调用了lock.tryLock(1, TimeUnit.SECONDS),运气好它拿到了同一把锁的等待资格,运气不好直接报IllegalMonitorStateException。日志里的异常栈指向Redisson的unlock方法,但看代码逻辑怎么都找不到是谁乱unlock。

问题的本质是:锁的持有者标识绑定的是“UUID:主线程ID”,子线程的ID完全不同。主线程加锁后,子线程在锁内调用tryLock,Redisson会认为子线程是另一个持有者。如果主线程在子线程没结束前unlock,锁就没了;如果子线程持锁完成后再unlock,又会因为子线程的field不存在而报错。

分布式锁不认线程池。可重入语义只在同一个线程的调用栈里有效。锁内若必须做异步操作,最常见的安全做法是让子线程自己加锁、自己解锁,锁key设计得更细;或者干脆避免在锁内跨线程传任务,把异步处理器整体挪到锁外面。用Spring的@Async时更要小心,方法级别的线程切换会直接切断锁的归属。

5.3 waitTime被低估,排队请求成批失败

第三个问题不那么血腥,但很影响业务成功率。某营销系统做活动积分发放,用户请求进来后用tryLock(2, TimeUnit.SECONDS)做防重复,锁key是activity:grant:{userId}。平时一切正常,活动开始时大量用户同时领积分,日志里不断出现“获取锁失败”的告警,成功率的曲线直接掉下来。

计算一下就能发现问题:单个用户积分发放的锁内操作平均耗时在500毫秒左右,锁key按用户维度拆分,但活动放量时同一用户的并发请求并不高,不同用户的锁互相不影响。问题出在别的地方——他们用了一个公共组件,组件里把锁key设计成了activity:grant这种不带userId的全局key,结果所有用户都在抢同一把锁。

这种情况从代码上根本看不出有锁粒度问题,只有去Redis查锁key的分布才能发现锁的并发窗口里只有一个key在跳动。排查时可以用redis-cli --bigkeys或者SCAN扫一下当前锁key的分布,看同一时段内活跃锁key数量是否符合预期。如果永远只有一个key,就说明锁粒度被无意识地放大了。

解决办法是把锁key精确到业务对象:活动维度用activity:grant:{activityId}:{userId},资源维度用resource:lock:{resourceId}。锁不是越粗越安全,过粗的锁等价于全局串行,分布式锁的并发能力就被浪费了。

5.4 集群主从切换下“双主锁”的现实困境

第四个问题是架构层面的。一个业务用Redis Sentinel做高可用,某次Sentinel触发主从切换,旧主节点上的锁key还没同步到新主节点。此时客户端A认为它仍持锁,因为A的看门狗还在续约,但续约请求已经打到新主节点上,发现根本没有这个key,续约脚本返回0,续约链路终止。

更麻烦的是,客户端B在新主节点上执行加锁脚本,此时旧主节点上的锁状态已经“消失”了,B会成功创建一把同名的锁。于是A和B以为自己拿到了同一把锁的互斥权,实际上两边手里的锁已经被主从切换隔断成两份。

这不是Redisson的实现问题,而是任何基于单Redis副本的分布式锁在“主从异步复制丢状态”场景下都绕不开的困境。如果你的业务对“同一时刻只有一个人在写”有极其严格的要求,同时Redis集群又存在主从切换窗口,最简单直接的缓解手段是Redis集群层面开启min-replicas-to-write 1,让写操作必须同步到至少一个从节点才算成功。但这也会牺牲可用性,主从断连时写请求会直接失败,需要业务侧权衡。

真正严谨的跨节点容错方案是RedLock或其他多节点仲裁机制,但这套方案成本高、实现细节多,生产里很少见。大多数业务其实可以接受“极小概率窗口内的双写”,只要把底层数据操作做成幂等,双写之后靠补偿或者唯一索引兜底,比为了锁去搭建复杂仲裁体系现实得多。

5.5 调试可重入时的实用小技巧

如果你怀疑某个锁的可重入状态不对,Redisson在运行时提供了getHoldCount()方法,可以返回当前锁持有的重入次数。在测试环境打印lock.getHoldCount()能快速确认锁是被嵌套加了几层,还是哪次unlock多减了一次。

另一个实用技巧是在解锁处给锁的key和threadId打日志,特别是线上环境,把lock.getName()和当前线程ID打到业务日志里。排查“谁在解锁谁”、”谁提前释放了锁“时,这行日志比任何监控都直接。

6. 我的默认参数习惯与最后一轮自查清单

6.1 不同业务场景下我常用的锁参数

每个人对锁参数都有自己的偏好,我经过了多次事故之后,现在基本形成了几个固定选择:

业务场景推荐写法理由
定时任务、批处理lock()愿意等锁,错过一批不亏,尤其适合“整点只该跑一次”的Job
普通接口防并发tryLock(1~3, TimeUnit.SECONDS)接口本身有超时时间,等待锁不应该超过接口容忍延迟
强一致且限定耗时的操作tryLock(waitTime, 10, unit),并让leaseTime显著大于业务P99耗时手动租约适合短临界区,但务必经过压测
热点资源并发很高细化锁key,缩小粒度避免所有请求排队抢同一把锁
对公平性有硬要求的排队任务redisson.getFairLock(key).lock()保证先来先服务

一个重要参数关系是waitTime和锁内业务耗时的匹配。假设锁平均被持有500毫秒,你在同一时刻有10个线程在等这把锁,那么最后一个线程等待的时间粗略估计是(N-1) × 500ms = 4.5秒。如果waitTime只给了2秒,必然有多个线程抢不到锁返回false。选waitTime前,先想清楚这个锁要承载多少并发竞争,不要凭感觉填数字。

6.2 上线前必自查的六个问题

我总结了一个简单的自查清单,每次新写一个分布式锁都会过一遍:

  1. 锁key是不是精确到业务对象,而不是一个笼统的常量。写锁时先问自己“这把锁到底在保护哪一份数据”。
  2. 锁内有没有调用外部RPC或远程服务。如果有关键依赖,优先把它移出临界区;移不出,确认leaseTime或看门狗能覆盖。
  3. 有没有使用线程池、异步线程、@Async在锁内干活。有了,要么统一到同一线程,要么让子线程自己拿锁。
  4. 能不能接受锁过期后另一个线程进入。如果数据层有唯一索引或状态机约束,双执行的影响可控;如果完全没有,需要把锁粒度、租约时间、Redis高可用方案一起重新评估。
  5. 是否经过等待时间估算。waitTime不是拍脑袋,要结合锁的持有时间、等待队列长度、业务可容忍延迟综合计算。
  6. 上线前是否打印过锁key、threadId、获取结果等关键日志。分布式锁的排错难度比普通代码高得多,前期日志越完整,后期排查越省事。

最后补一个我个人的小习惯:很少直接在生产代码里裸写lock()和unlock(),而是包一层简单的AOP或模板方法,统一处理tryLock结果判断、finally解锁和日志打印。这样即使团队里有人把tryLock返回值忽略,编译/检查阶段也能通过注释和模板把手限制住。Redisson给了你很强的工具箱,但选择权永远在写代码的人手里。

返回列表