8.0 KiB
tags, create time, update time
| tags | create time | update time | ||||||
|---|---|---|---|---|---|---|---|---|
|
2026-08-08 18:00 | 2026-08-08 18:00 |
Redis 分布式锁
概述
Redis 分布式锁是最广泛使用的分布式同步原语之一。本文深入分析 SETNX + EXPIRE 的原子性问题、Redlock 算法的设计与争议、Redisson 看门狗续期机制、可重入锁的 hash key 设计,以及各类边界场景的死锁分析与规避方案。
核心原理
SETNX + EXPIRE 的原子性陷阱
最直观的分布式锁实现是两条命令组合:
// ❌ 非原子操作 — 存在竞态条件
redis.set("lock", "client_id", "NX"); // 尝试加锁
redis.expire("lock", 30, "EX"); // 设置过期时间
问题:如果 set 成功但 expire 失败(网络抖动、进程崩溃),锁永远不会释放——死锁。
解决方案:使用 Redis 2.6.12+ 支持的原子语法 SET key value NX EX seconds:
// ✅ 原子操作
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 算法,旨在解决单点故障下锁不可用的问题:
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 (释放)
核心规则:
- 至少部署 5 个无主从复制的独立 Redis 实例。
- 客户端按顺序尝试 SET NX,记录总耗时 T。
- 获得多数派(≥ N/2+1)OK 才算加锁成功,有效 TTL = 设定值 − T。
- 释放锁时向所有节点发 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 秒后自动删除 |
// Redisson 看门狗工作原理伪代码
RLock lock = redisson.getLock("myLock");
lock.lock(); // 没有传入 leaseTime 参数
try {
doBusiness(); // 看门狗每 10s 续期一次,每次续 30s
} finally {
lock.unlock(); // 显式释放,关闭看门狗
}
Note
如果明确知道业务执行时间上限,建议在
lock(leaseTime)时直接传入精确的到期时间,避免看门狗的额外开销和复杂度。
可重入锁设计
可重入意味着同一个线程可以多次获取同一把锁而不被阻塞。Redisson 的实现方式是 hash key + count 计数器:
// 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 实现简单的分布式锁:
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 查询。