2. Redis 分布式锁核心原理:SETNX、EXPIRE 与原子性

分布式锁是微服务架构中控制共享资源并发访问的核心手段。Redis 凭借其高性能和简单易用的数据结构,成为实现分布式锁最流行的中间件之一。理解其底层原理——SETNX、EXPIRE 以及原子性操作——是正确使用和调优锁机制的前提。

2.1 核心命令:SETNX 与 EXPIRE

2.1.1 SETNX — 锁的抢占

SETNX 是 "SET if Not eXists" 的缩写。当且仅当 key 不存在时,设置 key 的值。若 key 已存在,则不做任何操作。这一特性天然适合实现锁的“互斥”语义:

  • 加锁成功:返回 1,表示当前客户端成功获取锁。
  • 加锁失败:返回 0,表示锁已被其他客户端持有。
典型加锁命令:
SETNX lock:order_123 "client_A"

如果返回 1,则 client_A 获得锁;如果返回 0,则 client_A 需等待或重试。

2.1.2 EXPIRE — 锁的自动释放

为防止持有锁的客户端崩溃导致锁永久占用,必须为锁设置过期时间(TTL)。EXPIRE 命令用于为已存在的 key 设置生存时间:

EXPIRE lock:order_123 30   // 30秒后自动释放
⚠️ 非原子性陷阱: 如果 SETNX 成功,但在执行 EXPIRE 之前客户端宕机,锁将永远无法释放,造成死锁。这是早期 Redis 分布式锁实现中最常见的错误。

2.2 原子性操作:从两步到一步

为了解决 SETNX + EXPIRE 的非原子性问题,Redis 从 2.6.12 版本开始支持在 SET 命令中直接组合多个参数,实现原子化的加锁与过期设置:

方式 命令示例 原子性 风险
旧方式(两步) SETNX key value + EXPIRE key seconds ❌ 非原子 SETNX 与 EXPIRE 之间宕机 → 死锁
新方式(一步) SET key value NX EX seconds ✅ 原子 无死锁风险
推荐实践: 始终使用原子化的 SET key value NX EX seconds 命令。其中 NX 等同于 SETNX 语义,EX 设置过期时间(秒),PX 可设置毫秒。

2.3 原子性操作的重要性图解

下图对比了非原子化两步操作与原子化一步操作在客户端崩溃场景下的不同结果:

原子性操作对比:两步 vs 一步 非原子化(两步) ① SETNX lock:order_123 "client_A" ② 客户端宕机 ❌ EXPIRE 未执行 → 锁永久占用 原子化(一步) SET lock:order_123 "client_A" NX EX 30 ② 客户端宕机 ❌ 30秒后锁自动释放 ✅ 原子化 SET 命令将“加锁”与“设置过期”合并为一个操作 即使客户端在命令执行后立即崩溃,锁也会在 TTL 到期后自动释放

2.4 锁的标识与安全释放

除了原子性加锁,锁的唯一标识和安全释放同样关键:

  • 唯一标识(value):每个客户端加锁时,value 应设置为全局唯一的随机字符串(如 UUID)。这用于在释放锁时验证锁的持有者是否还是自己。
  • 安全释放:释放锁时,必须先用 GET 命令检查 value 是否匹配,再执行 DEL。此过程也需要原子化,通常使用 Lua 脚本实现:
-- 安全释放锁的 Lua 脚本
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
为什么需要唯一标识? 假设客户端 A 持有锁,因业务执行时间过长导致锁自动过期;客户端 B 随后获取了锁。此时如果 A 执行 DEL,就会误删 B 的锁。唯一标识 + Lua 脚本可以杜绝这种误释放。

2.5 核心原理总结

原理要素 关键命令/技术 作用
互斥性 SETNX / SET NX 确保同一时刻只有一个客户端能持有锁
自动释放 EXPIRE / SET EX 防止死锁,即使客户端崩溃也能自动解锁
原子性 SET NX EX / Lua 脚本 避免加锁与过期设置之间的竞态条件
锁标识 唯一随机 value(如 UUID) 确保只有锁的持有者才能释放锁