Files
cs-note/hhs/Redis/09-高级特性/分布式锁.md
T
2026-05-27 00:12:00 +08:00

390 lines
15 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: [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 知识索引总览