📌 PDF :大白话说Java面试题 --- 07_Redis篇
第8题:setnx 做分布式锁存在的问题
📚 回答:
- 核心考点 :
SETNX是 Redis 分布式锁的"鼻祖"命令,但大厂面试不会只问"有哪些问题",而是深入考察 每个问题的底层原因 (为什么不可重入?为什么需要看门狗?)、生产级解决方案的演进路径 (从 SETNX 到 Redisson 的完整升级),以及 Redlock 算法的争议与替代方案。面试官真正想判断的是:你是否理解"简单方案"与"生产级方案"之间的鸿沟,以及如何在工程实践中做出正确选型。
1. 问题一:SETNX + EXPIRE 非原子,存在死锁风险
-
1.1 问题描述 早期实现中,
SETNX和EXPIRE是两个独立命令:bashSETNX lock:order:1001 unique_value # 步骤1:加锁 EXPIRE lock:order:1001 30 # 步骤2:设置过期时间如果执行
SETNX后,Redis 宕机或客户端崩溃,EXPIRE未执行,锁将永久存在 ,其他线程永远无法获取,形成死锁。 -
1.2 解决方案:SET 原子命令 Redis 2.6.12 引入
SET命令的NX和EX/PX参数,将加锁和设置过期时间合并为原子操作:bashSET lock:order:1001 unique_value NX EX 30参数 语义 作用 NXNot eXists Key 不存在时才设置,实现互斥 EX seconds秒级过期 30 秒后自动释放,防止死锁
2. 问题二:锁不可重入
-
2.1 问题描述
SETNX基于简单的 Key-Value 结构,无法记录锁的持有者和重入次数。同一线程在持有锁期间再次请求同一锁时,会阻塞等待(自己等待自己),导致死锁。javapublic void methodA() { if (setnx("lock:order:1001", "threadA")) { // 获取成功 methodB(); // 内部调用 } } public void methodB() { if (setnx("lock:order:1001", "threadA")) { // ❌ 返回0,阻塞! // 永远无法执行 } } -
2.2 解决方案:Hash 结构实现可重入锁 使用 Redis Hash 结构记录线程标识和重入次数:
bash# 首次加锁 HSET lock:order:1001 "UUID:ThreadID" 1 # 重入加锁 HINCRBY lock:order:1001 "UUID:ThreadID" 1 # value 变为 2 # 释放锁(重入次数-1) HINCRBY lock:order:1001 "UUID:ThreadID" -1 # value 变为 1 # 完全释放(重入次数=0) HDEL lock:order:1001 "UUID:ThreadID" IF HLEN(lock:order:1001) == 0 THEN DEL lock:order:1001Redisson 实现 :
RLock基于 Hash 结构,field为UUID:ThreadID,value为重入计数,支持同一线程多次获取锁。
3. 问题三:锁不可重试
-
3.1 问题描述
SETNX获取锁失败时直接返回 0,业务线程需自行实现循环重试逻辑。简单的while循环会导致忙等待(CPU 空转),浪费系统资源。java// ❌ 错误:忙等待,CPU 空转 while (!setnx("lock:order:1001", value)) { // 空转,消耗 CPU } -
3.2 解决方案:阻塞式重试 + Pub/Sub 通知 Redisson 通过 Redis Pub/Sub 机制实现事件驱动的高效重试:
- 首次获取锁失败时,订阅该锁的解锁频道(
redisson_lock__channel:{lockName}); - 阻塞等待解锁通知或超时,不消耗 CPU;
- 收到通知后再次尝试获取锁,循环直到超时。
java// Redisson 的 tryLock 逻辑(简化) public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) { long deadline = System.currentTimeMillis() + unit.toMillis(waitTime); Long ttl = tryAcquire(leaseTime, unit, threadId); if (ttl == null) return true; // 获取成功 // 订阅解锁通知频道 RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId); subscribeFuture.await(remaining, TimeUnit.MILLISECONDS); // 收到通知后重试 while (remaining > 0) { ttl = tryAcquire(leaseTime, unit, threadId); if (ttl == null) return true; remaining = deadline - System.currentTimeMillis(); } return false; } - 首次获取锁失败时,订阅该锁的解锁频道(
4. 问题四:超时释放(业务执行时间 > 锁过期时间)
-
4.1 问题描述 如果业务执行时间超过锁的过期时间,锁被自动释放,其他线程获取锁后并发操作数据,导致数据不一致 。更危险的是,原线程业务完成后执行 DEL,会误删新线程的锁。
时间线 线程 A 线程 B 说明 T1 获取锁(30秒过期) --- --- T2 执行业务(Full GC 50秒) --- --- T3 锁自动过期 获取锁成功 线程 A 仍在执行 T4 业务完成,执行 DEL --- ❌ 误删线程 B 的锁! -
4.2 解决方案:看门狗(Watchdog)自动续期 Redisson 看门狗机制在锁持有期间定期续期:
机制 说明 初始过期时间 30 秒( lockWatchdogTimeout)续期周期 每 10 秒检查一次( leaseTime / 3)续期操作 如果锁仍被当前线程持有,通过 Lua 脚本将过期时间重置为 30 秒 停止条件 业务完成、线程中断或显式解锁 javaRLock lock = redissonClient.getLock("order:1001"); lock.lock(); // 不指定 leaseTime,启用看门狗 // 业务逻辑执行 5 分钟... lock.unlock(); // 看门狗停止续期注意 :如果显式指定
leaseTime(如lock.lock(10, TimeUnit.SECONDS)),看门狗不会启用,10 秒后强制释放。
5. 问题五:主从一致性(锁丢失)
-
5.1 问题描述 在 Redis 主从架构中:
- 客户端 A 在主节点获取锁成功;
- 主节点尚未将锁信息同步到从节点时宕机;
- 从节点晋升为主节点,客户端 B 在新主节点获取同一锁成功;
- 客户端 A 和 B 同时持有锁,互斥性被破坏。
-
5.2 解决方案:Redlock 算法 在 N 个独立 Redis 实例上加锁,成功节点数 >= N/2 + 1 时认为获取锁成功:
javaConfig config = new Config(); config.useRedLock( new RedissonClientConfig("redis://node1:6379"), new RedissonClientConfig("redis://node2:6379"), new RedissonClientConfig("redis://node3:6379") ); RLock lock = redisson.getRedLock("order:1001"); -
5.3 Redlock 的争议 Martin Kleppmann 指出 Redlock 存在理论缺陷:
质疑点 说明 缓解措施 时钟漂移 节点时钟不同步导致锁过期计算错误 NTP 同步 + 时钟漂移补偿 GC 停顿 客户端 Full GC 超过锁过期时间 控制 GC、增加 fencing token 网络延迟 获取锁的 RTT 不确定 计算有效时间时扣除 RTT 无 fencing token 锁过期后客户端仍可能操作资源 资源服务端校验 token 业界共识:Redlock 在稳定环境中足够可靠,但极端强一致场景优先选择 ZooKeeper 或 etcd。
6. 问题六:缺乏锁状态监控与告警
-
6.1 问题描述 手写
SETNX方案缺乏对锁状态的监控,无法及时发现死锁、锁持有时间过长等异常。 -
6.2 解决方案:Redisson 监控集成
- 监控锁的持有时间,超过阈值(如 10 秒)触发告警;
- 监控锁 Key 数量突增,可能意味着死锁;
- 看门狗续期次数过多时告警(业务执行异常)。
7. 六种问题综合对比
| 问题 | 根本原因 | 风险等级 | 解决方案 | 生产必备 |
|---|---|---|---|---|
| 死锁(SETNX+EXPIRE分离) | 两个命令非原子 | 🔴 致命 | SET NX EX 原子命令 |
✅ 必须 |
| 不可重入 | 简单 KV 无法记录持有者 | 🟡 高 | Hash 结构 + 重入计数 | ✅ 必须 |
| 不可重试 | 获取失败直接返回 | 🟡 高 | Pub/Sub 事件驱动重试 | ✅ 推荐 |
| 超时释放 | 固定过期时间无法适配业务 | 🔴 致命 | 看门狗自动续期 | ✅ 必须 |
| 主从一致性 | 主从复制延迟 | 🟡 高 | Redlock / ZooKeeper | ⚠️ 视场景 |
| 无监控 | 缺乏运维手段 | 🟢 中 | 集成监控告警 | ✅ 推荐 |
8. 从 SETNX 到 Redisson 的演进路径
SETNX + EXPIRE(有死锁风险)
↓
SET NX EX + Lua DEL(解决死锁和误删,但功能简陋)
↓
Redisson 单节点(可重入 + 看门狗 + 阻塞重试,大多数场景首选)
↓
Redisson RedLock(多节点多数派,高可用要求)
↓
ZooKeeper / etcd(极端强一致,无时钟漂移风险)
9. 面试官追问与高分回答模板
-
追问 1:"SETNX 做分布式锁有哪些问题?"
低分回答:"不可重入、不可重试、超时释放、主从一致性。"(没有解释根本原因和解决方案)
高分回答:
"
SETNX做分布式锁存在六个核心问题,按风险等级排序:- 死锁风险 :
SETNX和EXPIRE是两个独立命令,非原子执行。如果中间崩溃,锁永久存在。必须用SET NX EX原子命令替代。 - 不可重入 :简单 KV 结构无法记录持有者和重入次数,同一线程嵌套调用会死锁。需用 Hash 结构记录
UUID:ThreadID和重入计数。 - 超时释放:固定过期时间无法适配业务执行时间(如 Full GC)。Redisson 看门狗每 10 秒自动续期,彻底解决。
- 不可重试:获取失败直接返回,忙等待浪费 CPU。Redisson 通过 Pub/Sub 订阅解锁通知,实现事件驱动的高效重试。
- 主从一致性:主节点宕机前未同步锁信息,从节点晋升后锁丢失。Redlock 算法通过多节点多数派解决,但存在时钟漂移争议。
- 无监控 :缺乏锁状态监控和告警。生产环境必须集成监控。
总结:手写 SETNX 严禁生产使用,必须直接使用 Redisson 等成熟框架。"
- 死锁风险 :
-
追问 2:"看门狗机制是怎么解决超时释放问题的?"
高分回答:
"看门狗通过动态续期解决固定过期时间的局限性:
- 当调用
lock.lock()(不指定 leaseTime)时,Redisson 默认启用看门狗,初始设置锁过期时间为 30 秒; - 后台启动定时任务(Netty 的 HashedWheelTimer),每 10 秒检查一次锁是否仍被当前线程持有;
- 如果持有,通过 Lua 脚本原子执行
pexpire将过期时间重置为 30 秒; - 业务完成调用
unlock()时,取消定时任务,停止续期。
这样即使业务执行 5 分钟,锁也不会提前释放。如果显式指定 leaseTime(如 10 秒),看门狗不启用,强制 10 秒后释放,适用于执行时间确定的场景。"
- 当调用
-
追问 3:"Redlock 算法有什么缺陷?为什么有争议?"
高分回答:
"Redlock 的核心缺陷来自对时钟和进程停顿的依赖:
- 时钟漂移:Redlock 假设所有 Redis 节点时钟同步,但实际 NTP 同步存在误差。如果节点 A 的时钟比 B 快 10 秒,A 上的锁已过期,B 上仍认为有效,导致两个客户端同时持有锁。
- GC 停顿:客户端获取锁后发生 Full GC,停顿 40 秒,而锁过期时间只有 30 秒。GC 恢复后,客户端认为仍持有锁,但锁已被其他线程获取,导致并发操作。
- 无 fencing token :Redlock 没有提供单调递增的 fencing token,资源服务端无法判断请求是否来自过期的锁持有者。
Martin Kleppmann 的论文指出这些缺陷后,业界对 Redlock 的可靠性产生质疑。实际工程中,Redlock 在时钟同步、GC 可控的环境中足够可靠,但极端强一致场景(如金融交易)应优先选择 ZooKeeper(临时节点 + 会话绑定)或 etcd(Lease 机制)。"
-
追问 4:"为什么 Redisson 用 Hash 结构而不是 String 实现可重入锁?"
高分回答:
"Redisson 使用 Hash 结构(
HSET/HINCRBY/HDEL)而非简单 String,原因有三:- 记录持有者 :Hash 的
field存储UUID:ThreadID,可以精确识别锁的持有者,避免不同 JVM 实例的线程 ID 冲突。 - 记录重入次数 :Hash 的
value存储重入计数,通过HINCRBY实现原子性的加锁/解锁计数,支持同一线程多次获取锁。 - 原子操作 :Lua 脚本可以原子执行
hexists(判断归属)→hincrby(修改计数)→del(完全释放),保证线程安全。
如果用 String 结构,需要多个命令组合(GET 判断 → INCR 计数 → DEL 释放),无法保证原子性。"
- 记录持有者 :Hash 的
-
追问 5:"分布式锁的 value 为什么要用 UUID + ThreadID?"
高分回答:
"锁的 value 必须全局唯一,用于释放锁时验证归属,防止误删。使用
UUID + ThreadID组合的原因:- UUID 区分 JVM 实例:同一服务的多个节点(如 K8s 多个 Pod)可能生成相同的线程 ID,UUID 确保不同实例的标识唯一。
- ThreadID 区分线程:同一 JVM 内不同线程的 ThreadID 不同,确保同实例内线程级别的唯一性。
- 组合后全局唯一 :
UUID:ThreadID在分布式环境下绝对唯一,释放锁时通过 Lua 脚本比对GET值与当前线程标识,一致才删除。
如果只用 ThreadID,多实例场景下可能冲突;如果只用 UUID,无法区分同实例内的不同线程重入。"
-
追问 6:"如果 Redis 是单节点(非主从集群),还需要 Redlock 吗?"
高分回答:
"单节点 Redis 不需要 Redlock,因为 Redlock 的设计目标是解决主从切换导致的锁丢失 问题。单节点不存在主从复制延迟,锁信息只有一份,不会丢失。
单节点场景下,Redisson 单节点锁已足够可靠,配合看门狗续期即可。但单节点 Redis 本身存在单点故障风险,需要通过 Redis Sentinel 或 Redis Cluster 保证高可用。
Redlock 的适用场景是:多个独立的 Redis 主节点(非主从关系),通过多数派机制容忍部分节点故障。如果只有一个 Redis 实例或一个主从集群,Redlock 没有实际意义。"
10. 方案选型速查表
| 场景 | 方案 | 说明 |
|---|---|---|
| 学习/演示 | SETNX + EXPIRE | ❌ 有死锁风险,严禁生产 |
| 简单场景(无重入) | SET NX EX + Lua | 轻量,但功能有限 |
| 大多数生产场景 | Redisson 单节点 | 可重入 + 看门狗 + 阻塞重试,首选 |
| 高可用要求 | Redisson RedLock | 多节点多数派,容忍故障 |
| 极端强一致 | ZooKeeper / etcd | 无时钟漂移,会话绑定释放 |
💡 面试官想要的满分总结:
SETNX做分布式锁的问题不是"有几个",而是从基础命令到生产级方案的完整鸿沟。核心问题按风险排序:死锁(非原子命令)、不可重入(简单 KV 结构)、超时释放(固定过期时间)、不可重试(忙等待)、主从一致性(复制延迟)。生产级方案必须满足五个条件:原子加锁 (
SET NX EX)、可重入 (Hash 结构)、自动续期 (看门狗)、防误删 (Lua 脚本 + 唯一 value)、高效重试(Pub/Sub 事件驱动)。Redisson 完整实现了这五个条件,是大多数场景的首选。Redlock 算法虽有理论争议,但在稳定环境中可靠性足够。极端强一致场景(如金融交易)应优先选择 ZooKeeper 或 etcd,它们的锁释放与客户端会话绑定,不存在时钟漂移风险。
最后记住:手写 SETNX 严禁生产使用,必须直接使用 Redisson 等成熟框架。
觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯