我用了上万字,走了一遍Redis实现分布式锁的坎坷之路,从单机到主从再到多实例,原来会发生这么多的问题

一、分布式锁的背景

在同一个jvm进程内,我们可以使用synchronized或者lock锁,来完成对共享资源的互斥访问。

然而现在大多数系统都是分布式系统,jvm进程分布在不同的节点上,为了全局数据的一致性,这个时候就需要分布式锁了。

实现分布式锁,有多种方案

(1)借助于数据库,乐观锁使用版本号机制悲观锁使用for update机制

(2)借助于Zookeeper,通过创建临时的顺序节点

(3)借助于Redis,这篇文章会详细说明Redis锁的演进历程。


二、Redis实现分布式锁的演进历程

很容易想到使用set key value nx命令来获取锁,nx代表不存在此key则存储,并且返回OK;若存在则会返回null

释放锁的时候使用del key删除key即可。

例如:

127.0.0.1:6379> set name qcy nx
OK
127.0.0.1:6379> set name qcy nx
(nil)

具体的加锁逻辑是这样的:

1、客户端1使用该命令获取锁,redis返回OK

2、客户端2同样使用该命令获取锁,因为key已经存在,因此redis返回null,代表加锁失败。

3、客户端1执行完临界代码后,删除该key,释放了锁。

4、客户端2获取锁成功。

Redis是采用单线程的模式来执行命令的,客户端1和客户端2就算同时发起执行,也只有一个客户端能够得到OK。

那么,这样做显然是有问题的。

因为没有对key设置过期时间,如果客户端1获取锁之后,客户端还没来得及释放锁,突然挂了,那么锁将永远释放不掉,造成死锁的状态

也就是说,这种方案需要配合过期时间。


1、先使用set nx获取锁,再使用set ex设置过期时间

set key value ex 5代表该key的过期时间为5秒,px则代表毫秒。

SpringBoot集成Redis的代码如下:

    @Resource
    StringRedisTemplate template;

    public void handle(String key, int expireTime) throws InterruptedException {
        //加锁,我们并不关心value的值
        while (!template.opsForValue().setIfAbsent(key, "")) {
            Thread.sleep(1000);
        }

        //设置过期时间,单位为秒
        template.expire(key, expireTime, TimeUnit.SECONDS);
        try {
            //执行业务代码
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //释放锁
            template.delete(key);
        }
    }

在这样设计中,可能会出现这样的一种情况:

当加锁成功后,即flag返回true时,刚准备执行设置过期时间的代码前,客户端突然挂掉了,这就造成了锁没有设置过期时间,又回到了开头的情况

出现这种问题,本质在于set nx与set ex不是原子操作


2、将set nx与set ex操作变成原子操作

redis本身已经提供了一个复合命令,例如set key value nx ex 5,代表该key5秒后过期。

当然,StringRedisTemplate也提供了重载方法,现在我们可以将1中代码进行改造下

    public void handle(String key, int expireTime) throws InterruptedException {
        //加锁并设置过期时间
        while (!template.opsForValue().setIfAbsent(key, "", expireTime, TimeUnit.SECONDS)) {
            Thread.sleep(1000);
        }

        try {
            //执行业务代码
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //释放锁
            template.delete(key);
        }
    }

看似已经完美了,那这里还存在什么问题呢?

(1)锁超时

假如超时时间设定为5秒,客户端1获取锁成功后,执行业务代码,而此时由于网络波动,连接加读取时间超过了5秒,这时候锁就被自动释放了。如果这个时候客户端2来获取锁,是可以成功的。这就造成了两个客户端同时在执行临界区的代码,数据有可能会变得不一致。

针对这种情况,我们可以根据以往的经验,对业务处理时间做出一个大致的估算,充分考虑到网络波动、慢sql查询等情况,能在一定程度上避免大部分的锁超时问题。不过这种方案还是存在风险性,一般只是作辅助用,后面我们将会通过对锁续期解决该问题。

(2)客户端1申请的锁被客户端2删除

其实这种情况和1有点类似,超时时间还是5秒,客户端1获取锁之后,执行业务代码时,这个时候业务执行时间超过了5秒,导致锁被释放。

客户端2拿到锁之后,在慢慢的执行业务代码。这个时候客户端1执行结束,接着把锁释放掉了。可是,这把锁并不是客户端1申请的,是客户端2申请的,也就是说,锁被误删了。

这就导致客户端2在执行时,客户端3又拿到了锁,又可能会造成数据的不一致。

因此,我们在删除前,需要检验一下这把锁是不是自己的


3、锁续期

为了避免业务执行时间大于超时时间的情况,需要对锁续期。

我们可以在获取锁之后,开启一个子线程,每隔1秒给锁的剩余时间加2秒。如果业务一直在执行,子线程就会一直在续期,最终业务执行完成后,打断该子线程,从而锁会过期自然释放。

如果该客户端突然挂掉,子线程也随之消亡,将不会对锁进行续期,锁还是会过期,不会造成死锁的情况。

现在,对2中的代码改造一下(仅仅提供一种思路,举个简单的例子)

    //续期子线程
    class RenewalThread extends Thread {
        String key;

        RenewalThread(String key) {
            this.key = key;
        }

        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    this.interrupt();
                }
                long ttl = template.getExpire(key, TimeUnit.SECONDS);
                template.expire(key, ttl + 2, TimeUnit.SECONDS);
            }
        }
    }

    public void handle(String key, int expireTime, TimeUnit timeUnit) throws InterruptedException {
        //加锁并设置过期时间
        while (!template.opsForValue().setIfAbsent(key, "", expireTime, timeUnit)) {
            Thread.sleep(1000);
        }

        RenewalThread thread = new RenewalThread(key);
        try {
            //续期子线程,这里只是做个演示
            thread.start();

            //执行业务代码
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //打断续期子线程
            thread.interrupt();
            //释放锁
            template.delete(key);
        }
    }

释放锁之前,需要关闭续期功能。采取的方式是打断续期子线程,这里使用到了interrupt方法,关于如何优雅停止一个线程的方案,可以先参考我的另外一篇文章面试官:如何停止一个正在运行的线程?我又懵了


4、删除前检验value

从始到终我们从来没用过value,因此可以将唯一标识存储在value中,删除前检验一下。

因此,我们改造2中的代码,先不考虑锁续期部分的代码

    public void handle(String key, int expireTime, TimeUnit timeUnit) throws InterruptedException {
        //唯一标识可以是机器id+线程id
        String value = getWorkerId() + Thread.currentThread().getId();
        while (!template.opsForValue().setIfAbsent(key, value, expireTime, timeUnit)) {
            Thread.sleep(1000);
        }
        
        try {
            //执行业务代码
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //释放锁前进行检验
            if (Objects.equals(template.opsForValue().get(key), key)) {
                template.delete(key);
            }
        }
    }

那这样还有什么问题呢,借鉴1中的思路,相邻的两条命令之间的执行可能不是我们想象中的那般顺利。

有这样的一种情况,客户端1在释放锁前,检验通过,在执行删除key前,锁突然过期了。

客户端2随后获取到了锁,准备执行业务代码。这个时候客户端1接着执行了锁删除的命令,于是就删了客户端2申请的锁,锁又被误删了。

(唉,这种场景,是不是和DCL的有点相似?)

所以,这里的方案依然是将检验锁和删除锁合并为一个原子操作


5、检验锁与删除锁合并为原子操作

我们改造下4中的代码

    public void handle(String key, int expireTime, TimeUnit timeUnit) throws InterruptedException {
        //唯一标识可以是机器id+线程id
        String value = getWorkerId() + Thread.currentThread().getId();
        while (!template.opsForValue().setIfAbsent(key, value, expireTime, timeUnit)) {
            Thread.sleep(1000);
        }

        try {
            //执行业务代码
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //释放锁前进行检验
            del(key, value);
        }
    }

    private void del(String key, String value) {
        String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        template.execute(redisScript, Arrays.asList(key, value));
    }

其中script是lua脚本,redis会保证执行该脚本的原子性。

此时这里的KEYS[1]是key,KEYS[2]是value

该段脚本的意思是,首先获取锁的value,判断是否等于期待的value,满足的话,则删除该锁,并返回1;否则直接返回0。

到了第5阶段,使用单机版的redis实现分布式锁,问题已经不大了。

那么我们在仔细想想,既然安全性已经得到了保证,那么怎么保证高可用性呢?

既然是单机版的,就会存在单点问题,一旦这个redis宕机,所有客户端都将陷入获取不到分布式锁的尴尬境地

活别干了,大家都散了吧。

不就是高可用吗,上主从模式呗。


6、使用主从模式

主从模式一般会配合哨兵模式一起使用,哨兵具有三个作用

监控:哨兵会定时对主节点进行存活性检查

选主:当主节点被判定为客观宕机后,哨兵集群会委托一个哨兵将某个从库切换为主库,将旧主库变为新主库的从节点

通知:将新节点的地址通知给客户端

那么,这样的组合确实可以保证redis的高可用。但是,分布式锁在该模式下,也并不是绝对安全的。有这样的一个场景:

1.首先客户端1在主节点上申请到了锁

2.在异步复制的情况下,主节点还没将锁同步到从节点,主节点宕机了

3.哨兵将从库切换为新主库,并通知到所有客户端

4.客户端2在新主库上申请到了同样的一把锁

那么这个时候,临界代码又一次被多个客户端同时执行了。

难道,高可用的redis分布式锁真的就无解了吗?

后来啊,redis作者提出了一个叫做RedLock的算法。


7、RedLock算法

首先我们需要多个主节点,这些节点之间不会进行主从同步,都是一个个独立的实例,一般来说,主节点需要在3个及以上。

算法基本思路是:客户端依次对多个实例请求加锁,只要一半以上(不包含一半)的节点成功,则视为加锁成功,客户端随后可以执行临界代码。但是在解锁的时候,需要对所有的实例进行解锁。

假设这里有多个实例,加锁的具体步骤如下:

1.客户端记录当前时间t1

2.客户端依次向多个实例发起加锁请求,并且每个请求会设置connect与read的超时时间,这两个时间远小于锁的过期时间。

3.如果在t2时刻,超过半数的实例加锁成功,此时不再请求剩余的实例。此时锁的有效期为t2-t1,如果有效期大于0,则代表客户端获取锁成功。否则,代表本轮获取锁失败。

4.如果半数及以上的节点加锁失败,也视为本轮失败。

5.如果本轮获取锁失败,则会对所有实例进行解锁。

这里可能有几个疑问点:

(1)为什么超过半数的实例加锁成功,本轮才算成功?

这个很好理解,我举个例子,如果当前实例数为6个,客户端1在其中3个加锁成功,客户端2在另外其中3个加锁成功,这样就造成了多个客户端同时执行了临界代码,会造成数据的不一致。

(2)为什么对每个实例发起加锁请求时,要设置connect与read的超时时间呢,并且要远小于锁的过期时间?

好家伙,如果不带上这两个超时时间,那么宕机或者反应慢的实例会严重影响效率。本来依次请求多个实例已经够耗时,真的是没耐心花太长时间去等待实例的响应。

(3)加锁的时候,半数及以上实例成功即可,不用再去请求剩余的实例。但是解锁的时候,要去请求所有实例去删除锁呢?仅对加锁成功的实例执行解锁请求不是更省事吗?

如果在某次客户端对实例进行加锁请求时,该实例确实收到了请求,也执行了set请求,但是由于响应包在网络中超时,客户端以为加锁失败,直接请求了下一个实例。

那么客户端在解锁的时候,也要清理该实例上的锁,所以这里直接对所有实例都进行解锁操作。

都做到这份上了,不会还有问题吧?

是的,还有....


8、RedLock算法下隐藏的问题

这里参考了Martin Kleppmann对RedLock的一些疑问,认为RedLock在某些方面是不安全的。以下的问题都是这位在分布式领域拥有多年经验的大佬提出来的,原文链接how-to-do-distributed-locking

然而,这篇文章发出来的第二天,redis的作者antirez随即发表了对Martin的回应Is Redlock safe?

我们先来看看Martin提出了哪些问题

1、GC导致进程暂停

步骤如下:

(1)客户端1获取锁之后,进入了一次比较长的GC中,续期线程和用户线程同时被终止,导致锁过期。

(2)客户端2获取锁之后,操作了共享资源,比如向存储中写数据。

(3)客户端1从GC中醒来后,以为自己还持有锁,也操作了共享资源。

GC会发生在任何时刻,我们没法在操作临界资源前进行检查,因为GC也可能在检查通过之后到来。

关于这个问题,其实核心的点在于GC的停顿时间不可控,如果停顿时间超过锁过期时间,就会造成这样的情况,这其实不仅仅是redis分布式锁的问题了

在这里,我的想法是,在java语言中,可以使用G1收集器,得益于可预测停顿时间,尽可能得回收较多的region,剩余region会在下次回收。在每次回收的间隙,会给续期线程执行锁续期的余地。不过,这个方法我并没有在生产环境试验过。

关于G1收集器,我可能会另开篇幅。

2、时钟跳跃

假设系统有五个 Redis 节点(A、B、C、D 和 E)和两个客户端(1 和 2)。如果其中一个 Redis 节点上的时钟向前跳跃会发生什么?

(1)客户端 1 获取节点 A、B、C 上的锁。由于网络问题,无法访问 D 和 E。

(2)节点 C 上的时钟向前跳跃,导致该节点上的key被销毁,即锁到期。

(3)客户端 2 获取节点 C、D、E 上的锁。由于网络问题,无法访问 A 和 B。

(4)客户端 1 和 2 都持有了锁,同时操作了共享资源,有可能会造成数据的不一致。

节点发生时钟跳跃的情况,比如是运维人员手动修改了系统时钟,也有可能是自动同步时间导致的。

antirez反驳道:

  • 手动修改时钟这是人为原因,不要那么做就是了。否则的话,如果有人使用“echo foo > /my/raft/log.bin”去修改Raft日志,那么Raft也将无法正常运作。
  • 如果自动同步ntp时钟时发现,会产生较大的跳跃。那么可以将这次大的时间差,分成多次进行调整,每次更新的时间尽量小(这一点,和我在第一个问题中设想使用G1收集器貌似是同样的道理)

3、节点崩溃重启

还是一样,有五个节点 

(1)客户端 1 获取节点 A、B、C 上的锁。

(2)节点 C 宕机,异常重启,假设未开启持久化,因此客户端1的锁信息丢失。

(3)客户端 2 获取节点 C、D、E 上的锁。

(4)客户端 1 和 2 都持有了锁

对于这个问题,antirez提出来两种解决方案:

  • 节点C崩溃后,不要立即去重启。等到节点C上的锁全部过期之后,再进行重启。
  • 节点C开启持久化,使用everysec策略每秒写一次磁盘,最坏情况下,会丢失一秒内的数据。也可以使用always策略,即每次修改都会写入AOF文件中,只是对性能造成了影响。

关于redis的持久化策略,我应该也会新开一篇文章。(老面试题了)


9、总结

单点的redis其实在很多场景已经能满足需求,性能也相对较高。

相比而言,RedLock更重,不过它能解决主从模式中故障转移带来的锁信息丢失的问题。

在java语言中,Redission框架实现了RedLock算法,支持锁续期、公平与非公平锁、可重入等特性。Redission源码分析也被列为下半年的写作计划中,大家等等哈。

然而,RedLock也不是绝对安全的,在一些极端场景下还是会存在一些棘手的问题。

在Martin与antirez的讨论最后,Martin写出了自己的感悟:

For me, this is the most important point: I don’t care who is right or wrong in this debate — I care about learning from others’ work, so that we can avoid repeating old mistakes, and make things better in future. So much great work has already been done for us: by standing on the shoulders of giants, we can build better software.
……
By all means, test ideas by arguing them and checking whether they stand up to scrutiny by others. That’s part of the learning process. But the goal should be to learn, not to convince others that you are right. Sometimes that just means to stop and think for a while.

(译文:
对我来说最重要的一点在于:我并不在乎在这场辩论中谁对谁错 —— 我只关心从其他人的工作中学到的东西,以便我们能够避免重蹈覆辙,并让未来更加美好。前人已经为我们创造出了许多伟大的成果:站在巨人的肩膀上,我们得以构建更棒的软件。
……
对于任何想法,务必要详加检验,通过论证以及检查它们是否经得住别人的详细审查。那是学习过程的一部分。但目标应该是为了获得知识,而不应该是为了说服别人相信你自己是对的。有时候,那只不过意味着停下来,好好地想一想。)

                                                                                                    以上英文以及译文来自于【飞哥荐读】基于Redis的分布式锁到底安全吗?

在Martin与antirez的辩论中,我收获到了很多。这也将激励我对分布式系统做出深入的学习,接受每个迎面而来的挑战。

Guess you like

Origin blog.csdn.net/qq_33591903/article/details/119920411