基于本文回答
0
评论

Redis分布式锁的实现

知识点图片

Redis 实现分布式锁是后端开发中非常经典的技术方案。一个完备的 Redis 分布式锁并不是简单地调用一个命令就能搞定的,它经历了一个不断演进、解决各种边界问题的过程。

下面我将从基础实现问题与优化工业级解决方案 (Redisson) 以及集群环境下的终极方案 (Redlock) 四个维度为您详细剖析。


一、 分布式锁的核心要求

一个靠谱的分布式锁,必须满足以下几个特性:

  1. 互斥性:在任意时刻,只有一个客户端能持有锁。
  2. 防死锁:即使持有锁的客户端崩溃,也能保证后续其他客户端可以获取锁(通常通过超时机制实现)。
  3. 加锁和解锁必须是同一个客户端:客户端不能把别人加的锁给解了。
  4. 高可用、高性能:获取锁和释放锁的操作要快,且 Redis 节点自身要高可用。

二、 Redis 分布式锁的演进过程

1. 青铜时代:SETNX + DEL

最开始,大家会想到用 Redis 的 SETNX (Set if Not eXists) 命令。

  • 加锁SETNX lock_key 1 (如果返回 1 说明加锁成功)。
  • 解锁DEL lock_key
  • 致命缺陷:如果加锁后,应用实例宕机了,没来得及执行 DEL,那么这个锁永远不会被释放,导致死锁

2. 白银时代:SETNX + EXPIRE

为了解决死锁,给锁加上过期时间。

  • 加锁SETNX lock_key 1
  • 设置过期EXPIRE lock_key 10 (10秒后自动释放)
  • 致命缺陷SETNXEXPIRE 是两条命令,不具备原子性。如果在 SETNX 成功后,执行 EXPIRE 前一瞬间应用宕机,依然会死锁。

3. 黄金时代:SET key value NX EX (Redis 2.6.12 之后)

Redis 官方修改了 SET 命令的参数,支持将判断存在和设置过期时间合并为一个原子操作。

  • 加锁SET lock_key unique_value NX EX 10
    (NX = Not eXists,EX = Seconds 过期时间)
  • 致命缺陷(误删锁)
    假设线程 A 拿到锁(过期时间10秒),但业务执行了15秒。
    第10秒时,锁自动释放了。此时线程 B 拿到了锁。
    第15秒时,线程 A 业务执行完,执行 DEL lock_key这时候 A 把 B 的锁给删了!

4. 钻石时代:唯一标识 + Lua 脚本解锁

为了解决误删锁的问题,我们在加锁时存入一个唯一标识(比如 UUID + ThreadID),解锁时先判断是不是自己加的锁,如果是再删除。
由于 "判断" 和 "删除" 是两步操作,为了保证原子性,必须使用 Lua 脚本

加锁(伪代码):

java
String uuid = UUID.randomUUID().toString() + Thread.currentThread().getId();
// 执行 SET lock_key uuid NX EX 10
boolean locked = redisTemplate.opsForValue().setIfAbsent("lock_key", uuid, 10, TimeUnit.SECONDS);

解锁(Lua 脚本):

plaintext
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

到这里,单机版的 Redis 分布式锁已经基本完善了。但还剩下一个难以解决的问题:锁过期了,但业务没执行完怎么办?


三、 工业级解决方案:Redisson 与 "看门狗"

为了解决 "业务没执行完,锁却自动过期了" 的问题,Java 生态中最著名的框架 Redisson 提供了一个完美的机制:看门狗 (Watchdog)

Redisson 的工作原理:

  1. 自动续期:当你使用 Redisson 加锁且不指定锁的超时时间时,Redisson 会默认设置锁的超时时间为 30 秒。
  2. 看门狗机制:同时,Redisson 会启动一个后台定时任务(看门狗)。它会每隔 10 秒(timeout / 3)去检查一下这个线程是否还持有锁,如果持有,就重新把锁的超时时间重置为 30 秒。
  3. 安全释放:如果应用宕机,看门狗线程也会随之销毁,锁会在 30 秒后自然过期,不会死锁。

Redisson 使用示例:

java
RLock lock = redissonClient.getLock("myLock");
try {
    // 阻塞式获取锁,默认开启看门狗机制
    lock.lock();
    
    // 执行业务逻辑
    
} finally {
    // 只有锁是当前线程持有,才进行解锁
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

注:Redisson 底层的加锁和解锁逻辑全部是使用极其复杂的 Lua 脚本实现的,并且支持可重入锁、读写锁、红绿灯等丰富的锁特性。


四、 集群环境下的终极挑战:Redlock (红锁)

上述所有的方案,都存在一个极端场景的隐患:Redis 主从切换导致锁丢失

场景说明:

  1. 线程 A 在 Redis Master 节点上成功获取了锁。
  2. 还没来得及同步到 Slave 节点,Master 节点宕机了。
  3. 哨兵模式/集群模式下,Slave 晋升为新的 Master。
  4. 线程 B 来申请锁,由于新 Master 上没有原来 A 的锁信息,B 加锁成功。
  5. 导致 A 和 B 同时持有了锁,互斥性被破坏!

Redlock 算法

为了解决这个问题,Redis 之父 Antirez 提出了 Redlock 算法。
核心思想:不使用主从复制,直接部署 N 个(通常为 5 个)独立的 Redis Master 节点。

加锁流程:

  1. 获取当前时间戳 T1
  2. 客户端尝试向这 5 个节点分别发送加锁请求(带上相同的 key 和 value,并设置较短的超时时间,防止在某个节点卡死)。
  3. 如果客户端在大多数(N/2 + 1,即至少 3 个)节点上成功获取了锁。
  4. 获取当前时间戳 T2。如果 T2 - T1 < 锁的过期时间,则认为加锁成功!
  5. 如果加锁失败(没凑够 3 个,或者耗时太长),客户端必须向所有节点发送解锁请求。

业界的取舍(非常重要)

虽然 Redlock 在理论上解决了单点故障引起的锁丢失问题,但在实际工业生产中,极少有公司真正使用 Redlock

  • Martin Kleppmann 的质疑:Redlock 强依赖服务器时钟,如果发生时钟跳跃(Clock Jump)或者长时间的 GC 停顿,Redlock 依然是不安全的。
  • 运维成本高:为了一个锁要维护 5 个独立的 Redis 节点。
  • 主流选择:对于 99% 的业务,使用 Redisson + Redis 主从/集群 已经足够了。如果偶尔发生极端的主从切换导致锁失效,可以通过数据库的乐观锁、唯一索引等兜底机制来保证数据一致性。
  • 如果你的业务对锁的安全性要求是 绝对的(比如金融核心交易),业界更推荐使用 Zookeeperetcd 来实现分布式锁(基于强一致性的共识算法)。

五、 总结与最佳实践

  1. 绝对不要自己手写 Redis 分布式锁(除非为了面试和学习)。里面的细节和边缘场景(Edge Cases)太多了。
  2. Java 开发者直接使用 Redisson。它帮你处理了 Lua 脚本、锁重入、自动续期(看门狗)等所有麻烦事。
  3. 加锁的 Key 要尽量小粒度。比如锁某一个用户的订单,Key 应该是 lock:order:{userId}:{orderId},千万不要锁整个 lock:order,以免影响并发性能。
  4. 务必在 finally 块中释放锁,并且先判断锁是否由当前线程持有。
  5. 考虑兜底方案:分布式锁防君子不防小人,数据库层面的乐观锁(版本号机制)和唯一约束依然是保证业务数据正确的最后一道防线。
右滑查看面试常问