摘要候选: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 致命缺陷:两条命令,不是原子操作
一旦 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 分布式锁的起点
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 # ❌
正确做法是唯一值:
UUIDrequestId机器 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 刚拿到的锁。这就是经典的「误删别人的锁」,会导致互斥失效、并发问题。
怎么解决? 释放锁时先校验「这把锁是不是自己的」,是才删:
GET lockKey,取出锁里存的 value;- 和自己的唯一标识(
requestId,或机器 ID + 线程 ID)比对; - 相同 才
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
删除流程:
五、完整面试答案(可直接背)
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 分钟版基础上补三点:
- 锁过期时间的取舍 :太短 → 业务没跑完锁就没了;太长 → 宕机后其它线程等待过久。经验上取「业务平均耗时 × 2~3 」,并用
PX。 - 误删问题的完整闭环:使用唯一 value + Lua 原子「比较再删」。
- 工程实践再往上 :生产一般用 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,直到显式解锁。
注意:看门狗只在未显式设置过期时间 时生效;自己传了
leaseTime就不会自动续期。
Q2:锁可重入吗?
SET NX PX原生不可重入:同一线程再次加锁会失败。- 实现可重入:用 Hash 存
requestId -> 重入次数,加锁时HINCRBY,解锁时递减到 0 才DEL。Redisson 就是这么做的。
Q3:Redis 主从切换会不会破坏锁的安全性?
一句话结论 :会。 Redis 主从是异步复制 ,主库加锁成功后如果还没来得及同步就宕机,从库升主时这把锁并不存在,别的线程就能重复加锁------互斥会在故障切换的瞬间被打破。
典型场景(下面三个条件同时成立才会触发):
- 线程 A 在 Master 上加锁成功;
- Master 在把锁同步到 Slave 之前宕机(异步复制存在丢失窗口);
- Slave 升为新主,此时新主上没有这把锁,线程 B 加锁成功 → 同一把锁被两个线程同时持有。
把时间轴拉直看,就是这样一个"复制还没来得及,主就挂了"的故事:
触发条件其实很苛刻:必须刚好在「加锁成功 → 同步完成」这段极短窗口内发生故障切换,概率很低,但资金类业务不能赌概率。
怎么解决?
-
方案一:RedLock 。部署 N(通常 5)个相互独立、无主从关系 的 Redis Master;加锁时向每个节点用
SET NX PX申请,超过半数(N/2+1)成功、且总耗时小于锁有效期 ,才算加锁成功;释放时向所有节点发 Lua 删除。思路是不依赖复制,改用多数派。luaC[客户端] --> 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/>且总耗时 < 锁有效期} 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));
八、总结
- 2.8 之前 :
SETNX + EXPIRE,两步非原子,可能死锁。 - 2.8+ 之后 :
SET key value NX PX expireTime,原子加锁 + 过期,标准起点。 - 完整方案:唯一 value 防混淆,Lua 释放防误删。
- 工程实践:Redisson 解决续期与可重入;主从切换有一致性边界,必要时上 RedLock 或 ZooKeeper。),后续所有请求全部阻塞。