199 lines
8.0 KiB
Markdown
199 lines
8.0 KiB
Markdown
|
|
---
|
|||
|
|
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 分布式锁]]
|
|||
|
|
- [[限流熔断降级]]
|