聊聊 Redis 分布式锁

作者: 殷天文 | 来源:发表于2022-11-10 19:50 被阅读0次

    0. 前言

    Redis 是日常开发中经常使用到的中间件,以优秀的性能著称。但是 Redis 分布式锁可以说是饱受争议,很多人认为 Redis 并不适合作为分布式锁。它确实存在着一些问题,今天我准备聊一聊 Redis 分布式锁如何实现、有什么问题、该如何解决以及它的进阶版本红锁(Red Lock)解决了哪些问题,又带来了哪些新的问题

    1. Redis 分布式锁的标准实现方式

    我们以一个 Redis 单实例为例

    1.1. 上锁原理

    Redis 通过 SET key value NX 命令实现锁的互斥机制。SETNX 含义为(SET if Not eXists),只有在 key 不存在时,才能 SET 成功

    完整的上锁命令如下所示

    # NX 代表 key 不存在才能 SET 成功,PX 指定 key 的过期时间
    SET resource_name client_id NX PX 30000
    

    key 的过期时间,就是锁的持有时间(或者说释放时间)

    client_id 可以是任何随机的唯一值(例如 UUID),它存在的意义是用于解锁。只有 client_id 匹配时,才能解锁(保证只有持有锁的客户端才可以解锁,防止其他客户端错误的解锁)

    1.2. 解锁原理

    解锁本质上就是删除 key,或者 key 过期。

    主动解锁通常使用 lua 脚本进行,因为我们希望原子性的完成判断和删除逻辑

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

    2. Redis 分布式锁的问题

    通过上一章节,我们已经了解到了,如何利用 Redis 机制实现一个分布式锁,现在我们要更深入的讨论下 Redis 是否可靠

    2.1. Redis 锁未设置过期时间,导致死锁

    场景:我使用 SET key client_id NX 持有了一个锁,但是我并没有设置过期时间。此时我的应用进程突然挂了,并没有来得及进行解锁操作。此时任何客户端都无法再持有这个锁了,必须得人工介入删除掉这个 key,业务才能继续流转

    对于这个场景有什么好的解决办法呢

    Redission 提供了一个很好的解决方案。Redission 提供了一个“看门狗”机制

    如果用户在申请锁的时候没有指定锁的释放时间,此时 Redission 会为锁指定一个 30 秒的过期时间。在锁释放之前,再额外使用一个线程不断地延长 key 的过期时间。这样就能保证即使应用进程意外挂掉,也不会出现“死锁”

    Redission 使用了 Netty API 来实现“看门狗”,这里为了方便大家理解,我使用 Java API 做一个简单的示例以供参考

    public class WatchDogExample {
        /**
         * 定义线程池
         */
        static ScheduledExecutorService scheduled = new ScheduledThreadPoolExecutor(8, new ThreadFactory() {
            private final AtomicInteger threadNumber = new AtomicInteger(1);
    
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r, "watchDog-" + threadNumber.getAndIncrement());
                // 设置为守护线程,防止主线程意外挂掉,看门狗线程依旧在执行
                thread.setDaemon(false);
                return thread;
            }
        });
    
        public static void main(String[] args) {
            // 模拟获得锁 tryLock()
            // SET NX 命令,过期时间为 30s
            System.out.println("SET KEY VALUE NX EX 30");
    
            // 向线程池提交 KEY 续期任务
            ScheduledFuture<?> future = scheduled.scheduleAtFixedRate(() -> {
                if (Thread.currentThread().isInterrupted()) {
                    return;
                }
                // EXPIRE 命令续期key 30s
                System.out.println("EXPIRE KEY 30");
            }, 10, 10, TimeUnit.SECONDS);
    
    
            try {
                // 模拟业务流程
                Thread.sleep(20000);
            } catch (InterruptedException e) {
            }
    
            // 解锁 unlock。停止任务调度 & 删除key
            future.cancel(true);
            System.out.println("DEL KEY");
    
            try {
                // 保证 main 线程不停止。如果 main 线程停止,守护线程将结束
                new CountDownLatch(1).await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
    }
    

    2.2. Redis 锁过期了,但是应用进程还在操作共享资源

    这种情况,我的理解是,如果你明确指定了锁的过期时间,那么你就必须保证在这个时间内完成对共享资源的操作。如果你无法保证,就使用 2.1 的方式,不指定过期时间

    2.3. 如何实现锁的等待

    其实 SETNX 实现的效果仅是一个 tryLock,以 Java 中常用的锁作为参考。通常来说我们如果无法立即获得锁,是可以选择等待锁释放后,继续尝试获取锁。那么 Redis 分布式锁可以实现这一效果吗?

    当然可以了,参考 Redission 的实现,我们可以利用 Redis 的发布订阅来实现锁的等待,思路如下

    a. 尝试获取锁
    b. 获取锁失败,订阅锁释放消息,线程进入等待状态
    c. 其它客户端解锁,发布锁释放消息
    d. 接受到锁释放消息,回到步骤 a

    感兴趣的同学,可以自行阅读下 Redission 源码

    图片来源小米技术团队

    2.4. Redis 锁是不可重入锁?

    首先我们要明白,可重入锁有什么意义?

    1. Java synchronized、ReentrantLock 都是可重入锁。可重入锁,即同一个线程可以多次获取同一个锁,其意义在于防止代码出现死锁。

    假设我们的 Redis 锁已经实现了 2.3 中提到的锁等待功能,此时我设置了锁的最大等待时间为 -1(无限等待),锁的持有时间也是 -1。我在编码的时候,没有注意,多次获取了相同的锁。由于是无限等待,我的代码在第二次获取锁时,就会出现死锁,永远的卡在那里。

    1. 对于 Redis 锁来说,可重入也可以代表锁续期

    在理解了可重入锁的意义之后,我们该如何让 Redis 分布式锁支持重入呢?同样参考 Redission 即可

    Redission 使用 Redis Hash 结构,存储结构为 lock_key,client_id,重入次数

        <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
            internalLockLeaseTime = unit.toMillis(leaseTime);
    
            return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                      // 是否存在锁
                      "if (redis.call('exists', KEYS[1]) == 0) then " +
                           // 不存在则创建
                          "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                          // 设置过期时间
                          "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                          // 竞争锁成功 返回null
                          "return nil; " +
                      "end; " +
                       // 如果锁已经被当前线程获取
                      "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                           // 重入次数加1
                          "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                          "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                          "return nil; " +
                      "end; " +
                      // 锁被其他线程获取,返回锁的过期时间
                      "return redis.call('pttl', KEYS[1]);",
    
                        // 下面三个参数分别为 KEYS[1], ARGV[1], ARGV[2]
                        // 即锁的name,锁释放时间,当前线程唯一标识
                        Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
        }
    

    2.5. Redis 主备切换,导致多个客户端同时持有锁

    这个是 Redis 作为分布式锁最大的问题。

    通常来说,我们会通过使用 Redis Sentinel 或者 Redis Cluster 来提高 Redis 的可用性,但是在提高 Redis 可用性的同时,也带来了丢失数据的风险

    场景:现在我们有一个 Redis Sentinel,客户端 A 从 Redis Master 获取了锁(写入了一个 key),此时 Master 挂了。并且非常倒霉,在挂掉之前并没有将客户端 A 写入的数据同步到 Slave 节点,然后 Slave 升级为 Master,客户端 B 也获取了相同的锁。

    这就是 Redis 分布式锁最尴尬的地方,当提高了 Redis 可用性之后,居然无法保证锁的互斥性,这是让人难以接受的。

    关于这个问题,有什么好的解决方法呢?就是我们接下来要讲到的 Redis RedLock

    3. Redis RedLock

    3.1 RedLock 核心逻辑

    该算法的前提是我们需要准备 N 个完全独立的 Redis Master。我们参考 Redis 官方文档,将 N 设为 5

    1. 获取当前时间戳(毫秒)

    2. 尝试对 N 个实例进行 lock 操作,在所有实例中使用相同的 key 和 value。在执行 Redis 命令时,需要设置一个较小的 command timeout。例如锁的施放时间是 10s,则 command timeout 范围最好在 5 ~ 50ms。因为我们要与多个 Redis 实例通信,尽可能防止命令阻塞在某台实例中。

    3. 当客户端能够在大多数实例中获取锁(大多数至少为 N / 2 + 1),并且获取锁的总用时小于锁的释放时间,则认为成功持有锁

    4. 锁的实际持有时间 = 执行的持有时间(或者说施放时间)- 获取锁用时

    5. 如果客户端未能成功持有锁(不能满足步骤3),则对所有已经加锁的实例进行 unlock 操作

    针对每一个节点的 lock 和 unlock 操作与上边提到过的实现方式相同

    3.2 RedLock 潜在问题

    在网上看到了国外的分布式专家与 RedLock 作者对 RedLock 是否可靠进行了许多的讨论。

    再结合一些其他的文章,对 RedLock 的问题总结一下,讨论中认为 RedLock 不可靠的几个关键点

    3.2.1. 时间钟跃进

    Redis key 的过期时间,最终会计算出一个时间戳。Redis 保证在系统时间超过这个时间戳时,删除这个 key。

    如果对系统时间进行修改,例如快进了一段时间,可能会造成 key 失效,最终多个客户端同时获得锁

    解决方法:

    1. 合理运维,不要修改系统时间。通常来说也没有人会这么做

    3.2.2. 客户端进程 GC 时间过长带来的问题

    客户端进程 GC 时间过长,导致锁过期,但是客户端无法感知,最终可能导致多个客户端同时持有锁。

    上文提到了使用看门狗的概念,一定程度上可以解决了锁过期导致多个客户端同时持有锁的问题。但是如果真的 GC 时间真的特别长,导致看门狗机制也无法续期 key 的话,那确实会让多个客户端都持有锁。

    解决方法:

    1. 延长 watchDog 的过期时间
    2. 优化 GC 时间

    3.2.3 网络分区带来的问题

    现有 A、B、C、D、E 5台 Redis 实例,分别部署在五台机器上。客户端如果只能访问到 A、B 两台,与 C、D、E 网络无法通信。这种情况和 Redis 宕机一样,在网络恢复之前,没有办法能够解决

    3.2.4 某个节点宕机引发的问题

    还是 A、B、C、D、E 5台 Redis 实例。客户端1在 A、B、C 三台机器上成功加锁,此时已成功持有 RedLock。

    C 节点突然宕机,然后重启,但是客户端1写入的数据并没有及时落盘,此时重启后数据丢失。

    最终可能导致客户端2 成功在 C、D、E 三台机器加锁,无法保证锁的互斥性

    解决方法:

    1. Redis AOF 设置为 fsync=always,通常来说使用 Redis 的用户不会开启这个选项,势必会影响性能,但也是一个可以考虑的选项

    2. 从运维方面解决,保证在 key 的最大 TTL 之后重启

    3. 代码优化方面的一些建议。尽可能在所有实例上加锁。但是这并不能完全解决。最好的方法还是方案2。

    3.2.5 其它问题

    使用 RedLock 必须要多搭建一套环境,比如项目中已经使用了 Redis Cluster 或者 Redis Sentinel,为了保证锁的可靠性必须再搭建一套 RedLock 环境。在一定程度上,增加了运维成本

    3.3 RedLock 实现

    Redission 也提供了关于 RedLock 的实现,各位可参考 Redission 源码

    4. 总结

    本文基本覆盖了 Redis 分布式锁的常见问题与解决方案

    如果对于分布式锁的互斥性要求并不高的话(例如系统需要计算某个数据,计算一次即可但是多次计算不会对业务有影响),传统 Redis 集群(Sentinel,Cluster)做分布式锁是没有问题的

    当对于分布式锁的互斥性有严格要求的话,就需要考虑使用 RedLock、Zookeeper、或者数据库写锁来解决了

    参考资料

    https://redis.io/docs/reference/patterns/distributed-locks/
    https://github.com/redisson/redisson/
    https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/

    相关文章

      网友评论

        本文标题:聊聊 Redis 分布式锁

        本文链接:https://www.haomeiwen.com/subject/mondzrtx.html