深度剖析:Redis分布式锁到底安全吗?看完这篇文章完全懂了!

微信搜索关注「水滴与银弹」公众号,第一时间获取优质技术干货。7年资深后端研发,给你呈现不同的技术视角。html

你们好,我是 Kaito。python

这篇文章我想和你聊一聊,关于 Redis 分布式锁的「安全性」问题。程序员

Redis 分布式锁的话题,不少文章已经写烂了,我为何还要写这篇文章呢?redis

由于我发现网上 99% 的文章,并无把这个问题真正讲清楚。致使不少读者看了不少文章,依旧云里雾里。例以下面这些问题,你能清晰地回答上来吗?算法

  • 基于 Redis 如何实现一个分布式锁?
  • Redis 分布式锁真的安全吗?
  • Redis 的 Redlock 有什么问题?必定安全吗?
  • 业界争论 Redlock,到底在争论什么?哪一种观点是对的?
  • 分布式锁到底用 Redis 仍是 Zookeeper?
  • 实现一个有「容错性」的分布式锁,都须要考虑哪些问题?

这篇文章,我就来把这些问题完全讲清楚。sql

读完这篇文章,你不只能够完全了解分布式锁,还会对「分布式系统」有更加深入的理解。shell

文章有点长,但干货不少,但愿你能够耐心读完。编程

为何须要分布式锁?

在开始讲分布式锁以前,有必要简单介绍一下,为何须要分布式锁?后端

与分布式锁相对应的是「单机锁」,咱们在写多线程程序时,避免同时操做一个共享变量产生数据问题,一般会使用一把锁来「互斥」,以保证共享变量的正确性,其使用范围是在「同一个进程」中。安全

若是换作是多个进程,须要同时操做一个共享资源,如何互斥呢?

例如,如今的业务应用一般都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程若是须要修改 MySQL 中的同一行记录时,为了不操做乱序致使数据错误,此时,咱们就须要引入「分布式锁」来解决这个问题了。

想要实现分布式锁,必须借助一个外部系统,全部进程都去这个系统上申请「加锁」。

而这个外部系统,必需要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另外一个返回失败(或等待)。

这个外部系统,能够是 MySQL,也能够是 Redis 或 Zookeeper。但为了追求更好的性能,咱们一般会选择使用 Redis 或 Zookeeper 来作。

下面我就以 Redis 为主线,由浅入深,带你深度剖析一下,分布式锁的各类「安全性」问题,帮你完全理解分布式锁。

分布式锁怎么实现?

咱们从最简单的开始讲起。

想要实现分布式锁,必需要求 Redis 有「互斥」的能力,咱们可使用 SETNX 命令,这个命令表示SET if Not eXists,即若是 key 不存在,才会设置它的值,不然什么也不作。

两个客户端进程能够执行这个命令,达到互斥,就能够实现一个分布式锁。

客户端 1 申请加锁,加锁成功:

127.0.0.1:6379> SETNX lock 1
(integer) 1     // 客户端1,加锁成功
复制代码

客户端 2 申请加锁,由于后到达,加锁失败:

127.0.0.1:6379> SETNX lock 1
(integer) 0     // 客户端2,加锁失败
复制代码

此时,加锁成功的客户端,就能够去操做「共享资源」,例如,修改 MySQL 的某一行数据,或者调用一个 API 请求。

操做完成后,还要及时释放锁,给后来者让出操做共享资源的机会。如何释放锁呢?

也很简单,直接使用 DEL 命令删除这个 key 便可:

127.0.0.1:6379> DEL lock // 释放锁
(integer) 1
复制代码

这个逻辑很是简单,总体的路程就是这样:

可是,它存在一个很大的问题,当客户端 1 拿到锁后,若是发生下面的场景,就会形成「死锁」:

  1. 程序处理业务逻辑异常,没及时释放锁
  2. 进程挂了,没机会释放锁

这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了。

怎么解决这个问题呢?

如何避免死锁?

咱们很容易想到的方案是,在申请锁时,给这把锁设置一个「租期」。

在 Redis 中实现时,就是给这个 key 设置一个「过时时间」。这里咱们假设,操做共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过时便可:

127.0.0.1:6379> SETNX lock 1    // 加锁
(integer) 1
127.0.0.1:6379> EXPIRE lock 10  // 10s后自动过时
(integer) 1
复制代码

这样一来,不管客户端是否异常,这个锁均可以在 10s 后被「自动释放」,其它客户端依旧能够拿到锁。

但这样真的没问题吗?

仍是有问题。

如今的操做,加锁、设置过时是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的状况发生呢?例如:

  1. SETNX 执行成功,执行 EXPIRE 时因为网络问题,执行失败
  2. SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
  3. SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行

总之,这两条命令不能保证是原子操做(一块儿成功),就有潜在的风险致使过时时间设置失败,依旧发生「死锁」问题。

怎么办?

在 Redis 2.6.12 版本以前,咱们须要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各类异常状况如何处理。

但在 Redis 2.6.12 以后,Redis 扩展了 SET 命令的参数,用这一条命令就能够了:

// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
复制代码

这样就解决了死锁问题,也比较简单。

咱们再来看分析下,它还有什么问题?

试想这样一种场景:

  1. 客户端 1 加锁成功,开始操做共享资源
  2. 客户端 1 操做共享资源的时间,「超过」了锁的过时时间,锁被「自动释放」
  3. 客户端 2 加锁成功,开始操做共享资源
  4. 客户端 1 操做共享资源完成,释放锁(但释放的是客户端 2 的锁)

看到了么,这里存在两个严重的问题:

  1. 锁过时:客户端 1 操做共享资源耗时过久,致使锁被自动释放,以后被客户端 2 持有
  2. 释放别人的锁:客户端 1 操做共享资源完成后,却又释放了客户端 2 的锁

致使这两个问题的缘由是什么?咱们一个个来看。

第一个问题,多是咱们评估操做共享资源的时间不许确致使的。

例如,操做共享资源的时间「最慢」可能须要 15s,而咱们却只设置了 10s 过时,那这就存在锁提早过时的风险。

过时时间过短,那增大冗余时间,例如设置过时时间为 20s,这样总能够了吧?

这样确实能够「缓解」这个问题,下降出问题的几率,但依旧没法「完全解决」问题。

为何?

缘由在于,客户端在拿到锁以后,在操做共享资源时,遇到的场景有多是很复杂的,例如,程序内部发生异常、网络请求超时等等。

既然是「预估」时间,也只能是大体计算,除非你能预料并覆盖到全部致使耗时变长的场景,但这其实很难。

有什么更好的解决方案吗?

别急,关于这个问题,我会在后面详细来说对应的解决方案。

咱们继续来看第二个问题。

第二个问题在于,一个客户端释放了其它客户端持有的锁。

想一下,致使这个问题的关键点在哪?

重点在于,每一个客户端在释放锁时,都是「无脑」操做,并无检查这把锁是否还「归本身持有」,因此就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!

如何解决这个问题呢?

锁被别人释放怎么办?

解决办法是:客户端在加锁时,设置一个只有本身知道的「惟一标识」进去。

例如,能够是本身的线程 ID,也能够是一个 UUID(随机且惟一),这里咱们以 UUID 举例:

// 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
复制代码

这里假设 20s 操做共享时间彻底足够,先不考虑锁自动过时的问题。

以后,在释放锁时,要先判断这把锁是否还归本身持有,伪代码能够这么写:

// 锁是本身的,才释放
if redis.get("lock") == $uuid:
    redis.del("lock")
复制代码

这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到咱们前面讲的原子性问题了。

  1. 客户端 1 执行 GET,判断锁是本身的
  2. 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生几率比较低,但咱们须要严谨地考虑锁的安全性模型)
  3. 客户端 1 执行 DEL,却释放了客户端 2 的锁

因而可知,这两个命令仍是必需要原子执行才行。

怎样原子执行呢?Lua 脚本。

咱们能够把这个逻辑,写成 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 实现的分布式锁,一个严谨的的流程以下:

  1. 加锁:SET $lock_key $unique_id EX $expire_time NX
  2. 操做共享资源
  3. 释放锁:Lua 脚本,先 GET 判断锁是否归属本身,再 DEL 释放锁

好,有了这个完整的锁模型,让咱们从新回到前面提到的第一个问题。

锁过时时间很差评估怎么办?

锁过时时间很差评估怎么办?

前面咱们提到,锁的过时时间若是评估很差,这个锁就会有「提早」过时的风险。

当时给的妥协方案是,尽可能「冗余」过时时间,下降锁提早过时的几率。

这个方案其实也不能完美解决问题,那怎么办呢?

是否能够设计这样的方案:加锁时,先设置一个过时时间,而后咱们开启一个「守护线程」,定时去检测这个锁的失效时间,若是锁快要过时了,操做共享资源还未完成,那么就自动对锁进行「续期」,从新设置过时时间。

这确实一种比较好的方案。

若是你是 Java 技术栈,幸运的是,已经有一个库把这些工做都封装好了:Redisson

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过时,这个守护线程咱们通常也把它叫作「看门狗」线程。

除此以外,这个 SDK 还封装了不少易用的功能:

  • 可重入锁
  • 乐观锁
  • 公平锁
  • 读写锁
  • Redlock(红锁,下面会详细讲)

这个 SDK 提供的 API 很是友好,它能够像操做本地锁的方式,操做分布式锁。若是你是 Java 技术栈,能够直接把它用起来。

这里不重点介绍 Redisson 的使用,你们能够看官方 Github 学习如何使用,比较简单。

到这里咱们再小结一下,基于 Redis 的实现分布式锁,前面遇到的问题,以及对应的解决方案:

  • 死锁:设置过时时间
  • 过时时间评估很差,锁提早过时:守护线程,自动续期
  • 锁被别人释放:锁写入惟一标识,释放锁先检查标识,再释放

还有哪些问题场景,会危害 Redis 锁的安全性呢?

以前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并无涉及到 Redis 的部署架构细节。

而咱们在使用 Redis 时,通常会采用主从集群 + 哨兵的模式部署,这样作的好处在于,当主库异常宕机时,哨兵能够实现「故障自动切换」,把从库提高为主库,继续提供服务,以此保证可用性。

那当「主从发生切换」时,这个分布锁会依旧安全吗?

试想这样的场景:

  1. 客户端 1 在主库上执行 SET 命令,加锁成功
  2. 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
  3. 从库被哨兵提高为新主库,这个锁在新的主库上,丢失了!

可见,当引入 Redis 副本后,分布锁仍是可能会受到影响。

怎么解决这个问题?

为此,Redis 的做者提出一种解决方案,就是咱们常常听到的 Redlock(红锁)

它真的能够解决上面这个问题吗?

Redlock 真的安全吗?

好,终于到了这篇文章的重头戏。啊?上面讲的那么多问题,难道只是基础?

是的,那些只是开胃菜,真正的硬菜,从这里刚刚开始。

若是上面讲的内容,你尚未理解,我建议你从新阅读一遍,先理清整个加锁、解锁的基本流程。

若是你已经对 Redlock 有所了解,这里能够跟着我再复习一遍,若是你不了解 Redlock,不要紧,我会带你从新认识它。

值得提醒你的是,后面我不只仅是讲 Redlock 的原理,还会引出有关「分布式系统」中的不少问题,你最好跟紧个人思路,在脑中一块儿分析问题的答案。

如今咱们来看,Redis 做者提出的 Redlock 方案,是如何解决主从切换后,锁失效问题的。

Redlock 的方案基于 2 个前提:

  1. 再也不须要部署从库哨兵实例,只部署主库
  2. 但主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,并且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。

Redlock 具体如何使用呢?

总体的流程是这样的,一共分为 5 步:

  1. 客户端先获取「当前时间戳T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每一个请求会设置超时时间(毫秒级,要远小于锁的有效时间),若是某一个实例加锁失败(包括网络超时、锁被其它人持有等各类异常状况),就当即向下一个 Redis 实例申请加锁
  3. 若是客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,若是 T2 - T1 < 锁的过时时间,此时,认为客户端加锁成功,不然认为加锁失败
  4. 加锁成功,去操做共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  5. 加锁失败,向「所有节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

我简单帮你总结一下,有 4 个重点:

  1. 客户端在多个 Redis 实例上申请加锁
  2. 必须保证大多数节点加锁成功
  3. 大多数节点加锁的总耗时,要小于锁设置的过时时间
  4. 释放锁,要向所有节点发起释放锁请求

第一次看可能不太容易理解,建议你把上面的文字多看几遍,加深记忆。

而后,记住这 5 步,很是重要,下面会根据这个流程,剖析各类可能致使锁失效的问题假设。

好,明白了 Redlock 的流程,咱们来看 Redlock 为何要这么作。

1) 为何要在多个实例上加锁?

本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

2) 为何大多数加锁成功,才算成功?

多个 Redis 实例一块儿来用,其实就组成了一个「分布式系统」。

在分布式系统中,总会出现「异常节点」,因此,在谈论分布式系统问题时,须要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。

这是一个分布式系统「容错」问题,这个问题的结论是:若是只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是能够提供正确服务的。

这个问题的模型,就是咱们常常听到的「拜占庭将军」问题,感兴趣能够去看算法的推演过程。

3) 为何步骤 3 加锁成功后,还要计算加锁的累计耗时?

由于操做的是多个节点,因此耗时确定会比操做单个实例耗时更久,并且,由于是网络请求,网络状况是复杂的,有可能存在延迟、丢包、超时等状况发生,网络请求越多,异常发生的几率就越大。

因此,即便大多数节点加锁成功,但若是加锁的累计耗时已经「超过」了锁的过时时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

4) 为何释放锁,要操做全部节点?

在某一个 Redis 节点加锁时,可能由于「网络缘由」致使加锁失败。

例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题致使读取失败,那这把锁其实已经在 Redis 上加锁成功了。

因此,释放锁时,无论以前有没有加锁成功,须要释放「全部节点」的锁,以保证清理节点上「残留」的锁。

好了,明白了 Redlock 的流程和相关问题,看似 Redlock 确实解决了 Redis 节点异常宕机锁失效的问题,保证了锁的「安全性」。

但事实真的如此吗?

Redlock 的争论谁对谁错?

Redis 做者把这个方案一经提出,就立刻受到业界著名的分布式系统专家的质疑

这个专家叫 Martin,是英国剑桥大学的一名分布式系统研究员。在此以前他曾是软件工程师和企业家,从事大规模数据基础设施相关的工做。它还常常在大会作演讲,写博客,写书,也是开源贡献者。

他立刻写了篇文章,质疑这个 Redlock 的算法模型是有问题的,并对分布式锁的设计,提出了本身的见解。

以后,Redis 做者 Antirez 面对质疑,不甘示弱,也写了一篇文章,反驳了对方的观点,并详细剖析了 Redlock 算法模型的更多设计细节。

并且,关于这个问题的争论,在当时互联网上也引发了很是激烈的讨论。

二人思路清晰,论据充分,这是一场高手过招,也是分布式系统领域很是好的一次思想的碰撞!双方都是分布式系统领域的专家,却对同一个问题提出不少相反的论断,到底是怎么回事?

下面我会从他们的争论文章中,提取重要的观点,整理呈现给你。

提醒:后面的信息量极大,可能不宜理解,最好放慢速度阅读。

分布式专家 Martin 对于 Relock 的质疑

在他的文章中,主要阐述了 4 个论点:

1) 分布式锁的目的是什么?

Martin 表示,你必须先清楚你在使用分布式锁的目的是什么?

他认为有两个目的。

第一,效率。

使用分布式锁的互斥能力,是避免没必要要地作一样的两次工做(例如一些昂贵的计算任务)。若是锁失效,并不会带来「恶性」的后果,例如发了 2 次邮件等,无伤大雅。

第二,正确性。

使用锁用来防止并发进程互相干扰。若是锁失效,会形成多个进程同时操做同一条数据,产生的后果是数据严重错误、永久性不一致、数据丢失等恶性问题,就像给患者服用重复剂量的药物同样,后果严重。

他认为,若是你是为了前者——效率,那么使用单机版 Redis 就能够了,即便偶尔发生锁失效(宕机、主从切换),都不会产生严重的后果。而使用 Redlock 过重了,不必。

而若是是为了正确性,Martin 认为 Redlock 根本达不到安全性的要求,也依旧存在锁失效的问题!

2) 锁在分布式系统中会遇到的问题

Martin 表示,一个分布式系统,更像一个复杂的「野兽」,存在着你想不到的各类异常状况。

这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC

  • N:Network Delay,网络延迟
  • P:Process Pause,进程暂停(GC)
  • C:Clock Drift,时钟漂移

Martin 用一个进程暂停(GC)的例子,指出了 Redlock 安全性问题:

  1. 客户端 1 请求锁定节点 A、B、C、D、E
  2. 客户端 1 的拿到锁后,进入 GC(时间比较久)
  3. 全部 Redis 节点上的锁都过时了
  4. 客户端 2 获取到了 A、B、C、D、E 上的锁
  5. 客户端 1 GC 结束,认为成功获取锁
  6. 客户端 2 也认为获取到了锁,发生「冲突」

Martin 认为,GC 可能发生在程序的任意时刻,并且执行时间是不可控的。

注:固然,即便是使用没有 GC 的编程语言,在发生网络延迟、时钟漂移时,也都有可能致使 Redlock 出现问题,这里 Martin 只是拿 GC 举例。

3) 假设时钟正确的是不合理的

又或者,当多个 Redis 节点「时钟」发生问题时,也会致使 Redlock 锁失效

  1. 客户端 1 获取节点 A、B、C 上的锁,但因为网络问题,没法访问 D 和 E
  2. 节点 C 上的时钟「向前跳跃」,致使锁到期
  3. 客户端 2 获取节点 C、D、E 上的锁,因为网络问题,没法访问 A 和 B
  4. 客户端 1 和 2 如今都相信它们持有了锁(冲突)

Martin 以为,Redlock 必须「强依赖」多个节点的时钟是保持同步的,一旦有节点时钟发生错误,那这个算法模型就失效了。

即便 C 不是时钟跳跃,而是「崩溃后当即重启」,也会发生相似的问题。

Martin 继续阐述,机器的时钟发生错误,是颇有可能发生的:

  • 系统管理员「手动修改」了机器时钟
  • 机器时钟在同步 NTP 时间时,发生了大的「跳跃」

总之,Martin 认为,Redlock 的算法是创建在「同步模型」基础上的,有大量资料研究代表,同步模型的假设,在分布式系统中是有问题的。

在混乱的分布式系统的中,你不能假设系统时钟就是对的,因此,你必须很是当心你的假设。

4) 提出 fecing token 的方案,保证正确性

相对应的,Martin 提出一种被叫做 fecing token 的方案,保证分布式锁的正确性。

这个模型流程以下:

  1. 客户端在获取锁时,锁服务能够提供一个「递增」的 token
  2. 客户端拿着这个 token 去操做共享资源
  3. 共享资源能够根据 token 拒绝「后来者」的请求

这样一来,不管 NPC 哪一种异常状况发生,均可以保证分布式锁的安全性,由于它是创建在「异步模型」上的。

而 Redlock 没法提供相似 fecing token 的方案,因此它没法保证安全性。

他还表示,一个好的分布式锁,不管 NPC 怎么发生,能够不在规定时间内给出结果,但并不会给出一个错误的结果。也就是只会影响到锁的「性能」(或称之为活性),而不会影响它的「正确性」。

Martin 的结论:

一、Redlock 不三不四:它对于效率来说,Redlock 比较重,不必这么作,而对于正确性来讲,Redlock 是不够安全的。

二、时钟假设不合理:该算法对系统时钟作出了危险的假设(假设多个节点机器时钟都是一致的),若是不知足这些假设,锁就会失效。

三、没法保证正确性:Redlock 不能提供相似 fencing token 的方案,因此解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper。

好了,以上就是 Martin 反对使用 Redlock 的观点,看起来有理有据。

下面咱们来看 Redis 做者 Antirez 是如何反驳的。

Redis 做者 Antirez 的反驳

在 Redis 做者的文章中,重点有 3 个:

1) 解释时钟问题

首先,Redis 做者一眼就看穿了对方提出的最为核心的问题:时钟问题

Redis 做者表示,Redlock 并不须要彻底一致的时钟,只须要大致一致就能够了,容许有「偏差」。

例如要计时 5s,但实际可能记了 4.5s,以后又记了 5.5s,有必定偏差,但只要不超过「偏差范围」锁失效时间便可,这种对于时钟的精度的要求并非很高,并且这也符合现实环境。

对于对方提到的「时钟修改」问题,Redis 做者反驳到:

  1. 手动修改时钟:不要这么作就行了,不然你直接修改 Raft 日志,那 Raft 也会没法工做...
  2. 时钟跳跃:经过「恰当的运维」,保证机器时钟不会大幅度跳跃(每次经过微小的调整来完成),实际上这是能够作到的

为何 Redis 做者优先解释时钟问题?由于在后面的反驳过程当中,须要依赖这个基础作进一步解释。

2) 解释网络延迟、GC 问题

以后,Redis 做者对于对方提出的,网络延迟wan、进程 GC 可能致使 Redlock 失效的问题,也作了反驳:

咱们从新回顾一下,Martin 提出的问题假设:

  1. 客户端 1 请求锁定节点 A、B、C、D、E
  2. 客户端 1 的拿到锁后,进入 GC
  3. 全部 Redis 节点上的锁都过时了
  4. 客户端 2 获取节点 A、B、C、D、E 上的锁
  5. 客户端 1 GC 结束,认为成功获取锁
  6. 客户端 2 也认为获取到锁,发生「冲突」

Redis 做者反驳到,这个假设实际上是有问题的,Redlock 是能够保证锁安全的。

这是怎么回事呢?

还记得前面介绍 Redlock 流程的那 5 步吗?这里我再拿过来让你复习一下。

  1. 客户端先获取「当前时间戳T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每一个请求会设置超时时间(毫秒级,要远小于锁的有效时间),若是某一个实例加锁失败(包括网络超时、锁被其它人持有等各类异常状况),就当即向下一个 Redis 实例申请加锁
  3. 若是客户端从 3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,若是 T2 - T1 < 锁的过时时间,此时,认为客户端加锁成功,不然认为加锁失败
  4. 加锁成功,去操做共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  5. 加锁失败,向「所有节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

注意,重点是 1-3,在步骤 3,加锁成功后为何要从新获取「当前时间戳T2」?还用 T2 - T1 的时间,与锁的过时时间作比较?

Redis 做者强调:若是在 1-3 发生了网络延迟、进程 GC 等耗时长的异常状况,那在第 3 步 T2 - T1,是能够检测出来的,若是超出了锁设置的过时时间,那这时就认为加锁会失败,以后释放全部节点的锁就行了!

Redis 做者继续论述,若是对方认为,发生网络延迟、进程 GC 是在步骤 3 以后,也就是客户端确认拿到了锁,去操做共享资源的途中发生了问题,致使锁失效,那这不止是 Redlock 的问题,任何其它锁服务例如 Zookeeper,都有相似的问题,这不在讨论范畴内

这里我举个例子解释一下这个问题:

  1. 客户端经过 Redlock 成功获取到锁(经过了大多数节点加锁成功、加锁耗时检查逻辑)
  2. 客户端开始操做共享资源,此时发生网络延迟、进程 GC 等耗时很长的状况
  3. 此时,锁过时自动释放
  4. 客户端开始操做 MySQL(此时的锁可能会被别人拿到,锁失效)

Redis 做者这里的结论就是:

  • 客户端在拿到锁以前,不管经历什么耗时长问题,Redlock 都可以在第 3 步检测出来
  • 客户端在拿到锁以后,发生 NPC,那 Redlock、Zookeeper 都无能为力

因此,Redis 做者认为 Redlock 在保证时钟正确的基础上,是能够保证正确性的。

3) 质疑 fencing token 机制

Redis 做者对于对方提出的 fecing token 机制,也提出了质疑,主要分为 2 个问题,这里最不宜理解,请跟紧个人思路。

第一,这个方案必需要求要操做的「共享资源服务器」有拒绝「旧 token」的能力。

例如,要操做 MySQL,从锁服务拿到一个递增数字的 token,而后客户端要带着这个 token 去改 MySQL 的某一行,这就须要利用 MySQL 的「事物隔离性」来作。

// 两个客户端必须利用事物和隔离性达到目的
// 注意 token 的判断条件
UPDATE table T SET val = $new_val, current_token = $token WHERE id = $id AND current_token < $token
复制代码

但若是操做的不是 MySQL 呢?例如向磁盘上写一个文件,或发起一个 HTTP 请求,那这个方案就无能为力了,这对要操做的资源服务器,提出了更高的要求。

也就是说,大部分要操做的资源服务器,都是没有这种互斥能力的。

再者,既然资源服务器都有了「互斥」能力,那还要分布式锁干什么?

因此,Redis 做者认为这个方案是站不住脚的。

第二,退一步讲,即便 Redlock 没有提供 fecing token 的能力,但 Redlock 已经提供了随机值(就是前面讲的 UUID),利用这个随机值,也能够达到与 fecing token 一样的效果。

如何作呢?

Redis 做者只是提到了能够完成 fecing token 相似的功能,但却没有展开相关细节,根据我查阅的资料,大概流程应该以下,若有错误,欢迎交流~​

  1. 客户端使用 Redlock 拿到锁
  2. 客户端在操做共享资源以前,先把这个锁的 VALUE,在要操做的共享资源上作标记
  3. 客户端处理业务逻辑,最后,在修改共享资源时,判断这个标记是否与以前同样,同样才修改(相似 CAS 的思路)

仍是以 MySQL 为例,举个例子就是这样的:

  1. 客户端使用 Redlock 拿到锁
  2. 客户端要修改 MySQL 表中的某一行数据以前,先把锁的 VALUE 更新到这一行的某个字段中(这里假设为 current_token 字段)
  3. 客户端处理业务逻辑
  4. 客户端修改 MySQL 的这一行数据,把 VALUE 当作 WHERE 条件,再修改
UPDATE table T SET val = $new_val WHERE id = $id AND current_token = $redlock_value
复制代码

可见,这种方案依赖 MySQL 的事物机制,也达到对方提到的 fecing token 同样的效果。

但这里还有个小问题,是网友参与问题讨论时提出的:两个客户端经过这种方案,先「标记」再「检查+修改」共享资源,那这两个客户端的操做顺序没法保证啊?

而用 Martin 提到的 fecing token,由于这个 token 是单调递增的数字,资源服务器能够拒绝小的 token 请求,保证了操做的「顺序性」!

Redis 做者对于这个问题作了不一样的解释,我以为颇有道理,他解释道:分布式锁的本质,是为了「互斥」,只要能保证两个客户端在并发时,一个成功,一个失败就行了,不须要关心「顺序性」。

前面 Martin 的质疑中,一直很关心这个顺序性问题,但 Redis 的做者的见解却不一样。

综上,Redis 做者的结论:

一、做者赞成对方关于「时钟跳跃」对 Redlock 的影响,但认为时钟跳跃是能够避免的,取决于基础设施和运维。

二、Redlock 在设计时,充分考虑了 NPC 问题,在 Redlock 步骤 3 以前出现 NPC,能够保证锁的正确性,但在步骤 3 以后发生 NPC,不止是 Redlock 有问题,其它分布式锁服务一样也有问题,因此不在讨论范畴内。

是否是以为颇有意思?

在分布式系统中,一个小小的锁,竟然可能会遇到这么多问题场景,影响它的安全性!

不知道你看完双方的观点,更赞同哪一方的说法呢?

别急,后面我还会综合以上论点,谈谈本身的理解。

好,讲完了双方对于 Redis 分布锁的争论,你可能也注意到了,Martin 在他的文章中,推荐使用 Zookeeper 实现分布式锁,认为它更安全,确实如此吗?

基于 Zookeeper 的锁安全吗?

若是你有了解过 Zookeeper,基于它实现的分布式锁是这样的:

  1. 客户端 1 和 2 都尝试建立「临时节点」,例如 /lock
  2. 假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败
  3. 客户端 1 操做共享资源
  4. 客户端 1 删除 /lock 节点,释放锁

你应该也看到了,Zookeeper 不像 Redis 那样,须要考虑锁的过时时间问题,它是采用了「临时节点」,保证客户端 1 拿到锁后,只要链接不断,就能够一直持有锁。

并且,若是客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁必定会被释放。

不错,没有锁过时的烦恼,还能在异常时自动释放锁,是否是以为很完美?

其实否则。

思考一下,客户端 1 建立临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?

缘由就在于,客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持链接。

若是 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过时了,也会把这个临时节点删除。

一样地,基于此问题,咱们也讨论一下 GC 问题对 Zookeeper 的锁有何影响:

  1. 客户端 1 建立临时节点 /lock 成功,拿到了锁
  2. 客户端 1 发生长时间 GC
  3. 客户端 1 没法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」
  4. 客户端 2 建立临时节点 /lock 成功,拿到了锁
  5. 客户端 1 GC 结束,它仍然认为本身持有锁(冲突)

可见,即便是使用 Zookeeper,也没法保证进程 GC、网络延迟异常场景下的安全性。

这就是前面 Redis 做者在反驳的文章中提到的:若是客户端已经拿到了锁,但客户端与锁服务器发生「失联」(例如 GC),那不止 Redlock 有问题,其它锁服务都有相似的问题,Zookeeper 也是同样!

因此,这里咱们就能得出结论了:一个分布式锁,在极端状况下,不必定是安全的。

若是你的业务数据很是敏感,在使用分布式锁时,必定要注意这个问题,不能假设分布式锁 100% 安全。

好,如今咱们来总结一下 Zookeeper 在使用分布式锁时优劣:

Zookeeper 的优势:

  1. 不须要考虑锁的过时时间
  2. watch 机制,加锁失败,能够 watch 等待锁释放,实现乐观锁

但它的劣势是:

  1. 性能不如 Redis
  2. 部署和运维成本高
  3. 客户端与 Zookeeper 的长时间失联,锁被释放问题

我对分布式锁的理解

好了,前面详细介绍了基于 Redis 的 Redlock 和 Zookeeper 实现的分布锁,在各类异常状况下的安全性问题,下面我想和你聊一聊个人见解,仅供参考,不喜勿喷。

1) 到底要不要用 Redlock?

前面也分析了,Redlock 只有创建在「时钟正确」的前提下,才能正常工做,若是你能够保证这个前提,那么能够拿来使用。

但保证时钟正确,我认为并非你想的那么简单就能作到的。

第一,从硬件角度来讲,时钟发生偏移是时有发生,没法避免的。

例如,CPU 温度、机器负载、芯片材料都是有可能致使时钟发生偏移。

第二,从个人工做经从来说,曾经就遇到过期钟错误、运维暴力修改时钟的状况发生,进而影响了系统的正确性,因此,人为错误也是很难彻底避免的。

因此,我对 Redlock 的我的见解是,尽可能不用它,并且它的性能不如单机版 Redis,部署成本也高,我仍是会优先考虑使用 Redis「主从+哨兵」的模式,实现分布式锁。

那正确性如何保证呢?第二点给你答案。

2) 如何正确使用分布式锁?

在分析 Martin 观点时,它提到了 fecing token 的方案,给我了很大的启发,虽然这种方案有很大的局限性,但对于保证「正确性」的场景,是一个很是好的思路。

因此,咱们能够把这二者结合起来用:

一、使用分布式锁,在上层完成「互斥」目的,虽然极端状况下锁会失效,但它能够最大程度把并发请求阻挡在最上层,减轻操做资源层的压力。

二、但对于要求数据绝对正确的业务,在资源层必定要作好「兜底」,设计思路能够借鉴 fecing token 的方案来作。

两种思路结合,我认为对于大多数业务场景,已经能够知足要求了。

总结

好了,总结一下。

这篇文章,咱们主要探讨了基于 Redis 实现的分布式锁,到底是否安全这个问题。

从最简单分布式锁的实现,处处理各类异常场景,再到引出 Redlock,以及两个分布式专家的辩论,得出了 Redlock 的适用场景。

最后,咱们还对比了 Zookeeper 在作分布式锁时,可能会遇到的问题,以及与 Redis 的差别。

这里我把这些内容总结成了思惟导图,方便你理解。

后记

这篇文章的信息量实际上是很是大的,我以为应该把分布锁的问题,完全讲清楚了。

若是你没有理解,我建议你多读几遍,并在脑海中构建各类假定的场景,反复思辨。

在写这篇文章时,我又从新研读了两位大神关于 Redlock 争辩的这两篇文章,可谓是是收获满满,在这里也分享一些心得给你。

一、在分布式系统环境下,看似完美的设计方案,可能并非那么「严丝合缝」,若是稍加推敲,就会发现各类问题。因此,在思考分布式系统问题时,必定要谨慎再谨慎

二、从 Redlock 的争辩中,咱们不要过多关注对错,而是要多学习大神的思考方式,以及对一个问题严格审查的严谨精神。

最后,用 Martin 在对于 Redlock 争论事后,写下的感悟来结尾:

前人已经为咱们创造出了许多伟大的成果:站在巨人的肩膀上,咱们能够才得以构建更好的软件。不管如何,经过争论和检查它们是否经得起别人的详细审查,这是学习过程的一部分。但目标应该是获取知识,而不是为了说服别人,让别人相信你是对的。有时候,那只是意味着停下来,好好地想想。

共勉。


qr_block.jpg

想看更多硬核技术文章?欢迎关注个人公众号「水滴与银弹」。

我是 Kaito,是一个对于技术有思考的资深后端程序员,在个人文章中,我不只会告诉你一个技术点是什么,还会告诉你为何这么作?我还会尝试把这些思考过程,提炼成通用的方法论,让你能够应用在其它领域中,作到触类旁通。


参考文献:

相关文章
相关标签/搜索