Files
autumn-recruitment/05.架构/distributed-lock/Redis 分布式锁.md
T

199 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 分布式锁]]
- [[限流熔断降级]]