☰
Redis分布式锁演进:从SETNX到RedLock的避坑指南
2026/9/26 23:36:42 网站建设 项目流程

聊到 Redis 分布式锁,很多人的第一反应是“SETNX 加 EXPIRE,锁不就这么回事吗”。但真正在生产环境跑过的人都知道,Redis 分布式锁从单机到集群,每一步都长满了坑:单机版要操心锁过期、误删锁;主从架构下主节点一挂,锁可能直接丢;到了 Cluster 集群模式,你以为节点多了更安全,实际上锁的 key 还是落在某一个主节点上,故障转移时照样可能失效。这篇文章我想用“演进”的视角,把 Redis 分布式锁从最朴素的 SETNX 实现,讲到 Redisson 看门狗、主从复制的锁丢失、RedLock 算法和集群环境下的靠谱姿势。内容会尽量还原实际踩坑现场,也适合准备分布式锁面试题的朋友当复习提纲。

1. 分布式锁到底要解决什么问题,为什么单机锁不够用

1.1 一个最简单的抢单场景

设想一个电商库存扣减:用户下单,后端要判断库存是否充足,然后扣减。单机时代,我们可以在方法上直接加synchronized,或者用 JVM 内部的ReentrantLock,反正同一个进程内只有一份内存,锁能管住所有线程。可一旦服务水平扩展成多个实例,前面加一层负载均衡,同一个用户请求可能落到不同实例上。每个实例的synchronized锁的都是各自 JVM 的对象头,互相之间完全隔离。两个实例同时读到库存为 1,各自扣减成功,最终库存变成 -1,这就是典型的超卖问题。

所以分布式锁的本质,是把“互斥判断”从进程内存挪到所有实例都能访问的公共组件上,比如 Redis、ZooKeeper、etcd、数据库。Redis 因为性能高、接入简单,成为最流行的选择。

1.2 分布式锁的三个硬指标

一个分布式锁要称得上可靠,至少得满足三条硬指标。第一条是互斥性:同一时刻只能有一个客户端持有锁,这是锁的基本功能。第二条是安全性:锁不能被其他客户端误删,必须只能由持有者释放,否则你辛辛苦苦加的锁被别人删掉,互斥瞬间失效。第三条是活性:锁必须能自动过期,或者说持有者崩溃后锁一定要能释放,否则出现一个“永久锁”,整个系统直接卡死。

除了这三条,实际使用时还会关注可重入性(同一线程能不能重复加锁)、非阻塞尝试(获取不到锁是等待还是立即失败)、以及容错性(Redis 挂了或者主从切换了,锁还能不能保证安全)。后面的演进历程,其实就是这些指标被不断打破、又不断被弥补的过程。

2. 单机 Redis 时代:SETNX + EXPIRE 的经典套路和它埋的雷

2.1 第一版锁:靠 setnx 命令实现互斥

网上搜“Redis 分布式锁”,最早的教程几乎都会教你两条命令:先SETNX lockKey clientId尝试加锁,返回 1 表示成功;然后EXPIRE lockKey 30000给它设置过期时间,防止死锁。简化成代码大概是:

# 加锁:只有 key 不存在时才能写入 SET lockKey clientId NX PX 30000 # 释放锁 DEL lockKey

有人会问:为什么不分开用SETNX和EXPIRE?因为这两条命令合并不是原子的。假设客户端 A 执行SETNX成功,还没来得及执行EXPIRE,进程直接崩溃了,那这个 key 就永远留在 Redis 上,其他客户端永远拿不到锁。所以后来官方给出的推荐姿势是一条命令搞定:

SET lockKey clientId NX PX 30000

这条命令里,NX表示只有当 key 不存在时才设置,PX 30000表示 30 秒后自动过期。原子性由 Redis 保证,不会再出现“锁没设置过期时间”的尴尬。

2.2 误删锁:为什么必须用 Lua 脚本释放

单机版的第一个大坑是误删别人的锁。拿最简单的DEL lockKey举例:客户端 A 持有锁,但因为业务执行时间过长,锁在 30 秒后自己过期了;此时客户端 B 加锁成功,拿到同一个 lockKey;A 业务终于跑完,随手执行DEL lockKey,把 B 的锁删掉了。B 觉得自己还持有锁,实际上锁已经没了,另一个客户端 C 又能加锁进来,互斥瞬间失效。

解决误删的思路很简单:加锁时把 value 设成一个只有自己知道的唯一标识,比如 UUID。释放锁时先检查当前 key 的 value 是不是自己的标识,是自己的才删。但这里又面临一个新问题:GET判断和DEL删除是两个操作,中间如果有线程切换,锁还是可能被误删。所以正确做法是把“判断”和“删除”写进同一个 Lua 脚本,让 Redis 原子执行:

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

这段脚本的意思很直白:先取 lockKey 的 value,和客户端传进来的唯一标识对比,相等才删除。原子性由 Redis 单线程执行 Lua 保证,不存在中间状态。我在实际项目里见到过不少“看起来没问题”的误删事故,最后排查下来都是因为没有用 Lua 或者原子命令,所以这一步建议直接当标准规范写进团队代码规范里。

2.3 过期时间怎么定,锁提前失效怎么办

单机版第二个大坑是过期时间设置。设置太短,业务没跑完锁就自己释放,别的客户端进来了,并发问题照旧;设置太长,一旦持有锁的进程卡死,其他客户端要等很久才能抢到锁,系统吞吐量直线下降。很多同学会拍脑袋设一个“看起来足够大”的时间,比如 10 分钟,但本质上还是赌业务一定能在这个时间内结束。

更麻烦的问题是“锁提前失效”这件事无法完全避免。因为业务执行时间不可控,JVM 一次 Full GC 可能停顿好几秒,一次外部接口超时可能拖上几十秒,你设置的过期时间再保守,也总有被突破的可能。所以单机版的演进并没有结束,后面 Redisson 的看门狗机制就是专门来治这个问题的。

3. 单机再进化:Redisson 的看门狗如何避免锁提前过期

3.1 Redisson 不只是一个客户端

可能很多人用过 Jedis、Lettuce,但真正把分布式锁做进客户端层面的,是 Redisson。Redisson 是一个非常丰富的 Redis Java 客户端,它把分布式锁、队列、信号量、限流器都封装成了高层 API。其中RLock接口的体验接近 JDK 的ReentrantLock,用法非常直观,更重要的是它内置了一套“看门狗”续期机制,专门解决锁过期时间难以拍板的问题。

Redisson 的加锁流程简单说就是:客户端创建一个锁对象RLock lock = redisson.getLock("anyLock"),然后调用lock.lock()或lock.tryLock()。如果没指定锁的租期(leaseTime),Redisson 会默认给锁设置 30 秒过期时间,同时启动一个后台定时任务,每 10 秒检查一次这个锁是否还被当前线程持有。如果还持有,就刷新过期时间,继续续期。这个后台任务就像一条看门狗,盯着锁别太早死掉。

3.2 看门狗续期机制到底怎么工作

看门狗的官方名字叫 Watchdog,默认触发逻辑是:在加锁成功之后,Redisson 会向 Redis 提交一个延迟任务,延迟时间为锁的leaseTime / 3。假设默认租期是 30 秒,那延迟任务就是 10 秒后执行。任务执行时,它会用 Lua 脚本检查锁是否还在自己手里,如果还在,就执行PEXPIRE把过期时间重置到 30 秒,同时再安排下一个 10 秒后的检查任务。

这里的关键设计是:如果持有锁的客户端崩溃了,看门狗线程也会跟着消失,不会有人再去续期,锁最多 30 秒后自动过期,避免了死锁。整个过程对业务代码完全透明,你不需要自己再维护什么定时续期逻辑。需要注意,看门狗只在没显式指定leaseTime时生效。如果你调用了lock.lock(10, TimeUnit.SECONDS),Redisson 就认为你想让锁 10 秒后一定过期,不会再安排续期。所以业务时长不稳定的话,我建议优先用不传 leaseTime 的写法,把续期完全交给看门狗。

3.3 可重入锁与线程标识

Redisson 实现的锁还支持可重入,也就是说同一个线程可以多次加锁同一个 key,内部维护一个计数器,每加锁一次计数加一,每释放一次计数减一,只要计数不为零锁就不会真正删除。这跟 Java 的ReentrantLock语义是一样的。实现上,Redisson 在 Redis 里存的 value 不只是 UUID,而是“UUID:线程ID”这样的结构,用来区分不同客户端甚至同一客户端的不同线程。重新加锁时,它通过 Lua 脚本判断当前线程标识是否和已有锁的 value 相同,相同就直接增加计数。

这一点在实际项目里很实用。比如一个方法内部调用了另一个也需要加同一把锁的方法,如果锁不可重入,第二次加锁会直接把自己阻塞住,然后死锁。可重入锁帮我们避免了不少这种“不小心”的问题。另外,Redisson 也提供了isHeldByCurrentThread()方法,可以在 finally 块里判断是否真的持有锁,避免释放别人持有的锁。

4. 从单机到集群的关键拐点:主从复制带来的锁丢失

4.1 故障转移场景还原:异步复制丢了锁

单机 Redis 做分布式锁看起来很稳,但生产环境没人敢只部署一个 Redis 实例,一方面有性能瓶颈,另一方面是单点故障。于是最常见的架构变成了主从复制:一个主节点负责写,若干个从节点负责同步备份。可恰恰是这种架构,把分布式锁最大的痛点暴露了出来。

想象这样一个时间线:客户端 A 在主节点上执行SET lockKey uuid NX PX 30000,加锁成功。但主节点还没来得及把这条写命令异步复制到从节点,主节点进程突然崩溃或者宕机了。哨兵检测到主节点不可用,把某个从节点提升为新的主节点。问题是,这个新主节点上根本没有刚才那条锁记录,因为它没收到异步复制。这时候客户端 B 再来加锁,对着新主节点执行SET lockKey uuid2 NX PX,直接就成功了。A 和 B 同时持有锁,互斥性被彻底打破。

这个问题的根源在于 Redis 主从复制是异步的,主节点写成功并不代表数据已经被从节点持久化。只要存在“写主成功但复制没完成”的时间窗口,任何基于 Redis 的分布式锁都会有锁丢失风险。架一个哨兵看着也不行,因为哨兵只负责自动切换主从,它解决不了异步复制带来的数据缺口。

4.2 哨兵模式为什么同样救不了锁

Redis Sentinel 模式能实现高可用:主节点挂了,哨兵会自动选举一个新主节点,业务客户端感知不到短暂故障。听起来用哨兵模式部署 Redis 分布式锁应该挺安全了,但仔细想一下,哨兵切换的只是“可用性”,不是“一致性”。

举个例子:主节点 M 写入锁之后挂了,从节点 S 被提升为主节点。此时客户端 A 持有的锁可能已经“像从没存在过一样”消失在新的主节点上,但 A 不知道。A 继续执行业务,B 在新主节点上加锁成功,两个客户端同时操作同一份数据。哨兵能做的只是在 30 秒内完成切换,它没法把 M 上最后那几毫秒的写操作补发给 S。所以结论很明确:只要还用 Redis 主从架构,锁丢失的问题就治标不治本。

4.3 Redis Cluster 模式下锁还是不是单点

既然主从/哨兵有锁丢失风险,那直接上 Redis Cluster 集群是不是就好了?不少人觉得 Cluster 有多个主节点,分布式锁肯定更安全。其实这里的坑比主从架构更隐蔽。

Redis Cluster 采用哈希槽机制,每个 key 通过 CRC16 算法计算出一个槽位,然后映射到某个主节点。也就是说,你调getLock("myLock")的时候,这个锁的 key 只会被存放到其中一个主节点上,另外那个主节点的从节点负责异步复制。它并没有把这些锁分散到多个节点上存储,而是和主从架构一样,同一个 key 的锁在某个时间点真正能用的其实就一个主节点。一旦这个主节点挂掉,并且从节点没有同步到锁的记录,锁照样会丢。

所以 Redis Cluster 提供的是数据分片和水平扩展能力,不是分布式锁的一致性保证。很多团队把业务从单机 Redis 搬到 Cluster 后,发现分布式锁偶尔还是会失效,原因就在这里:他们把“Redis 变成了集群”误以为“锁也变成多副本强一致了”。这是整个演进历程里最容易被误解的一环。

5. 集群时代的正解:RedLock 算法和它的现实争议

5.1 RedLock 加锁过程拆解

为了解决“单点锁丢失”问题,Redis 作者 Antirez 提出了 RedLock 算法。核心思想很简单:不要把鸡蛋放在一个篮子里。不在单个 Redis 实例上加锁,而是同时在多个互相独立的 Redis 主节点上加锁,只要超过半数节点加锁成功,就认为锁获取成功。

具体流程大致是这样的:假设有 5 个完全独立的 Redis 主节点(注意,不是主从架构,每个节点都是独立可写的)。客户端加锁时,会用一个相同的 key 和唯一 value,依次向这 5 个节点发送SET key value NX PX expireTime。整个加锁过程会记一个起始时间,最后的“有效加锁时间”用锁的过期时间减去加锁总耗时。如果客户端能在有效时间内,成功在至少 3 个节点上加锁,就认为锁获取成功。释放锁时,客户端向所有 5 个节点发送释放锁的 Lua 脚本,不管之前有没有成功,全都删一遍。

这套思路利用了“多数派”原则:即使有一两个节点挂了,只要剩下的多数节点上还有锁,其他客户端就无法在同一把锁上拿到多数票。这也是为什么 RedLock 建议 5 个节点而不是 3 个或 7 个——5 个节点能容忍 2 个节点故障,同时把客户端请求压力控制在可接受范围。

5.2 为什么很多专家不推荐 RedLock

RedLock 听起来很完美,但分布式系统领域对它的争议非常大。一个著名的反驳点是“时钟漂移”。RedLock 判断锁是否有效,依赖各个节点上的过期时间。假设客户端 A 在 5 个节点上加锁成功,锁的过期时间是 30 秒。此时某个节点的系统时钟因为 NTP 时间同步或者其他原因出现跳跃,向前快了 20 秒,这个节点上的锁就会提前过期。其他客户端就能趁虚而入,在另一个节点上加锁成功。只要多个节点的时钟不是完全同步,RedLock 的“安全”就是不绝对的。

另一个问题是 GC 暂停。Java 应用在加锁过程中如果发生较长时间的 Full GC,客户端可能已经成功写入了 3 个节点,但还没来得及返回给应用层,锁就已经过期了。等 GC 结束,应用认为自己持有锁,其实锁已经失效。这个时间窗口和单机版遇到的问题一样,但 RedLock 并没有从机制上消除它。所以包括一些资深分布式系统专家在内,都认为 RedLock 并不比单实例 Redis 锁更安全,只是把风险分散了,并没有真正解决。

5.3 用 Redisson 的 MultiLock 实现类 RedLock

虽然 RedLock 在理论上备受争议,但工程上确实有办法把“多数派”思想落地。Redisson 提供了一个RedissonMultiLock,可以把你定义的多把锁组合成一个整体。使用姿势大概是:

RLock lock1 = redissonA.getLock("orderLock"); RLock lock2 = redissonB.getLock("orderLock"); RLock lock3 = redissonC.getLock("orderLock"); RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2, lock3); multiLock.lock();

当你调用multiLock.lock()时,Redisson 会按顺序尝试获取里面所有的锁,只有当全部锁都加成功时,才认为整体加锁成功;如果其中某一把锁超时,会主动释放已经获取到的锁,避免产生“部分成功”的残留锁。这个方案和原始 RedLock 略有区别,但思想一致:用多节点互备来降低单点故障概率。

这里特别提醒一点:RedissonMultiLock通常要求各个RLock对应不同的 RedissonClient,也就是不同的 Redis 实例。如果你只是在同一个 Cluster 集群上分别创建了几个锁对象,那是没有意义的,因为它们的 key 可能最终还是会落到同一个节点上,谈不上真正独立。

6. 实操:在集群环境写一个靠谱的 Redis 分布式锁

6.1 基于 Redisson 的 Cluster 连接配置

聊了这么多理论,最终还是要落地。我自己的习惯是,除非业务有极强的强一致要求,否则优先用 Redisson 的普通锁 + 合理的业务兜底,而不是一上来就上 MultiLock。原因很简单:普通 Redis 分布式锁在实际大多数场景下够用,而且成本低、排查方便。RedLock 虽然理论更“强”,但实现复杂、性能损耗大,很多人用不好反而引入新问题。

假设你们的 Redis 已经是 Cluster 模式,用 Redisson 连接集群的配置很简单。先创建一个Config对象,配置好所有 master 节点地址,然后创建 RedissonClient:

Config config = new Config(); config.useClusterServers() .setScanInterval(2000) .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001", "redis://127.0.0.1:7002"); RedissonClient redisson = Redisson.create(config);

之后通过redisson.getLock("yourLockKey")就能拿到锁对象。Redis Cluster 模式下,Redisson 会根据 key 自动计算 slot,找到对应的主节点,所以客户端不需要手动指定节点,使用体验和单机版几乎一样。

6.2 锁的代码实现与释放

日常项目里,我推荐用tryLock而不是无脑lock,因为lock会一直阻塞等待锁释放,在某些业务场景下容易把线程池拖垮。tryLock可以设置一个最大等待时间,获取不到就直接返回失败,让上层快速降级或者报错。一个比较稳妥的写法是:

RLock lock = redisson.getLock("inventory:lock:" + skuId); boolean locked = false; try { locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException("系统繁忙,请稍后重试"); } // 这里写真正的库存扣减逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("获取锁被中断", e); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } }

这里用的是tryLock(3, TimeUnit.SECONDS),意思是等待 3 秒拿不到锁就放弃;没传 leaseTime,所以看门狗会自动续期,默认 30 秒过期。最后释放锁之前一定要用isHeldByCurrentThread()再确认一次,防止因为锁已经过期、当前线程已经不持有锁的情况下调用 unlock,导致异常或者误删别人的锁。

6.3 锁粒度、超时和并发压测经验

锁粒度是很容易忽略的点。如果你把全公司的所有下单操作都锁在同一个 key 上,那跟单机synchronized一样,并发度直接跪了。正确做法是尽量把锁粒度缩小到业务维度,比如库存扣减按skuId加锁,用户操作按userId加锁,而不是全局一个大锁。锁粒度越小,能同时执行的请求越多,系统吞吐量才会上去。

另外,使用看门狗续期时,不要天真地认为“锁永远不会失效”。如果一个线程里阻塞了非常久,比如外部接口调用 5 分钟不返回,看门狗会持续续期,锁一直不释放,其他线程一直拿不到锁,这其实是一种慢性死锁。所以看门狗解决的是“业务偶尔慢一点锁别提前过期”,不是让你无限期占用锁。业务代码里最好给锁内操作设置超时兜底,比如用线程池 + Future 的get(timeout),强制业务在一个合理时间内结束。

我压测过同一个 Redisson 锁在集群和单机模式下的表现,单机模式下加锁释放大约耗时 1 到 2 毫秒,集群模式会增加一跳网络路由,大约 2 到 4 毫秒。整体性能依旧可以接受,但如果你在每次业务请求里频繁加锁、释放锁,这个耗时会被放大,最好先做一个批量操作或者减少锁内逻辑,而不是盲目优化 Redis 本身的性能。锁内逻辑越短,锁冲突概率越低,系统才能更平滑。

7. 分布式锁面试高频题与排查实录

7.1 面试官最爱的 6 个问题

分布式锁是面试高频考点,我整理了六个最常被问到的问题。第一,“Redis 分布式锁如何实现?”回答时不能只背命令,要从SET key value NX PX加锁、Lua 脚本释放锁、value 用唯一标识防误删这几个点展开。第二,“为什么不用 SETNX 加 EXPIRE 两条命令?”要答出原子性问题,以及单条 SET 命令怎么解决。第三,“锁过期时间怎么设置?”可以提到默认 30 秒、看门狗续期机制,以及业务超时兜底。

第四,“主从切换下锁丢失怎么办?”要说出异步复制带来的时间窗口,再引出 RedLock 或者 Redisson MultiLock。第五,“RedLock 是否绝对安全?”正常回答是理论上有争议,时钟漂移和 GC 暂停都会破坏安全性,工程上只能降低概率,不能完全消除。第六,“Redis 分布式锁和 ZooKeeper 分布式锁怎么选?”Redis 锁性能高、实现简单,适合允许短时间不一致的场景;ZooKeeper 通过 ZAB 协议和临时节点能提供更强的强一致,但有性能瓶颈和运维成本。没有完美方案,只有适合场景的方案。

7.2 线上锁失效的排查套路

如果在线上发现分布式锁偶尔失效,我的排查套路一般是四步。第一步看 Redis 慢查询日志和监控,确认锁操作是不是触发了长时间阻塞,比如大 key、持久化阻塞、网络抖动,这些都会直接导致加锁时间超过预期。第二步看代码里有没有显式传 leaseTime,如果传了很小的值,业务稍慢锁就自动过期,这种最隐蔽,需要结合业务日志里的耗时分布确认。

第三步检查你有没有用唯一标识做锁的 value,以及释放锁时是不是走了 Lua 脚本。只要有一处是用DEL直接删的,锁被误删的概率就极高。第四步确认 Redis 架构是不是主从复制或 Cluster,如果确实是,锁丢失几乎是必然的,只能考虑换 ZooKeeper 或者在业务层做幂等兜底。比如我经历过一个真实案例:订单服务用 Redis 主从架构,某次主节点故障切换后,两台机器同时处理同一笔订单,最后靠数据库里的订单状态字段做了二次校验才避免资损。这个案例让我从那以后养成了一个习惯:任何 Redis 分布式锁都只当成“并发控制的加速器”,真正关键的数据最终还是要靠数据库的唯一约束或者版本号来兜底。

以上这些内容,就是我从单机 Redis 一路折腾到集群环境之后,对分布式锁演进的完整理解。如果你也在调整自己的锁方案,记住一点:没有绝对安全的技术方案,只有适不适合当前业务场景的选择。Redis 分布式锁很好用,但一定要清楚它在什么情况下会失效,以及你的业务能否承受这个失效概率。

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

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

立即咨询