mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3502 字
9 分钟
Redis 分布式锁:从 SETNX 到 Redlock 的实现与陷阱
2024-08-22

单机时代,进程内的锁(sync.Mutexsynchronized)就能协调并发。到了分布式系统,多个进程跨机器跑同一份逻辑,进程内锁管不到别的进程,需要一把大家都认的锁。Redis 凭借单线程串行执行命令的特性,天然适合做这把锁的载体,但”用 Redis 做分布式锁”远不止 SETNX 一行命令那么简单。

本文梳理 Redis 分布式锁的演进:从最朴素的 SETNXSET NX PX,到释放锁的原子性,到看门狗续期,再到多节点的 Redlock,最后讨论它在 GC 停顿和时钟漂移下的失效场景。缓存击穿场景里用到的互斥锁只是这条路上的一个中间形态,本文把它放回完整的脉络里看。

Important

本文假设你了解 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 加了 NXPX 选项,把”不存在才设置”和”设置过期时间”合并成一条原子命令,彻底消除了两步竞态:

# 原子加锁:不存在才设置 + 同时设过期时间
SET lock:order:1001 <uuid> NX PX 30000
# NX:key 不存在才设置(等价 SETNX 语义)
# PX 30000:30000 毫秒后自动过期
# 返回 OK 表示加锁成功,返回 nil 表示锁已被占用

加锁这一步,SET ... NX PX 已经是生产可用的标准写法。value 不要写固定值,要写一个客户端唯一标识(通常是 UUID),这是后面安全释放锁的前提。

Note

为什么用 PX(毫秒)而不是 EX(秒)?分布式锁的过期时间通常较短(几秒到几十秒),毫秒精度能更精细地控制锁寿命,减少持锁客户端崩溃后其他客户端的等待时间。功能上两者等价,选 PX 只是精度习惯。

2.3 锁的过期时间怎么定#

过期时间是个两难。设短了,业务还没执行完锁就过期了,别的客户端提前拿到锁,出现”双客户端同时持锁”的并发问题。设长了,持锁客户端崩溃后,其他客户端要干等很久才能接手。

经验做法是:过期时间设为预估业务执行时间的 2~3 倍,留足余量。但预估不准是常态,更彻底的解法是让锁在业务没执行完时自动续期,这就是看门狗机制。

三、释放锁的原子性#

加锁解决了,释放锁同样有陷阱。最直觉的写法是直接 DEL

# 错误的释放方式:直接 DEL
r.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 0
end

KEYS[1] 是锁的 key,ARGV[1] 是客户端的 UUID。脚本在 Redis 单线程里执行,getdel 之间不会被其他命令打断。加锁时写入的 UUID,在这里就是”这把锁是我的”凭证。这一步是分布式锁正确性的关键,任何省略它的实现都是错的。

四、看门狗:锁续期#

过期时间两难的根治方案是看门狗(watchdog)。Redisson(Java 的 Redis 客户端)的实现是这套机制的代表:加锁时不设过期时间(或设一个较短的兜底时间),同时启动一个后台线程定期检查持锁线程是否还在执行,是的话就把过期时间往后推。

sequenceDiagram participant C as 客户端 participant R as Redis participant W as 看门狗线程 C->>R: SET lock NX PX 30000(加锁,30s 兜底) C->>W: 启动看门狗,每 10s 续期一次 loop 每 10 秒 W->>R: PEXPIRE lock 30000(重置过期时间到 30s) end Note over C: 业务执行中,锁持续被续期 C->>W: 业务完成,停止看门狗 C->>R: Lua 脚本释放锁

看门狗续期把”锁过期早于业务结束”的风险降到很低:只要客户端进程还活着、看门狗线程还在跑,锁就不会过期。代价是引入了后台线程和定时续期的网络开销,以及一个新的故障模式:如果看门狗线程因为 JVM GC 停顿或机器负载过高没有及时续期,锁仍会过期。看门狗降低了风险,没有消除风险。

Tip

Redisson 的看门狗默认续期间隔是锁过期时间的 1/3(默认过期 30 秒,每 10 秒续期一次)。加锁时如果显式指定了过期时间,Redisson 认为你自己管生命周期,就不会启动看门狗。这是一个容易踩的坑:显式设了 leaseTime,续期就没了。

五、Redlock:多节点容错#

前面所有方案都建立在”单实例 Redis”上。如果这个 Redis 实例主从切换,主节点挂掉时还没把锁同步到从节点,从节点升主后会丢失这把锁,新的客户端能加到同一把锁,互斥性被破坏。Redlock 是 Redis 作者 Antirez 提出的多节点算法,试图在不依赖主从复制的前提下解决单点问题。

5.1 算法流程#

Redlock 用 N 个(通常 5 个)独立的 Redis 实例,客户端向所有实例依次加锁,多数派成功才算加锁成功:

1
客户端记录当前时间 T1
2
依次向 5 个 Redis 实例执行 SET lock NX PX <ttl>,每个请求设一个较短的超时(比如 50ms),防止单个实例卡住拖慢整体
3
统计成功数,记录加锁总耗时(T2 - T1)
4
如果成功数 ≥ 3(多数派),且总耗时 < 锁的 TTL,认为加锁成功;否则加锁失败
5
加锁失败时,向所有实例(包括成功的)发释放请求,清理半成品锁

判断”总耗时 < 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 在”性能与正确性”之间偏向性能,不适合对互斥性要求极其严格的场景。

Warning

如果你的业务对互斥性零容忍(比如金融扣款),不要用 Redis 分布式锁,包括 Redlock。应该用基于共识算法的锁服务(etcd、ZooKeeper),它们的 lease 和 fencing 机制在 GC 停顿和时钟漂移下仍能保证正确性。Redis 分布式锁适合”偶尔冲突代价可接受”的场景,比如限流、防重复提交、缓存重建互斥。

六、fencing token:治本之防#

Kleppmann 针对 GC 停顿问题提出的解法是 fencing token。每次加锁成功,锁服务返回一个单调递增的 token(版本号),后续对共享资源的写操作必须带上这个 token,资源端拒绝比已见过的 token 更小的请求。

sequenceDiagram participant A as 客户端A participant L as 锁服务 participant R as 共享存储 participant B as 客户端B A->>L: 加锁 L-->>A: token=33 Note over A: A 发生长 GC 停顿 L-->>A: 锁过期 B->>L: 加锁 L-->>B: token=34 B->>R: 写入(token=34) ✅ Note over A: A 从 GC 恢复,仍以为自己持锁 A->>R: 写入(token=33) R-->>A: 拒绝,33 < 34 ❌

即使客户端 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 锁”的具体技术原因。

七、失效场景清单#

把前面散落的陷阱汇总成一份失效场景清单,做选型时逐条对照:

失效场景成因缓解措施能否根治
两步竞态SETNXEXPIRE 之间崩溃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 才是治本之策,而那需要换一个原生支持单调序号的锁服务。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Redis 分布式锁:从 SETNX 到 Redlock 的实现与陷阱
https://blog.souloss.cn/posts/middleware/cache/redis-distributed-lock/
作者
Souloss
发布于
2024-08-22
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时