Redis 分布式锁
问题
如何用 Redis 实现分布式锁?有哪些常见问题?Redisson 的看门狗机制是什么?RedLock 靠谱吗?
答案
一、基础实现
# 加锁:SET key value NX EX(原子操作)
SET lock:order:123 "request-id-xxx" NX EX 30
# NX:不存在才设置(互斥)
# EX 30:30 秒过期(防死锁)
# 解锁:Lua 脚本保证原子性
EVAL "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:order:123 "request-id-xxx"
为什么解锁必须用 Lua 脚本?
如果分开执行 GET + DEL,可能出现:
- 客户端 A 锁快过期了,GET 检查是自己的锁
- 锁过期了,客户端 B 获取了锁
- 客户端 A 执行 DEL,把客户端 B 的锁删了
Lua 脚本保证 GET + DEL 原子执行。
二、常见问题与解决方案
| 问题 | 说明 | 解决方案 |
|---|---|---|
| 死锁 | 持锁进程崩溃,锁不释放 | 设置过期时间 |
| 误删别人的锁 | 过期后被重新获取的锁 | 锁值设唯一标识,解锁前校验 |
| 锁过期但业务未完成 | 业务执行时间 > 锁过期时间 | 看门狗自动续期 |
| 可重入 | 同一线程多次获取同一把锁 | 需要额外计数器 |
| 主从切换丢锁 | 主库加锁成功但未同步到从库 | RedLock |
三、Redisson(推荐方案)
Redisson 是 Java 的 Redis 客户端,封装了完整的分布式锁实现。
看门狗(Watchdog)机制
- 默认锁过期时间 30 秒
- Watchdog 每 10 秒(leaseTime / 3)检查并续期
- 客户端宕机后 Watchdog 也停止,锁最终超时释放
Redisson 支持的锁类型
| 锁类型 | 说明 |
|---|---|
RLock | 可重入锁(最常用) |
RFairLock | 公平锁(按请求顺序获取) |
RReadWriteLock | 读写锁 |
RMultiLock | 联锁(多个锁同时获取) |
RRedLock | RedLock 实现 |
RSemaphore | 信号量 |
四、RedLock 算法
Redis 主从切换时可能丢锁。RedLock 使用 多个独立 Redis 节点 来提高安全性:
加锁步骤:
- 获取当前时间 T1
- 依次向 N 个独立 Redis 节点发送加锁请求
- 如果超过半数节点(N/2 + 1)加锁成功,且总耗时 < 锁过期时间
- 加锁成功,实际有效期 = 过期时间 - 加锁耗时
- 否则向所有节点释放锁
RedLock 的争议
Martin Kleppmann(《DDIA》作者)对 RedLock 提出了质疑:
- GC 暂停、网络延迟可能导致锁失效
- 依赖系统时钟,时钟跳变会出问题
Antirez(Redis 作者)进行了反驳。
实践建议:
- 大多数场景使用单节点 + Watchdog 续期即可
- 如果需要更强一致性,考虑使用 ZooKeeper 或 etcd 的分布式锁
五、最佳实践
1. 锁粒度尽量小(lock:order:123 而非 lock:order)
2. 设置合理的过期时间(略大于业务执行时间)
3. 使用唯一标识(UUID/RequestID)防止误删
4. 解锁用 Lua 脚本保证原子性
5. 生产环境建议使用 Redisson 等成熟框架
6. 锁内业务要做到幂等(防止重试导致重复执行)
常见面试问题
Q1: Redis 分布式锁和 ZooKeeper 分布式锁的区别?
答案:
| 对比项 | Redis | ZooKeeper |
|---|---|---|
| 一致性 | AP(可能丢锁) | CP(强一致) |
| 性能 | 高 | 中 |
| 实现复杂度 | 简单 | 复杂 |
| 锁释放 | 过期时间 | 临时节点(会话断开自动删) |
| 可重入 | 需额外实现 | ZK 原生支持 |
| 适用场景 | 对一致性要求不极端 | 强一致性要求 |
Q2: 如果 Redis 加锁成功但还没来得及设过期时间就宕机了?
答案:
这就是为什么必须使用 SET key value NX EX 一条命令完成。如果分两步(SETNX + EXPIRE),SETNX 成功后宕机,锁就永远不过期。
Q3: 可重入锁怎么实现?
答案:
用 Hash 结构存储锁和计数:
# 加锁(Lua 脚本)
if redis.call('exists', KEYS[1]) == 0 then
redis.call('hset', KEYS[1], ARGV[1], 1) -- 新建锁,计数=1
redis.call('expire', KEYS[1], ARGV[2])
return 1
elseif redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[1], 1) -- 计数+1
redis.call('expire', KEYS[1], ARGV[2])
return 1
else
return 0 -- 锁被别人持有
end