搞分布式系统的人,基本都绕不过分布式锁这道坎。无论是扣库存、防止重复下单、还是定时任务避免多节点重复执行,你都需要一把"跨进程的锁"。刚开始做的时候,大家习惯用SET NX EX自己撸一个,看起来挺简单,但真跑到线上,各种奇奇怪怪的问题就冒出来了:锁超时自动释放导致业务还没执行完锁就没了、A线程的锁被B线程释放掉、主从切换瞬间丢锁……这些问题排查起来一个比一个难受。
后面我用上了Redisson,这个库封装的分布式锁用起来就像操作JDK自带的ReentrantLock一样自然,而且它内部把上面说的那些坑都填得差不多了。但有一说一,网上关于Redisson锁的原理文章不少,大部分却只停留在"看门狗续期"这个点上,讲得太浅。你真正遇到锁失效、死锁、性能瓶颈的时候,光知道一个看门狗是不够的。这篇我把Redisson分布式锁的核心原理从源码层面拆开揉碎讲清楚,同时把我在实际项目中踩过的坑和处理思路一并写出来,希望能帮到正在研究分布式锁的各位。
1. 分布式锁的选型决策,Redisson为什么会成为主流选择
1.1 什么时候你真的需要一把分布式锁
先聊聊分布式锁的使用场景,别一上来就谈技术选型。很多团队的分布式锁是"被需求推着走的",真正要派上用场的场景大致有这么几类:
第一类是并发资源控制,典型的就是电商库存扣减。库存数据一般放在Redis里,多个服务实例同时对同一个商品SKU执行"读取-扣减-写回",如果不加锁,超卖几乎是必然的。本地Synchronized或JUC Lock只能管住当前JVM里的线程,多实例部署之后必须用跨JVM的锁。
第二类是幂等性保障,比如支付回调、MQ消费。消息中间件有"至少一次"的投递语义,消费端如果没做幂等,同样的订单可能被处理两次。分布式锁在这里的作用,是确保同一个业务标识(比如订单号)在同一时间只被一个节点处理。
第三类是定时任务防重。当你把定时任务部署到多台机器上的时候,很自然地希望同一时刻只有一台机器在执行。这个问题用分布式锁解决非常顺手,"抢锁成功者执行"。
第四类是分布式事务中的资源协调,比如多个服务需要按顺序操作同一个共享资源,靠分布式锁编排顺序。
这些场景的共同点,是跨进程 + 临界区共享。判断自己是否需要分布式锁,就看这两条是否同时成立。如果只有一个实例,别引入分布式锁,zookeeper、etcd、redis都别急着上,本地锁最香,省掉一大堆网络开销和运维成本。
1.2 自己用SETNX实现锁,坑在哪里
很多教程会让你先用SET resource_key request_id NX EX 10自己实现一个,说这样能理解原理。这个方向本身没错,但如果你真的照这个方案上生产,会接连踩到几个坑。
坑一:业务执行时间超过锁的过期时间。你给锁设置了10秒过期,但业务卡了15秒,锁提前释放了,另一个线程就能拿到锁,临界区瞬间失去保护。解决思路是"续期",但自己写续期逻辑就要额外起一个守护线程,代码复杂度一下就上去了。
坑二:锁误删。A线程拿到锁,执行中因为Full GC停顿了很长时间,锁超时释放。B线程立刻拿到锁开始执行。A线程恢复后执行完,自信地DEL掉锁——实际上删的是B线程的锁。虽然可以通过request_id(Token)来判断归属,但判断和删除是两个操作,必须用Lua脚本包成原子的才行,很多人根本没想到这一层。
坑三:主从架构下的锁失效。Redis主从复制是异步的,A线程在主节点写入锁key,主节点还没来得及同步到从节点就发生故障切换,从节点升为主节点后发现根本没有这个锁key,B线程趁虚而入。这是Redis分布式锁的"先天缺陷",每个方案都绕不开,必须从更高的架构层面去权衡。
坑四:没有可重入能力。同一个线程进入两次加锁逻辑(比如外层方法加了锁,内部调用的方法又加了锁),如果是非可重入锁,直接就死锁了。自己实现可重入,就需要记录持有者信息和重入计数,又是一个不小的工程。
这些坑理论上都能填,但填完之后你相当于重新造了一个轮子,而且大概率没有Redisson在极端场景下打磨得那么细致。这也是我选择分析Redisson的原因——它的核心实现思路,本质上就是上面四个坑的"标准解法"。
2. Redisson锁的存储结构与加锁流程全解析
2.1 锁在Redis里到底长什么样
Redisson加锁后,Redis里并不是简单存一个"字符串key=1",而是使用Hash结构,这是它实现可重入的关键设计。我经常让团队里刚接触Redisson的同学做一件事:手动在Redis客户端模拟一次加锁,用HGETALL命令看看锁的结构。一次加锁之后,你会看到一个类似下面的结构:
1) "myLock" 2) 1) "8b1e6f3d-5d77-4d9e-9c3e-2d4d2e6d8f12:thread-1" 2) "1"这个Hash的key是锁名称(就是getLock("myLock")里传入的名称),Hash里的field是"UUID+线程ID"的组合,value是重入计数。
为什么field要用UUID:threadId这种格式?两个原因。第一个原因是唯一标识持有者,UUID区分不同JVM实例(注意:UUID在Redisson客户端启动时就生成一次),线程ID区分当前JVM内的不同线程,二者组合才能全局唯一地确定"谁持有了这把锁"。第二个原因是为可重入做准备,后面重入一次,就对value加一,释放一次就减一,减到0再从Redis里删除整个key。
你可能会问:为什么不直接用Redis的SETNX,而要费劲用Hash?因为SETNX在语义上就是个"不存在则设置"的命令,它天然不支持计数。Hash配合HINCRBY,才既能实现互斥,又能记录重入次数。数据结构的选型,从根上决定了锁的功能边界,Redisson用Hash,不是炫技,是真的有需要。
2.2 加锁的Lua脚本,原子性是怎么保证的
Redisson加锁的核心,是一段Lua脚本。我看过的很多源码解析文章,会把脚本直接贴出来然后说"这段脚本就是加锁",但很少讲清楚每一行为什么要这么写。我们先看核心部分,然后逐行拆解:
-- KEYS[1]: 锁的名称,如 myLock -- ARGV[1]: 锁的自动过期时间(毫秒),默认30000 -- ARGV[2]: 持有者标识,UUID:threadId -- ARGV[3]: 随机签名,用于防止锁误删,一般是当前线程的随机token if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', 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])这段脚本的逻辑分成三个分支:
分支一:锁不存在,直接抢占。exists判断锁key不存在,说明当前没有其他线程持有锁,当前线程通过HINCRBY把重入计数设为1,同时用PEXPIRE设置过期时间。注意,PEXPIRE的单位是毫秒,默认值30000毫秒,也就是30秒,后面会讲到这个30秒是看门狗续期的时间基准。
分支二:锁存在且持有者是自己,重入。HEXISTS检查Hash中是否存在当前线程的UUID:threadId这个field。存在就把value递增1,同时刷新过期时间。这就是可重入能力的底层实现:同一个线程嵌套加锁,不会阻塞,只会让计数累加。
分支三:锁被其他线程持有,返回剩余生存时间。这是最容易被忽视的一条路径。当锁被别人持有时,PTTL返回当前锁的剩余过期毫秒数,这个返回值会用于后续的阻塞等待逻辑——线程需要知道"我还得等多久",才能在合适的时候重新申请加锁。
这里我要特地强调一下:这段脚本之所以用Lua,是因为Redis从2.6开始支持在服务器端原子地执行Lua脚本,整个脚本作为一个整体执行,执行过程中不会有其他命令插入。如果你不用Lua,改成先在客户端判断、再执行HINCRBY,判断和写入之间就有时间窗口,并发条件下就可能让两个线程同时认为自己"抢锁成功"。这个原子性,是分布式锁正确性的根基。
2.3 阻塞等待与唤醒,Redisson如何处理竞争
我把Redis当成"锁服务器",它只负责保存锁状态和执行Lua脚本,真正的线程阻塞和唤醒,是在客户端完成的。
当一个线程尝试加锁,而锁正被其他线程持有时,Redisson并不会让这个线程傻乎乎地死循环轮询Redis(高频轮询会把Redis打爆)。它的做法是:订阅锁对应的Redis Channel,然后让当前线程阻塞等待,直到锁被释放,再被唤醒重新尝试加锁。
具体来说,Redisson加锁失败时,会进入一个while (true)循环,循环内先subscribe锁的发布订阅频道,然后Semaphore阻塞等待。当持有锁的线程解锁时,会向这个频道发布一条"解锁"消息,等待的线程通过监听器收到消息后立刻重新发起加锁请求。
这个机制有两点值得玩味:
第一,它是"事件驱动"而不是"轮询驱动"。锁释放后,等待线程能立刻感知并尝试竞争,延迟极低。不像手动SETNX + sleep + 重试那种方案,线程醒了也不知道锁是否已经释放,只能碰运气。
第二,它仍然保留了"自旋重试"的兜底逻辑。万一发布订阅消息丢失(比如网络抖动),等待线程也不会永久沉睡。Redisson在阻塞等待时设置了超时时间,超时后会主动重试加锁。如果你对Redisson的锁做过压测,会发现高竞争场景下它的表现依旧平稳,靠的就是"事件通知为主,超时自旋兜底"这套组合逻辑。
2.4 加锁流程的时序总结
我把加锁流程整理成一条逻辑线,方便面试或者写文档的时候直接引用:
- 线程发起
lock()调用,Redisson生成全局唯一的UUID:threadId作为持锁标识。 - 执行Lua脚本尝试加锁,脚本内部通过
exists和hexists判断是「抢占」还是「重入」。 - 加锁成功,启动看门狗(WatchDog)后台续期线程——这一步
lock()和tryLock()有区别,后面细说。 - 加锁失败,订阅锁释放的Channel,Semaphore阻塞当前线程。
- 锁被释放,收到订阅通知,唤醒线程,回到第2步重新尝试,或者自旋等待超时后重试。
3. 解锁流程、看门狗机制与可重入的底层实现
3.1 解锁为什么也必须用Lua脚本
解锁这段,Redisson同样使用Lua脚本。核心脚本如下:
-- KEYS[1]: 锁名称 -- KEYS[2]: 锁的发布订阅Channel名称 -- ARGV[1]: 解锁的发布消息内容 -- ARGV[2]: 持有者标识 UUID:threadId -- ARGV[3]: 锁的默认过期时间 if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then return nil end local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1) if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[3]) return 0 else redis.call('del', KEYS[1]) redis.call('publish', KEYS[2], ARGV[1]) return 1 end这段脚本的逻辑同样分成三条路径,我来拆解一下:
路径一:锁不在自己手里,直接返回。HEXISTS判断当前线程是否持有锁,如果field不存在,说明锁根本不是自己的,直接返回nil,什么都不做。这一步就杜绝了"误删别人锁"的问题。注意这里的return nil在Redis返回给客户端时会表现为nil,但正常流程里你很难触发这条路径,因为Redisson的每一次解锁都会先检查持有者,只有自己持锁才会继续执行。它存在的意义,更多是防止非持锁线程恶意解锁——这看起来"多余",却是分布式锁必须具备的安全边界。
路径二:重入计数不为零,只减计数不删锁。HINCRBY将value减1,如果减完后计数仍大于0,说明这个线程还没完全退出临界区(外层锁还没释放),此时只需要刷新过期时间,锁继续保留。这就是可重入锁的"完整释放"语义:加锁几次,就得解锁几次,计数归零才真正释放。
路径三:计数归零,删除锁并通知等待线程。减完后计数等于0毁灭,说明是最后一次解锁,此时用DEL删除整个Hash key,同时通过PUBLISH向锁的Channel发送一条解锁消息。这条消息就是所有阻塞等待线程的"起床哨",它们收到后立刻发起新一轮的加锁竞争。
每次看这段脚本,我都要感叹一句:"先判断再删除"这两个动作,必须在一个Lua脚本里原子地执行。如果你把它拆成两条Redis命令,比如先HEXISTS检查,再DEL删除,那在两条命令之间,锁的持有者可能已经变了,你就又踩回"删除别人锁"的那个坑了。Redisson把所有关键操作都封装成Lua脚本,就是为了从根本上杜绝这种检查与执行分离导致的竞态。
3.2 看门狗机制,锁续期背后藏着的权衡
看门狗(WatchDog)是Redisson分布式锁被讨论最多的机制,网上的讲解很多,但我发现很多人对其中的"设计意图"理解是错位的。我先说结论:看门狗解决的核心问题,是"业务还没执行完,锁先过期了"这种超时错杀现象。
Redisson加锁默认的过期时间是30秒。如果业务执行时间超过30秒,锁就会提前释放,另一个线程就能拿到锁,临界区的保护就失效了。看门狗的续期机制是:加锁成功后,会启动一个后台线程(每10秒执行一次),每次执行都会检查锁是否还存在,如果存在就把过期时间重置为30秒。这样只要线程还活着,锁就一直在,直到线程主动解锁,看门狗才会停掉。
这段逻辑可以用一张时序来理解,但我更想强调一个大家容易忽略的点:看门狗续期不是"固定地每10秒续期一次",而是在每次执行时重新设置新的过期时间。所以30秒、10秒这两个数字之间的关系是:30秒是过期基准,10秒是续期间隔,只要续期线程在锁过期前执行到,锁的生命周期就能不断延续。
这里有一个很有意思的细节:tryLock(long waitTime, long leaseTime, TimeUnit unit)这个方法,如果你手动传入了leaseTime(自定义过期时间),看门狗是不会启动的。这个设计很多人第一次看到会觉得"反直觉",为什么我传了过期时间反而不给我续期了?
原因在于语义约定:你手动设置过期时间,就代表你明确知道自己业务最长执行多久,你主动告诉Redisson"超过这个时间你就把锁释放吧"。如果你传了leaseTime还让看门狗继续续期,这两个参数就会相互冲突——你一边说"10秒后释放",一边又让后台线程把过期时间刷新回30秒,整个逻辑就撕裂了。
所以使用Redisson有一个很反直觉的实践要点:只要你需要看门狗的自动续期能力,就不要传leaseTime参数,让锁使用默认的30秒+看门狗续期模式。只有当你确定业务执行时间可控、且你完全不希望锁"超长待机"时,才手动设置leaseTime。我在实际项目里见过不止一次,同事为了让锁更"可靠"特意传了一个很长的leaseTime,结果看门狗没启动,业务卡顿后锁照样提前释放,瞬间产生并发问题。
3.3 可重入的完整生命周期
借用JUC的ReentrantLock来理解Redisson的可重入会非常顺畅。我把它们的对应关系列一下:
| 概念 | ReentrantLock | Redisson |
|---|---|---|
| 锁标识 | 当前线程对象 | UUID:threadId字符串 |
| 状态存储 | JVM内存中的state字段 | Redis Hash的value计数 |
| 加锁 | compareAndSetState(0→1) | Lua脚本HINCRBY |
| 解锁 | 状态减1直到0 | Lua脚本HINCRBY -1直到0再DEL |
| 重入 | state累加 | Hash value累加 |
JUC和Redisson在这个问题上的思路惊人地一致:都是"持有者标识 + 计数器"的模式。区别只是存储载体不同——一个在JVM内部,一个在Redis里。如果你已经理解了ReentrantLock,再看Redisson的可重入,其实就是"把内存换成了Redis,把CAS换成了Lua脚本"。
实际开发中有个典型的可重入场景:你在Service层的A方法里加了锁,在A方法内部又调用了B方法,B方法也用同一把锁做了加锁。如果是非可重入锁,B方法必然死锁;有了可重入能力,B方法会直接重入成功,计数器从1变成2。注意,这种情况下你必须保证解锁次数与加锁次数一致,A解锁一次、B解锁一次。Redisson的RLock实现,lock()和unlock()必须成对出现,如果你在A方法里调用了lock(),但在B方法里只lock()不unlock(),计数器就永远归不了零,锁会被一直持有,直到看门狗误伤——实际上看门狗会一直续期,相当于你的锁变成了"永久锁",这是很危险的。
3.4 unlock到底能不能删别人的锁
我在排查问题的过程中,见过一次非常典型的误用:用一个tryLock()返回了false的线程去执行unlock(),结果那把锁被解掉了。我当时第一反应是"源码里不是有HEXISTS判断吗?怎么会解掉",后来才发现问题出在调用者自己身上。
Redisson的unlock()内部确实会检查持有者,如果你不是锁的持有者,它会直接返回,根本不会碰别人的锁。但有一个前提:你必须在加锁成功后才调用unlock。如果你在tryLock()返回false(压根没拿到锁)的情况下,代码里还是调用了unlock(),Redisson会因为"当前线程未持有锁"而抛出IllegalMonitorStateException,而不是静默地删除那把锁。
这个设计我要给个好评。抛出异常比静默不做事要好得多,它能立刻暴露出调用者的逻辑错误。但很多团队的代码是这么写的:
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // do something } else { log.warn("获取锁失败"); return; } lock.unlock();看到了吗?unlock()被放在了if外面,不管有没有拿到锁都会执行。如果tryLock()返回false,这个unlock()就会抛异常,而且是在你正常返回之后抛,异常信息还不容易跟业务请求关联上。正确写法应该是把unlock()放在finally块里,并且确认加锁成功后才进业务代码。这一点老生常谈,但我真在公司里的核心链路上见过不下三次。
4. 源码视角:RLock接口、公平锁与红锁争议
4.1 RLock与RedissonLock的工程结构
看Redisson源码时,建议先看清楚接口体系,再从入口往下追。Redisson的分布式锁顶层接口是RLock,它继承了java.util.concurrent.locks.Lock接口,同时补了tryLock(long waitTime, long leaseTime, TimeUnit unit)这类支持自动解锁的扩展方法。RedissonLock是它的核心实现类,里面承载了前面分析的所有Lua脚本逻辑。
从工程角度看,RedissonLock内部有几个关键部件:
- CommandAsyncExecutor:所有Redis命令和Lua脚本的异步执行器,Redisson用Netty的异步模型来处理命令交互。这也是Redisson性能好的重要原因——它把大部分需要等待的Redis操作都异步化了,阻塞等待都是在业务侧用Semaphore模拟出来的。
- PubSub:用来订阅锁Channel的监听器容器,负责收到解锁消息后唤醒等待线程。
- LockResetter:看门狗续期线程的调度器,基于Netty的
HashedWheelTimer时间轮实现定时任务。
很多人会觉得"分布式锁嘛,核心就看个脚本就完了",其实不然。工程性能上的差距,往往就藏在异步执行器、时间轮调度这些看似不起眼的地方。同样是加锁,用同步命令与用异步命令组合,锁竞争激烈时的吞吐表现能差出好几倍。
4.2 等待锁时线程在做什么
前面提到过,加锁失败后线程会进入"订阅 + 阻塞等待"的模式。很多教程到这里就结束了,但我特别喜欢深挖一层:这个"阻塞等待"到底是怎么实现的,它是不是真的让JVM线程挂起?
看RedissonLock的源码,等待逻辑大致是这样:加锁失败后,调用subscribeFuture去订阅锁的Channel,然后getEntry().getLatch().tryAcquire(leaseTime, TimeUnit.MILLISECONDS),这个Latch本质是一个Semaphore,初始许可数为0。线程在tryAcquire上等待,只有两种唤醒路径:
- 订阅监听器收到解锁消息,调用
latch.release()释放一个许可,线程被唤醒。 - 等待超时(比如
tryLock传了最大等待时间),线程被唤醒并返回false。
理解了这个机制,你就能明白一个常见疑问:为什么Redisson的等待线程在锁竞争激烈时,CPU占用率并没有飙升?因为线程确实被挂起了,它不是自旋轮询。这是"事件驱动"和"轮询"在资源占用上天然的本质区别。当然,如果你的条件允许,Redisson还提供了RedissonSpinLock——完全自旋的锁实现,适用于临界区极短、等待线程不多的高吞吐场景。用哪个,看你的业务模型,没有银弹。
4.3 公平锁与默认锁的差异
Redisson还有个容易被忽略的功能:getFairLock(),公平锁。它和普通锁的关键区别在于,等待的线程要按先来后到的顺序获取锁,而不是靠"谁手快谁先抢"。
公平锁的内部实现,是维护了一个FIFO队列,加锁时先用一个Lua脚本检查队列头部是否有等待者,如果有,当前线程只能排队。源码里公平锁的脚本比普通锁复杂得多,因为它要处理"排队序号""队列清理""唤醒下一个"这些逻辑。
我的实际建议是:除非业务有强制的公平性要求,否则用默认的非公平锁就够了。为什么?因为公平锁的吞吐量通常更低。Redis执行Lua脚本本身就有开销,公平锁一个加锁动作要执行比普通锁复杂得多的脚本,QPS能差出一个量级。分布式锁解决的是"互斥"问题,不是"公平"问题,后者只有在极少数业务里才是硬需求。清楚自己到底要什么,比盲目选择"更高级""更可靠"的方案重要得多。
4.4 关于RedLock,业界吵了很久的话题
聊到Redis分布式锁,绕不开RedLock。RedLock本质上是一个由Redis作者提出的多节点锁算法,建议在多个独立的Redis节点上同时加锁,超过半数加锁成功才认为加锁成功,目的是解决主从切换时的锁丢失问题。这个算法当年被Martin Kleppmann公开质疑过,反方阵营也给出过回应,两边都有道理。
我对RedLock的立场一直是"谨慎观望"。首先,大部分业务团队没有五个独立Redis节点的资源,占比超半数、同步协调的成本也不低。其次,RedLock无法解决"暂停导致锁过期后GC恢复继续执行"这种客户端侧的问题,它只是把单点问题做了一定程度的缓解,并非银弹。如果你真的对锁的可靠性有超乎寻常的要求(比如金融级对账、资金操作),与其引入RedLock,不如认真考虑ZooKeeper或etcd实现的分布式锁——它们基于顺序节点的机制,在"锁状态一致性""故障感知"上比Redis锁要强不少。
这并不影响Redisson作为Redis锁方案的主流地位。对大多数互联网业务的常规并发场景,Redisson锁已经做得足够好。把"分布式锁为什么需要超过半数节点"这种问题留给面试官就好,做工程的人更关心的,应该是锁在你的业务场景下会不会失效、失效能造成多大损失、能否通过其他手段兜底。
5. 实战向:Redisson锁的使用细则、常见故障与排查记录
5.1 结合业务的锁设计:锁粒度与被保护资源的匹配
很多人用分布式锁,随手RLock lock = redissonClient.getLock("order_" + orderId);就完事,但锁粒度设计不对,也是隐性事故的温床。
锁粒度要跟被保护的资源边界严格对齐。比如扣减库存,如果多规格商品的库存是分开的,那把productId:specId拼成锁key是合理的;但如果把所有规格都用一个productId维度加锁,那么不同规格的购买请求之间会相互阻塞,吞吐量白白损耗。反过来也是,如果本该一把锁保护的两个操作拆成了两把锁,并发条件下就会同时进入临界区,数据一致性瞬间破功。
我推荐一个简单粗暴的判断标准:你在加锁的临界区里操作的数据,它的唯一标识范围就是锁的最小粒度。你对Redis Key = inventory:{skuId}做读写,那锁的范围就应该是这个skuId,不需要向上聚合,也不要向下拆分。
还有一点容易被忽略:锁的key命名空间要全局统一。一个系统里可能有多套业务共用同一个Redis Cluster,如果只拿userId当锁key,不同业务模块之间就会产生锁冲突。正确做法是在锁key中带上业务前缀,比如order:pay:lock:{orderId},inventory:lock:{skuId}。这个习惯在微服务拆分之后尤为重要,因为不同服务的Redis往往是共享的。
5.2 常见故障一:线程持有锁时间过长导致任务堆积
这是我在性能排查中遇到过最多的问题。现象是:某个用Redisson分布式锁保护的定时任务,业务处理变慢之后,大量请求堵在tryLock的等待上,表现为接口TP99急剧上升,Redis的slowlog里出现大量Lua脚本超时。
排查下来原因通常有两个:一是临界区里做了重活,比如远程调用、批量DB更新;二是锁等待时间设置得太大,线程们长时间挂在锁上不放。对应的处理思路有二:
- 缩小临界区。分布式锁只保护真正需要互斥的部分,比如"检查当前处理状态"和"写入处理中标记",那些不涉及共享资源的耗时操作移出临界区。锁里的代码越短,线程占锁时间越短,系统整体吞吐就越高。
- 合理设置等待时间。
tryLock(waitTime, leaseTime, TimeUnit)里的waitTime并不是越大越好,你需要的只是"能容忍的最大排队时长",超过这个时长直接放弃并返回失败,比无限等待更符合生产要求。比如一个接口的正常执行时间是50ms,你把waitTime设成30秒,每个线程平均等15秒,这不叫容错,这叫拖垮系统。
5.3 常见故障二:锁未释放导致死锁
还有一类故障很隐蔽:线程执行到一半抛了异常,unlock()没有执行到,锁一直不释放,后续请求全部卡死。这种情况往往是代码没有把解锁放进finally导致的。我用Redis客户端HGETALL一看,锁的Hash躺在那里,持锁者还是那个已经"消失"的线程,计数不为0,看门狗还在持续续期,好嘛,真的锁死了。
处理这种问题的正确姿势,一是代码上保证try { ... } finally { lock.unlock(); }是铁律,二是在监控层面做兜底——比如在Redis侧扫描长时间未释放的锁key,或者在业务入口记录"加锁与解锁的日志配对",发现前置有锁、后置无解锁日志的情况立刻告警。
关于锁异常分布还有个高频点:unlock()本身也可能抛异常。比如线程在finally里调用unlock()时,因为之前加锁已经超时被Redisson主动释放(当你传入leaseTime时),此时unlock()会抛IllegalMonitorStateException。所以unlock()的执行必须放在finally的最内层,且要严格保证与lock()成功配对。
5.4 常见故障三:主从切换瞬间锁丢失
这个故障和RedLock争议是一脉相承的。当你的Redis采用主从架构时,如果主节点刚写入锁key还没来得及同步到从节点就宕机,新的主节点上没有锁的痕迹,另一个线程就能乘虚而入。
Redisson默认方案解决不了这个问题,Redis官方给的答案是RedLock,但它要求你部署多个独立的Redis实例,很多团队根本没这个条件。我的建议是分场景看待:
- 业务能接受极小概率的"锁失效"吗?比如活动秒杀的库存扣减,偶尔超卖一单,问题不大,那用Redisson默认方案就够了。
- 如果业务绝对不能接受锁失效,比如资金类的幂等处理,那请把"锁"和"唯一性约束"叠加使用:锁负责挡并发,数据库的唯一索引或状态机负责兜底。
我从来不建议把分布式锁当成"银弹中的银弹",它只是一个并发控制手段,你的系统一定还有校验和兜底的层次,这一点心里要有数。
5.5 故障排查时的Redis命令速查
遇到锁相关的线上问题,我最常做的排查动作就是连上Redis,用几个命令快速定位现场情况。这里整理一个速查表:
| 排查目的 | 命令 | 判断依据 |
|---|---|---|
| 查看锁是否还存在 | EXISTS myLock | 返回1表示存在,0表示已释放 |
| 查看锁的持有者和重入次数 | HGETALL myLock | 能看到持锁线程标识和计数 |
| 查看锁剩余过期时间 | PTTL myLock | 小于0说明已过期 |
| 模拟持锁者的续期 | PEXPIRE myLock 30000 | 临时延长锁时间,给排查留出窗口 |
| 检查Redis慢查询 | SLOWLOG GET 10 | 查看是否有Lua脚本超时 |
日常巡检时,可以周期性地扫描几百个锁key,看看有没有长尾未释放的。这不是给Redisson擦屁股,而是分布式系统运维的常规动作——你永远无法保证所有代码路径都是优雅退出。Redis锁只是工具,你得为它建立一套完整的观测手段。
6. 从实践到面试:把原理讲清楚比背结论重要
Redisson分布式锁是面试高频题,但我发现很多候选人的回答模式是"Lua脚本 + 看门狗 + 可重入"三板斧,讲完就停。面试官稍微追问一下"看门狗具体怎么续期的""锁等待过程中线程在干什么",立刻就卡壳了。这说明很多人的认知是"知道结论,没理解机制"。
一个好的回答路径应该是这样的:先说自己对分布式锁本质的认知——锁就是一个跨进程的互斥变量,难点在于争夺、释放、异常兜底;然后引出Redisson用Hash + Lua脚本解决互斥与可重入,用看门狗解决超时续期,用发布订阅解决等待唤醒;最后必须落到"这个方案有什么代价,适合什么场景,不适合什么场景"。这样答出来的感觉,才是真正做过工程的深度。
从面试的角度,我建议额外把tryLock和lock的区别、leaseTime与看门狗的关系、以及"为什么Lua脚本能保证原子性"这三个点准备透。这三个点是区分"背过文档"和"读懂源码"的分水岭。
我自己带团队时有一个习惯,凡是要引入中间件,不管是缓存、锁还是消息队列,都要让核心研发把"最坏情况下它会怎么挂"写进设计文档。Redisson分布式锁也一样——你心里必须清楚,它默认依赖于Redis的可用性,Redis一挂,锁就不可用;它默认租约续期依赖于看门狗线程,进程一卡,续期就可能滞后到线程卡拐点;它默认只能保护"作用于Redis保护的数据",你用它去锁MySQL事务性操作,是概念错配。想清楚了这些边界条件,你才真正"掌握"了分布式锁。
另外一个实用一点的经验:Redisson锁使用中,建议要给Redis锁操作配上独立连接池或至少独立的RedissonClient实例,避免锁的获取/释放流量和普通业务缓存流量互相争抢连接,造成不必要的命令排队。越是在高并发场景下,一个小小的连接池隔离,能帮你的锁操作减少大量等待时间。
最后再说个排查技巧:如果你怀疑锁的请求量太大导致Redis高负载,除了优化业务,你还可以在RedissonClient层面开启自定义监听器统计lock和unlock的耗时和调用频次,通过监控曲线一眼看出锁的竞争激烈程度,再决定是优化锁粒度还是引入本地锁前置过滤。我靠这个手段,排查过不止一次线上偶发超时问题,说实话,比纯靠猜效率高太多了。