Redis 分布式锁:从 2.8 之前到 2.8+,一篇讲透

摘要候选:2.8 之前为什么用 SETNX + EXPIRE?2.8+ 之后 SET NX PX 为什么成为标准?本文按版本演进讲清 Redis 分布式锁,附安全释放、锁续期与主从一致性边界,并给出 1 分钟 / 3 分钟面试答法。


一、为什么要按版本来讲

Redis 分布式锁的面试题,很多人一上来就背:

redis 复制代码
SET lockKey uuid NX PX 30000

但面试官真正想听的是演进逻辑:

  • 2.8 之前:为什么老方案不够好?
  • 2.8+ 之后:为什么它成了标准做法?
  • 再往后:标准做法还有哪些坑?

按「2.8 之前 → 2.8+ → 工程实践」三段式讲,既有深度又有条理。


二、2.8 之前:SETNX + EXPIRE

2.1 怎么用

早期没有一条命令式的加锁方案,通常分两步:

redis 复制代码
SETNX lockKey requestId
EXPIRE lockKey 30
  • SETNX:key 不存在才设置成功,用来实现互斥
  • EXPIRE:给 key 补一个过期时间,防止死锁

2.2 致命缺陷:两条命令,不是原子操作

sequenceDiagram participant A as 线程 A participant R as Redis participant S as 服务进程 A->>R: SETNX lockKey requestId R-->>A: 返回 1(加锁成功) Note over S: 进程异常退出 / 宕机 A--xA: EXPIRE lockKey 30 未执行 Note over R: lockKey 永不过期 → 死锁

一旦 SETNX 成功、EXPIRE 还没执行时进程挂掉,这把锁就永远不会释放(僵尸锁),后续所有请求全部阻塞。

2.3 面试话术

"Redis 2.8 之前,分布式锁通常用 SETNX + EXPIRE 实现。它能实现互斥,但因为是两步操作、不具备原子性,如果在设置过期时间之前进程异常退出,锁可能无法自动释放,形成死锁,所以这个方案有明显缺陷。"


三、2.8+ 之后:SET key value NX PX expireTime

3.1 一句命令搞定

Redis 2.8 起,SET 命令支持了 NX、PX 等参数,可以直接:

redis 复制代码
SET lockKey requestId NX PX 30000
参数 含义
NX key 不存在才设置(Not eXists)
PX 30000 过期时间 30000 毫秒
requestId 当前线程 / 请求的唯一标识

注意:EX 是秒,PX 是毫秒,别写混。

3.2 它解决了什么

  • 把「加锁 」和「设置过期时间 」合并进一条命令
  • 避免了 SETNX 成功而 EXPIRE 未执行导致的死锁
  • 原子性由单条命令保证,是标准 Redis 分布式锁的起点
sequenceDiagram participant A as 线程 A participant R as Redis A->>R: SET lockKey requestId NX PX 30000 R-->>A: OK(加锁 + 过期原子完成) Note over R: 30s 后自动释放,不会死锁

3.3 面试话术

"Redis 2.8 之后,SET 命令新增了 NX、PX 参数,所以可以写成 SET key value NX PX expireTime 一条命令:

  • NX 表示 key 不存在才设置成功,保证同一时刻只有一个线程能加锁;
  • PX expireTime 表示顺便把过期时间一起设上,锁到期自动释放,不会死锁;
  • value 放当前线程的唯一标识,后面释放锁要靠它校验锁的归属。

这样加锁和设置过期在一次原子操作 里完成,不会再出现 SETNX 成功、EXPIRE 却没执行导致的死锁,所以它是标准的 Redis 分布式锁实现方式。"


四、但还没讲完:标准做法仍有两个坑

4.1 坑一:value 不能写固定值

很多人写成:

redis 复制代码
SET lockKey 1 NX PX 30000   # ❌

正确做法是唯一值:

  • UUID
  • requestId
  • 机器 ID + 线程 ID + 请求 ID

原因:释放锁时要靠它判断「这把锁到底是不是我自己的」。固定值会让所有线程长得一模一样,无法区分归属。

4.2 坑二:释放锁不能直接 DEL

先看一个例子:两个线程 A、B 抢同一把锁。

时刻 线程 A 线程 B Redis 中的锁
T0 SET lockKey A NX PX 30000 → OK lockKey = A
T1 业务执行中...... lockKey = A
T2 业务还没跑完(已超过 30s) 锁自动过期,lockKey 消失
T3 SET lockKey B NX PX 30000 → OK lockKey = B
T4 业务终于跑完,执行 DEL lockKey 把 B 的锁删掉了!

问: A 在 T4 删掉的,是自己的锁吗? 答:不是。 A 的锁早在 T2 就过期消失了,T4 删掉的是 B 刚拿到的锁。这就是经典的「误删别人的锁」,会导致互斥失效、并发问题。

sequenceDiagram participant A as 线程 A participant R as Redis participant B as 线程 B A->>R: SET lockKey A NX PX 30000 R-->>A: OK Note over A: 业务执行 > 30s(锁已到期) Note over R: lockKey 自动过期删除 B->>R: SET lockKey B NX PX 30000 R-->>B: OK(B 拿到锁) A->>R: DEL lockKey Note over R: A 删掉了 B 的锁!

怎么解决? 释放锁时先校验「这把锁是不是自己的」,是才删:

  1. GET lockKey,取出锁里存的 value;
  2. 和自己的唯一标识(requestId,或 机器 ID + 线程 ID)比对;
  3. 相同 才 DEL,不同就什么都不做。

注意:如果只用线程 ID ,不同机器、不同实例上可能撞号,所以生产上更推荐「机器 ID + 线程 ID + 请求 ID」或直接用 UUID,保证全局唯一。

但「先比较、再删除」如果拆成两条命令,中间仍可能被其它线程插入(刚比较完还没删,锁就过期了),所以必须用 Lua 脚本把「比较 + 删除」做成原子操作:

lua 复制代码
-- KEYS[1] = lockKey, ARGV[1] = 自己的唯一标识(requestId)
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

删除流程:

flowchart TD A[释放锁] --> B[GET lockKey] B --> C{value == 自己的标识?} C -->|是| D[DEL lockKey] C -->|否| E[return 0 不删] D --> F[释放成功] E --> F

五、完整面试答案(可直接背)

5.1 1 分钟版

"Redis 2.8 之前用 SETNX + EXPIRE,能互斥但两步不原子,中间宕机可能死锁。2.8 之后 SET 支持 NX、PX,可以用 SET key value NX PX expireTime 原子完成加锁和过期设置,这是标准做法。其中 value 必须是唯一标识,释放锁也不能直接 DEL,要先比对 value 再用 Lua 原子删除,避免误删别人的锁。"

5.2 3 分钟版

在 1 分钟版基础上补三点:

  1. 锁过期时间的取舍 :太短 → 业务没跑完锁就没了;太长 → 宕机后其它线程等待过久。经验上取「业务平均耗时 × 2~3 」,并用 PX。
  2. 误删问题的完整闭环:使用唯一 value + Lua 原子「比较再删」。
  3. 工程实践再往上 :生产一般用 Redisson ,它提供可重入锁、watchdog 自动续期、公平锁、RedLock 等封装。

5.3 一句话总结

阶段 方案 问题 / 结论
2.8 之前 SETNX + EXPIRE 两步非原子,可能死锁
2.8+ SET key value NX PX t 原子加锁 + 过期,标准起点
完整版 唯一 value + Lua 释放 避免误删别人的锁

六、面试官追问清单(提前准备)

Q1:锁过期了业务还没执行完怎么办?

  • 问题:业务超时 → 锁自动释放 → 别的线程进来 → 并发失控。
  • 方案:看门狗续期 。Redisson 默认锁 30s,后台线程每 10s(lockWatchdogTimeout / 3)检测一次,若业务还在跑就把锁续到 30s,直到显式解锁。
sequenceDiagram participant A as 线程 A participant W as Watchdog participant R as Redis A->>R: 加锁 30s loop 每 10s W->>R: 业务仍持有? 续期到 30s end A->>R: 业务完成,Lua 释放锁

注意:看门狗只在未显式设置过期时间 时生效;自己传了 leaseTime 就不会自动续期。

Q2:锁可重入吗?

  • SET NX PX 原生不可重入:同一线程再次加锁会失败。
  • 实现可重入:用 Hash 存 requestId -> 重入次数,加锁时 HINCRBY,解锁时递减到 0 才 DEL。Redisson 就是这么做的。

Q3:Redis 主从切换会不会破坏锁的安全性?

一句话结论 :会。 Redis 主从是异步复制 ,主库加锁成功后如果还没来得及同步就宕机,从库升主时这把锁并不存在,别的线程就能重复加锁------互斥会在故障切换的瞬间被打破。

典型场景(下面三个条件同时成立才会触发):

  1. 线程 A 在 Master 上加锁成功;
  2. Master 在把锁同步到 Slave 之前宕机(异步复制存在丢失窗口);
  3. Slave 升为新主,此时新主上没有这把锁,线程 B 加锁成功 → 同一把锁被两个线程同时持有。

把时间轴拉直看,就是这样一个"复制还没来得及,主就挂了"的故事:

sequenceDiagram participant A as 线程A participant M as Master participant S as Slave participant B as 线程B A->>M: 加锁成功(SET NX PX) M-->>S: 异步复制这把锁... Note over M: Master 宕机,复制中断 Note over S: 新主上没有这把锁 S->>S: Slave 被提升为新 Master B->>S: 加锁成功(SET NX PX) Note over A,B: 同一把锁被 A、B 同时持有 ❌

触发条件其实很苛刻:必须刚好在「加锁成功 → 同步完成」这段极短窗口内发生故障切换,概率很低,但资金类业务不能赌概率。

怎么解决?

  • 方案一:RedLock 。部署 N(通常 5)个相互独立、无主从关系 的 Redis Master;加锁时向每个节点用 SET NX PX 申请,超过半数(N/2+1)成功、且总耗时小于锁有效期 ,才算加锁成功;释放时向所有节点发 Lua 删除。思路是不依赖复制,改用多数派。

    lua 复制代码
        C[客户端] --> G{向 5 个独立 Master<br/>分别 SET NX PX}
        G --> R1[Redis 1 ✅]
        G --> R2[Redis 2 ✅]
        G --> R3[Redis 3 ✅]
        G --> R4[Redis 4 ❌]
        G --> R5[Redis 5 ❌]
        R1 --> D{成功 3 个 ≥ N/2+1<br/>且总耗时 &lt; 锁有效期}
        R2 --> D
        R3 --> D
        D --> OK[加锁成功 ✅]

    图中 5 个节点互相独立、没有主从关系,所以任何一台挂了都不影响多数派判断------这正是它绕开"异步复制丢锁"的思路。

    • 争议 :Martin Kleppmann 指出 RedLock 依赖各节点的时钟单调性 ,一旦发生时钟漂移或长时间 GC 停顿,仍可能出现两个客户端同时持锁;antirez 则回应这些假设过于苛刻。结论:RedLock 不能 100% 保证安全,生产是否该用仍存在分歧。
  • 方案二(更稳):换成强一致组件 。一致性要求极高时直接上 ZooKeeper / etcd,基于 quorum(ZAB / Raft)+ 临时顺序节点 + 会话,天生解决主从切换丢锁的问题。

工程建议(面试加分):

  • 大多数业务:SET NX PX + 唯一 value + Lua 释放,再配合业务幂等就够了,不必上 RedLock;
  • 强一致场景:用 ZooKeeper / etcd,或引入 fencing token(每次加锁拿一个单调递增的 token,写数据时带上,旧 token 的写被拒绝),从数据侧兜底。

面试话术:"会破坏。因为主从是异步复制,Master 加锁后还没同步就宕机,Slave 升主时锁就丢了,会出现两个线程同时持锁。严格解决可以用 RedLock,但它依赖时钟、本身也有争议;更稳妥的是 ZooKeeper / etcd,或者引入 fencing token,再配合业务幂等兜底。"

Q4:为什么不用 SETNX 而用 SET NX?

SETNX 无法在同一条命令里设置过期时间,必须补 EXPIRE,丢掉了原子性。SET ... NX PX 一条命令即可完成,安全性更高。

Q5:怎么主动解决过期时间难估的问题?

除了看门狗,还可以:

  • 把大事务拆小,缩短持锁时间;
  • 用 tryLock(waitTime, leaseTime) 显式控制等待与租期;
  • 对幂等操作可不强依赖锁,改用幂等设计。

七、速查代码

java 复制代码
// 加锁:原子设置 + 唯一值 + 过期时间
String requestId = UUID.randomUUID().toString();
String result = jedis.set("lockKey", requestId, "NX", "PX", 30000);
if (!"OK".equals(result)) {
    // 加锁失败,快速返回或重试
}

// 解锁:Lua 保证「比较 + 删除」原子
String lua =
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "  return redis.call('del', KEYS[1]) " +
    "else return 0 end";
jedis.eval(lua, Collections.singletonList("lockKey"),
           Collections.singletonList(requestId));

八、总结

  1. 2.8 之前 :SETNX + EXPIRE,两步非原子,可能死锁。
  2. 2.8+ 之后 :SET key value NX PX expireTime,原子加锁 + 过期,标准起点。
  3. 完整方案:唯一 value 防混淆,Lua 释放防误删。
  4. 工程实践:Redisson 解决续期与可重入;主从切换有一致性边界,必要时上 RedLock 或 ZooKeeper。),后续所有请求全部阻塞。
相关推荐
程序猿乐锅3 小时前
【黑马点评 | 第十一篇】关注 Feed 流实现
java·数据库·redis·分布式·后端·缓存·maven
ShineWinsu4 小时前
对于Redis:List类型的解析
数据库·c++·redis·链表·缓存·面试·list
XS0301065 小时前
Redis 学习指南
数据库·redis·缓存
夕除5 小时前
redis--集群
java·redis
喜欢的名字被抢了6 小时前
09-Redis 进阶原理篇:单线程、多线程、过期、LRU-LFU、Fork 与 Lua
数据库·redis·lua
zl_dfq15 小时前
Redis 之 【高可用架构】(从主从复制、哨兵到集群)
redis
hweiyu0018 小时前
Redis命令:HPERSIST
redis·缓存
hweiyu0018 小时前
Redis命令:HPEXPIRE
redis·缓存
ShineWinsu1 天前
对于Redis:string类型的解析
java·c++·redis·分布式·缓存·面试·string