vault backup: 2026-08-08 19:01:04
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
---
|
||||
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 分布式锁]]
|
||||
- [[限流熔断降级]]
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
tags: [arch/distributed, zookeeper-distributed-lock, zab-protocol, ephemeral-node, watch-mechanism, cp-system]
|
||||
create time: 2026-08-08 18:00
|
||||
update time: 2026-08-08 18:00
|
||||
---
|
||||
|
||||
# ZooKeeper 分布式锁
|
||||
|
||||
## 概述
|
||||
|
||||
ZooKeeper 基于 ZAB 协议的强一致性保证,天然适合构建分布式协调服务。本文深入分析 ZK 分布式锁的核心机制——临时顺序节点、Watch 一次性触发、公平锁实现原理,以及与 Redis 锁的系统级对比。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### 临时顺序节点创建过程
|
||||
|
||||
ZK 锁的核心数据结构是 **临时顺序节点(Ephemeral Sequential Node)**:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[客户端连接 ZK] --> B[在 /locks 下创建 EPHEMERAL_SEQUENTIAL 节点]
|
||||
B --> C["节点名: /locks/lock-0000000001"]
|
||||
C --> D[获取 /locks 下的所有子节点并排序]
|
||||
D --> E{自己是最小的节点?}
|
||||
E -- 是 --> F["加锁成功<br/>lock-0000000001 获得锁"]
|
||||
E -- 否 --> G["监听比自己小的那个节点<br/>watch(lock-0000000000)"]
|
||||
G --> H["等待 Watch 通知"]
|
||||
I[持锁客户端断开连接] --> J[ZK 检测到 session 过期]
|
||||
J --> K["自动删除自己的 EPHEMERAL 节点"]
|
||||
K --> L["被监听者收到 NODE_DELETED 事件"]
|
||||
L --> D
|
||||
```
|
||||
|
||||
**关键语义:**
|
||||
- **EPHEMERAL(临时性)**:客户端 session 断开(心跳超时),ZK 自动清理节点。不需要手动 unlock,避免死锁。
|
||||
- **SEQUENTIAL(顺序性)**:每个创建的节点附带单调递增的序号,保证了全局有序性和公平性。
|
||||
|
||||
### Watch 机制(一次性触发 + NodeChildrenChanged)
|
||||
|
||||
Watch 是 ZK 分布式锁能够工作的核心机制:
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 一次性(One-shot) | 触发后自动注销,必须重新注册。防止重复消费。 |
|
||||
| WATCHED 状态 | 客户端调用 `exists` / `getChildren` 时设置 watch=true,服务端记录此订阅关系。 |
|
||||
| EventType | 锁场景使用 `NodeChildrenChanged`——子节点列表变化时通知所有相关监听者。 |
|
||||
| 网络传输 | ZK 用独立的 watcher event 通道推送,与数据读取通道分离,保证低延迟。 |
|
||||
|
||||
```java
|
||||
// 伪代码:ZK 锁的 watch 逻辑
|
||||
Stat stat = zk.exists("/locks/" + prevNode, event -> {
|
||||
if (event.getType() == EventType.NodeDeleted) {
|
||||
// 上一个锁释放了,重新竞争
|
||||
tryAcquire();
|
||||
}
|
||||
});
|
||||
if (stat == null) {
|
||||
// 没有更小的节点,直接获得锁
|
||||
holdLock();
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Watch 的一次性特性意味着客户端必须在接收到事件后立刻重新注册。如果客户端处理事件耗时过长而错过了 re-register 窗口,可能永远不再收到下一次通知。Redisson 的 ZK 模块会做这个重试。
|
||||
|
||||
### 公平锁的实现
|
||||
|
||||
因为节点是 SEQUENTIAL 的,所以按序号大小决定锁的持有权,天然实现了公平性:
|
||||
|
||||
```
|
||||
/zk-lock/lock-0000000001 ← Alice (最小,获得锁)
|
||||
/zk-lock/lock-0000000002 ← Bob (监听 lock-0000000001)
|
||||
/zk-lock/lock-0000000003 ← Carol (监听 lock-0000000002)
|
||||
/zk-lock/lock-0000000004 ← Dave (监听 lock-0000000003)
|
||||
```
|
||||
|
||||
Alice 释放后,Bob 收到 NODE_DELETED → Bob 成为新的最小节点 → 获得锁。整个过程严格遵循 FIFO 顺序。
|
||||
|
||||
> [!TIP]
|
||||
> 面试考点:ZK 的公平性是"最终公平"而非"严格公平"。因为存在网络延迟和序列化差异,实际获得的顺序可能与创建节点的顺序不完全一致(但概率极低)。
|
||||
|
||||
### CP 性质保证 — ZAB 协议
|
||||
|
||||
Zookeeper 通过 **ZAB(ZooKeeper Atomic Broadcast)协议** 保证 CP(一致性 + 分区容错性):
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> LOOKING: 节点启动
|
||||
LOOKING --> LEADING: Leader Election 完成
|
||||
LOOKING --> OBSERVING: Observer 模式
|
||||
LEADING --> FOLLOWING: 平滑切换
|
||||
FOLLOWING --> LEADING: Leader 故障后选举
|
||||
LEADING --> OBSERVING: 降级为 Observer
|
||||
FOLLOWING --> OBSERVING: Observer 不选举也不提案
|
||||
|
||||
state LEADING {
|
||||
[*] --> Proposal: 接收客户端写请求
|
||||
Proposal --> Broadcast: 广播 proposal
|
||||
Broadcast --> AckCollect: 收集过半 ACK
|
||||
AckCollect --> Commit: 提交到事务日志
|
||||
}
|
||||
```
|
||||
|
||||
**ZAB 的两个核心阶段:**
|
||||
1. **Leader Election**:通过 ZXID(事务 ID)比较选出拥有最大 ZXID 的节点作为 Leader。选举过程中所有写操作排队等待。
|
||||
2. **Atomic Broadcast**:Leader 将写操作封装为 Proposal,follower 回复 ACK,达到半数后提交并广播给所有节点。
|
||||
|
||||
**脑裂问题(Split Brain)讨论:**
|
||||
|
||||
当网络分区导致集群分裂为两部分时:
|
||||
|
||||
| 场景 | 行为 | 安全性 |
|
||||
|------|-----|--------|
|
||||
| 多数派分区(≥ N/2+1) | 继续正常工作,选新 Leader | ✅ 安全 |
|
||||
| 少数派分区(< N/2+1) | 失去 Quorum,拒绝写操作 | ✅ 安全(CP 优先) |
|
||||
| N=3, 两个分区各 1 个 | 都无法达成 Quorum,全部停止 | ✅ 安全,但可用性为零 |
|
||||
|
||||
N 必须是奇数(3/5/7),这确保任何分区都只有一种结果:要么有 Quorum,要么没有。不会出现两个分区都认为自己有 Quorum 的情况。
|
||||
|
||||
### Redis 锁 vs ZK 锁全面对比
|
||||
|
||||
| 维度 | Redis 锁 | ZooKeeper 锁 |
|
||||
|------|---------|-------------|
|
||||
| 一致性模型 | AP(单实例)/ 不确定(Redlock) | CP(ZAB 强一致) |
|
||||
| 实现复杂度 | 简单(SET NX EX) | 中等(需理解临时节点和 Watch) |
|
||||
| 性能 | 极高(内存操作,百万级 QPS) | 较低(需要写事务日志和同步复制) |
|
||||
| 公平性 | 非公平(先到先得,无顺序保证) | 天然公平(SEQUENTIAL 序号) |
|
||||
| 看门狗续期 | Redisson 内部定时任务 | 自动(session 保活即可) |
|
||||
| 宕机安全性 | 依赖过期时间(可能被误删) | 自动清理(EPHEMERAL 节点) |
|
||||
| 适用规模 | 大规模分布式系统 | 中小规模协调服务 |
|
||||
| 运维成本 | 低(Redis 集群成熟) | 高(ZK 集群需要维护 odd-numbered nodes) |
|
||||
|
||||
> [!WARNING]
|
||||
> 不要为了"更高级"而去用 ZK 锁。如果你的业务只需要简单的互斥锁,Redis + SET NX 足够好。ZK 的优势在于它提供的不只是锁——还有顺序节点、Watch 通知、配置管理等一系列协调原语。
|
||||
|
||||
## 代码示例
|
||||
|
||||
Java 中使用 Curator 实现分布式锁(最流行的 ZK Java 客户端):
|
||||
|
||||
```java
|
||||
// Curator 封装了复杂的 ZK API
|
||||
InterProcessMutex lock = new InterProcessMutex(
|
||||
curatorFramework, "/locks/resource-a");
|
||||
|
||||
try {
|
||||
if (lock.acquire(10, TimeUnit.SECONDS)) {
|
||||
doBusiness();
|
||||
}
|
||||
} finally {
|
||||
lock.release(); // 递归释放所有重入计数
|
||||
}
|
||||
```
|
||||
|
||||
Curator 在底层做的事情:
|
||||
1. 创建 `/locks/resource-a` 目录(如不存在)。
|
||||
2. 在目录下创建 EPHEMERAL_SEQUENTIAL 子节点。
|
||||
3. 获取子节点列表,判断自己是否是最小序号。
|
||||
4. 如果是 → 加锁成功;否则 → 对前一个节点注册 Watch。
|
||||
5. Watch 触发 → 回到第 3 步重新判断。
|
||||
|
||||
## 实践场景
|
||||
|
||||
**秋招高频问题:**
|
||||
|
||||
- "ZK 为什么不直接用 EXISTS 命令不断轮询?" — EXISTS 不带 watch 会返回当前状态但不订阅变更。如果用 while(!exists) sleep(10ms) 的方式,不仅浪费 CPU,还可能在两次 exists 之间错过节点删除事件,导致无限等待。
|
||||
- "ZK 的会话超时怎么设?" — 默认 40 秒太长,建议设为 3~10 秒。短超时能更快感知客户端宕机并释放锁。但要平衡心跳间隔(heartbeat interval = timeout / 3),太短的 heartbeat 会增加不必要的网络开销。
|
||||
- "ZK 脑裂为什么不会导致两把锁同时存在?" — ZAB 协议要求写操作必须得到多数派确认后才会提交。脑裂时少数派分区无法获得 Quorum,任何写操作都不会被确认,因此不可能出现两个 Leader 各自分配同一个资源的情况。
|
||||
|
||||
> [!TIP]
|
||||
> 实战经验:ZK 锁最适合用于"需要强一致性的少量并发控制"场景——如数据库主从切换中的 Leader 选举、分布式调度器的 Job 唯一绑定。对于高频短锁(如计数器增减),Redis 明显更适合。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[Redis 分布式锁]]
|
||||
- [[幂等设计方案]]
|
||||
Reference in New Issue
Block a user