前面写了一篇关于用Redis来解决秒杀业务场景下超卖的文章,罗列了秒杀场景下,为什么会超卖?如何解决超卖?使用Redis分布式锁有哪些问题?提到了几种实现技术方案。原文链接。感兴趣的可以阅读。
今天继续给大家分享一篇关于Redis分布式锁的文章,其中的主角就是Redis作者提到的redlock。
在上一篇文章的结尾处,我当时提出了这样一个问题。如果是集群、主从复制和哨兵模式的部署模式情况下,Redis的分布式锁如何保证实现高可用。大家看到此处的时,可以先思考一下,如何保证?
Redlock定义
Redlock是Redis作者针对集群、主从复制等业务场景下,用Redis实现分布式锁高可用的一种实现算法,主要是保证Redis服务不可用场景下的锁失效问题。
这种算法具体是怎么实现的呢?就是部署多台与master节点同等级别的其他节点,这几个Redis不参与其他的业务。每一个线程在向master节点请求锁的同时,也向这几个同等级别的节点发送加锁请求,只有当超过一半的节点数加锁成功,此时的分布式锁才算真正的成功。大致的逻辑图如下:
- 一个thread表示一个请求,当前的thread首先向master节点发送加锁请求。
- 同样的,该thread需要向node1,node2,node3发送加锁请求。
- 只有当master节点和nodex节点返回加锁成功,才表示当前的thread加锁成功,否则加锁失败。
Redlock由来
假设我们的Redis部署架构是一主多从的模式,每一个thread都会往master节点写入数据,读数据都是从slave节点读数据。大致的架构模式如下:
- 当有一个thread线程向master节点加锁成功之后,此时master节点会把加锁的数据发送给slave节点,其他的thread在根据slave节点中的锁数据,判断当前是否有锁。如果有锁则无法进行加锁操作,无锁则有且只有一个thread能够实现加锁成功。
Redis的主从复制是异步操作的,就是说客户端在向master发送写数据之后,master不会马上把写入的数据发送给slave节点,而是先响应客户端写入数据成功之后在把新写入的数据同步给slave节点。
- 当然1中的描述从理论上来说是完全没有问题的,但是我们考虑一下,如果master节点在同步数据的过程中挂了。
slave升级为master节点
,升级为master节点的slave节点此时是没有锁数据的。其他的thread肯定会进行加锁操作。试想一下,此时整个系统只会存在一把锁吗?
这里需要注意一下,slave切换master之后,之前的master在服务恢复之后变为slave,会情况自身的所有数据。
通过上面的分析,我们就不难得出,Redis分布式锁在高可用架构的模式下并不一定完全可靠。因此,Redlock就诞生了。
使用要求
- Redis的节点要选择奇数个节点,并且获取锁成功的节点数量必须是
成功获取锁数量 >= (节点数) / 2 1
。奇数个是为了提高加锁成功的概念。试想一下如果是4个几点,一半加锁成功,一半加锁失败,各自占50%的几率。只有成功超过或者失败的概率超过50%,此时我就才好判断是成功与否。 - 记录获取锁的开始时间和结束时间。在判断锁是否成功时,要把两个时间相减,最终确认锁的存活时间。如果加锁的时间大于锁有效时长则表示加锁失败。如果活的存活时间过小,低于预估的业务时间,也要判断加锁失败。
- 执行业务之后,一定要向所有节点发送释放锁请求,哪怕是锁会自动失效。因为不主动释放锁,在设置锁时长过大的情况下,当前业务执行完毕之后。其他的请求仍然无法获取到锁。
代码示例
在Redloc定义中提到了实现的思路,下面使用伪代码
演示,从代码层面该如何去实现。其实Redlock的加锁逻辑和上一篇文章提到的单机加锁逻辑都是一样的,无非就是多了记录加锁时长、判断加锁成功与否的情况处理。
function redLock()
{
// 记录加锁开始时间(这里简单一点,就用秒为单位了。实际情况用毫秒记录。)
$lockBeginTime = time();
// 锁时长
$expireTime = 3;
$redisClient = new Redis();
// 1. 向master节点发送加锁请求
$result1 = $redisClient->set('key', 'clientId', ['nx', 'px' => $expireTime * 1000]);
// 2. 向node1发送加锁请求
$result2 = $redisClient->set('key', 'clientId', ['nx', 'px' => $expireTime * 1000]);
// 3. 向node2发送加锁请求
$result2 = $redisClient->set('key', 'clientId', ['nx', 'px' => $expireTime * 1000]);
// 4. 向node3发送加锁请求
$result3 = $redisClient->set('key', 'clientId', ['nx', 'px' => $expireTime * 1000]);
// 记录加锁结束时间(实际情况下,同样使用毫秒。)
$lockEndTime = time();
// 判断加锁是否成功
if ($result1 && $result2 && $result3 && ($lockEndTime-$lockBeginTime) < $expireTime) {
// 加锁成功,执行对应的业务逻辑代码。
// 释放锁
} else {
// 加锁失败,执行释放锁操作。
}
}
一定要记住,在进行释放锁的时候,需要向每一个加锁的节点发送释放锁请求。
加锁和解锁优化
上面的示例代码,都是使用的同步操作去加锁和解锁。在这个过程中无疑是增加了时间上的成本消耗。某一个加锁比较慢,也很容易导致加锁失败。因此推荐在加锁和解锁的过程都采用多线程去执行加锁。
分布式锁总结
罗列一下个人对分布式锁中需要特别注意的事项做几个总结。这几点属于个人总结,大家阅读时,需要多多思考是否完全正确。
- 锁安全。既然是锁,就说明不管在任何的情况下,
同一时刻
,只有一个线程能够获取到资源的执行权,其他的线程是不能对该资源进行操作。这也可以理解为锁互斥。 - 灵活性。如果某一个或者某些节点挂了,仍然能够保证锁的稳定性、正确性,而不是某一个节点挂了就不能正常使用了。因为在实际的生产环境中,任何意向不到的情况都有可能发生。
Anything is possible, but nothing is easy.
。 - 加锁和释放。在使用完锁之后,一定要记得释放锁。哪怕是当前系统中存不存在锁,都不会影响业务的情况下也要及时的释放掉资源的占用。
Redlock现状
通过上面的分析,咱们基本明白了Redlock的一个实现原理。可能你也会觉得这样实现分布式锁已经没问题了,这样你就大错特错了。当Redis作者提出该概念之后,就受到很多质疑,因为这样实现分布式锁也会存在很多的问题。下面罗列一些个人现目前认知水平已经能够知道的和Redis官网的说明。后面有其他的认知,也会更新。
- 增加了部署成本,因为使用Redlock需要增加几台与master同等级的节点来实现加锁。这几个节点啥也不干,就只是负责加锁和释放锁逻辑。
- 安全争议。这个算法安全么?我们可以从不同的场景讨论一下。让我们假设客户端从大多数Redis实例取到了锁。所有的实例都包含同样的key,并且key的有效时间也一样。然而,key肯定是在不同的时间被设置上的,所以key的失效时间也不是精确的相同。我们假设第一个设置的key时间是T1(开始向第一个server发送命令前时间),最后一个设置的key时间是T2(得到最后一台server的答复后的时间),我们可以确认,第一个server的key至少会存活 MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT{.highlighter-rouge}。所有其他的key的存活时间,都会比这个key时间晚,所以可以肯定,所有key的失效时间至少是MIN_VALIDITY。当大部分实例的key被设置后,其他的客户端将不能再取到锁,因为至少N/2 1个实例已经存在key。所以,如果一个锁被(客户端)获取后,客户端自己也不能再次申请到锁(违反互相排斥属性)。然而我们也想确保,当多个客户端同时抢夺一个锁时不能两个都成功。如果客户端在获取到大多数redis实例锁,使用的时间接近或者已经大于失效时间,客户端将认为锁是失效的锁,并且将释放掉已经获取到的锁,所以我们只需要在有效时间范围内获取到大部分锁这种情况。在上面已经讨论过有争议的地方,在MIN_VALIDITY{.highlighter-rouge}时间内,将没有客户端再次取得锁。所以只有一种情况,多个客户端会在相同时间取得N/2 1实例的锁,那就是取得锁的时间大于失效时间(TTL time),这样取到的锁也是无效的。
- 系统活性争议。系统的活性安全基于三个主要特性: 锁的自动释放(因为key失效了):最终锁可以再次被使用。客户端通常会将没有获取到的锁删除,或者锁被取到后,使用完后,客户端会主动(提前)释放锁,而不是等到锁失效另外的客户端才能取到锁。当客户端重试获取锁时,需要等待一段时间,这个时间必须大于从大多数Redis实例成功获取锁使用的时间,以最大限度地避免脑裂。然而,当网络出现问题时系统在失效时间(TTL){.highlighter-rouge}内就无法服务,这种情况下我们的程序就会为此付出代价。如果网络持续的有问题,可能就会出现死循环了。这种情况发生在当客户端刚取到一个锁还没有来得及释放锁就被网络隔离。如果网络一直没有恢复,这个算法会导致系统不可用。