Redis 分布式锁的底层实现经历了从简单到复杂、从不可靠到相对可靠的演进过程。其核心思想是:在分布式系统中,用一个共用的 Redis服务来充当一个公证人,所有客户端通过在这个公证人那里占坑的方式来获取锁,通过删坑来释放锁。


最基础的实现:SETNX + DEL

这是最原始的想法,利用 Redis 的 SETNX(Set if Not eXists)命令。

  • 加锁SETNX lock_key my_random_value
    • 如果返回 1,说明设置成功,客户端获取到锁。
    • 如果返回 0,说明 key 已存在,锁被其他客户端持有,当前客户端获取失败。
  • 解锁DEL lock_key
    • 删除这个 key,释放锁供其他客户端使用。

存在的问题:

  1. 死锁:如果获取锁的客户端宕机,无法执行 DEL 命令,那么这个锁将永远无法被释放,其他客户端再也无法获得锁。
  2. 误释放:客户端 A 获取锁后,如果业务执行时间过长,锁超时被 Redis 自动释放(假设设置了过期时间),客户端 B 获取到了锁。此时客户端 A 执行完,调用 DEL 命令,就会错误地释放了客户端 B 的锁。

改进版:SETNX + EXPIRE + Lua 脚本

为了解决死锁问题,引入了锁的超时时间。

  • 加锁
    1. SETNX lock_key my_random_value
    2. 如果成功,紧接着执行 EXPIRE lock_key 10 (设置 10 秒后自动过期)
  • 解锁:使用 Lua 脚本保证原子性。
    1
    2
    3
    4
    5
    if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
    else
    return 0
    end
    • 脚本的作用是:只有当前 key 对应的 value 是自己设置的那个随机值,才执行删除操作。这解决了误释放的问题。

仍然存在的问题:
SETNXEXPIRE 是两条命令,不是原子操作。如果在执行完 SETNX 后客户端宕机,EXPIRE 没有执行,依然会造成死锁。


官方推荐的标准实现:SET 命令扩展

Redis 2.6.12 之后,SET 命令增加了扩展参数,可以一次性完成set和expire的操作,彻底解决了原子性问题。

加锁命令:

1
SET lock_key my_random_value NX PX 10000
  • NX:等同于 SETNX 的效果,只有 key 不存在时才设置。
  • PX 10000:设置 key 的过期时间为 10000 毫秒。

解锁命令:
依然使用上述的 Lua 脚本,保证判断 value 和删除 key 这两个操作的原子性。

这个方案是目前单节点 Redis 分布式锁最成熟、最广泛的实现方案。

为什么要用随机值 my_random_value
这个值通常是一个随机生成的 UUID 或客户端 ID。它最大的作用是标识锁的归属。防止其他客户端错误地释放了不属于自己的锁。解锁时通过 Lua 脚本校验 value 是否匹配,只有匹配才删除。


高可用问题与 RedLock 算法

上面的方案在单节点 Redis 下工作是没问题的。但在主从复制(Sentinel)或集群(Cluster) 模式下,可能会遇到问题:

场景

  1. 客户端 A 从 Master 节点获取了锁。
  2. 在锁被同步到 Slave 节点之前,Master 节点宕机。
  3. Sentinel 集群选举出一个新的 Master(原来的一个 Slave)。
  4. 新的 Master 节点上并没有客户端 A 持有的锁(因为还没同步过来)。
  5. 客户端 B 向新的 Master 节点申请锁,成功获取。此时,客户端 A 和客户端 B 都认为自己持有锁,导致分布式锁失效。

为了解决这个问题,Redis 的作者 Antirez 提出了 RedLock(Redis Distributed Lock) 算法。

RedLock 核心思想
它不依赖单个 Redis 实例,而是同时与多个(通常是奇数个,如 5 个)独立的 Redis 主节点(注意:不是主从集群,而是互相独立的主节点)进行交互。

获取锁的步骤:

  1. 获取当前时间(T1)。
  2. 客户端依次向 N 个独立的 Redis 实例 发送加锁命令(SET key random_value NX PX timeout)。
  3. 只有当客户端从超过半数(N/2 + 1) 的节点上成功获取到锁,并且总的获取时间小于锁的有效时间(TTL),才认为加锁成功。
    • 总获取时间 = 当前时间(T2) - T1。
    • 锁的有效时间 = 最初设置的 TTL - 总获取时间。
  4. 如果获取锁失败(要么成功节点数未过半,要么总耗时超过了 TTL),客户端会向所有 Redis 实例发送解锁命令(即使它认为某个实例加锁失败)。

优缺点:

  • 优点:在存在节点宕机的情况下,提供了更高的安全性。
  • 缺点
    • 实现复杂,需要维护多个独立的 Redis 实例。
    • 性能开销大。
    • 存在争议:Martin Kleppmann 等人曾撰文质疑其安全性,认为它依赖于一个不安全的系统模型(比如依赖不可靠的时钟)。社区对此有广泛讨论。

建议:除非你的业务对分布式锁的可靠性有极高的要求(比如金融核心资产),并且愿意付出高昂的运维和性能成本,否则一般不建议使用 RedLock。对于绝大多数场景,基于单实例或主从复制的分布式锁已经足够。


现成的轮子:Redisson

在实际项目中,我们几乎不会自己从头实现上述逻辑,而是使用成熟的客户端库。在 Java 领域,Redisson 是最佳选择。

Redisson 的分布式锁实现:

  1. 加锁:它内部使用了 Lua 脚本,保证了原子性。其加锁命令就是我们上面提到的标准命令:SET lock_name my_uuid NX PX timeout
  2. 看门狗(Watchdog)机制:这是 Redisson 的一大亮点。它会在你获取锁成功后,创建一个定时任务(看门狗),每隔一段时间(比如锁过期时间的 1/3)就去延长锁的过期时间。只要客户端还活着(JVM 没挂),并且锁还在被持有(没解锁),这个续期操作就会一直进行,从而避免了因为业务执行时间过长而导致锁提前过期的问题。
  3. 解锁:同样使用 Lua 脚本原子性地释放锁。同时会取消看门狗的定时任务。
  4. 可重入性:Redisson 的锁是可重入的,它通过在 Redis 中存储 Hash 结构来实现,key 是锁名,field 是客户端 ID,value 是重入次数。
  5. 等待机制:提供了 tryLock 等方法来支持获取锁的等待,内部使用了 Pub/Sub 机制来订阅锁释放的消息,避免无效的轮询,减少 Redis 压力。

总结

方案 核心命令/机制 优点 缺点
基础版 SETNX + EXPIRE 简单 非原子性,易死锁;易误释放
标准版 SET key value NX PX timeout + Lua 脚本 原子性加锁和设置超时;安全释放 单节点/主从模式有故障转移问题
RedLock 向多个独立节点申请锁,遵循多数派原则 高可用,更高可靠性 实现复杂,性能差,存在争议
生产推荐 使用 Redisson 库(内置看门狗、可重入、Pub/Sub等待) 功能完善,生产级可靠性,开箱即用 需要引入第三方库

最佳实践建议:
对于绝大多数应用,使用 单主节点 Redis + Redisson 客户端 来实现分布式锁就已经非常可靠和实用了。除非有极端要求,否则应避免使用复杂度极高的 RedLock 算法。