1 Redis分布式锁的特性
在实现分布式锁时,需要保证锁实现的安全性和可靠性。基于这点特点,实现分布式锁需要具备如下三个特性:
- 互斥,不管任何时候,只有一个客户端能持有同一个锁。
- 不会死锁,最终一定会得到锁,就算一个持有锁的客户端宕掉或者发生网络分区。
- 容错,只要大多数Redis节点正常工作,客户端应该都能获取和释放锁。
2 单机版分布式锁
2.1 setnx指令实现分布式锁
分布式锁本质上要实现的目标就是在Redis里面占一个“坑”,当别的进程要来占坑时,发现已经被占据了,就只好放弃或者稍后重试。
占坑一般使用setnx
指令,只允许呗一个客户端占据。占完后,再调用del
指令释放掉。
> setnx lock true
OK
........ do something critical ...
> del lock
(integer) 1
上面是最简单分布式锁的实现,但是这里存在一个问题就是在客户端执行操作的时候挂掉了,那么这个锁就无法释放,出现死锁,锁将永远得不到释放。
2.2 带过期时间的分布式锁
为了防止程序在执行期间挂掉,导致死锁问题。我们可以在拿到锁时,给锁添加一个过期时间,以防止上诉情况的发送。下面是用expire
指令的改进锁。
> setnx lock true
OK
> exipre lock 5
........ do something critical ...
> del lock
(integer) 1
上述步骤解决了在客户端执行业务逻辑时挂掉导致的死锁问题。由于setnx
和expire
这两条指令的执行并不是原子性的,所有可能导致setnx
执行成功,expire
执行不成功,还是存在锁无法释放的问题。
Redis2.8版本扩展了set执行,使得setnx
和expire
指令能够原子的执行来解决死锁问题。
> set lock true ex 5 nx
OK
........ do something critical ...
> del lock
(integer) 1
2.3 超时问题
如果在加锁的过程中,业务逻辑执行的时间很长的话,那么就会导致多个客户端同时获取到锁的情况发生,违反了锁的互斥性。Redis分布式锁不能解决超时问题。
目前较安全的处理方法就是让业务逻辑执行的足够短,或者将超时时间设置的长一点。
超时还会导致误释放锁,举个例子:一个客户端拿到了锁,被某个操作阻塞了很长时间,过了超时时间后自动释放了这个锁,然后这个客户端之后又尝试删除这个其实已经被其他客户端拿到的锁。所以单纯的用DEL指令有可能造成一个客户端删除了其他客户端的锁。
为了解决这个问题,可以在设置键的时候给键设置一个全局唯一的值,在释放锁的时候判断值是否相等,然后在删除。在判断和删除的时候使用lua脚本保证执行的原子性。
3 集群分布式锁
单机版的Redis分布式锁存在一个问题:在我们的系统中出现单点故障,将导致Redis锁不可用,如果Redis的master节点加一个slave节点!在master宕机时用slave就行了!但是其实这个方案明显是不可行的,因为这种方案无法保证安全互斥性,因为Redis的复制是异步的。 总的来说,这个方案里有一个明显的竞争条件(race condition),举例来说:
- 客户端A在master节点拿到了锁。
- master节点在把A创建的key写入slave之前宕机了。
- slave变成了master节点
- B也得到了和A还持有的相同的锁(因为原来的slave里还没有A持有锁的信息)
3.1 RedLock算法
在分布式版本的算法里我们假设我们有N个Redis master节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。如下是一个客户端获取锁的过程:
获取当前时间(单位是毫秒)。
轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。
3.2 失败的重试
当一个客户端获取锁失败时,这个客户端应该在一个随机延时后进行重试,之所以采用随机延时是为了避免不同客户端同时重试导致谁都无法拿到锁的情况出现。同样的道理客户端越快尝试在大多数Redis节点获取锁,出现多个客户端同时竞争锁和重试的时间窗口越小,可能性就越低,所以最完美的情况下,客户端应该用多路传输的方式同时向所有Redis节点发送SET命令。 这里非常有必要强调一下客户端如果没有在多数节点获取到锁,一定要尽快在获取锁成功的节点上释放锁,这样就没必要等到key超时后才能重新获取这个锁(但是如果网络分区的情况发生而且客户端无法连接到Redis节点时,会损失等待key超时这段时间的系统可用性)
3.3 释放锁
释放锁比较简单,因为只需要在所有节点都释放锁就行,不管之前有没有在该节点获取锁成功。
3.4 性能、崩溃恢复和redis同步
如何提升分布式锁的性能?以每分钟执行多少次acquire/release操作作为性能指标,一方面通过增加redis实例可用降低响应延迟,另一方面,使用非阻塞模型,一次发送所有的命令,然后异步读取响应结果,这里假设客户端和redis之间的RTT差不多。
如果redis没用使用备份,redis重启后,那么会丢失锁,导致多个客户端都能获取到锁。通过AOF持久化可以缓解这个问题。redis key过期是unix时间戳,即便是redis重启,那么时间依然是前进的。但是,如果是断电呢?redis在启动后,可能就会丢失这个key(在写入或者还未写入磁盘时断电了,取决于fsync的配置),如果采用fsync=always,那么会极大影响性能。如何解决这个问题呢?可以让redis节点重启后,在一个TTL时间段内,对客户端不可用即可。
4 Redisson实现分布式锁
Redisson作为Redis的客户端程序,同时支持redis单实例、redis哨兵、redis cluster、redis master-slave等各种部署架构的分布式锁。
先直观的感受一下:
Redisson实现Redis分布式锁的底层原理
下面是Redisson这个开源框架对Redis分布式锁的实现原理。
4.1 加锁机制
如果该客户端面对的是一个redis cluster集群,首先会根据hash节点选择一台机器。
这里注意,仅仅只是选择一台机器!这点很关键!
紧接着,就会发送一段lua脚本到redis上,那段lua脚本如下所示:
为啥要用lua脚本呢?
因为一大坨复杂的业务逻辑,可以通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。
这段lua脚本的意思如下:
KEYS[1]代表的是你加锁的那个key,比如说:
RLock lock = redisson.getLock("myLock");这里你自己设置了加锁的那个锁key就是“myLock”。
ARGV[1]代表的就是锁key的默认生存时间,默认30秒。
ARGV[2]代表的是加锁的客户端的ID,类似于下面这样:8743c9c0-0795-4907-87fd-6c719a6b4586:1
第一段if判断语句,就是用exists myLock
命令判断加锁的那个key是否存在,不存在就用命令:hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
进行加锁。
通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:
上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。
接着会执行pexpire myLock 30000
命令,设置myLock这个锁key的生存时间是30秒。
好了,到此为止,ok,加锁完成了。
4.2 锁互斥机制
那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,第一个if判断会执行exists myLock
,发现myLock这个锁key已经存在了。
接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。
所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。
此时客户端2会进入一个while循环,不停的尝试加锁。
4.3 watch dog自动延期机制
客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?
简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
4.4 可重入加锁机制
如果客户端1都已经持有了这把锁了,可重入的加锁会怎么样呢?
比如下面这种代码:
这时我们来分析一下上面那段lua脚本。
第一个if判断肯定不成立,exists myLock
会显示锁key已经存在了。
第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”
此时就会执行可重入加锁的逻辑,他会用:
incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端1的加锁次数,累加1。
此时myLock数据结构变为下面这样:
那个myLock的hash数据结构中的那个客户端ID,就对应着加锁的次数
4.5 释放锁机制
如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。
其实说白了,就是每次都对myLock数据结构中的那个加锁次数减1。如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:del myLock
命令,从redis里删除这个key。然后呢,另外的客户端2就可以尝试完成加锁了。这就是所谓的分布式锁的开源Redisson框架的实现机制。
4.6 小结
一般我们在生产系统中,可以用Redisson框架提供的这个类库来基于redis进行分布式锁的加锁与释放锁。
《Redis官方文档》用Redis构建分布式锁 | 并发编程网 – ifeve.com
拜托,面试请不要再问我Redis分布式锁的实现原理【石杉的架构笔记】