--- tags: [arch/distributed, redis-distributed-lock, redlock, redisson, reentrant-lock, watch-dog] create time: 2026-08-08 18:00 update time: 2026-08-08 18:00 --- # Redis 分布式锁 ## 概述 Redis 分布式锁是最广泛使用的分布式同步原语之一。本文深入分析 SETNX + EXPIRE 的原子性问题、Redlock 算法的设计与争议、Redisson 看门狗续期机制、可重入锁的 hash key 设计,以及各类边界场景的死锁分析与规避方案。 ## 核心原理 ### SETNX + EXPIRE 的原子性陷阱 最直观的分布式锁实现是两条命令组合: ```java // ❌ 非原子操作 — 存在竞态条件 redis.set("lock", "client_id", "NX"); // 尝试加锁 redis.expire("lock", 30, "EX"); // 设置过期时间 ``` **问题**:如果 `set` 成功但 `expire` 失败(网络抖动、进程崩溃),锁永远不会释放——死锁。 **解决方案**:使用 Redis 2.6.12+ 支持的原子语法 `SET key value NX EX seconds`: ```java // ✅ 原子操作 String result = redis.set("lock", clientId, "NX", "EX", 30); if ("OK".equals(result)) { // 加锁成功 } ``` Redis 将 `SET + NX + EX/PX` 打包成一条指令执行,消除了中间状态的竞态窗口。 > [!WARNING] > 只支持 `SET ... NX` 而不设过期时间是另一个常见错误。客户端宕机后锁永久不释放,需要人工介入清理。 ### Redlock 多实例容错 Redisson 作者 Yonatan Katz 提出的 Redlock 算法,旨在解决单点故障下锁不可用的问题: ```mermaid sequenceDiagram participant Client as 锁客户端 participant R1 as Redis Master 1 participant R2 as Redis Master 2 participant R3 as Redis Master 3 participant R4 as Redis Master 4 participant R5 as Redis Master 5 loop 依次向 5 个节点发送 SET NX Client->>R1: SET lock uuid NX EX 30 R1-->>Client: OK (t1) Client->>R2: SET lock uuid NX EX 30 R2-->>Client: OK (t2) Client->>R3: SET lock uuid NX EX 30 R3-->>Client: OK (t3) Client->>R4: SET lock uuid NX EX 30 R4-->>Client: nil (已超时或未写入) Client->>R5: SET lock uuid NX EX 30 R5-->>Client: OK (t5) end Note over Client: success_count = 4 >= N/2+1 = 3 Note over Client: acquireTime = t5 - t1 Note over Client: effectiveTTL = 30s - acquireTime Client->>R2: DEL lock (释放) Client->>R5: DEL lock (释放) ``` **核心规则:** 1. 至少部署 5 个无主从复制的独立 Redis 实例。 2. 客户端按顺序尝试 SET NX,记录总耗时 T。 3. 获得多数派(≥ N/2+1)OK 才算加锁成功,有效 TTL = 设定值 − T。 4. 释放锁时向所有节点发 DEL(即使某节点没拿到锁也没关系)。 **Redlock 的争议(Erik Hellemans 的质疑):** - 当 clock drift 发生时,一个实例可能已经过期并释放了锁,而客户端持有的旧锁仍然有效,导致两把锁"同时存在"。 - 解决方案是设置足够大的过期时间(如 10s~30s),使 clock drift(通常 < 200ms)的影响可忽略不计。 - Martin Kleppmann 和 Conan Ho 在论文中证明了在特定 clock-drift 和网络延迟条件下,Redlock 确实可能失效,但实际生产中 5 实例 + 大超时 + 业务幂等的组合已经足够可靠。 > [!TIP] > 面试回答:如果你的业务对锁的正确性是绝对不可妥协的(如金融扣款),不要依赖任何单一的分布式锁实现。应该加上数据库层面的二次校验(唯一约束 + CAS),用"锁 + 数据一致性"双重保障。 ### Redisson 看门狗续期机制 Redis 分布式锁的致命问题是:业务逻辑执行时间超过锁的过期时间会怎样?锁自动释放 → 其他客户端进入 → 数据竞争。 Redisson 的方案是**看门狗(Watch Dog)**: | 步骤 | 行为 | 触发时机 | |------|-----|---------| | 加锁时不指定 leaseTime | 启动看门狗后台任务 | 首次获取锁成功 | | 每 10 秒检查一次锁是否仍持有 | 续期到默认 30 秒 | 定时调度 | | 业务未结束时重复续期 | 锁不会被误释放 | 只要业务还在运行 | | 业务完成主动 unlock | 关闭看门狗 | finally 块中 | | 客户端意外宕机 | 看门狗停止 → 锁自然过期 | Redis 侧 30 秒后自动删除 | ```java // Redisson 看门狗工作原理伪代码 RLock lock = redisson.getLock("myLock"); lock.lock(); // 没有传入 leaseTime 参数 try { doBusiness(); // 看门狗每 10s 续期一次,每次续 30s } finally { lock.unlock(); // 显式释放,关闭看门狗 } ``` > [!NOTE] > 如果明确知道业务执行时间上限,建议在 `lock(leaseTime)` 时直接传入精确的到期时间,避免看门狗的额外开销和复杂度。 ### 可重入锁设计 可重入意味着同一个线程可以多次获取同一把锁而不被阻塞。Redisson 的实现方式是 **hash key + count 计数器**: ```go // Lua 脚本:可重入加锁 local key = KEYS[1] local threadId = ARGV[1] local lockValue = redis.call('get', key) if lockValue == false then redis.call('set', key, threadId, 'EX', ARGV[2]) return 1 end if redis.call('hexists', key, threadId) == 1 then redis.call('hincrby', key, threadId, 1) redis.call('expire', key, ARGV[2]) return 1 // 重入成功 end return 0 // 被其他线程持有 ``` 每个锁的 key 是一个 Hash:`{threadID}: 1` 表示第一个线程已获得锁且计数为 1;第二次重入变为 `{threadID}: 2`。unlock 时递减计数,归零才真正删除 key。 ### 锁超时时间设定原则 | 因素 | 建议 | 说明 | |------|-----|------| | GC 停顿时间 | ≥ 3 × GC 最大停顿 | 避免因 Full GC 导致看门狗错过续期 | | 网络 RTT 波动 | ≥ 网络 99.9th percentile | P999 的网络延迟才有代表性 | | 业务执行时间 | 不确定时不设或设大值 | 能用看门狗就让它工作 | | 最小安全值 | 30 秒起步 | 小于 10 秒的锁几乎必然有问题 | ### 死锁场景分析 | 场景 | 原因 | 解法 | |------|-----|------| | 客户端宕机 | 锁未释放 | 过期时间兜底 + 看门狗机制 | | 并发持有多把锁 | A 持有锁1等锁2,B 持有锁2等锁1(经典死锁) | 全局一致的加锁顺序 | | 时钟回拨 | NTP 同步异常导致系统时钟倒退 | 检测时钟回拨 > 阈值则拒绝服务 | | Redis 主从切换 | 主节点加了锁还没来得及复制到从节点就宕机 | Redlock + 业务幂等校验 | ## 代码示例 Go 中使用 go-redis 实现简单的分布式锁: ```go func tryLock(ctx context.Context, key, val string, ttl time.Duration) bool { ok, err := rdb.SetNX(ctx, key, val, ttl).Result() if err != nil || !ok { return false } return true } func releaseLock(ctx context.Context, key, val string) bool { lua := redis.NewScript(` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0 `) return lua.Run(ctx, []string{key}, val).Int() == 1 } ``` > 解锁必须用 Lua 保证"检查 value + 删除"的原子性。否则出现竞态:A 读到自己的 value,在 del 之前 B 重新获得了锁,A 的 del 就把 B 的锁删掉了。 ## 实践场景 **秋招高频问题:** - "为什么 Redis 锁不用 WATCH MULTI/EXEC?" — Watch 是基于 optimistic locking 的 MVCC 思想,适合读多写少的场景。分布式锁需要的是互斥语义而非版本碰撞检测,WATCH 在高并发下冲突率极高,性能远不如 SET NX。 - "Redlock 真的有用吗?什么时候该用/不该用?" — 如果只是微服务内的普通业务锁(如防止用户重复点击),单机 Redis + SET NX 完全够用。只有对可用性要求极高的核心链路(如秒杀库存扣减)才值得上 Redlock。代价是多倍的 Redis 实例和维护复杂度。 - "缓存穿透 + 分布式锁怎么配合?" — 典型模式:先查缓存 → miss → 用分布式锁保护 DB 查询 → 落缓存。关键是用锁保护"查 + 写缓存"这个复合操作,而不是只保护 DB 查询。 ## 关联笔记 - [[ZooKeeper 分布式锁]] - [[限流熔断降级]]