vault backup: 2026-05-27 00:12:00

This commit is contained in:
hhs
2026-05-27 00:12:00 +08:00
parent 838e5e7824
commit 91b4fe3f36
9 changed files with 1116 additions and 180 deletions
+195 -27
View File
@@ -107,56 +107,156 @@ if replicaCount >= 1 {
### 全量同步 vs 增量同步
主从同步分为两种模式,理解它们的前提是知道一个关键概念:**Replication Offset(复制偏移量)**——你可以把它想象成一条数据"流水线"上的**刻度尺**。Master 每写入一条命令,刻度就往后移一点;Replica 同步到哪里了,也有自己的刻度。只要两个刻度对得上,就能"增量同步"。
回到"抄作业"的类比:你缺了一整学期的笔记,那就得**全量抄一本**(全量同步);你只是昨天请了一天假,那就**只抄昨天那几页**(增量同步)。Redis 的主从同步也一样,分这两种模式:
> [!NOTE] PSync:一次连接,多次复用
> Redis 2.8+ 引入 PSYNC 协议替代旧版 SYNC,核心优势:**断线重连后可增量同步**。旧版 SYNC 每次断线都得全量传输(相当于每次请假都得抄一整本笔记),PSYNC 改为"只抄你缺的那几页"。
- **全量同步(Full Resync)**:Master 把**整个数据集**打包成 RDB 文件发给 Replica。适用于首次连接、断线太久等场景。代价大,应尽量避免
- **增量同步(Partial Resync)**:Master 只发送 **Replica 缺失的那部分命令**。适用于正常运行期间的短暂断线。代价小,是常态
> [!QUESTION] 那 Redis 怎么判断应该走"全量"还是"增量"呢?
> 这就要说到两个关键的"身份标识"——Run ID 和 Replication Offset。
#### 前置概念:两个关键标识
要理解全量/增量的判断逻辑,需要先搞清楚两个东西:
**1. Run ID —— "你是哪个 Master?"**
每个 Redis 实例启动时会随机生成一个 40 字符的唯一标识符(如 `8b1f8a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a`)。Replica 第一次连接 Master 时会记住它的 Run ID。之后每次重连,Replica 先报出"我记得的 Run ID",Master 对比:
- **Run ID 匹配** → "你还认识我" → 有可能增量同步
- **Run ID 不匹配** → "我不认识你"(Master 重启了 / 换了台机器)→ 只能全量同步
**2. Replication Offset —— "你同步到哪了?"**
你可以把它想象成一条**数据流水线上的刻度尺**。Master 和 Replica 各自维护一个 offset:
- Master 每写入一条命令,offset 就往后移(比如从 `1000` 到 `1050`)
- Replica 每同步一条命令,自己的 offset 也往后移
- 只要 Master 的 **backlog 缓冲区** 里还保留着 Replica 需要的那段数据(即 Replica 的 offset 在 backlog 覆盖范围内),就能增量同步
```mermaid
flowchart TD
subgraph Identity["两个身份标识"]
RunID["Run ID<br/>回答: 你是哪个 Master?"]
Offset["Replication Offset<br/>回答: 我同步到哪了?"]
end
RunID -->|"匹配"| CheckOffset["检查 Offset"]
RunID -->|"不匹配"| FullSync["只能全量同步"]
CheckOffset -->|"Offset 在 backlog 范围内"| PartialSync["增量同步"]
CheckOffset -->|"Offset 已被覆盖"| FullSync
```
> [!NOTE] PSYNC 协议:让增量同步成为可能
> Redis 2.8 之前用的是 `SYNC` 命令,**每次断线都全量传输**——相当于你每次请假都得抄一整本笔记,不管你只缺一天还是缺一个月。Redis 2.8+ 引入了 `PSYNC` 协议,Replica 重连时带上 Run ID 和自己的 Offset,Master 就能判断"你只缺了一点"还是"你缺太多了",从而决定增量还是全量。
#### 全量同步:逐步拆解
全量同步发生在 **首次连接** 或 **断线太久导致增量同步不可能** 的场景。整个过程分为三个阶段:
```mermaid
sequenceDiagram
participant R as Replica
participant M as Master
Note over R,M: "阶段 1: 全量同步(首次连接或断线过久)"
R->>M: PSYNC ? -1 (首次连接, 没有 offset 记录)
M->>M: 触发 BGSAVE, 生成 RDB 快照文件
M-->>R: +FULLRESYNC <runId> <offset>, 然后发送 RDB 文件
Note over R,M: "阶段 1: 握手与身份确认"
R->>M: PSYNC ? -1
Note right of R: "? = 我不知道你的 Run ID<br/>-1 = 我没有 offset 记录"
M->>M: 执行 BGSAVE 生成 RDB 快照
M-->>R: "+FULLRESYNC <runId> <offset>"
Note left of M: "意思是: 好, 全量同步,<br/>我的 Run ID 是 xxx,<br/>当前 offset 是 yyy"
M-->>R: 发送 RDB 文件
R->>R: 清空旧数据, 用 RDB 全量覆盖
Note over M: "RDB 生成期间, 新的写入命令不能丢"
Note over R,M: "阶段 2: RDB 传输期间的新写入不能丢"
Note over M: "BGSAVE 期间, 客户端仍在写入"
loop "RDB 传输期间的新写入"
M->>M: 追加到 repl-backlog-buffer
M->>M: 新命令追加到 repl-backlog-buffer
end
M-->>R: 发送 backlog 中缓存的命令流
R->>R: 重放缓冲命令, 追上 Master 的进度
Note over R,M: "阶段 3: 发送缓冲命令, 追赶进度"
M-->>R: 发送 backlog 中缓存的增量命令
R->>R: 逐条重放, 追上 Master 最新进度
```
Note over R,M: "阶段 2: 增量同步(后续心跳, 每秒一次)"
loop "正常运行期间"
R->>M: PSYNC <masterRunId> <myOffset>
M->>R: 只发送 offset 之后的增量命令
> [!NOTE] 阶段 2 为什么不会丢数据?
> 你可能会担心:BGSAVE 是某一时刻的快照,那快照之后到 RDB 传输完成这段时间的写入怎么办?答案是 **backlog 缓冲区**。Master 在生成 RDB 的同时,会把所有新写入的命令追加到 backlog 里,等 RDB 传完后再把这些缓冲命令发给 Replica。所以 Replica 先拿到"截止到某个时间点的完整快照",再拿到"快照之后的增量命令",两者拼起来就是完整的最新数据。
#### 增量同步:逐步拆解
增量同步是 **正常运行时的常态**。Replica 短暂断线后重连,只需要补齐断线期间缺失的命令:
```mermaid
sequenceDiagram
participant R as Replica
participant M as Master
Note over R,M: "Replica 短暂断线后重连"
R->>M: PSYNC <runId> <myOffset>
Note right of R: "我认识你, Run ID 是 xxx,<br/>我的 offset 是 10000"
M->>M: 检查 runId 匹配<br/>检查 offset 10000 是否在 backlog 范围内
alt "offset 在 backlog 范围内 → 增量同步"
M-->>R: "+CONTINUE"
M-->>R: 发送 offset 10000 之后的增量命令
Note left of M: "只发你缺的 10001 ~ 10500,<br/>不用重传整个数据集"
R->>R: 重放增量命令, offset 追到 10500
else "offset 已被覆盖 → 退化为全量同步"
M-->>R: "+FULLRESYNC"
Note left of M: "你缺的太多了, 增量同步<br/>不可能了, 转为全量重传"
end
```
> [!QUESTION] 全量同步的代价是什么?
> 全量同步需要 Master 执行 `BGSAVE`(fork 子进程生成 RDB 文件),然后通过网络传给 Replica。如果数据量大(比如 10GB),这个过程会**消耗大量 CPU、内存和网络带宽**。所以我们的目标是:**尽量避免全量同步,让增量同步成为常态**。
> [!QUESTION] 怎么理解"offset 在 backlog 范围内"?
> 回到"圆形笔记本"的比喻——backlog 是一个固定大小的环形缓冲区(默认仅 1MB)。Master 不断往里写新命令,写满了就从头覆盖最旧的内容。当 Replica 断线重连时,它报出自己的 offset,Master 检查:
>
> - 这个 offset 对应的数据**还在 backlog 里**(没被覆盖)→ 只需发送 backlog 中这段数据 → 增量同步
> - 这个 offset 对应的数据**已经被覆盖了**(断线太久,backlog 装不下)→ 只能全量同步
#### PSYNC 完整决策流程
把上面的逻辑串起来,PSYNC 的完整判断过程如下:
```mermaid
flowchart TD
Start["Replica 发送<br/>PSYNC <runId> <offset>"] --> CheckRunID{"runId 匹配?"}
CheckRunID -->|"不匹配<br/>(Master 重启或新 Master)"| FullSync["返回 +FULLRESYNC<br/>全量同步"]
CheckRunID -->|"匹配"| CheckOffset{"offset 对应的数据<br/>还在 backlog 里?"}
CheckOffset -->|"是"| PartialSync["返回 +CONTINUE<br/>增量同步"]
CheckOffset -->|"否<br/>(已被覆盖)"| FullSync
CheckRunID -->|"runId = '?'<br/>(首次连接)"| FullSync
```
> [!NOTE] 一句话总结 PSYNC 的决策逻辑
> 先看你"认不认识我"(Run ID),再看你"缺的那部分我还记不记得"(Offset 是否在 backlog 内)。两个条件都满足才走增量,否则全量。
#### 全量同步的代价
> [!QUESTION] 全量同步开销有多大?
> 全量同步需要 Master 执行 `BGSAVE`(fork 子进程生成 RDB 文件),然后通过网络传给 Replica。如果数据量大(比如 10GB),这个过程会**消耗大量 CPU、内存和网络带宽**。更糟糕的是,如果多个 Replica 同时触发全量同步,Master 会被拖垮。
>
> 所以我们的目标是:**尽量避免全量同步,让增量同步成为常态**。
#### 什么情况下触发全量同步?
| 触发条件 | 为什么? | 能避免吗? |
|---------|------|------|
| **首次连接** | Replica 是空的,没有数据也没有 offset,只能全量下载 | ❌ 必然发生 |
| **Master 重启** | Master 重启后 Run ID 变了,Replica 认不出"老朋友" | ⚠️ 持久化可缓解(重启后 Run ID 不变) |
| **Replica 手动执行 SLAVEOF** | 相当于主动"认新主人",必须重新来过 | ❌ 手动触发,通常可控 |
| **Backlog 溢出** | 断线太久,Master 的"缓冲笔记本"已经翻页覆盖了断线前的内容 | ✅ **增大 `repl-backlog-size`** |
| **config-resetstat 执行** | 元数据被重置,offset 信息丢失 | ⚠️ 少用此命令 |
| **首次连接** | Replica 是空的,没有 Run ID 也没有 offset,PSYNC 只能发 `? -1` | ❌ 必然发生 |
| **Master 重启** | 重启后 Run ID 变了,Replica 发的旧 Run ID 匹配不上 | ⚠️ 持久化可缓解(配置 `save` 后重启 Run ID 不变) |
| **Replica 执行 SLAVEOF 指向新 Master** | 新 Master 的 Run ID 和旧的不同,必须全量重来 | ❌ 手动触发,通常可控 |
| **Backlog 溢出** | 断线太久,Replica 需要的 offset 已被环形缓冲区覆盖 | ✅ **增大 `repl-backlog-size`** |
| **config-resetstat 执行** | 重置 INFO 统计计数器,可能干扰监控判断,但不会直接影响复制 offset | ⚠️ 少用此命令 |
> [!WARNING] 生产环境最常见的"不必要全量同步"
> 绝大多数情况是 **Backlog 过小**导致的。网络抖动几秒,Backlog 不够用,Replica 被迫全量同步——这在网络不稳定的环境中会反复发生,严重拖垮 Master 性能。**加大 Backlog 是性价比最高的优化手段**。
> 绝大多数情况是 **Backlog 过小**导致的。网络抖动几秒,Backlog 不够用,Replica 被迫全量同步——这在网络不稳定的环境中会反复发生,严重拖垮 Master 性能。**加大 Backlog 是性价比最高的优化手段**,具体配置和估算方法见下一节。
### 复制背压(Replication Backlog)
每个 Master 内部维护一个固定大小的 **环形缓冲区**(默认仅 1MB),这是实现增量同步的关键:
每个 Master 内部维护一个固定大小的 **环形缓冲区**(64-bit 系统上默认 1MB,Redis 7.0+ 默认 64MB),这是实现增量同步的关键:
```mermaid
flowchart LR
@@ -180,6 +280,69 @@ repl-backlog-ttl 3600 # 无人订阅时多久自动释放(秒)
- backlog = 1MB → 约支持 **2 秒** 断线恢复
- backlog = 256MB → 约支持 **8 分钟** 断线恢复
### REPLCONF ACK:Master 如何感知 Replica 状态
> [!QUESTION] Master 一直在往 Replica 发数据,但它是单向发送的——Master 怎么知道 Replica 是否还活着、同步到哪了?
> 前面讲的都是 "Master → Replica" 的数据流。但复制是双向"互动"的:Master 需要知道每个 Replica 的实时状态,才能做运维决策(比如判断延迟、拒绝写入保护数据一致性)。这个感知能力靠的是 **Replica 主动向 Master 汇报**。
**工作原理**:Replica 每秒向 Master 发送一次 `REPLCONF ACK <offset>` 命令,告诉 Master 两件事:
1. **"我还活着"** — 心跳保活
2. **"我的同步进度是 offset=xxx"** — 用于计算延迟
Master 收到后更新该 Replica 的状态。通过 `INFO replication` 可以看到:
```text
connected_slaves:3
slave0:ip=192.168.1.21,port=6379,state=online,offset=12345600,lag=0
slave1:ip=192.168.1.22,port=6379,state=online,offset=12345600,lag=0
slave2:ip=192.168.1.23,port=6379,state=online,offset=12344800,lag=2
```
- **offset**:该 Replica 当前同步到哪里了(对比 Master 的 `master_repl_offset` 可知差距)
- **lag**:Master 有多久(秒)没收到这个 Replica 的 ACK,lag 越大说明 Replica 越"失联"
如果 Master 在 `repl-timeout`(默认 60 秒)内没有收到某 Replica 的 ACK,就将其标记为 `state=offline`。
```mermaid
sequenceDiagram
participant R as Replica
participant M as Master
Note over M: "Master 持续写入, offset 推进"
R->>M: REPLCONF ACK 10000
Note left of M: "记录: slave0 offset=10000, lag=0"
M->>M: 继续写入, offset 到 10100
R->>M: REPLCONF ACK 10050
Note left of M: "记录: slave0 offset=10050, lag=1"
Note over R: "Replica 断线..."
Note over M: "repl-timeout(60s) 内没收到 ACK"
Note left of M: "标记: slave0 state=offline"
```
> [!NOTE] REPLCONF ACK 的实际意义:写入保护
> 这个机制不只是"看看状态"——它直接支撑了**脑裂场景下的写入保护**。配合以下配置:
> ```conf
> min-replicas-to-write 1 # 至少 1 个 Replica 在线
> min-replicas-max-lag 10 # 且 lag 不超过 10 秒
> ```
> Master 根据 REPLCONF ACK 汇报的 lag 值判断:**在线且低延迟的 Replica 数量不满足条件时,Master 直接拒绝写入**。这样即使 Master 被网络隔离(脑裂),它也不会默默接受写入——因为没有 Replica 能同步这些数据,写入最终会丢失。
>
> 简单说:**没有 REPLCONF ACK → Master 对 Replica 状态一无所知 → 无法做写入保护 → 脑裂时必然丢数据**。
> [!QUESTION] REPLCONF ACK 和 Sentinel 的心跳有什么区别?
> 两者都是"心跳检测",但**方向和目的不同**:
>
> | 对比 | REPLCONF ACK | Sentinel PING |
> |------|-------------|---------------|
> | 方向 | Replica → Master | Sentinel → Master/Replica |
> | 频率 | 每秒 1 次 | 每秒 1 次 |
> | 目的 | Master 感知 Replica 存活和同步进度 | Sentinel 感知节点存活,触发故障转移 |
> | 判定失联后 | Master 标记 Replica 为 offline,触发 min-replicas 保护 | Sentinel 标记 SDOWN/ODOWN,触发选主和 Failover |
>
> 两者互补,缺一不可。
### 大 Key 问题
> [!QUESTION] 一个 key 有 5MB 大小,会有什么问题?
@@ -282,10 +445,15 @@ replica-lazy-flush no # 确保同步前快速清理旧数据
```conf
# 适用于金融、账务等场景
WAIT 1 100 # 写入时确认至少 1 个副本成功
repl-backlog-size 512mb # 更大缓冲区容纳更多增量命令
```
```go
// WAIT 是运行时命令,不能写在 redis.conf 里,需在业务代码中调用
// 写入后等待至少 1 个副本确认同步,最多阻塞 100ms
client.Do(ctx, "WAIT", 1, 100)
```
### 常用调试命令
```bash
@@ -413,8 +581,8 @@ flowchart TD
| 优先级 | 评估维度 | 含义 | 举例 |
|--------|---------|------|------|
| 1(最高) | **复制偏移量最大** | 同步进度最接近 Master | Replica-A offset=10000, Replica-B offset=9998 → 选 A |
| 2 | **`replica-priority` 最小** | 配置中人为指定的优先级 | priority=10 优先于 priority=100;priority=0 表示"永不选我" |
| 1(最高) | **`replica-priority` 最小** | 配置中人为指定的优先级 | priority=10 优先于 priority=100;priority=0 表示"永不选我" |
| 2 | **复制偏移量最大** | 同步进度最接近 Master | Replica-A offset=10000, Replica-B offset=9998 → 选 A |
| 3(最低) | **Run ID 字典序最小** | 纯粹的 tie-breaker | 两个 Replica 前两项完全相同,选 Run ID 字母序靠前的 |
> [!WARNING] 如果所有 Replica 都不健康怎么办?
+4
View File
@@ -141,6 +141,9 @@ if ok == 1 {
}
```
> [!tip] 分布式锁深入阅读
> 本节仅展示 Lua 实现分布式锁的核心代码。关于安全解锁、锁续期(Watchdog)、Redlock 算法、可重入锁等进阶内容,详见 [[hhs/Redis/09-高级特性/分布式锁]]。
### 经典场景 2:限流器(令牌桶简化版)
```lua
@@ -465,6 +468,7 @@ flowchart TD
## 关联笔记
- [[hhs/Redis/09-高级特性/分布式锁]] — 分布式锁完整解析(Redlock、Watchdog、可重入锁)
- [[hhs/Redis/06-主从与哨兵]] — Sentinel 故障检测(也是基于 PING/PONG 心跳)
- [[hhs/Redis/07-集群方案]] — Cluster 下 Pipeline/Lua 的限制
- [[hhs/Redis/README]] — 知识索引总览
+389
View File
@@ -0,0 +1,389 @@
---
tags: [Redis, 分布式锁, 分布式系统, 一致性]
create time: 2026-05-26 23:41
---
# 分布式锁
## 概述
分布式锁是分布式系统中协调多节点访问共享资源的核心机制。Redis 凭借单线程模型和高性能成为实现分布式锁的首选方案,但「用 SET NX 就够了」的想法往往埋下隐患。本文从最基础的实现出发,逐步剖析安全解锁、锁续期、Redlock 算法等关键问题,帮助你构建生产级的分布式锁。
## 一、为什么需要分布式锁?
在单机环境下,一个 `sync.Mutex` 或 `synchronized` 就能保护临界区。但在分布式场景下,多个服务实例部署在不同机器上,进程内的锁无法跨节点生效。
> [!QUESTION] 想象一个场景
> 电商系统有 3 个订单服务实例,用户下单时都需要扣减库存。如果两个实例同时读到库存为 1,各扣减 1,最终库存变成 -1——超卖了。这就是典型的**并发竞态问题**。
分布式锁的核心语义很简单:**在同一时刻,只有一个客户端能持有锁**。但实现起来需要满足以下条件:
| 条件 | 说明 |
|------|------|
| **互斥性** | 任意时刻只有一个客户端持有锁 |
| **无死锁** | 持有锁的客户端崩溃后,锁能自动释放 |
| **容错性** | 只要大部分节点存活,加锁/解锁就能正常工作 |
| **解铃还须系铃人** | 谁加的锁谁解,不能误删别人的锁 |
## 二、基本实现:SET NX EX
最简单也最常用的方案——利用 Redis 的 `SET` 命令附带 `NX` 和 `EX` 选项:
```bash
SET lock:order:1001 $client_token NX EX 30
# ↑ key ↑ 唯一标识 ↑ 不存在才设置 ↑ 30秒过期
```
```go
// 加锁
ok, err := rdb.SetNX(ctx, "lock:order:1001", clientToken, 30*time.Second).Result()
if !ok {
// 未获取到锁,处理重试或失败逻辑
return errors.New("lock is held by another client")
}
// 执行业务逻辑
doBusiness()
// 解锁(必须用 Lua 保证原子性!)
rdb.Eval(ctx, unlockScript, []string{"lock:order:1001"}, clientToken)
```
> [!WARNING] 为什么必须带 EX(过期时间)?
> 如果不设过期时间,持有锁的客户端崩溃后,这把锁将**永远不会释放**——其他客户端全部死等,形成死锁。`EX` 是分布式锁的安全网。
> [!QUESTION] 为什么 client_token 必须是全局唯一的?
> 假设你用固定字符串 `"my-lock"` 作为 value,那么任何客户端解锁时都无法判断这把锁是不是自己加的。UUID 或 Snowflake ID 能保证每个客户端的标识独一无二。
## 三、安全解锁:Lua 原子性释放
解锁看似简单——`DEL key` 就行?**不行**。考虑这个时序:
```mermaid
sequenceDiagram
participant A as "Client A"
participant R as "Redis"
participant B as "Client B"
A->>R: GET lock → 自己的 token
Note over A: 业务执行太久...
Note over R: lock 过期自动释放
B->>R: SET lock NX → 成功!
A->>R: DEL lock 💥
Note over B: 我的锁被 A 删了!
```
**解决方案**:解锁时必须先检查 value 是否是自己的 token,且「检查 + 删除」必须原子化——这正是 Lua 脚本的强项:
```lua
-- unlock.lua
-- KEYS[1] = lock key
-- ARGV[1] = client token
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0 -- 不是我的锁,拒绝删除
end
```
```go
var unlockScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`)
// 解锁时传入当初加锁用的同一个 token
result, _ := unlockScript.Run(ctx, rdb, []string{"lock:order:1001"}, clientToken).Int()
if result == 0 {
log.Warn("锁已过期或不属于当前客户端")
}
```
> [!TIP] 完整的加锁/解锁流程
> 1. 生成唯一 token(UUID)
> 2. `SET key token NX EX 30` 加锁
> 3. 执行业务逻辑
> 4. Lua 脚本原子性检查 + 释放锁
> 5. 无论成功失败,都要走到第 4 步(建议 `defer`)
## 四、锁续期:Watchdog 机制
> [!QUESTION] 业务执行时间超过了锁的 TTL 怎么办?
> 如果锁设了 30 秒过期,但业务跑了 40 秒,第 30 秒时锁自动释放,其他客户端就能拿到锁——互斥性被打破。
Redisson(Java 生态中 Redis 客户端)的解决方案是 **Watchdog**:后台线程定期检查锁是否还被持有,如果是就续期。用 Go 模拟这个思路:
```go
// Watchdog 自动续期(简化版)
func startWatchdog(ctx context.Context, rdb *redis.Client, key, token string, ttl time.Duration) context.CancelFunc {
ctx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(ttl / 3) // 每 TTL/3 检查一次
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return // 锁已释放,停止续期
case <-ticker.C:
// 原子续期:只有还持有锁才续
renewed := renewScript.Run(ctx, rdb, []string{key}, token, int(ttl.Seconds()))
if renewed.Int() == 0 {
return // 锁已不属于我,停止续期
}
}
}
}()
return cancel // 调用 cancel() 即停止 watchdog
}
```
```lua
-- renew.lua(原子续期)
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
end
return 0
```
```mermaid
flowchart LR
A["加锁 TTL=30s"] --> B["业务执行中"]
B -->|"T/3 = 10s"| C{"还持有锁?"}
C -->|"是"| D["续期 30s"]
D --> B
C -->|"否"| E["停止续期"]
B --> F["业务完成"]
F --> G["释放锁 + 停止 watchdog"]
```
> [!WARNING] Watchdog 不是银弹
> - 如果续期时 Redis 节点刚好故障,续期请求会失败——锁仍会过期
> - 真正需要超长锁持有时间时,考虑拆分业务逻辑,缩短临界区
> - Watchdog 的 interval 建议设为 TTL 的 1/3,留出容错空间
## 五、Redlock 算法
### 单节点的隐患
上述方案都基于单个 Redis 实例。如果这个实例主从切换时,锁数据还没同步到从节点,锁就「凭空消失」了。
> [!QUESTION] 主从切换时锁丢失的例子
> 1. Client A 在 master 上加锁成功
> 2. Master 还没来得及把锁同步到 slave 就宕机了
> 3. Slave 升级为新 master
> 4. Client B 在新 master 上加同一把锁——成功了!
> 结果:A 和 B 同时持有锁,互斥性被打破。
### Redlock 的思路
Antirez(Redis 作者)提出的 Redlock 算法:**向 N 个独立的 Redis 节点(非集群,互不通信)同时加锁**,当且仅当大多数节点(N/2 + 1)加锁成功且总耗时未超过锁的 TTL,才算获取成功。
```mermaid
flowchart TD
C["Client"] -->|"SET NX EX"| N1["Redis Node 1"]
C -->|"SET NX EX"| N2["Redis Node 2"]
C -->|"SET NX EX"| N3["Redis Node 3"]
C -->|"SET NX EX"| N4["Redis Node 4"]
C -->|"SET NX EX"| N5["Redis Node 5"]
N1 -->|"OK ✅"| C
N2 -->|"OK ✅"| C
N3 -->|"FAIL ❌"| C
N4 -->|"OK ✅"| C
N5 -->|"OK ✅"| C
Note over C: "4/5 成功 > 5/2+1=3,加锁成功!"
```
```go
// Redlock 伪代码(生产环境建议使用 redsync 等成熟库)
func redlock(ctx context.Context, clients []*redis.Client, key, token string, ttl time.Duration) bool {
successCount := 0
startTime := time.Now()
// 并发向所有节点发起加锁
var wg sync.WaitGroup
var mu sync.Mutex
for _, c := range clients {
wg.Add(1)
go func(c *redis.Client) {
defer wg.Done()
ok, _ := c.SetNX(ctx, key, token, ttl).Result()
if ok {
mu.Lock()
successCount++
mu.Unlock()
}
}(c)
}
wg.Wait()
elapsed := time.Since(startTime)
quorum := len(clients)/2 + 1
// 加锁有效时间 = TTL - 加锁耗时 - 时钟漂移
// 注意:elapsed 包含了网络往返时间,clock drift 需要根据实际环境估算(通常几毫秒)
validity := ttl - elapsed
return successCount >= quorum && validity > 0
}
```
### Redlock 的争议
Martin Kleppmann 在 [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) 中指出了 Redlock 的几个问题:
| 问题 | 说明 |
|------|------|
| **时钟跳跃** | NTP 同步导致节点时钟突变,锁的实际过期时间不可控 |
| **GC 停顿** | 客户端获取锁后进入 GC,锁在客户端视角未过期但实际已过期 |
| **复杂度高** | 需要部署 5 个独立 Redis 实例,运维成本远高于单节点 |
### Fencing Token:Kleppmann 的解法
Kleppmann 在批评 Redlock 的同时,提出了一个更安全的思路——**Fencing Token(防护令牌)**。核心思想:锁服务每次授予锁时,递增一个单调递增的 token,客户端携带这个 token 去访问资源,资源端拒绝持有过期 token 的写入。
```mermaid
sequenceDiagram
participant L as "Lock Service"
participant A as "Client A"
participant B as "Client B"
participant S as "Storage"
A->>L: 获取锁 → token=33
Note over A: GC 停顿...
Note over L: A 的锁过期
B->>L: 获取锁 → token=34
B->>S: 写入(token=34) ✅ 成功
Note over A: GC 恢复,以为还持有锁
A->>S: 写入(token=33) ❌ 被拒绝
Note over S: "33 < 34,拒绝过期请求!"
```
> [!NOTE] 为什么 Fencing Token 比 Redlock 更安全?
> Redlock 依赖**时间假设**(所有节点时钟一致),Fencing Token 把安全性转移到了**资源端校验**——不依赖时钟,只要资源端(如数据库)能比较 token 大小即可。实际场景中,可以在数据库的 `WHERE` 条件中加入 token 版本号,天然实现幂等校验。
> [!TIP] 实际生产中的选择
> - **对互斥性要求一般**(如防重复提交):单节点 `SET NX EX` + Lua 解锁足矣
> - **对互斥性要求较高**:Redlock 或 etcd/ZooKeeper 的分布式锁(基于 Raft/ZAB 协议,不依赖时钟)
> - **金融级强一致**:直接用数据库行锁或分布式共识系统
## 六、可重入锁
如果同一个线程/协程需要多次获取同一把锁(比如递归调用),普通锁会自己把自己锁死——这就是需要**可重入锁**的场景。
核心思路:value 不再是简单的 token,而是一个包含 **持有者标识 + 重入次数** 的结构。
```lua
-- reentrant_lock.lua
local key = KEYS[1]
local token = ARGV[1]
local ttl = ARGV[2]
local holder = redis.call("hget", key, "holder")
if holder == token then
-- 已持有,增加重入计数
redis.call("hincrby", key, "count", 1)
redis.call("expire", key, ttl)
return 1
end
if holder == false then
-- 无人持有,获取锁
redis.call("hset", key, "holder", token, "count", 1)
redis.call("expire", key, ttl)
return 1
end
return 0 -- 被其他人持有
```
```go
var reentrantLockScript = redis.NewScript(`
local holder = redis.call("hget", KEYS[1], "holder")
if holder == ARGV[1] then
redis.call("hincrby", KEYS[1], "count", 1)
redis.call("expire", KEYS[1], ARGV[2])
return 1
end
if holder == false then
redis.call("hset", KEYS[1], "holder", ARGV[1], "count", 1)
redis.call("expire", KEYS[1], ARGV[2])
return 1
end
return 0
`)
```
```go
var reentrantUnlockScript = redis.NewScript(`
local holder = redis.call("hget", KEYS[1], "holder")
if holder ~= ARGV[1] then
return 0 -- 不是自己的锁
end
local count = redis.call("hincrby", KEYS[1], "count", -1)
if count <= 0 then
redis.call("del", KEYS[1]) -- 重入归零,释放锁
end
return 1
`)
```
> [!NOTE] Hash 结构的选择
> 使用 Hash(`HSET`)而非 String,是因为 Hash 可以在一个 key 中同时存储 `holder` 和 `count`,解锁时原子性递减计数,归零才删除 key。
## 七、常见陷阱与最佳实践
### 陷阱清单
| 陷阱 | 后果 | 解决方案 |
|------|------|---------|
| 不设 TTL | 崩溃后死锁 | `SET NX EX`,TTL 设为业务预估时间的 2-3 倍 |
| 直接 DEL 解锁 | 可能误删别人的锁 | Lua 脚本原子性检查 + 删除 |
| 锁粒度太粗 | 并发度低,吞吐下降 | 按资源维度加锁(如 `lock:order:{id}`) |
| 锁粒度太细 | 管理复杂,容易遗漏 | 抽象锁工具层,统一管理 |
| 未考虑网络分区 | 锁失效但客户端认为持有 | Watchdog + 业务层幂等 |
| 未做重试 | 瞬时竞争直接失败 | 指数退避 + jitter 重试 |
### Redis Cluster 下的注意事项
Redis Cluster 采用哈希槽分片,Lua 脚本要求操作的 key 在同一个 slot 上。单把分布式锁只涉及一个 key,通常没有问题,但有两个场景需要注意:
> [!WARNING] 多 key 加锁的坑
> 如果业务需要同时锁定多个资源(如 `lock:order:1` 和 `lock:order:2`),这两个 key 可能分布在不同 slot,Lua 脚本会报 `CROSSSLOT` 错误。解决方案:
> 1. 使用 Hash Tag 强制同 slot:`lock:{order}:1`、`lock:{order}:2`(花括号内的部分决定 slot)
> 2. 逐个加锁,配合死锁预防(如按 key 字典序依次获取)
> [!NOTE] Cluster 故障转移与锁丢失
> Cluster 模式下的主从切换和哨兵模式类似——异步复制导致锁可能在故障转移后丢失。Cluster 不会自动重试 Lua 脚本,客户端需要自己处理 `MOVED`/`ASK` 重定向。
### 重试策略:指数退避
```go
func acquireLockWithRetry(ctx context.Context, rdb *redis.Client, key, token string, ttl time.Duration, maxRetries int) (bool, error) {
for i := 0; i < maxRetries; i++ {
ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
if ok {
return true, nil
}
if err != nil {
return false, err
}
// 指数退避 + 随机抖动
backoff := time.Duration(1<<uint(i)*100) * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
time.Sleep(backoff + jitter)
}
return false, errors.New("max retries exceeded")
}
```
> [!TIP] 分布式锁的黄金法则
> 1. **锁要短命**——临界区尽量小,TTL 尽量短
> 2. **解锁要安全**——Lua 原子性检查 + 删除
> 3. **业务要幂等**——即使锁失效,幂等性也能兜底
> 4. **不要过度设计**——单节点方案能解决的,不要上 Redlock
## 关联笔记
- [[hhs/Redis/09-高级特性]] — Lua 脚本基础、事务机制
- [[hhs/Redis/06-主从与哨兵]] — 主从同步延迟导致的锁丢失问题
- [[hhs/Redis/07-集群方案]] — Cluster 模式下 Lua 脚本的 slot 限制
- [[hhs/Redis/README]] — Redis 知识索引总览