单机时代,进程内的锁(sync.Mutex、synchronized)就能协调并发。到了分布式系统,多个进程跨机器跑同一份逻辑,进程内锁管不到别的进程,需要一把大家都认的锁。Redis 凭借单线程串行执行命令的特性,天然适合做这把锁的载体,但”用 Redis 做分布式锁”远不止 SETNX 一行命令那么简单。
本文梳理 Redis 分布式锁的演进:从最朴素的 SETNX 到 SET NX PX,到释放锁的原子性,到看门狗续期,再到多节点的 Redlock,最后讨论它在 GC 停顿和时钟漂移下的失效场景。缓存击穿场景里用到的互斥锁只是这条路上的一个中间形态,本文把它放回完整的脉络里看。
本文假设你了解 Redis 的基本命令和单线程模型。缓存击穿里用 SET NX PX 做互斥锁的实战代码见 Redis 缓存三大问题与实战 的击穿章节,本文不重复贴那段业务代码,而是聚焦锁本身的实现与陷阱。
一、为什么需要分布式锁
分布式锁要解决的问题和单机锁一样:互斥。但它多了两个单机锁没有的约束。
第一,锁要跨进程可见。进程 A 加的锁,进程 B 在另一台机器上也要能感知到,否则互斥就失效了。这要求锁的状态存在所有进程都能访问的共享存储里,Redis 就是这样一个共享存储。
第二,锁要在故障下仍尽量安全。持锁进程崩溃了,锁不能永远不释放,否则其他进程永远拿不到锁,系统卡死。这就引出了锁的过期时间。
一个合格的分布式锁要满足三个性质:
| 性质 | 含义 |
|---|---|
| 互斥性 | 任意时刻只有一个客户端持有锁 |
| 避免死锁 | 持锁客户端崩溃后,锁最终能被释放 |
| 容错性 | 多数 Redis 节点存活时,锁服务可用 |
前两个是单实例 Redis 就能保证的,第三个需要多节点(Redlock)。下面按实现难度逐步展开。
二、从 SETNX 到 SET NX PX
2.1 朴素 SETNX 的两步竞态
最早的 Redis 分布式锁用 SETNX(Set if Not eXists)实现:SETNX lock_key value,返回 1 表示加锁成功,返回 0 表示 key 已存在、加锁失败。
# 朴素实现:SETNX + EXPIRE 两步SETNX lock:order:1001 1 # 返回 1,加锁成功EXPIRE lock:order:1001 30 # 设置 30 秒过期问题在于这是两条独立命令,中间存在时间窗口。如果客户端执行完 SETNX 后、还没来得及 EXPIRE 就崩溃了,这个 key 永远没有过期时间,锁就永远不会释放,后续所有请求都拿不到锁。这就是经典的”两步竞态”。
2.2 SET NX PX:原子加锁
Redis 2.6.12 给 SET 加了 NX 和 PX 选项,把”不存在才设置”和”设置过期时间”合并成一条原子命令,彻底消除了两步竞态:
# 原子加锁:不存在才设置 + 同时设过期时间SET lock:order:1001 <uuid> NX PX 30000# NX:key 不存在才设置(等价 SETNX 语义)# PX 30000:30000 毫秒后自动过期# 返回 OK 表示加锁成功,返回 nil 表示锁已被占用加锁这一步,SET ... NX PX 已经是生产可用的标准写法。value 不要写固定值,要写一个客户端唯一标识(通常是 UUID),这是后面安全释放锁的前提。
为什么用 PX(毫秒)而不是 EX(秒)?分布式锁的过期时间通常较短(几秒到几十秒),毫秒精度能更精细地控制锁寿命,减少持锁客户端崩溃后其他客户端的等待时间。功能上两者等价,选 PX 只是精度习惯。
2.3 锁的过期时间怎么定
过期时间是个两难。设短了,业务还没执行完锁就过期了,别的客户端提前拿到锁,出现”双客户端同时持锁”的并发问题。设长了,持锁客户端崩溃后,其他客户端要干等很久才能接手。
经验做法是:过期时间设为预估业务执行时间的 2~3 倍,留足余量。但预估不准是常态,更彻底的解法是让锁在业务没执行完时自动续期,这就是看门狗机制。
三、释放锁的原子性
加锁解决了,释放锁同样有陷阱。最直觉的写法是直接 DEL:
# 错误的释放方式:直接 DELr.delete(lock_key)问题在于客户端 A 持有的锁可能已经过期,客户端 B 已经通过 SET NX PX 拿到了同一把锁,此时客户端 A 执行完业务回来 DEL,删掉的是客户端 B 的锁。后续客户端 C 又能加锁成功,互斥性被破坏。
正确做法是:释放前先判断锁的 value 是不是自己当初写进去的 UUID,是才删。但”判断”和”删除”又是两步,中间也有竞态。Redis 没有原生的”条件删除”命令,解法是用 Lua 脚本,让判断和删除在 Redis 单线程里原子执行:
-- 释放锁的 Lua 脚本:判等 + 删除,原子执行if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1])else return 0endKEYS[1] 是锁的 key,ARGV[1] 是客户端的 UUID。脚本在 Redis 单线程里执行,get 和 del 之间不会被其他命令打断。加锁时写入的 UUID,在这里就是”这把锁是我的”凭证。这一步是分布式锁正确性的关键,任何省略它的实现都是错的。
四、看门狗:锁续期
过期时间两难的根治方案是看门狗(watchdog)。Redisson(Java 的 Redis 客户端)的实现是这套机制的代表:加锁时不设过期时间(或设一个较短的兜底时间),同时启动一个后台线程定期检查持锁线程是否还在执行,是的话就把过期时间往后推。
看门狗续期把”锁过期早于业务结束”的风险降到很低:只要客户端进程还活着、看门狗线程还在跑,锁就不会过期。代价是引入了后台线程和定时续期的网络开销,以及一个新的故障模式:如果看门狗线程因为 JVM GC 停顿或机器负载过高没有及时续期,锁仍会过期。看门狗降低了风险,没有消除风险。
Redisson 的看门狗默认续期间隔是锁过期时间的 1/3(默认过期 30 秒,每 10 秒续期一次)。加锁时如果显式指定了过期时间,Redisson 认为你自己管生命周期,就不会启动看门狗。这是一个容易踩的坑:显式设了 leaseTime,续期就没了。
五、Redlock:多节点容错
前面所有方案都建立在”单实例 Redis”上。如果这个 Redis 实例主从切换,主节点挂掉时还没把锁同步到从节点,从节点升主后会丢失这把锁,新的客户端能加到同一把锁,互斥性被破坏。Redlock 是 Redis 作者 Antirez 提出的多节点算法,试图在不依赖主从复制的前提下解决单点问题。
5.1 算法流程
Redlock 用 N 个(通常 5 个)独立的 Redis 实例,客户端向所有实例依次加锁,多数派成功才算加锁成功:
判断”总耗时 < TTL”是关键:如果加锁过程本身就耗掉了大半 TTL,真正能用的锁寿命所剩无几,不如直接判失败重试。多数派(N/2+1)保证了即使少数节点宕机,锁仍然有效。
5.2 Redlock 的争议
Redlock 提出后遭到分布式系统专家 Martin Kleppmann 的激烈批评,核心争议集中在两点。
第一,GC 停顿会击穿锁。 客户端 A 拿到锁后,如果发生长时间 GC 停顿(Stop-The-World),期间锁过期,客户端 B 在另一个节点拿到锁。A 从 GC 恢复时以为自己还持锁,于是和 B 同时操作共享资源,互斥性被破坏。GC 停顿在托管语言(Java、Go)里是常态,这个攻击很有力。
第二,时钟漂移会让 TTL 不可靠。 Redlock 依赖各节点的本地时钟计算过期时间。如果某节点的系统时钟被 NTP 跳变或人为调整,锁的过期判断就会失准。Kleppmann 的论点是:依赖时钟的算法在异步分布式系统里不可靠,而 fencing token 方案不依赖时钟,更稳健。
Antirez 的回应是:实际场景里 GC 停顿和时钟跳变的影响被夸大了,Redlock 的目标是在多数节点存活时提供”足够好”的锁,不是追求严格的正确性。这场争论没有定论,但它揭示了一个事实:Redlock 在”性能与正确性”之间偏向性能,不适合对互斥性要求极其严格的场景。
如果你的业务对互斥性零容忍(比如金融扣款),不要用 Redis 分布式锁,包括 Redlock。应该用基于共识算法的锁服务(etcd、ZooKeeper),它们的 lease 和 fencing 机制在 GC 停顿和时钟漂移下仍能保证正确性。Redis 分布式锁适合”偶尔冲突代价可接受”的场景,比如限流、防重复提交、缓存重建互斥。
六、fencing token:治本之防
Kleppmann 针对 GC 停顿问题提出的解法是 fencing token。每次加锁成功,锁服务返回一个单调递增的 token(版本号),后续对共享资源的写操作必须带上这个 token,资源端拒绝比已见过的 token 更小的请求。
即使客户端 A 从 GC 停顿中醒来、误以为自己还持锁,它带的是旧 token 33,而共享存储已经处理过 token 34 的请求,会拒绝 A 的写入。fencing token 把”持锁身份”从时间维度(TTL)转成了序号维度,不依赖时钟,从根本上杜绝了过期锁的写入。
问题在于 Redis 的 SET NX PX 不返回单调递增的 token,原生 Redis 分布式锁拿不到 fencing token。要做 fencing,要么用 INCR 自己维护一个计数器(但这个计数器本身又面临同样的过期问题),要么换用支持 fencing 的锁服务(etcd 的 revision、ZooKeeper 的 zxid 天然单调递增)。这也是”为什么对正确性要求高时不该用 Redis 锁”的具体技术原因。
七、失效场景清单
把前面散落的陷阱汇总成一份失效场景清单,做选型时逐条对照:
| 失效场景 | 成因 | 缓解措施 | 能否根治 |
|---|---|---|---|
| 两步竞态 | SETNX 和 EXPIRE 之间崩溃 | 用 SET NX PX 原子加锁 | 能 |
| 误删他人锁 | 释放锁时直接 DEL | 用 Lua 脚本判等后删 | 能 |
| 锁提前过期 | 业务执行时间超过 TTL | 看门狗续期 | 不能(GC 仍可能错过续期) |
| GC 停顿击穿 | 持锁客户端 STW,锁过期后被他人获取 | fencing token 拒绝旧写入 | 需换锁服务,Redis 原生不支持 |
| 时钟漂移 | 节点时钟跳变导致 TTL 失准 | 不依赖时钟的 fencing | 需换锁服务 |
| 主从切换丢锁 | 主挂了,锁未同步到从,从升主后锁丢失 | Redlock 多数派 | 争议中,不保证严格正确 |
前三种是单实例 Redis 锁能解决的工程问题,靠 SET NX PX + Lua 释放 + 看门狗就能做到生产可用。后三种是分布式系统深层问题,Redis 锁只能在”代价可接受”的前提下缓解,根治需要 etcd/ZooKeeper 这类基于共识算法的锁服务。
小结
Redis 分布式锁的正确实现有一套相对固定的最小可用组合:SET key uuid NX PX <ttl> 原子加锁,Lua 脚本判等删除原子释放,看门狗续期兜底业务超时。这套组合在单实例下覆盖了绝大多数业务场景。再往上追求多节点容错,Redlock 提供了多数派方案,但它在 GC 停顿和时钟漂移下的正确性争议提醒我们:Redis 锁是性能优先的”尽力而为”锁,不是严格的互斥保证。对正确性零容忍的场景,fencing token 才是治本之策,而那需要换一个原生支持单调序号的锁服务。
参考资料
- Distributed Locks with Redis - Redis 官方分布式锁文档
- Redlock: distributed locks with Redis - Redlock 算法官方说明
- Martin Kleppmann: How to do distributed locking - 对 Redlock 的经典批评,fencing token 论证
- antirez: Is Redlock safe? - Redis 作者对批评的回应
- Redisson Distributed locks and synchronizers - Redisson 看门狗与锁实现
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






