跳到主要内容

Redis 分布式锁

问题

如何用 Redis 实现分布式锁?SETNX 方案存在什么问题?Redisson 如何解决这些问题?RedLock 是什么?

答案

为什么需要分布式锁

在分布式系统中,多个进程/服务实例可能同时操作共享资源。Java 的 synchronizedReentrantLock 只能保证单 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 脚本保证比较+删除的原子性:

释放锁的 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
Java 调用示例
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 客户端,封装了完善的分布式锁实现。

基本使用

Redisson 分布式锁
// 获取锁对象
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 秒。

指定 leaseTime 时不启动 Watchdog

如果调用 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 脚本逻辑:

  1. 锁不存在 → 创建 Hash,重入次数设为 1
  2. 锁存在且是自己持有 → 重入次数 +1
  3. 锁存在但不是自己持有 → 加锁失败
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 作者提出的分布式锁算法,解决主从切换时锁丢失的问题:

  1. 获取当前时间 T1
  2. 依次向 N 个独立的 Redis 实例(推荐 5 个)请求加锁
  3. 计算加锁总耗时 = 当前时间 - T1
  4. 如果在过半实例(≥ N/2 + 1)上加锁成功,且总耗时 < 锁的过期时间 → 加锁成功
  5. 否则向所有实例释放锁
RedLock 的争议

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 在新主上加锁成功 → 两个线程同时持有锁。

解决方案:

  1. RedLock 算法:向多个独立 Redis 实例加锁,过半成功才算成功。但有争议
  2. 使用 ZooKeeper/etcd:基于共识协议(Paxos/Raft),保证强一致性
  3. 业务端兜底:数据库乐观锁(版本号)作为最后一道防线

实际中大多数业务能接受极低概率的锁丢失风险,用 Redisson 单节点方案即可。

Q4: Redisson 的看门狗原理?

答案

  1. 调用 lock()tryLock(waitTime) 时(不指定 leaseTime),Redisson 默认设置锁过期时间为 30 秒lockWatchdogTimeout
  2. 加锁成功后,启动一个基于 Netty 的 TimerTask,每 30/3 = 10 秒 执行一次
  3. TimerTask 检查当前线程是否仍持有锁,如果是则调用 Lua 脚本执行 PEXPIRE 续期到 30 秒
  4. unlock() 被调用或线程 ID 对应的锁不存在时,TimerTask 被取消

注意:如果业务线程异常退出(如 OOM)且没有调用 unlock,Watchdog 的 TimerTask 也会停止(因为在同一个 JVM 中),锁到期后自动释放,不会造成死锁。

Q5: 分布式锁的使用注意事项?

答案

  1. 锁粒度要细:不要用一把大锁锁住所有资源,按业务实体加锁(如 lock:order:{orderId}
  2. 设置合理的过期时间:过短导致锁提前释放,过长导致故障恢复慢
  3. 释放锁用 try-finally:确保异常时也能释放锁
  4. 避免长时间持有锁:锁内只放必要的临界区代码
  5. 考虑可重入性:嵌套调用场景需要可重入锁
  6. 做好降级:Redis 不可用时的降级策略(如数据库锁)

相关链接