Redis分布式锁的正确实现方式
// 目录 · contents
引言
分布式锁是分布式系统中最常见的协调原语之一。在微服务架构下,多个服务实例需要互斥地访问共享资源(如库存扣减、订单创建等),分布式锁就是解决这类问题的核心工具。Redis由于其高性能和广泛使用,成为实现分布式锁的热门选择,但要正确实现一个可靠的Redis分布式锁并不简单。
基础方案:SETNX
最简单的分布式锁
1 | |
sequenceDiagram
participant C1 as Client 1
participant R as Redis
participant C2 as Client 2
C1->>R: SET lock:order NX EX 30 (value=uuid1)
R->>C1: OK (获取锁成功)
C2->>R: SET lock:order NX EX 30 (value=uuid2)
R->>C2: nil (获取锁失败,key已存在)
Note over C1: 执行业务逻辑...
C1->>R: Lua脚本: if GET == uuid1 then DEL
R->>C1: 1 (释放锁成功)
C2->>R: SET lock:order NX EX 30 (value=uuid2)
R->>C2: OK (现在获取锁成功)
为什么需要随机 value
value必须是一个随机唯一值(如UUID),用于释放锁时验证身份。
sequenceDiagram
participant C1 as Client 1
participant R as Redis
participant C2 as Client 2
C1->>R: SET lock NX EX 30
R->>C1: OK
Note over C1: 业务处理耗时过长<br/>锁自动过期了!
C2->>R: SET lock NX EX 30
R->>C2: OK (锁已过期,C2获取到锁)
Note over C1: 业务处理完成,释放锁
C1->>R: DEL lock
Note over R: 删除了C2的锁!
Note over C2: C2的锁被C1误删<br/>其他客户端可以获取锁<br/>互斥性被破坏!
正确的解锁操作
必须使用Lua脚本保证”检查value并删除”的原子性:
1 | |
1 | |
基础方案的问题
- 锁过期但业务未完成:TTL到期后锁自动释放,但业务还在执行
- 不可重入:同一线程不能重复获取同一把锁
- 不支持阻塞等待:获取失败只能轮询重试
- 单点故障:Redis Master宕机时,Slave可能没有同步到锁数据
Redisson 实现方案
Redisson是Java生态中最成熟的Redis客户端,提供了功能完善的分布式锁实现。
Redisson 锁的核心机制
flowchart TD
A[尝试获取锁] --> B{锁是否存在?}
B -->|不存在| C[创建Hash<br/>field=clientId:threadId<br/>value=1]
B -->|存在| D{是否是自己的锁?}
D -->|是| E[重入: value+1]
D -->|否| F[返回锁的剩余TTL]
C --> G[设置过期时间]
E --> G
G --> H[启动WatchDog<br/>每10秒续期到30秒]
F --> I[订阅解锁消息<br/>等待通知后重试]
Redisson 加锁的 Lua 脚本
1 | |
WatchDog 自动续期
Redisson的WatchDog机制解决了”锁过期但业务未完成”的问题:
sequenceDiagram
participant C as Client
participant WD as WatchDog Timer
participant R as Redis
C->>R: 获取锁 (TTL=30s)
R->>C: OK
C->>WD: 启动WatchDog
Note over C: 执行业务逻辑...
WD->>R: 第10秒: PEXPIRE lock 30000 (续期到30s)
R->>WD: OK
Note over C: 继续执行...
WD->>R: 第20秒: PEXPIRE lock 30000 (再次续期)
R->>WD: OK
Note over C: 业务完成
C->>R: 释放锁
C->>WD: 停止WatchDog
1 | |
Redlock 算法
单节点 Redis 的问题
sequenceDiagram
participant C1 as Client 1
participant M as Redis Master
participant S as Redis Slave
C1->>M: SET lock NX EX 30
M->>C1: OK (获取锁成功)
Note over M: Master宕机!<br/>锁数据尚未同步到Slave
S->>S: 提升为新Master
participant C2 as Client 2
C2->>S: SET lock NX EX 30
S->>C2: OK (也获取到了锁!)
Note over C1, C2: 两个客户端同时持有锁!<br/>互斥性被破坏!
Redlock 算法流程
Redis作者antirez提出的Redlock算法,使用N个(通常5个)独立的Redis实例来实现更安全的分布式锁:
flowchart TD
A[开始获取锁] --> B[记录当前时间 T1]
B --> C[依次向N个Redis实例请求加锁<br/>每个实例设置较短的超时时间]
C --> D[记录当前时间 T2]
D --> E{在过半实例 N/2+1<br/>上加锁成功?}
E -->|否| F[在所有实例上释放锁<br/>随机延迟后重试]
E -->|是| G{T2-T1 小于锁的过期时间?}
G -->|否| F
G -->|是| H[获取锁成功!<br/>锁的有效时间 = 过期时间 - T2加T1]
H --> I[执行业务逻辑]
I --> J[在所有实例上释放锁]
Redlock的关键步骤:
- 获取当前时间T1(毫秒级)
- 依次向N个独立Redis实例发送SET NX EX请求,每个实例设置较短的网络超时(如5-50ms)
- 获取当前时间T2,计算获取锁总耗时
- 如果在过半实例(N/2+1)上加锁成功,且总耗时小于锁的TTL,则认为获取锁成功
- 锁的实际有效时间 = TTL - (T2-T1) - 时钟漂移补偿
- 如果获取失败,在所有实例上释放锁
1 | |
Martin Kleppmann 的质疑
分布式系统专家Martin Kleppmann发表了著名的文章”How to do distributed locking”,对Redlock算法提出了深刻的质疑。
质疑一:GC暂停导致锁失效
sequenceDiagram
participant C1 as Client 1
participant R as Redis 5个实例
participant DB as 共享资源
C1->>R: 获取Redlock TTL=30s
R->>C1: 锁获取成功
Note over C1: GC暂停了35秒!<br/>Full GC / STW
Note over R: 30秒后锁过期
participant C2 as Client 2
C2->>R: 获取Redlock
R->>C2: 锁获取成功
Note over C1: GC恢复,认为自己还持有锁
C1->>DB: 写入数据
C2->>DB: 写入数据
Note over DB: 数据不一致!
质疑二:时钟跳变问题
Redlock的安全性依赖于各Redis实例的时钟大致同步。如果某个实例发生时钟跳变(NTP调整、闰秒等),可能导致锁提前过期:
1 | |
Fencing Token 方案
Martin Kleppmann提出,真正安全的分布式锁应该使用Fencing Token:
sequenceDiagram
participant C1 as Client 1
participant Lock as Lock Service
participant DB as Storage
C1->>Lock: 获取锁
Lock->>C1: token=33
Note over C1: GC暂停,锁过期
participant C2 as Client 2
C2->>Lock: 获取锁
Lock->>C2: token=34
C2->>DB: 写入 token=34
DB->>DB: 记录最新token=34
DB->>C2: OK
Note over C1: GC恢复
C1->>DB: 写入 token=33
DB->>DB: token=33 小于最新token=34
DB->>C1: REJECTED 过期的token
Note over DB: 数据一致性得到保证!
Fencing Token的关键点: 1. 每次获取锁时返回一个单调递增的token 2. 写入共享资源时必须携带token 3. 共享资源(如数据库)拒绝旧token的写入
Redis 分布式锁 vs ZooKeeper
graph TD
subgraph Redis分布式锁
R1[优点] --> R1a[性能极高 10万+ QPS]
R1 --> R1b[实现简单]
R1 --> R1c[客户端生态丰富]
R2[缺点] --> R2a[主从切换可能丢失锁]
R2 --> R2b[时钟依赖]
R2 --> R2c[没有原生Fencing Token]
end
subgraph ZooKeeper分布式锁
Z1[优点] --> Z1a[CP系统 一致性保证强]
Z1 --> Z1b[临时有序节点 天然支持]
Z1 --> Z1c[Watch机制 无需轮询]
Z2[缺点] --> Z2a[性能较低 1万左右QPS]
Z2 --> Z2b[运维复杂]
Z2 --> Z2c[客户端实现复杂]
end
| 维度 | Redis | ZooKeeper | etcd |
|---|---|---|---|
| 一致性模型 | AP(最终一致性) | CP(强一致性) | CP(强一致性) |
| 性能 | 极高 | 中等 | 中等偏高 |
| 锁释放机制 | TTL过期 | 临时节点 + Session | Lease + TTL |
| 可重入 | 需要客户端实现 | 客户端支持 | 需要客户端实现 |
| 公平性 | 需要额外实现 | 有序节点天然支持 | 需要额外实现 |
| 生态 | Redisson等成熟方案 | Curator | clientv3 |
实践建议
选择合适的方案
flowchart TD
A[需要分布式锁] --> B{对一致性要求?}
B -->|极高: 金融/交易| C[ZooKeeper / etcd]
B -->|较高: 库存/订单| D{能否接受极低概率的锁失效?}
D -->|能| E[Redisson + 幂等设计]
D -->|不能| C
B -->|一般: 缓存/限流| F[Redis SETNX 基础方案]
E --> G[配合业务层Fencing]
F --> H[注意设置合理的TTL]
最佳实践
1 | |
锁超时时间的选择
1 | |
总结
Redis分布式锁的正确实现需要理解其局限性并做出合理的权衡:
- SETNX + TTL + Lua解锁是基础方案,适用于对一致性要求不极高的场景
- Redisson提供了可重入锁、公平锁、WatchDog等完善的功能,是Java生态的首选
- Redlock算法通过多个独立Redis实例提升安全性,但Martin Kleppmann指出了其在GC暂停和时钟跳变场景下的理论缺陷
- Fencing Token是从根本上解决分布式锁安全性问题的方案,但需要共享资源端的配合
- 对一致性要求极高的场景,建议使用ZooKeeper或etcd等CP系统
- 无论使用哪种分布式锁方案,都应该在业务层面做好幂等性设计作为最后的安全网
踩坑记录
优惠券核销服务出现重复核销
bug,损失了一批优惠券额度,复盘才发现历史遗留代码用的是
SETNX + EXPIRE
两步实现分布式锁。在高并发下,SETNX
成功后服务进程偶发重启,EXPIRE
没来得及执行,锁永久不释放,其他线程全部阻塞在等锁。
更严重的是另一个问题:锁超时设置 5s,但优惠券核销偶尔因下游支付系统慢会超过 5s,锁到期后自动释放,第二个线程拿到锁进来,导致同一张券被核销两次。
修复分三步:第一步用 SET key value NX EX seconds
原子命令替换两步操作;第二步加 Lua
脚本保证「只有持有者才能释放锁」(验证 value 是自己的 UUID);第三步引入
Redisson 的 watchdog 机制,持有锁期间每 10s
自动续期,彻底消除业务超时导致的锁提前释放。
实测结果
| 方案 | 压测重复核销率(1000次/轮) | 锁永久不释放风险 | 业务超时安全性 |
|---|---|---|---|
SETNX + EXPIRE(两步) |
0.3% | 有(进程崩溃时) | 不安全(超时自动丢锁) |
SET NX EX + Lua 验证 |
0% | 无 | 不安全(超时仍丢锁) |
| Redisson(含 watchdog) | 0% | 无 | 安全(自动续期) |
bug 影响范围:约 3,000 张优惠券被重复核销,损失已通过补偿处理。
我的看法
Redis 分布式锁有一个经常被忽视的问题:即使用了 Redlock,在 Redis 主从切换的瞬间,新主节点可能没有原来的锁数据,导致两个客户端同时持锁。
对资金、库存、优惠券这种强一致性场景,我更倾向于用数据库的
SELECT FOR UPDATE 或乐观锁(version
字段),性能低一点,但可靠性远高于 Redis 锁。Redis
分布式锁更适合「排他但允许偶发失效」的场景,比如防重复提交、任务调度去重。不要把
Redis 锁用在不能出错的地方。