Redis分布式锁

Yunxiao 2022年01月16日 194次浏览

前言

分布式锁在分布式系统中被广泛的应用,对诸如商品秒杀,电商促销等业务场景来说十分重要,实现分布式的锁方案多种多样,一言以蔽之,我们需要实现让不同的进程以互斥的方式使用共享资源进行操作

一、为什么需要分布式锁

在具体介绍Redis分布式锁的实现前,我们可以先考虑在单体应用的情况下,我们使用的锁是怎么样的,考虑一个电商减库存的业务逻辑:

    synchronized (this){
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // 查询库存
        if (stock > 0){
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // 更新库存值
            System.out.println("扣减成功,剩余库存:" + realStock);
            //具体的业务逻辑...
        }else {
            //库存不足的相关处理,这里只是简单的输出了信息
            System.out.println("扣减失败,库存不足!");
        }
    }
}

在单体架构应用的情况下,用synchronized能避免线程安全的问题。但在分布式架构的系统下,这样的JVM进程级别锁仍然是存在问题的。考虑一个最简单的情况,我们有两台服务器,在某一时刻,有两个请求分别被nginx分发到这两台不同的服务器,他们都查询并操作了同一个库存值,这就会出现了所谓的“超卖”。

二、Redis实现分布式锁(单节点Redis)

2.1第一版

为了避免上述问题,我们考虑使用redis的setnx来让不同请求互斥并设置过期时间,即不管什么情况,只有一个客户端持有锁。这听起来并不难,只需要将一中的代码扩展(使用到了stringRedisTemplate来操作redis):

String lockKey = "product001"; //这个key值就相当于一把锁,也即是在分布式系统中不同客户端需要争用的的资源。
try{
    	//获取锁的同时设置过期时间,防止某个客户端因阻塞长期持有一把锁导致其他业务无法进行
    	//将获取锁和设置过期时间通过一条语句执行,保证了原子性。
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, value, 10, TimeUnit.SECONDS);
        if (!result){
            //获取锁失败的操作
            return "error";
        }

        // 同“一”中的具体业务逻辑处理
        synchronized (this)
        {
            //具体业务逻辑... 
        }
    
	}finally {
    		// 释放锁,放在finally块中防止没有释放锁
    		stringRedisTemplate.delete(lockKey);
			}

似乎上面的伪代码就已经实现了redis分布式锁,但其实仍然存在问题,比如key对于的value应该填写什么?过期时间的具体选择。

考虑如下情况:我们设置过期时间为10秒(这个时间可能来源于往期业务的平均值),但是由于多种多样的原因,比如网络原因,进程A的业务执行的时间变成了15秒,而在10秒的时候锁就被释放了,同时另外一个进程B申请到了这个锁。

那么,在第15秒的时候,如果进程B的业务还未执行完,进程A就释放了进程B的锁。再往下循环,进程C又申请到了锁,B又释放了C的锁,整个分布式锁彻底失效。

2.2第二版

为避免2.1中提出的问题,我们要解决的核心问题是:谁获取锁,谁解锁。也就是只能解自己申请的锁。

如果每一个进程申请锁时所用的value值时特殊唯一的,同时解锁时判断当前锁是否是自己申请的,问题也就解决了。

扩展2.2中的代码实现上述效果:

String lockKey = "product001"; //这个key值就相当于一把锁,也即是在分布式系统中不同客户端需要争用的的资源。
String clientID = UUID.randomUUID().toString(); //每个经常的唯一标识,这里使用了UUID,也有使用UUID+进程号的做法
try{
    	//设置value值为我们生存的clientID
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientID, 10, TimeUnit.SECONDS);
        if (!result){
            //获取锁失败的操作
            return "error";
        }

        // 同“一”中的具体业务逻辑处理
        synchronized (this)
        {
            //具体业务逻辑... 
        }
    
	}finally {
    // 先判断key对于的value是否是我们设置的唯一标识,再释放锁
        if (clientID.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
            stringRedisTemplate.delete(lockKey);
        }
	}

OK,我们现在解决了锁的互斥,以及只能自己解锁的问题,但仍然存在一个非常关键的问题:失效时间的设置,如果我们的业务时间突然增大,而过期时间太短,锁被提前释放,那我们的具体业务没了锁的保护,自然也就是不安全的了。

一个非常原始的想法是把过期时间弄得长一点,但是一来我们无法预估由于网络等原因造成的业务延时到底又多大,二来过长的过期时间本身就是不合理的且对用户不友好。

2.3第三版

为了解决业务还未完成而锁过期的问题,一种被认可的解决方案是在锁过期之前提前检测并续期锁的过期时间。例如锁的过期时间是30s,那我们可以在大约1/3的时间左右检测业务进程是否结束,如果没有就续期。

具体实现可以通过redisson这个包,这个包专门针对分布式情况做了很多优化处理,同时它也是redis官方推荐的分布式锁的JAVA实现。

引入redisson后,伪代码扩展如下:

RLock lock = redisson.getLock(lockKey); 
try{
    // 解决过期时间内还未完成操作的问题
    lock.lock(30, TimeUnit.SECONDS); // 先拿锁,再设置超时时间
    // 业务逻辑
    synchronized (this){
        //... 具体业务逻辑
    }
}finally {
    lock.unlock(); // 释放锁
}

且redisson底层帮助我们封装实现了第一版以及第二版中的提出相关问题。

总结

上面三版redis分布式锁逐步递进地说明了实现redis分布式锁的大概流程以及关键问题。主要说明了底层使用了redis的setnx,用过期时间来保证了不会某个进程长期持有锁,用唯一标识符解决了自己上锁只能自己解锁的问题。最后,用redisson框架处理了过期时间自动延时的问题。

但分布式锁问题是一个庞大的的系统问题,上面说明的所有情况都是在单体redis的情况下去实现的分布式锁,如果是redis集群,又会存在不同的问题。

三、redis集群架构下的分布式锁

前面我们所讨论的Redis分布式锁都是在Redis是单体服务器的情况下,如果Redis是集群的,甚至集群内部存在复杂的架构。又会衍生出更多的问题。关于Redis集群的分布式锁问题,Redis的作者antirez提出中一种[机制:](Distributed locks with Redis – Redis)RedLock,下面简要介绍一下这种锁机制

RedLock

在Redis的分布式环境中,我们假设最孤立现象(最苛刻环境下):有N个Redis master。这些节点完全互相独立(正常集群不会做成这么孤立),不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点

为了取到锁,客户端应该执行以下操作:

  • 获取当前Unix时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)

上述大概就是RedLock的实现思路,具体的实现我们同样可以借助redisson这个框架,其中提供了完全按照上述思路实现的RedLock

四、更多

RedLock就是分布式锁的最佳解决方案了吗?可能并非如此,分布式系统专家Martin Kleppmann就对RedLock的安全性提出了一些质疑,原文是:How to do distributed locking — Martin Kleppmann’s blog,主要问题是redlock对系统的计时假设有强依赖,也就是说,各分布式系统的时钟要可靠,Martin认为在实际生产中时钟达不到分布式锁需要的精度。

然而,Redis的作者antirez又针对Martin的质疑做了回复,声称只要运维得当,系统完全可以到达redlock所需要的计时假设。总的来说,redis分布式锁在一些特殊情况下仍然存在争议。

最终,Martin得出了一个结论,我也比较认同:

  • 如果是为了效率(efficiency)而使用分布式锁,允许锁的偶尔失效,那么使用单Redis节点的锁方案就足够了,简单而且效率高。Redlock则是个过重的实现(heavyweight)。
  • 如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用Redlock。它不是建立在异步模型上的一个足够强的算法,它对于系统模型的假设中包含很多危险的成分(对于timing)。应该考虑类似Zookeeper的方案,或者支持事务的数据库。