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