Redis 分布式锁
问题
如何用 Redis 实现分布式锁?SETNX 方案存在什么问题?Redisson 如何解决这些问题?RedLock 是什么?
答案
为什么需要分布式锁
在分布式系统中,多个进程/服务实例可能同时操作共享资源。Java 的 synchronized 和 ReentrantLock 只能保证单 JVM 内的线程安全,跨进程需要分布式锁。
基于 SETNX 的实现
基本版本
# 加锁:SET key value NX EX seconds(原子操作)
SET lock:order:123 thread-id-1 NX EX 30
# 释放锁:先比较再删除(Lua 脚本保证原子性)
# ❌ 错误:SETNX 和 EXPIRE 不是原子操作,中间宕机会导致死锁
SETNX lock:order:123 thread-id-1
EXPIRE lock:order:123 30
# ✅ 正确:使用 SET NX EX 一条命令
SET lock:order:123 thread-id-1 NX EX 30
释放锁 —— Lua 脚本
释放锁时必须验证是自己持有的锁,使用 Lua 脚本保证比较+删除的原子性:
-- KEYS[1] = lock key, ARGV[1] = 当前线程标识
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
String luaScript = "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) else return 0 end";
Long result = jedis.eval(luaScript,
Collections.singletonList("lock:order:123"),
Collections.singletonList(threadId));
SETNX 方案的问题
| 问题 | 描述 | 后果 |
|---|---|---|
| 锁超时释放 | 业务未执行完,锁已过期 | 其他线程获得锁,数据不一致 |
| 不可重入 | 同一线程再次获取锁失败 | 递归或嵌套调用死锁 |
| 无法续期 | 固定过期时间 | 超时释放问题无法根本解决 |
| 单点故障 | 主节点宕机,锁丢失 | 锁的安全性无法保证 |
Redisson —— 生产级分布式锁
Redisson 是 Redis 的 Java 客户端,封装了完善的分布式锁实现。
基本使用
// 获取锁对象
RLock lock = redissonClient.getLock("lock:order:123");
try {
// 尝试加锁,最多等待 5 秒,锁持有时间 30 秒
boolean acquired = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (acquired) {
// 执行业务逻辑
processOrder();
}
} finally {
// 释放锁(只有持有者能释放)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
Redisson 如何解决核心问题
1. 看门狗(Watchdog)自动续期
如果不指定 leaseTime,默认锁持有时间 30 秒,Redisson 启动看门狗线程,每 10 秒(默认 30/3)检查锁是否仍被持有,如果是则自动续期 30 秒。
如果调用 tryLock(5, 30, TimeUnit.SECONDS) 传了 leaseTime,Watchdog 不会启动,到期自动释放。只有使用 lock() 或 tryLock(5, TimeUnit.SECONDS) 时才会启动 Watchdog。
2. 可重入锁
Redisson 使用 Hash 结构 记录锁的持有者和重入次数:
# Hash: lock:order:123
# field: thread-uuid-1 (线程标识)
# value: 2 (重入次数)
HSET lock:order:123 thread-uuid-1 2
加锁的 Lua 脚本逻辑:
- 锁不存在 → 创建 Hash,重入次数设为 1
- 锁存在且是自己持有 → 重入次数 +1
- 锁存在但不是自己持有 → 加锁失败
3. 公平锁
RLock fairLock = redissonClient.getFairLock("lock:order:123");
使用 Redis 的 List + ZSet 维护等待队列,保证先到先得(FIFO)。
4. 联锁和红锁
// 联锁:同时锁定多个资源
RLock lock1 = client1.getLock("lock1");
RLock lock2 = client2.getLock("lock2");
RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2);
multiLock.lock();
// 红锁(RedLock 算法)
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();
RedLock 算法
Redis 作者提出的分布式锁算法,解决主从切换时锁丢失的问题:
- 获取当前时间 T1
- 依次向 N 个独立的 Redis 实例(推荐 5 个)请求加锁
- 计算加锁总耗时 = 当前时间 - T1
- 如果在过半实例(≥ N/2 + 1)上加锁成功,且总耗时 < 锁的过期时间 → 加锁成功
- 否则向所有实例释放锁
Martin Kleppmann(《DERTA》作者)发表了 How to do distributed locking 质疑 RedLock 的安全性(时钟漂移、GC 暂停等场景可能导致锁失效)。Redis 作者也进行了回应。
实际生产中:
- 对正确性要求极高的场景,使用 ZooKeeper 或 etcd 的分布式锁
- 大多数业务场景,Redisson 的单节点锁 + Watchdog 已足够
分布式锁方案对比
| 方案 | 性能 | 可靠性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Redis SETNX | 高 | 中 | 低 | 简单场景 |
| Redisson | 高 | 中高 | 低(封装好) | Java 生产环境首选 |
| ZooKeeper | 中 | 高 | 高 | 强一致性要求 |
| etcd | 中 | 高 | 中 | 云原生场景 |
| MySQL 行锁 | 低 | 高 | 低 | 低并发场景 |
常见面试问题
Q1: Redis 分布式锁怎么实现?
答案:
基本实现:SET lock_key unique_value NX EX timeout
NX:只在 key 不存在时设置,保证互斥EX:设置过期时间,防止死锁unique_value:使用线程唯一标识,释放时验证身份
释放锁:使用 Lua 脚本原子执行"先比较再删除",防止误删别人的锁。
生产环境使用 Redisson 封装好的 RLock,内置看门狗续期、可重入、公平锁等特性。
Q2: 锁超时释放了怎么办?
答案:
这是 Redis 分布式锁的经典问题。业务执行时间超过锁的过期时间,锁被自动释放,其他线程获取到锁,造成并发冲突。
解决方案:使用 Redisson 的 Watchdog(看门狗) 机制:
- 不指定 leaseTime 时,默认锁持有 30 秒
- Watchdog 后台线程每 10 秒检查,如果业务线程仍持有锁,自动续期 30 秒
- 业务完成释放锁或线程异常退出时,Watchdog 停止,锁到期自动释放
Q3: 主从切换导致锁丢失怎么办?
答案:
场景:线程 A 在主节点加锁成功 → 主节点宕机,锁还没同步到从节点 → 从节点晋升为新主 → 线程 B 在新主上加锁成功 → 两个线程同时持有锁。
解决方案:
- RedLock 算法:向多个独立 Redis 实例加锁,过半成功才算成功。但有争议
- 使用 ZooKeeper/etcd:基于共识协议(Paxos/Raft),保证强一致性
- 业务端兜底:数据库乐观锁(版本号)作为最后一道防线
实际中大多数业务能接受极低概率的锁丢失风险,用 Redisson 单节点方案即可。
Q4: Redisson 的看门狗原理?
答案:
- 调用
lock()或tryLock(waitTime)时(不指定 leaseTime),Redisson 默认设置锁过期时间为 30 秒(lockWatchdogTimeout) - 加锁成功后,启动一个基于 Netty 的 TimerTask,每
30/3 = 10 秒执行一次 - TimerTask 检查当前线程是否仍持有锁,如果是则调用 Lua 脚本执行
PEXPIRE续期到 30 秒 - 当
unlock()被调用或线程 ID 对应的锁不存在时,TimerTask 被取消
注意:如果业务线程异常退出(如 OOM)且没有调用 unlock,Watchdog 的 TimerTask 也会停止(因为在同一个 JVM 中),锁到期后自动释放,不会造成死锁。
Q5: 分布式锁的使用注意事项?
答案:
- 锁粒度要细:不要用一把大锁锁住所有资源,按业务实体加锁(如
lock:order:{orderId}) - 设置合理的过期时间:过短导致锁提前释放,过长导致故障恢复慢
- 释放锁用 try-finally:确保异常时也能释放锁
- 避免长时间持有锁:锁内只放必要的临界区代码
- 考虑可重入性:嵌套调用场景需要可重入锁
- 做好降级:Redis 不可用时的降级策略(如数据库锁)