转自:
https://mp.weixin.qq.com/s/RnSokJxYxYDeenOP_JE3fQ
首先,让我们先思考下这些问题:
在开始讲分布式锁之前,有必要简单介绍一下,为什么需要分布式锁?
与分布式锁相对应的是「单机锁」,我们在写多线程程序时,为了避免同时操作一个共享变量时产生数据问题,通常会使用一把锁来「互斥」,以保证共享变量的正确性,其使用范围是在「同一个进程」中。
那如果换做是多个进程需要同时操作一个共享资源,如何互斥呢?
例如,现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那如果这多个进程都需要修改 MySQL 中的同一行记录,为了避免操作乱序导致数据错误,此时我们就需要引入「分布式锁」来解决这个问题了。
想要实现分布式锁,必须借助一个外部系统,所有的进程都去这个系统上申请「加锁」。
而这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。
这个外部系统,可以是 MySQL,也可以是 Redis 或 Zookeeper。但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做。
我们从最简单的开始讲起。
想要实现分布式锁,必须要求 Redis 有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示 SET IF NOT EXISTS,即如果 key 不存在,才会设置它的值,否则什么也不做。
两个客户端进程可以通过执行这个命令,达到互斥,从而实现一个分布式锁。
客户端 1 申请加锁,加锁成功:
// 客户端 1 加锁成功
127.0.0.1:6379> SETNX lock 1
(integer) 1
客户端 2 申请加锁,因为它后到达,加锁失败:
// 客户端 2 加锁失败
127.0.0.1:6379> SETNX lock 1
(integer) 0
此时,加锁成功的客户端,就可以去操作「共享资源」,例如:修改 MySQL 的某一行数据,或者调用一个 API 请求。
操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。那么该如何释放锁呢?
这也很简单,直接使用 DEL 命令删除这个 key 即可:
// 释放锁
127.0.0.1:6379> DEL lock
(integer) 1
这个逻辑非常简单,整体的流程就是这样:
但是,它存在一个很大的问题:当客户端 1 拿到锁后,如果发生下面的场景就会造成「死锁」:
这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」也拿不到这把锁了。
怎么解决这个问题呢?
我们很容易想到的方案是:在申请到锁的时候,给这把锁设置一个「租期」。
在 Redis 中的实现,就是给这个 key 设置一个「过期时间」。这里我们假设,操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可:
// 加锁
127.0.0.1:6379> SETNX lock 1
(integer) 1
// 10s后自动过期
127.0.0.1:6379> EXPIRE lock 10
(integer) 1
这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。
但这样还是存在问题。
加锁、设置过期时间是 2 条命令,有没有可能出现只执行了第一条,第二条却「来不及」执行的情况呢?例如:
总之,如果这两条命令不能保证是原子性操作(即一起成功),就有潜在的风险导致过期时间设置失败,随即造成发生「死锁」的问题。
这该怎么办?
在 Redis 2.6.12 版本之前,我们需要想尽各种办法,保证 SETNX 和 EXPIRE 是原子性操作,还要考虑各种异常情况下该如何处理。
但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:
// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
这样就解决了死锁问题,也比较简单。
但这样也还是存在问题。试想这样一种场景:
这里存在两个严重的问题:
导致这两个问题的原因是什么?
第一个问题,可能是由于我们评估操作共享资源的时间不够准确所导致的。
例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险。
既然过期时间太短,那我们延长过期时间,例如设置过期时间为 20s,这样总可以了吧?
这样确实可以「缓解」这个问题,降低出问题的概率,但是依旧无法「彻底解决」问题。
原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的实际场景有可能是很复杂的,例如:程序内部发生异常、网络请求超时等。
既然是「预估」时间,所以也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难。
关于这个问题,会在后面详细来讲对应的解决方案。
而第二个问题在于,一个客户端错误地释放了其它客户端持有的锁。
导致这个问题的关键点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!
如何解决这个问题呢?
解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。
例如,可以是自己的线程 id,也可以是一个 UUID(随机且唯一),这里我们以 UUID 举例:
// 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:
// 锁是自己的才释放
if (redis.get("lock") == $uuid) {
redis.del("lock");
}
这里释放锁使用的是 GET + DEL 两条命令,这时又会遇到我们前面讲的原子性的问题:
由此可见,这两个命令还是必须要原子性执行才行。
我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。
因为 Redis 处理每一个请求都是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。
安全释放锁的 Lua 脚本如下:
// 判断锁是自己的才释放
if redis.call("GET", KEYS[1]) == ARGV[1]
then
return redis.call("DEL", KEYS[1])
else
return 0
end
我们先简单小结一下,基于 Redis 实现的分布式锁,一个严谨的流程应当如下:
现在有了这个完整的锁模型,让我们重新回到前面提到的第一个问题。
前面我们提到,锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。
当时给的妥协方案是,尽量「冗余」过期时间,以降低锁提前过期的概率。
这个方案其实也不能完美地解决问题,那怎么办呢?
是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
看上去这确实一种比较好的方案。
如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson。
Redisson 是一个用 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。
除此之外,这个 SDK 还封装了很多易用的功能:
这个 SDK 提供的 API 非常友好,它可以像操作本地锁一样,操作分布式锁。
这里不重点介绍 Redisson 的使用,大家可以看官方 Github 学习如何使用,比较简单。
到这里我们再简单小结一下,基于 Redis 实现分布式锁,前面遇到的问题以及对应的解决方案:
那么还有哪些问题场景,会危害 Redis 锁的安全性呢?
之前分析的场景,都是分析锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。
而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式进行部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。
那当「主从发生切换」时,这个分布锁会依旧安全吗?试想下这样的场景:
由此可见,当引入 Redis 副本后,分布锁还是有可能会受到影响的。
为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)。
现在我们来看,Redis 作者提出的 Redlock 方案,是如何解决主从切换后锁失效问题的。
Redlock 的方案基于 2 个前提:
也就是说,要想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。
注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。
Redlock 具体如何使用呢?整体的流程是这样的,一共分为 5 步:
简单总结一下,有 4 个重点:
在明白了 Redlock 的流程之后,我们来看下 Redlock 为什么要这么做。
为什么要在多个实例上加锁?
本质上是为了「容错」,即使部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。
为什么大多数实例加锁成功,才算成功?
多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。
在分布式系统中,总会出现「异常节点」,所以在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。
这是一个分布式系统的「容错」问题,这个问题的结论是:如果存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。
这个问题的模型,就是我们经常听到的「拜占庭将军」问题,感兴趣可以去看算法的推演过程。
为什么在步骤 3 加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且因为网络请求、网络情况是复杂的,有可能存在延迟、丢包或者超时等情况,随着网络请求越多,异常发生的概率就越大。
所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那么此时有些实例上的锁可能已经失效了,这个锁本身就没有任何意义了。
为什么释放锁要操作所有节点?
对某一个 Redis 节点加锁时,可能会因为「网络原因」导致加锁“失败”。
例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,由于网络问题导致读取失败,但其实这把锁已经在 Redis 上加锁成功了。
所以释放锁时,不管之前有没有加锁成功,都需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。
明白了 Redlock 的流程和相关问题,看似 Redlock 确实解决了 Redis 节点异常宕机后锁失效的问题,保证了锁的「安全性」。但事实真的如此吗?
这个方案一经提出,就马上受到业界著名的分布式系统专家的质疑!
这个专家叫 Martin,是英国剑桥大学的一名分布式系统研究员。在此之前他曾是软件工程师和企业家,从事大规模数据基础设施相关的工作。他还经常在大会做演讲、写博客、写书,也是开源贡献者。
他马上写了篇文章,质疑这个 Redlock 的算法模型是有问题的,并对分布式锁的设计,提出了自己的看法。
之后,Redis 作者 Antirez 面对质疑,不甘示弱,也写了一篇文章,反驳了对方的观点,并详细剖析了 Redlock 算法模型的更多设计细节。
而且,关于这个问题的争论,在当时互联网上也引起了非常激烈的讨论。
二人思路清晰,论据充分,这是一场高手过招,也是分布式系统领域非常好的一次思想碰撞!双方都是分布式系统领域的专家,却对同一个问题提出很多相反的论断,这究竟是怎么回事?
在他的文章中,主要阐述了以下 4 个论点。
分布式锁的目的是什么?
Martin 表示,你必须先清楚你使用分布式锁的目的是什么?
他认为,如果你是为了前者——效率,那么使用单机版 Redis 就可以了,即使偶尔可能会发生锁失效(宕机、主从切换),都不会产生严重的后果。而且使用 Redlock 太重了,没必要。
而如果是为了正确性,Martin 认为 Redlock 根本达不到安全性的要求,也依旧存在锁失效的问题!
锁在分布式系统中会遇到的问题
Martin 表示,一个分布式系统,更像是一个复杂的「野兽」,存在着很多你想不到的各种异常情况。
这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山(NPC):
Martin 用一个进程暂停(GC)的例子,指出了 Redlock 的安全性问题:
Martin 认为,GC 可能发生在程序的任意时刻,而且执行时间是不可控的。
当然,即使是使用没有 GC 的编程语言,在发生网络延迟、时钟漂移时,也都有可能导致 Redlock 出现问题,这里只是拿 GC 举例。
假设时钟都正确是不合理的
当多个 Redis 节点「时钟」发生问题时,也会导致 Redlock 锁失效:
Martin 觉得,Redlock 必须「强依赖」多个节点的时钟是保持同步的,而一旦有节点时钟发生错误,那这个算法模型就失效了。
即使 C 不是时钟跳跃,而是「崩溃后立即重启」,也可能会发生类似的问题。
Martin 继续阐述,机器的时钟发生错误,是很有可能发生的:
总之,Martin 认为,Redlock 的算法是建立在「同步模型」的基础上的,而有大量研究资料表明,同步模型的假设,在分布式系统中是有问题的。
在混乱的分布式系统中,你不能假设系统时钟就是对的,所以,你必须非常小心你的假设。
提出 fecing token 的方案,保证正确性
相应的,Martin 提出了一种被叫作 fecing token 的方案,用来保证分布式锁的正确性。这个模型流程如下:
这样一来,无论 NPC 中哪种异常情况发生,都可以保证分布式锁的安全性,因为它是建立在「异步模型」上的。
而 Redlock 无法提供类似 fecing token 的方案,所以它无法保证安全性。
他还表示,一个好的分布式锁,无论 NPC 怎么发生,都可以允许不在规定时间内给出结果,但一定不会给出一个错误的结果。也就是只会影响到锁的「性能」(或者称之为活性),而不会影响它的「正确性」。
Martin 的结论:
以上就是 Martin 反对使用 Redlock 的观点,看起来有理有据。
下面我们来看 Redis 作者 Antirez 是如何反驳的。
在 Antirez 的文章中,重点一共有 3 个。
解释时钟问题
首先,Antirez 一眼就看穿了对方提出的最为核心的问题:时钟问题。
Antirez 表示,Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,即允许有「误差」。
例如要计时 5s,但实际上可能记了 4.5s,之后又记了 5.5s,有一定误差,但只要不超过「误差范围」内的锁失效时间即可,它对于时钟的精度要求并不是很高,而且这也符合现实场景。
对于对方提到的「时钟修改」问题,Antirez 反驳到:
为什么 Redis 作者优先解释时钟问题?因为在后面的反驳过程中,需要依赖这个基础做出进一步的解释。
解释网络延迟、GC 问题
之后,Antirez 对于对方提出的,网络延迟、进程 GC 等情况可能会导致 Redlock 失效的问题,也做了反驳。我们先重新回顾一下,Martin 提出的问题假设:
Antirez 反驳到,这个假设其实是有问题的,Redlock 是可以保证锁安全的。
还记得前面介绍 Redlock 流程的那 5 步吗:
重点是步骤 1-3,在步骤 3,加锁成功后为什么还要重新获取「当前时间戳 T2」?而且还要用 T2 - T1 的时间与锁的过期时间做比较?
Antirez 强调:如果在步骤 1-3 发生了网络延迟、进程 GC 等耗时长的异常情况,那在第 3 步 T2 - T1 的时候,是可以检测出来的。如果超出了锁设置的过期时间,就认为加锁失败,然后释放所有节点的锁。
Antirez 继续论述,如果对方认为,发生网络延迟、进程 GC 等情况是在步骤 3 之后,也就是客户端确认拿到了锁,去操作共享资源的途中发生了问题,导致锁失效,那这不止是 Redlock 的问题,任何其它锁服务(包括 Zookeeper),都会有类似的问题,所以这不应该在讨论范畴内。
举个例子解释一下上面的论述:
Antirez 的结论:
所以,Antirez 认为 Redlock 在保证时钟正确的基础上,是可以保证正确性的。
质疑 fencing token 机制
Antirez 对于对方提出的 fecing token 机制,也提出了质疑,主要分为 2 个问题。
例如,要操作 MySQL,从锁服务拿到一个递增的 token,然后客户端要带着这个 token 去修改 MySQL 的某一行,这就需要利用 MySQL 的「事务隔离性」。
// 两个客户端必须利用事务和隔离性达到目的
// 注意token的判断条件
UPDATE table T SET val = $new_val WHERE id = $id AND current_token < $token
但如果操作的不是 MySQL 呢?例如是向磁盘上写一个文件,或者发起一个 HTTP 请求,那这个方案就为力了,这对资源服务器提出了更高的要求。
也就是说,大部分操作的资源服务器,都是没有这种互斥能力的。
再者,既然资源服务器都有了「互斥」能力,那还要分布式锁干什么?
所以,Antirez 认为这个方案是站不住脚的。
Antirez 只是提到了可以完成 fecing token 类似的功能,但却没有展开相关细节,根据查阅的相关资料,大概流程应该如下:
还是以 MySQL 为例:
UPDATE table T SET val = $new_val WHERE id = $id AND current_token = $redlock_value
可见,这种方案依赖于 MySQL 的事务机制,也能实现和对方提到的 fecing token 一样的效果。
但这里还有个小问题,是网友参与问题讨论时提出的:两个客户端通过这种方案,先「标记」再「检查 + 修改」共享资源,那这两个客户端的操作顺序无法保证啊?
而用 Martin 提到的 fecing token,因为这个 token 是单调递增的,资源服务器可以拒绝小的 token 请求,保证了操作的「顺序性」!
Antirez 对这个问题有不同的看法,他解释道:分布式锁的本质,是为了「互斥」,只要能保证两个客户端在并发时,一个成功,一个失败就好了,不需要关心「顺序性」。
前面 Martin 的质疑中,一直很关心这个顺序性问题,但 Antirez 的看法却不同。
综上,Antirez 的结论:
讲完了双方对于 Redis 分布锁的争论,你可能也注意到了,Martin 在他的文章中,推荐使用 Zookeeper 实现分布式锁,认为它更安全,但事实确实如此吗?
如果你有了解过 Zookeeper,基于它实现的分布式锁是这样的:
Zookeeper 不像 Redis 那样,需要考虑锁的过期时间,它采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。
而且,如果客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放。
没有锁过期的烦恼,还能在异常时自动释放锁,是不是觉得很完美?其实不然。
在客户端 1 创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁的呢?
原因就在于,客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 依赖于客户端的「定时心跳」来维持连接。
如果 Zookeeper 长时间接收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。
同样基于此问题,我们也讨论一下 GC 问题对 Zookeeper 的锁会有什么影响:
可见,即使是使用 Zookeeper,也无法保证在进程 GC、网络延迟等异常场景下的安全性。
这就是前面 Antirez 在反驳的文章中提到的:如果客户端都已经拿到了锁,但客户端与锁服务器发生「失联」(例如 GC),那不止 Redlock 有问题,其它任何锁服务都会有类似的问题,Zookeeper 也是一样!
所以,这里我们就能得出结论了:一个分布式锁,在极端情况下,不一定是安全的。
如果你的业务数据非常敏感,在使用分布式锁时,一定要注意这个问题,不能假设分布式锁 100% 安全。
现在来总结一下 Zookeeper 在使用分布式锁时的优劣。
Zookeeper 的优点
Zookeeper 的劣势
到底要不要用 Redlock?
前面也分析了,Redlock 只有在建立了「时钟正确」的前提下,才能正常工作,如果你可以保证这个前提,那么可以拿来使用。
但保证时钟正确,却并不是那么简单就能做到的:
所以,对 Redlock 的个人看法是,尽量不用它,而且它的性能不如单机版 Redis,部署成本也高,还是应当优先考虑使用主从+ 哨兵的模式实现分布式锁。
如何正确使用分布式锁?
在分析 Martin 观点时,它提到了 fecing token 的方案,虽然这种方案有很大的局限性,但对于保证「正确性」的场景,是一个非常好的思路。
所以,我们可以把这两者结合起来用:
两种思路的结合,对于大多数业务场景而言,已经可以满足要求了。
最后,用 Martin 在对于 Redlock 争论过后,写下的感悟来结尾:
“前人已经为我们创造出了许多伟大的成果:站在巨人的肩膀上,我们可以才得以构建更好的软件。无论如何,通过争论和检查它们是否经得起别人的详细审查,这是学习过程的一部分。但目标应该是获取知识,而不是为了说服别人,让别人相信你是对的。有时候,那只是意味着停下来,好好地想一想。”
共勉。
这篇文章写的实在是太棒了 > - <
因篇幅问题不能全部显示,请点此查看更多更全内容