Redis分布式锁实现

前言

锁(lock)在避免多个工作流(多个进程 或者 多个线程)对共享资源的访问出现冲突中有着大量的应用。

在许多时候,锁代表着对共享资源的独占申明,当一个工作流获得了锁之后,其他的工作流应当能看到这个资源已经被占了。

所以锁的申明和释放一定要基于各个工作流都可见的区域来实现。具体的实现方式可以有很多,比如同一个进程中的多个线程可以利用全局变量来实现锁,同一个机器中的多个进程可以利用共享内存,或者用一个数据库甚至使用一个文件来实现。

特性

  1. 互斥性:同一时刻,一个锁只能被一个工作流持有
  2. 避免死锁:避免持有锁的工作流因为故障无法释放锁,进而使其他的工作流永远都无法获得锁
  3. 加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
  4. 具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁

比如我们在12306上订票,如果没有良好得锁机制,很可能会导致数据库里只剩下一张票,但是有多个人在12306网站上同时抢到了这张票,每个人都以为自己已经抢上了票,但是一刷新或者支付得时候又发现网站上提示没票了。

我们常规使用锁的场景都是在同一进程中。但是随着目前分布式处理的广泛使用,我们很时候需要协调位于不同机器上的不同进程对共享资源的独占使用,这个时候我们就需要在所有机器都能看见的区域来实现锁,同时我们还得应对分布式环境下各个机器节点的不可靠性、网络的不稳定性、各个机器时钟的不一致性等问题来努力的保证上述锁的性质被满足。

常见分布式方案

基于mysql 表唯一索引

  1. 表增加唯一索引
  2. 加锁:执行insert语句,若报错,则表明加锁失败
  3. 解锁:执行delete语句

「优点」

完全利用DB现有能力,实现简单

「缺点」

  1. 锁无超时自动失效机制,有死锁风险
  2. 不支持锁重入,不支持阻塞等待
  3. 操作数据库开销大,性能不高

基于分布式协调系统 基于ZooKeeper

  1. 加锁:在/lock目录下创建临时有序节点,判断创建的节点序号是否最小。若是,则表示获取到锁;否,则则watch /lock目录下序号比自身小的前一个节点
  2. 解锁:删除节点

「优点」

  1. 由zk保障系统高可用
  2. Curator框架已原生支持系列分布式锁命令,使用简单

「缺点」

需单独维护一套zk集群,维保成本高

基于缓存 基于redis命令

  1. 加锁:执行setnx,若成功再执行expire添加过期时间
  2. 解锁:执行delete命令

「优点」

实现简单,相比数据库和分布式系统的实现,该方案最轻,性能最好

「缺点」

  1. setnx和expire分2步执行,非原子操作;若setnx执行成功,但expire执行失败,就可能出现死锁
  2. delete命令存在误删除非当前线程持有的锁的可能
  3. 不支持阻塞等待、不可重入

基于redis Lua脚本能力

  1. 加锁:执行SET lock_name random_value PX milliseconds NX 命令
  2. 解锁:执行Lua脚本,释放锁时验证random_value
-- ARGV[1]为random_value,  KEYS[1]为lock_name
if redis.call("get", KEYS[1]) == ARGV[1] then

    return redis.call("del",KEYS[1])

else

    return 0

end

「优点」

实现逻辑上也更严谨

「缺点」

  1. 不支持锁重入(当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的)
  2. 不支持阻塞等待
  3. 单点不能高可用

redis具体实现

释放锁时先检查

带有超时特性的锁满足了避免死锁的性质,但是这种auto release的机制的却很有可能破坏锁的互斥性质。

「举个栗子

比如当进程A获得了锁,并设置锁的超期时间为10s,进程A由于处理任务花费的时间较长,10s后任务还没处理完,但是此时锁已经过期被释放了,进程B重新获得了锁(不要忘了,锁实际上是对资源独占的一种申明)。这个时候由于进程A没有主动释放锁,进程B又获得了锁,对A、B来讲,他们都认为自己独占了资源,当他们按照独占资源的想法去操作资源的时候就可能会导致冲突。

同时还存在的另一个问题,A的锁由于超时被释放了且B重新获得了锁,但是A并不知道自己的锁已经被释放了,A做完处理工作之后开始释放锁,然而这时释放的其实是B的锁(因为都删除的是mylock键)。B吃着火锅唱着歌,回头一看,锁没了==,很糟糕。

对于第一个问题的解决方案我们后续Redlock介绍,但是对于第二个误释放别人的锁,我们可以在unlock中使用如下步骤释放锁:

从redis中使用GET命令得到mylock的值,并检查锁对应的值是不是自己当时存的random_value

如果是,那就使用DELETE释放,如果不是,说明自己的锁已经被自动释放了,则不做任何处理。

为保证上述整个操作的原子性,防止在GET之后,DELETE之前的期间Redis恰巧把锁给自动释放了,一般把上述的过程写到一个Lua的脚本中提交给Redis执行,因为Redis执行Lua脚本中的命令是原子性质的。

Redlock算法

当我们只依赖单个Redis节点时,我们就只能承受单点不可靠带来的风险。而我们都知道,在分布式系统中,常用的应对单点风险的解决方案就是冗余节点。那我们能不能多启动几个Redis,保留键的多副本,这样即使一个Redis因为意外挂掉了,我们也可以使用别的Redis服务器继续正常服务?答案是YES! 这就是我们接下来要介绍的RedLock。

RedLock是Redis之父Salvatore Sanfilippo提出来的基于多个Redis实例的分布式锁的实现方案。其核心思想就在于使用多个Redis冗余实例来避免单Redis实例的不可靠性。比如我们采用5个Redis实例,我们可以把5个Redis全部部署到同一台机器上,也可以把5个Redis部署在5个不同的机器上。一般为了实现更好的读写性能以及抗风险能力,我们选择部署5个Redis在5个机器上。

基于Redis的锁的实现本质都是针对数据库的读写操作。那么采用5个Redis节点我们就需要考虑副本读写的一致性问题。基于不同的准则,我们有不同的权衡,比如写入的副本一致性,可以要求到只要一个节点写入成功则成功,或者依据法团准则,写入(N/2 +1)个节点后才成功,或者写入所有的节点后才成功等等。RedLock采用的就是依据法团准则的方案:

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

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

Redlock的问题

延迟重启

如果一个Redis节点挂了出现锁被同时两个客户端获得的情况。假设我们总共有5台机器,客户端A从R1,R2,R3上获得了锁,但是在R1未来得及把这个操作持久化到磁盘上时,R1挂掉了。

此时R1重启之后,其从磁盘上恢复的数据并没有A的锁的信息,所以进程B可以从R1,R4,R5再次获得锁(满足法团协议),这样就又造成了冲突。为解决这个问题,Redis作者提出了延迟重启的解决方案。

假设R1挂掉了之后,我们不再让R1提供服务会怎么样呢?

首先可以保证的时,上述的A、B同时获得锁的情况不会发生,因为B最多从R4,R5获得两个锁,不满足法团协议。

但是显然我们不能让R1永远的不提供服务。但我们可以让他等一会再重新对外提供服务,那得等待多久呢?我们可以发现的是,受R1挂了然后接着重启这件事影响的锁只是在R1挂的那个时刻R1上存的所有锁,之后创建的新锁或者没在R1上存储的锁都不受R1挂了这件事的影响。

而我们前面又知道,在设置锁的时候,为避免陷入死锁的困境,我们给每个锁设置了一个过期时间。那R1只需要等到R1挂掉的那个时刻其上面所有的键都过期之后再对外提供服务即可,即可以等待一个所有键的MAX TTL即可。但是这个MAX TTL我们是没法只通过统计R1上的键准确的知道的,因为R1有一部分键的信息由于没有持久化到磁盘上已经丢失了。

但是为了保险,我们可以通过统计当前时刻所有机器上的MAX TTL,然后取所有机器的MAX TTL即可。这样我们就可以保证R1加入服务后,其上所有的锁都肯定已经失效了。有了延迟重启和多Redis实例的解决方案,我们对Redis节点可能会挂这个风险有了更强的的抵抗能力。

请求超时

假设有5个Redis服务器,客户端A试图获得一个超时期限为10s的锁。

按照上述的流程,我们是从R1到R5依次尝试获得锁mylock,当前时间戳假设是12300

  1. 我们先从R1获得了锁,此时R1机器上记录的mylock的到期时间戳为12310
  2. 我们再尝试从R2获得锁,由于网络的问题,等R2获得请求时,时间已经到了12302了,那么R2机器上记录的mylock的到期时间戳即为12312
  3. 同理,当我们再次尝试从R3获得锁时,网络畅通,当前时间戳仍然是12302,R3上记录的mylock的到期时间戳为12312

R1, R2,R3的到期时间戳是不一样的。如果我们按照三个机器的最大时间戳来当作mylock的过期时间戳会导致如果客户端B在时间戳为12311时尝试获得mylock锁,由于R1中mylock已经过期,

则B从R1,R4,R5获得锁,满足法团协议,获得获得锁成功,此时出现A、B同时得到锁。所以显然不能使用最大时间戳来当作过期时间戳,使用理论时间戳(即开始设置时,本地机器的时间戳+TTL)是最保险的方案,因为他肯定是最小的。

为了避免在获取锁的过程中因为网络的问题占用了过多的锁可使用时间,每次从一个机器获取锁的时候都在网络上只等一个非常小的时间,超时还未获得锁就立马尝试下一个节点。

时间流速一致性

什么叫时间流速一致性假设呢?

就是机器A上过了一分钟,机器B上也过了几乎一分钟。

其问题在于,一个机器的时间是有可能跳变的。比如管理员重新校正机器时间,或者机器的时钟模块收到外部更新信号,重新校对时间等。

这就有可能导致如下的情况出现:客户端A从R1,R2,R3获得了锁,并设置了过期时间为10s,但是在其中的某个时刻,可能R1的时间被重新校正,“快进了10s”,调整完时间之后,R1上的锁就已经过期了。

此时B再次申请同样的锁,则可以从R1,R4,R5获得锁,满足法团协议。获得锁成功。当然,这种问题发生的概率可能是足够低的,能不能承受这样的情况带来的损失决定着是否采用Redis来实现分布式锁

小结

Redis 以其高性能著称,但使用其实现分布式锁来解决并发仍存在一些困难。Redis 分布式锁只能作为一种缓解并发的手段,如果要完全解决并发问题,仍需要考虑其它的分布式锁防并发手段。

参考文章:

  • http://redis.cn/topics/distlock.html
  • https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers
  • https://blog.csdn.net/w372426096/article/details/103761286
  • https://zhuanlan.zhihu.com/p/100140241


原文始发于微信公众号(码农札记):Redis分布式锁实现

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/98587.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!