使用 Redis 如何设计分布式锁?

一、什么是分布式锁?

要使用redis来设计分布式锁,首先要了解什么是分布式锁,而要了解什么是分布式锁,先要提到与分布式锁相对应的线程锁和进程锁。
线程锁:线程锁主要是用来给方法和代码块加锁。当某个方法或者某段代码使用线程锁时,在同一时刻仅有一个线程执行该方法或该代码段。线程锁只在同一个JVM中有效果,因为线程锁的实现根本上是依靠线程之间共享内存来实现的,比如synchronized是共享对象头,显示锁Lock是共享某个变量(state)。
进程锁:为了控制同一个操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过synchronized等线程锁来实现进程锁。
分布式锁:当我们在某一生产环境中启动多个订单服务时,就是多个JVM,内存中的锁显然是不共享的,每个JVM进程都有自己的锁,自然无法保证线程的互斥了,这个时候我们就需要使用到分布式锁。也就是说,当多个进程不在同一个系统中时,我们用分布式锁来控制多个进程对资源的访问。

二、分布式锁的使用场景

线程间的并发问题和进程间的并发问题都是可以通过分布式锁来解决的,但是强烈不建议这么做!因为采用分布式锁来解决这些小问题是非常消耗资源的!分布式锁应该用来解决分布式情况下的多进程并发问题才是最合适的。

比如有这么一个场景,线程A和线程B都共享某个变量X。
如果是在单机情况下(即单JVM),线程之间共享内存,那么用线程锁就能解决并发问题。
如果是在分布式情况下(即多JVM),线程A和线程B很可能不是在同一个JVM中,这样线程锁就无法使用了,这时候就需要用分布式锁来解决。

实现分布式锁常用的有三种解决方案:1.基于数据库实现 2.基于zookeeper的临时序列化节点实现 3.redis实现。本文我们介绍的就是redis的实现方式。

三、分布式锁的实现

实现分布式锁有 3 点需要注意:

  1. 互斥(即同一时刻只能有一个线程获取到锁)
  2. 不能死锁,因此锁信息必须是会过期超时的,不能让一个线程长期占有锁而导致死锁
  3. 容错(只要大部分 Redis 节点创建了这把锁就可以)

几个要用到的redis命令:

  1. SETNX(key,value)
  2. GET(key)
  3. GETSET(key,value)
  4. EXPIRE(key,seconds)

Redis官方给出了两种基于Redis实现分布式锁的方法。(点击这里获取详情)

四、Redis最普通的分布式锁(单Redis实例实现分布式锁)

1. 加锁
加锁实际上就是,在redis中使用SET key value [EX seconds] [PX milliseconds] NX命令给Key键设置一个值,为了避免死锁,还要给定一个过期时间。如执行以下命令:

SET lock_key random_value NX PX 5000

其中:
lock_key为resource_name。
random_value 为key的值(一个随机值),这个值在所有的客户端必须是唯一的,所有同一key的获取者(竞争者)这个值都不能一样。可以用snowflake算法生成分布式唯一id
NX表示只在key不存在时,才对key进行设置操作; PX 5000表示设置key的过期时间为5000毫秒。 即这个命令仅在不存在key的时候才能被执行成功(NX选项),并且这个key有一个5秒的自动失效时间(PX属性)

这样,如果上面的命令执行成功,则证明客户端获取到了锁。

2. 解锁
解锁即释放锁,解锁的过程就是将key键给删除。但是也不能乱删,比如说有这么个场景,客户端1先获取到了锁,但是阻塞了很长时间才执行完,比如说超过了30秒,这个时候可能已经自动释放锁了,此时客户端2可能已经获取到了锁,这个时候如果直接删除key的话肯定会出问题的,不能用客户端1的请求来将客户端2的锁给删除掉。这时候random_value的作用就体现出来了。可以用随机值和LUA脚本对key进行删除避免上述情况,因为脚本仅会删除value等于客户端1的value的key(value相当于客户端的一个签名)。

首先要判断key是否存在并且存储的值是否和传入的值一样,若是则删除key,解锁成功。

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

单节点Redis的分布式锁的实现比较简单,但是也存在比较大的问题,最重要的一点是,锁不具有可重入性。如果是Redis普通主从,那Redis主从异步复制,若主节点挂了的话(也就是key没有了),此时key还没同步到从节点,此时从节点切换到主节点,别人就可以set key,从而拿到锁,会造成比较严重的安全问题。

五、RedLock算法

这个场景是假设有一个Redis集群,有5个Redis master节点,这些节点完全互相独立,不存在主从复制或者其他集群协调机制。之前我们已经描述了在Redis单实例下怎么安全地获取和释放锁。这里我们确保将在每(5)个实例上使用此方法获取和释放锁,我们需要在5台机器上面或者5台虚拟机上面运行这些实例,这样保证他们不会同时都宕掉。

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

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

这个算法是异步的吗?

算法基于这样一个假设:虽然多个进程之间没有时钟同步,但每个进程都以相同的时钟频率前进,时间差相对于失效时间来说几乎可以忽略不计。这种假设和我们的真实世界非常接近:每个计算机都有一个本地时钟,我们可以容忍多个计算机之间有较小的时钟漂移。
从这点来说,我们必须再次强调我们的互相排斥规则:只有在锁的有效时间(在步骤3计算的结果)范围内客户端能够做完它的工作,锁的安全性才能得到保证(锁的实际有效时间通常要比设置的短,因为计算机之间有时钟漂移的现象)。

失败时重试

当客户端无法取到锁时,应该在一个随机延迟后重试,防止多个客户端在同时抢夺同一资源的锁(这样会导致脑裂,没有人会取到锁)。同样,客户端取得大部分Redis实例锁所花费的时间越短,脑裂出现的概率就会越低(必要的重试),所以,理想情况一下,客户端应该同时(并发地)向所有Redis发送SET命令。
需要强调,当客户端从大多数Redis实例获取锁失败时,应该尽快地释放(部分)已经成功取到的锁,这样其他的客户端就不必非得等到锁过完“有效时间”才能取到(然而,如果已经存在网络分裂,客户端已经无法和Redis实例通信,此时就只能等待key的自动释放了,等于被惩罚了)。

释放锁

释放锁比较简单,向所有的Redis实例发送释放锁命令即可,不用关心之前有没有从Redis实例成功获取到锁。

详情查看:
Redis中国官方网站-Redis分布式锁

关于Redlock算法是否安全的争论

2016年2月8号分布式系统的专家马丁·克莱普曼博士(Martin Kleppmann)在一篇文章How to do distributed locking 指出分布式锁设计的一些原则并且对Antirez的Redlock算法提出了一些质疑。
Martin指出:

  1. 即使我们拥有一个完美实现的分布式锁,在没有共享资源参与进来提供某种fencing栅栏机制的前提下,我们仍然不可能获得足够的安全性
  2. 由于Redlock本质上是建立在一个同步模型之上,对系统的时间有很强的要求,本身的安全性是不够的

针对第1点,Martin认为,获取锁的客户端在持有锁时可能会暂停一段较长的时间,尽管锁有一个超时时间,避免了崩溃的客户端可能永远持有锁并且永远不会释放它,但是如果客户端的暂停持续的时间长于锁的到期时间,并且客户没有意识到它已经到期,那么它可能会继续进行一些不安全的更改,换言之由于客户端阻塞导致的持有的锁到期而不自知
对于这种情况马丁指出要增加fencing机制,具体来说是fencing token隔离令牌机制。他给了个例子:
假设客户端1获得锁并且获得序号为33的令牌,但随后它进入长时间暂停,直至锁超时过期,客户端2获取锁并且获得序号为34的令牌,然后将其写入发送到存储服务。随后,客户端1复活并将其写入发送到存储服务,然而存储服务器记得它已经处理了具有较高令牌号34的写入,因此它拒绝令牌33的请求。Redlock算法并没有这种唯一且递增的fencing token生成机制,这也意味着Redlock算法不能避免由于客户端阻塞带来的锁过期后的操作问题,因此是不安全的。

针对第2点,Martin认为,Redlock是个强依赖系统时间的算法,这样就可能带来很多不一致问题。他同样给出了个例子:

假设多节点Redis系统有五个节点A/B/C/D/E和两个客户端C1和C2,如果其中一个Redis节点上的时钟向前跳跃会发生什么?

  • 客户端C1获得了对节点A、B、C的锁定,由于网络问题,无法到达节点D和节点E
  • 节点C上的时钟向前跳,导致锁提前过期
  • 客户端C2在节点C、D、E上获得锁定,由于网络问题,无法到达A和B
  • 客户端C1和客户端C2现在都认为他们自己持有锁

分布式异步模型:
上面这种情况之所以有可能发生,本质上是因为Redlock的安全性对Redis节点系统时钟有强依赖,一旦系统时钟变得不准确,算法的安全性也就无法保证。

马丁其实是要指出分布式算法研究中的一些基础性问题,好的分布式算法应该基于异步模型,算法的安全性不应该依赖于任何记时假设

分布式异步模型中进程和消息可能会延迟任意长的时间,系统时钟也可能以任意方式出错。这些因素不应该影响它的安全性,只可能影响到它的活性,即使在非常极端的情况下,算法最多是不能在有限的时间内给出结果,而不应该给出错误的结果,这样的算法在现实中是存在的比如Paxos/Raft,按这个标准衡量Redlock的安全级别是达不到的。

对此,Redlock算法作者Antirez进行了反驳:地址
Antirez认为马丁的文章对于Redlock的批评可以概括为两个方面:

  • 带有自动过期功能的分布式锁,必须提供某种fencing栅栏机制来保证对共享资源的真正互斥保护,Redlock算法提供不了这样一种机制
  • Redlock算法构建在一个不够安全的系统模型之上,它对于系统的记时假设有比较强的要求,而这些要求在现实的系统中是无法保证的

Antirez对这两方面分别进行了细致的反驳。

关于fencing机制

Antirez提出了质疑:既然在锁失效的情况下已经存在一种fencing机制能继续保持资源的互斥访问了,那为什么还要使用一个分布式锁并且还要求它提供那么强的安全性保证呢?
退一步讲Redlock虽然提供不了递增的fencing token隔离令牌,但利用Redlock产生的随机字符串可以达到同样的效果,这个随机字符串虽然不是递增的,但却是唯一的。

关于记时假设

Antirez针对算法在记时模型假设集中反驳,马丁认为Redlock失效情况主要有三种:

  1. 时钟发生跳跃
  2. 长时间的GC pause
  3. 长时间的网络延迟

后两种情况来说,Redlock在当初之处进行了相关设计和考量,对这两种问题引起的后果有一定的抵抗力。
时钟跳跃对于Redlock影响较大,这种情况一旦发生Redlock是没法正常工作的。
Antirez指出Redlock对系统时钟的要求并不需要完全精确,只要误差不超过一定范围不会产生影响,在实际环境中是完全合理的,通过恰当的运维完全可以避免时钟发生大的跳动

总结

分布式系统本身就很复杂,机制和理论的效果需要一定的数学推导作为依据,马丁和Antirez都是这个领域的专家,对于一些问题都会有自己的看法和思考,更重要的是很多时候问题本身并没有完美的解决方案,只有站在巨人的肩膀上才能做出更好的成绩。

posted @ 2020-08-10 17:44  heaven096  阅读(1049)  评论(0编辑  收藏  举报