Redis 分布式锁如何实现?

Redis 分布式锁如何实现?Redis 分布式锁如何实现?

非常经典的面试题,也是分布式系统中一个至关重要的实践。实现一个分布式锁不难,但实现一个安全、高效、可靠的分布式锁,里面充满了细节和陷阱。

我将从演进的角度,为您剖析 Redis 分布式锁的实现,从简陋到工业级强度。

一、 基础版本:SETNX + DEL (及其缺陷)

这是最直观的想法,但绝不推荐在生产环境使用

# 加锁
SETNX lock_key unique_value
# 如果返回1,表示加锁成功。返回0,表示锁已被占用。

# 对锁设置过期时间为 30000 毫秒
EXPIRE lock_key 30000

# 业务操作...
# ...

# 解锁
DEL lock_key

这个方案存在三大致命缺陷:

  1. 死锁风险: 如果客户端在持有锁后崩溃,没有执行 DEL,那么这个锁将永远无法被释放,导致系统死锁。
  2. 误删他锁: 客户端 A 执行时间过长,导致锁超时自动释放。客户端 B 获得了锁。此时客户端 A 恢复,执行 DEL,错误地删除了客户端 B 的锁。
  3. 非原子性操作: SETNX 和后续的过期时间设置不是原子的。

二、 改进版本:SET with NX PX (业界基础标准)

Redis 2.6 之后,SET 命令增加了扩展参数,使得加锁操作可以原子性地完成。

核心加锁命令:

SET lock_key unique_value NX PX 30000
  • NX: 仅当 Key 不存在时才设置,对应 SETNX 的语义。
  • PX 30000: 设置 Key 的过期时间为 30000 毫秒。这是解决死锁问题的关键。
  • unique_value: 必须是一个全局唯一的标识(如 UUID、RequestId等),这是解决误删问题的关键。

核心解锁脚本(Lua):

由于解锁需要判断 unique_value 再执行 DEL,这必须是原子操作,因此必须使用 Lua 脚本。

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

这个方案已经相当可靠,但它仍面临一个核心挑战:锁过期时间的管理。

  • 问题场景: 客户端 A 在 30 秒内没有完成业务逻辑,锁自动释放。客户端 B 获得了锁并开始操作。此时客户端 A 完成了操作,并执行了解锁脚本(由于 unique_value 不匹配,不会删除 B 的锁,这是正确的)。但系统已经出现了数据不一致:A 和 B 同时持有了锁并在修改数据。

三、 高级版本:Redlock 算法 (应对极端情况)

当我们的 Redis 是单实例时,上述方案在绝大多数场景下是足够的。但如果这个 Redis 实例宕机了(即使是主从切换,也可能因异步复制导致锁丢失),那么整个分布式锁就失效了。

为了解决这个问题,Redis 作者 Antirez 提出了 Redlock 算法,旨在提供一个在多节点 Redis 环境下的高可靠性分布式锁。

Redlock 核心步骤:

假设有 N(通常为5)个独立的 Redis Master 节点(不是主从集群,而是真正独立的实例)。

  1. 获取当前时间(T1),以毫秒为单位。
  2. 依次向 N 个 Redis 节点执行加锁命令(即 SET lock_key unique_value NX PX TTL)。并为每个请求设置一个远小于锁 TTL 的网络超时时间,避免长时间阻塞。
  3. 客户端计算获取锁花费的总时间T2 = 当前时间 - T1)。只有当客户端在大多数(N/2 + 1)节点上成功获取锁,并且总耗时小于锁的 TTL 时,锁才被认为获取成功。
    • 锁的有效时间 = 初始TTL - 获取锁总耗时(T2)。这确保了锁不会在客户端开始使用前就过期。
  4. 如果获取锁失败(即未获得大多数节点的锁,或总耗时已超),则客户端必须向所有 Redis 节点发起释放锁的请求(使用同样的 Lua 脚本),即使那些它没有成功获取锁的节点也要尝试释放,以防万一。

Redlock 的争议与考量:

Redlock 需要多个独立实例,成本高,实现复杂。它依赖于一个 “所有节点独立失败” 的假设。在学术界和实践中(如 Martin Kleppmann 的文章)存在一些关于时钟跳跃、GC pause 等极端场景的争议。

四、 生产级实现:Redisson

Redission 实现 Redis 分布式锁Redission 实现 Redis 分布式锁

理解了底层原理后,在生产环境中,我们通常会选择 Redisson 这样的成熟客户端。因为它把我们讨论的所有最佳实践和陷阱规避都封装成了简单易用的 API。

Redisson 的核心价值与特性:

  1. 开箱即用的分布式锁, 代码如下:

    RLock lock = redisson.getLock("myLock");
    lock.lock();
    try {
        // ... 业务逻辑
    } finally {
        lock.unlock();
    }
    

    就这么简单,无需手动编写 Lua 脚本。

  2. 自动看门狗机制 - 解决锁续期难题:这是 Redisson 最核心的特性之一。

    • 工作原理:当你使用 lock() 且未指定 leaseTime 时,Redisson 会启动一个后台守护线程(看门狗)。
    • 默认行为:锁的默认看门狗超时时间是 30 秒。守护线程每隔 10 秒(锁超时时间的 1/3)检查客户端是否还持有锁,如果是,则自动将锁的超时时间重置为 30 秒。
    • 巨大优势:只要客户端实例存活且业务未完成,锁就不会过期,从根本上解决了业务执行时间大于锁TTL的问题。
  3. 完整的可重入支持: Redisson 的 RLock 实现了 java.util.concurrent.locks.Lock 接口,天然支持可重入。

    lock.lock();
    try {
        // 外层业务
        innerMethod(); // 内层方法可以再次获取同一把锁
    } finally {
        lock.unlock();
    }
    
    void innerMethod() {
        lock.lock(); // 计数器+1
        try {
            // ... 内部业务
        } finally {
            lock.unlock(); // 计数器-1,为0时才真正释放
        }
    }
    
  4. 灵活的锁获取方式

    • lock(): 阻塞直到获取成功,支持看门狗续期。
    • tryLock(): 尝试获取锁,可指定等待时间 (waitTime) 和锁租期 (leaseTime)。
    • lockInterruptibly(): 可响应中断的加锁。
  5. 对 Redlock 的内置支持:如果需要使用 Redlock 算法,Redisson 让其实现变得异常简单:

    RLock lock1 = redissonInstance1.getLock("lock");
    RLock lock2 = redissonInstance2.getLock("lock");
    RLock lock3 = redissonInstance3.getLock("lock");
    
    RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
    redLock.lock();
    try {
        // ... 操作临界资源
    } finally {
        redLock.unlock();
    }
    
  6. 其他高级特性

    • 公平锁:按照请求的顺序分配锁。
    • 读写锁:支持分布式读写锁。
    • 信号量闭锁:提供完整的分布式并发工具集。

五、总结

对于 Redis 分布式锁,深入理解其底层原理(SET NX PX + Lua 解锁)是基础,这能帮助我们在遇到问题时具备排查能力。但在实际的 Java 生产环境开发中,我们应该毫不犹豫地选择 Redisson 这样的成熟框架。

Redisson 通过其看门狗机制解决了锁续期的核心难题,通过可重入实现简化了编程模型,通过丰富的 API 覆盖了各种应用场景。它代表了从"能用"到"好用、可靠"的工业级飞跃。

因此,我的首选方案是:基于单 Redis 实例(或集群),使用 Redisson 的 RLock,并利用其默认的看门狗机制来处理锁续期。这个方案在复杂性、性能和可靠性之间取得了最佳平衡,能够应对绝大多数生产场景。