【大白话说Java面试题 第172题】【07_Redis篇】第8题:`setnx` 做分布式锁存在的问题

📌 PDF :大白话说Java面试题 --- 07_Redis篇

第8题:setnx 做分布式锁存在的问题

📚 回答:

  • 核心考点SETNX 是 Redis 分布式锁的"鼻祖"命令,但大厂面试不会只问"有哪些问题",而是深入考察 每个问题的底层原因 (为什么不可重入?为什么需要看门狗?)、生产级解决方案的演进路径 (从 SETNX 到 Redisson 的完整升级),以及 Redlock 算法的争议与替代方案。面试官真正想判断的是:你是否理解"简单方案"与"生产级方案"之间的鸿沟,以及如何在工程实践中做出正确选型。

1. 问题一:SETNX + EXPIRE 非原子,存在死锁风险
  • 1.1 问题描述 早期实现中,SETNXEXPIRE 是两个独立命令:

    bash 复制代码
    SETNX lock:order:1001 unique_value    # 步骤1:加锁
    EXPIRE lock:order:1001 30             # 步骤2:设置过期时间

    如果执行 SETNX 后,Redis 宕机或客户端崩溃,EXPIRE 未执行,锁将永久存在 ,其他线程永远无法获取,形成死锁

  • 1.2 解决方案:SET 原子命令 Redis 2.6.12 引入 SET 命令的 NXEX/PX 参数,将加锁和设置过期时间合并为原子操作:

    bash 复制代码
    SET lock:order:1001 unique_value NX EX 30
    参数 语义 作用
    NX Not eXists Key 不存在时才设置,实现互斥
    EX seconds 秒级过期 30 秒后自动释放,防止死锁

2. 问题二:锁不可重入
  • 2.1 问题描述 SETNX 基于简单的 Key-Value 结构,无法记录锁的持有者和重入次数。同一线程在持有锁期间再次请求同一锁时,会阻塞等待(自己等待自己),导致死锁

    java 复制代码
    public 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:1001

    Redisson 实现RLock 基于 Hash 结构,fieldUUID:ThreadIDvalue 为重入计数,支持同一线程多次获取锁。


3. 问题三:锁不可重试
  • 3.1 问题描述 SETNX 获取锁失败时直接返回 0,业务线程需自行实现循环重试逻辑。简单的 while 循环会导致忙等待(CPU 空转),浪费系统资源。

    java 复制代码
    // ❌ 错误:忙等待,CPU 空转
    while (!setnx("lock:order:1001", value)) {
        // 空转,消耗 CPU
    }
  • 3.2 解决方案:阻塞式重试 + Pub/Sub 通知 Redisson 通过 Redis Pub/Sub 机制实现事件驱动的高效重试

    1. 首次获取锁失败时,订阅该锁的解锁频道(redisson_lock__channel:{lockName});
    2. 阻塞等待解锁通知或超时,不消耗 CPU;
    3. 收到通知后再次尝试获取锁,循环直到超时。
    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 秒
    停止条件 业务完成、线程中断或显式解锁
    java 复制代码
    RLock lock = redissonClient.getLock("order:1001");
    lock.lock();  // 不指定 leaseTime,启用看门狗
    
    // 业务逻辑执行 5 分钟...
    
    lock.unlock();  // 看门狗停止续期

    注意 :如果显式指定 leaseTime(如 lock.lock(10, TimeUnit.SECONDS)),看门狗不会启用,10 秒后强制释放。


5. 问题五:主从一致性(锁丢失)
  • 5.1 问题描述 在 Redis 主从架构中:

    1. 客户端 A 在主节点获取锁成功;
    2. 主节点尚未将锁信息同步到从节点时宕机;
    3. 从节点晋升为主节点,客户端 B 在新主节点获取同一锁成功;
    4. 客户端 A 和 B 同时持有锁,互斥性被破坏
  • 5.2 解决方案:Redlock 算法 在 N 个独立 Redis 实例上加锁,成功节点数 >= N/2 + 1 时认为获取锁成功:

    java 复制代码
    Config 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 做分布式锁存在六个核心问题,按风险等级排序:

    1. 死锁风险SETNXEXPIRE 是两个独立命令,非原子执行。如果中间崩溃,锁永久存在。必须用 SET NX EX 原子命令替代。
    2. 不可重入 :简单 KV 结构无法记录持有者和重入次数,同一线程嵌套调用会死锁。需用 Hash 结构记录 UUID:ThreadID 和重入计数。
    3. 超时释放:固定过期时间无法适配业务执行时间(如 Full GC)。Redisson 看门狗每 10 秒自动续期,彻底解决。
    4. 不可重试:获取失败直接返回,忙等待浪费 CPU。Redisson 通过 Pub/Sub 订阅解锁通知,实现事件驱动的高效重试。
    5. 主从一致性:主节点宕机前未同步锁信息,从节点晋升后锁丢失。Redlock 算法通过多节点多数派解决,但存在时钟漂移争议。
    6. 无监控 :缺乏锁状态监控和告警。生产环境必须集成监控。
      总结:手写 SETNX 严禁生产使用,必须直接使用 Redisson 等成熟框架。"
  • 追问 2:"看门狗机制是怎么解决超时释放问题的?"

    高分回答

    "看门狗通过动态续期解决固定过期时间的局限性:

    1. 当调用 lock.lock()(不指定 leaseTime)时,Redisson 默认启用看门狗,初始设置锁过期时间为 30 秒;
    2. 后台启动定时任务(Netty 的 HashedWheelTimer),每 10 秒检查一次锁是否仍被当前线程持有;
    3. 如果持有,通过 Lua 脚本原子执行 pexpire 将过期时间重置为 30 秒;
    4. 业务完成调用 unlock() 时,取消定时任务,停止续期。
      这样即使业务执行 5 分钟,锁也不会提前释放。如果显式指定 leaseTime(如 10 秒),看门狗不启用,强制 10 秒后释放,适用于执行时间确定的场景。"
  • 追问 3:"Redlock 算法有什么缺陷?为什么有争议?"

    高分回答

    "Redlock 的核心缺陷来自对时钟和进程停顿的依赖

    1. 时钟漂移:Redlock 假设所有 Redis 节点时钟同步,但实际 NTP 同步存在误差。如果节点 A 的时钟比 B 快 10 秒,A 上的锁已过期,B 上仍认为有效,导致两个客户端同时持有锁。
    2. GC 停顿:客户端获取锁后发生 Full GC,停顿 40 秒,而锁过期时间只有 30 秒。GC 恢复后,客户端认为仍持有锁,但锁已被其他线程获取,导致并发操作。
    3. 无 fencing token :Redlock 没有提供单调递增的 fencing token,资源服务端无法判断请求是否来自过期的锁持有者。
      Martin Kleppmann 的论文指出这些缺陷后,业界对 Redlock 的可靠性产生质疑。实际工程中,Redlock 在时钟同步、GC 可控的环境中足够可靠,但极端强一致场景(如金融交易)应优先选择 ZooKeeper(临时节点 + 会话绑定)或 etcd(Lease 机制)。"
  • 追问 4:"为什么 Redisson 用 Hash 结构而不是 String 实现可重入锁?"

    高分回答

    "Redisson 使用 Hash 结构(HSET/HINCRBY/HDEL)而非简单 String,原因有三:

    1. 记录持有者 :Hash 的 field 存储 UUID:ThreadID,可以精确识别锁的持有者,避免不同 JVM 实例的线程 ID 冲突。
    2. 记录重入次数 :Hash 的 value 存储重入计数,通过 HINCRBY 实现原子性的加锁/解锁计数,支持同一线程多次获取锁。
    3. 原子操作 :Lua 脚本可以原子执行 hexists(判断归属)→ hincrby(修改计数)→ del(完全释放),保证线程安全。
      如果用 String 结构,需要多个命令组合(GET 判断 → INCR 计数 → DEL 释放),无法保证原子性。"
  • 追问 5:"分布式锁的 value 为什么要用 UUID + ThreadID?"

    高分回答

    "锁的 value 必须全局唯一,用于释放锁时验证归属,防止误删。使用 UUID + ThreadID 组合的原因:

    1. UUID 区分 JVM 实例:同一服务的多个节点(如 K8s 多个 Pod)可能生成相同的线程 ID,UUID 确保不同实例的标识唯一。
    2. ThreadID 区分线程:同一 JVM 内不同线程的 ThreadID 不同,确保同实例内线程级别的唯一性。
    3. 组合后全局唯一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 等成熟框架。


觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯

相关推荐
喜欢打篮球的普通人3 小时前
LLVM后端指令选择
android·java·javascript
吴声子夜歌3 小时前
Redis 6.x——整合SpringBoot
数据库·spring boot·redis
某不知名網友3 小时前
C++ 七大排序算法完整讲解
java·算法·排序算法
CodeStats3 小时前
《源纹天书》第一百六十一章至第一百六十五章:开源化宣言、元定义的力量、栈帧重构、容器道场升级、技术债的化身!
java·源纹天书
不良手残3 小时前
MyBatisPlus代码自动生成器
java
SamDeepThinking3 小时前
一次线程池线上故障复盘:四层防线如何避免数据丢失
java·后端·程序员
Rain的Java大神实战圈3 小时前
每日排行榜功能如何设计
redis·架构设计·面试经验·场景设计题
花生智源3 小时前
Java实现Prompt工程的技巧——从模板到工程化,告别字符串拼接
java·人工智能
折哥的程序人生 · 物流技术专研4 小时前
Java 23 种设计模式:从踩坑到精通 | 番外:策略 vs 模板方法 —— 组合与继承的终极对决
java·策略模式·java面试·comparator·模版方法模式·java设计模式·从踩坑到精通