Is Redlock Safe? 一场关于 Redlock 的辩论

1,697 阅读19分钟

前言

  之前在 Redis 官网上看了一下他们官方推荐的用 Redis 做分布式锁的算法,即 Redlock 算法的文档,这篇文档网址为: redis.io/topics/dist… ,文档末尾有如下图的一句话:

image.png

  就是说有一个叫Martin Kleppmann 的人对 Redlock 进行了分析,但是 Redis 作者不同意他的分析(可见 Kleppmann 的分析是不利于 Redlock 的),Redis 作者同样给出了自己的反驳回复。

  这两篇文章的网址分别为:martin.kleppmann.com/2016/02/08/…antirez.com/news/101

  我去把这两篇文章分别仔细看了一下,本文将对两位大佬的原文章进行一个简单的翻译(不是逐字逐句的翻译,主要呈现下两位大佬的观点)。其实网上关于这场辩论的解析也有一些,不过我还是想自己写一下,锻炼下自己的英语阅读能力。

辩论的双方

Martin Kleppmann

  这位 Martin Kleppmann 大家可能不太了解,但是下面这本书,大家肯定都知道:

image.png

  这本书被奉为神书,我在各个技术社区都看见有人推荐它,豆瓣评分也是极高。Martin Kleppmann 本人的水平,可想而知。

antirez

  antirez 是 Redis 主要作者 Salvatore Sanfilippo 的网名,水平也是很厉害。

image.png

  这是他的 GitHub 首页,右下角那个仓库就是 Redis 里字符串的底层实现:动态字符串,简称 sds。

Redlock 简介

  既然要写关于 Redlock 的辩论,还是得首先介绍下 Redlock。本节内容也是从 Redis 官方文档归纳而来。

为啥要有 Redlock?

  Redis 作者说了,我们可以用一个单 Redis 实现一个分布式锁,但是单 Redis 是有风险的,万一它挂了,咋办?办法很简单,给单 Redis 加一个主从结构。但是作者说,这样也是不行的,他给了一个例子:

image.png

  就是说啊,A 在主节点上加了一把分布式锁,由于 Redis 的主从复制是异步的,主节点在给从节点发送此锁的复制消息前突然挂了,然后从节点提升为主节点,从节点里并没有这把锁的记录,于是 B 也可以加上这把同样的分布式锁,A 和 B 同时拿了一把锁,锁的安全性得到了破坏。

  可见,单点的 Redis,即使加上了主从复制,还是无法保证安全性。

Redlock 算法步骤

  Redlock 算法的主要步骤在官方文档里已有描述,如下图:

image.png

  我简单翻一下:

  1. 要请求分布式锁的客户端得到当前的毫秒时间戳
  2. 客户端向 N 个 Redis 实例发送相同的加锁请求,然后这里客户端向每个 Redis 加锁的时候,都应该设置一个远小于锁自动释放时间的 timeout,目的是为了防止客户端在一个已经 down 掉的 Redis 处阻塞太久
  3. 客户端在成功的在 N / 2 + 1个 Redis 上加成了锁,并且加锁的时间小于锁的有效时间,就认为加上了锁
  4. 如果分布式锁加上了,这把锁的真正有效时间是初始有效时间减去加锁用的时间
  5. 如果加分布式锁失败了,客户端要去向所有的 Redis 释放锁,即使是它认为没加成功的锁(这个可能是因为网络延迟的关系,你以为在这个 Redis 上没加成功,其实已经加成功了,所以所有的 Redis 都得去释放锁)

算法是否有效

  乍一看,用了多个 Redis 实例,自然没有单点故障问题了,至于到底怎么样,让我们看下 Martin Kleppmann 的分析。

Martin Kleppmann 的质疑

开篇明义

  Kleppmann 先是商业互吹了一下,说他也很喜欢 Redis,也曾经用过它云云。接着 Kleppmann 提出了使用分布式锁的两个目的:

  • 效率:为了不重复做一些操作(比如一些昂贵的计算),并且如果分布式锁失败了,也不会有什么严重的后果
  • 正确性:为了防止系统一些数据的并发修改,如果分布式锁出现了问题,会对系统造成重大的损害

  接着 Kleppmann 就给出了自己的观点:如果你为了效率而使用分布式锁,那你直接用单 Redis + 主从复制就行了,Redlock 代价太大;如果你为了正确性而使用分布式锁,Redlock 在他将列出的场景下不能保证正确性。

用分布式锁保护资源

  Kleppmann 并没有一上来就讨论 Redlock 算法,而是带我们看了下分布式锁存在的一些问题。Kleppmann 认为:由于不同节点和网络都可能崩溃,故分布式锁比单机多线程的锁复杂的多。

  Kleppmann 举了个例子:一个客户端要去更新一个共享内存,并且用如下的分布式锁代码来保证不会有并发修改:

// THIS CODE IS BROKEN
function writeData(filename, data) {
    var lock = lockService.acquireLock(filename);
    if (!lock) {
        throw 'Failed to acquire lock';
    }

    try {
        var file = storage.readFile(filename);
        var updated = updateContents(file, data);
        storage.writeFile(filename, updated);
    } finally {
        lock.release();
    }
}

  这段代码是很合理的加锁/释放锁的代码。然而,Kleppmann 认为即使你的锁服务是完美的,这段代码仍然是无效的,他给出了一个时序图:

image.png

  Kleppmann 提供了这样一个场景:Client1 获得了分布式锁,接着进入了stop-the-world的 GC 阶段(这个stop-the-world,学过 Java 的都不陌生,JVM 在此刻只进行 GC 活动,其它语言类似),并且在持有的锁已经自动释放了之后(分布式锁一般都有一个自动释放的时间,以防客户端在显式释放锁之前挂了,此锁将永远无法释放),结束了 GC 过程。此时,由于分布式锁已经自动释放,Client2 早已拿到了锁,然而刚刚醒来的 Client1 也认为自己持有锁,这会导致对共享文件的并发修改。

  有人说了,那我在修改资源之前,检查下我的锁到底有没有过期不就行了。Kleppmann 说不行,因为 GC 可以发生在任何时刻,可能在你检查未过期和写操作之间发生,那你检查也是白检查。

  有人又说,我的客户端用 C 语言写的,不需要 GC,Kleppmann 说还是不行,进程总会因为各种奇怪的原因暂停,而且即使进程没有暂停,也有可能网络太卡,你的写请求在锁早就过期的时候才到达,还是有问题。

  总之,客户端获得了分布式锁之后,对于锁是否已经自动过期是没有感知的,一些异常情况下,这会导致两个客户端都相信自己正持有着锁,导致资源的并发修改。

用 fencing 解决上述问题

  Kleppmann 给出了上述问题的解决方案,如下图:

image.png

  每次客户端获得锁时,从锁服务那得到一个递增的 token,每次请求资源带着此 token,即使某客户端由于 GC 在锁过期之后醒来,在另一个客户端已经得到锁的情况下,仍然去请求修改资源,由于另一客户端 token 更大,资源识别出,你这个 token 不合法,拒绝此请求。

  当然了,这种方案需要共享资源可以对 token 进行识别,Kleppmann 认为这并不难;同时,ZooKeeper 也可以提供递增 token 的功能。

Redlock 的第一个问题

  铺垫了这么多,Kleppmann 也指出了 Redlock 的第一个问题:Redlock 算法并没有产生递增 token 的步骤,并且 Redlock 算法也并不容易产生递增的 token。因此,Redlock 不能解决 Kleppmann 提出的客户端同时持有分布式锁的问题。

Redlock 的第二个问题

  紧接着,Kleppmann 给出了 Redlock 的第二个问题:过度依赖时间(原文是 timing,我可能翻的有问题,大家懂这个意思就行)的正确性。

  Kleppmann 首先说了,在学术领域,分布式锁算法的最实用模型都是异步模型,也就是说算法对时间的正确性没有任何假设(进程会暂停,网络会延迟,时钟会错误),这些算法根本不期待时间会正确。这些算法只会在产生 timeout 时,才会使用时钟,然而 timeout 本身可以不精确,所以,这些算法是没问题的。

  Kleppmann 又指出,Redis 使用gettimeofday,而不是一个单调时钟,这个时间本身是不准的,可能快或慢。然后 Redlock 的安全性基于许多时间假设:Redis 节点时间是对的;网络延迟很小;进程暂停很短。故 Redlock 肯定是不安全的。

用 bad timings 来打破 Redlock

  Kleppmann 给出一个例子来证明 Redlock 的错误:

image.png

  1. 现在有 A,B,C,D,E 五个 Redis 实例,Client1 用 Redlock 算法去向它们加分布式锁,由于网络问题,给 D 和 E 的请求没有到
  2. C 的时钟向前跳跃,C 上的锁立刻就失效了
  3. Client2 也加分布式锁,由于 C 失效了,Client2 在 C,D,E 上都加上了锁
  4. 两个客户端都认为自己拿到了锁

  Kleppmann 认为这种情况同样会发生在 C 在持久化锁之前挂了,之后又重启的情况。虽然,Redlock 文档使用延迟重启技术来预防此种情况,然而此技术同样依赖于正确的时间。

  总之这种情况下, Redlock 是不安全的。

  Kleppmann 又提出,好吧,即使你对你系统的时间非常自信,但还是有问题,比如下面这个场景:

image.png

  1. Client1 请求 5 个节点
  2. 在回复到达 Client1之前,Client1 陷入了 GC
  3. 5 个节点上的锁都失效了
  4. Client2 获取了这 5 个节点上的锁
  5. Client1 结束了 GC,收到了 5 个节点的成功响应,认为自己加锁成功
  6. 两个客户端同时持有一个锁

  Kleppmann 又提出,不仅 GC 会造成这种情况,网络延迟过高也会导致同样的后果。总之,Redlock 在这个场景下还是不行。

  这个场景大家可能感觉和 Kleppmann 给出的 fencing token 那个时序图类似,其实还是不太一样。那个时序图拿到锁的时候确实是正常的,只是由于后面的进程暂停导致锁过期了,这里这个场景应该是客户端拿到的锁本身就是过期的,压根就不该拿到。

Redlock 的同步假设

  Kleppmann 随后认为 Redlock 强烈依赖于同步系统模型,此系统网络延迟,进程暂停和时钟错误和锁的有效时间比起来都是很小的。他认为如果你期望分布式锁的正确性,那么 Redlock 或许可以带来most of the time的正确性,但在混乱的分布式系统里,这是远远不够的,必须保证完全的正确性。

小结

  Kleppmann 最后再次做了总结,Redlock 有 2 个问题:一个是依赖于时间和系统时间的正确;另一个是无法生成他提到的递增 token,无法解决他文章中的第一个问题。

  他认为 Redlock 不伦不类(原文是neither fish nor fowl,批评真的很严格),既没有效率,又没有安全性。如果你为了效率而使用分布式锁,直接用单 Redis 即可;如果为了系统的绝对正确性,应该使用 ZooKeeper 这样的中间件。

antirez 的回应

  首先,antirez 肯定是不同意 Kleppmann 的观点的(废话),然后他先概括了 Kleppmann 的观点:一是 Redlock 没有避免客户端在锁过期之后继续使用锁的机制;二是 Redlock 依赖于实际系统无法保证的系统模型。antirez 接下来对这两个问题分别作出回应。

关于 Kleppmann 提到的 token

  antirez 做出了如下回应:

  1. antirez 首先说了,在共享资源没有其它控制手段时,分布式锁是很有用的。既然你的方案里,你的共享资源在分布式锁失败的情况下,都可以对不同的 token 做出反应了,那还要分布式锁来保证干什么?最后,antirez 表示,即使在你这种非常人造的情景下,Redlock 也可以做的很好
  2. antirez 认为,如果你的存储系统只可以接收最大 token 的写请求,那你的存储系统是一个线性存储(原文是linearizable store,我也不知道线性存储是什么,总之,存储系统有这种识别 token 的能力)。那既然你有一个这么强的存储,你这个存储可以给每一个得到 Redlock 的客户端生成一个递增 ID ,这样 Redlock 和你提到的那些可以生成递增 token 的中间件实际上是一样的了
  3. antirez 认为,虽然 Redlock 没有自增的 token,但是每个 Redlock 都有一个随机 token(这个在 Redlock 算法里讲过,防止锁已过期的客户端释放正持有其它客户端的锁),并且这个 token 的冲突可以忽略不计。所以,存储系统可以拿 Redlock 的这个近似唯一的 token 来做 CAS 操作,防止并发修改
  4. antirez 认为顺序的 token 并没有实际的意义,由于 Kleppmann 所说的 GC,那后得到锁的客户端也可能 GC,所以可能 token 比较小的客户端反而先进行了共享资源的写请求,分布式锁只要保证只有一个客户端修改资源就行了,得到锁的顺序并不重要
  5. antirez 再次强调,如果你的共享资源能力那么强,是不需要太强的分布式锁的,即使需要,Redlock 的随机唯一 token 也可以完成目的,而且更实用。

关于时间的正确性

  antirez 指出,Redlock 仅仅依赖于可以用more or less的速度来计算相对时间的流逝,不同的进程并不需要绝对时间上的有限误差,举个栗子,它们只需要在 10 % 的误差内数完 5 秒即可。

  之后 antirez 提到了 Kleppmann 所说的时钟跳跃问题,此问题主要有两个原因:

image.png

  一个是系统管理员手动调整时钟,另一个是 ntpd 更新了时钟。antirez 认为,第一个问题可以靠禁止管理员做这种操作来避免,而对第二个问题,可以在一个较长的时间跨度慢慢的更新,而非突然更新,这样就不会有那么大的影响。这其实就反驳了 Kleppmann 设计的那个 C 的时钟突然跳跃的场景。

  不过最后 antirez 也承认,用 Kleppmann 所说的单调时钟确实有很多好处,他会在之后进行这个功能的实现。

  最终,antirez 认为,假设 Redlock 使用了单调时钟 API,那么不同进程在一个最大误差范围内计算相对时间是可行的。

  插一句,这个每个 Redis 以相同的速度流逝时间,个人感觉是有道理的,实际上确实不需要每个 Redis 实例时间绝对一样,锁的目的就是在一段时间里,只有一个客户端可以做事情,只要每个 Redis 保证相对时间的正确,就可以了。你是 8:00-8:05,它是9:00-9:05,我认为问题也不大,反正能表达 5 分钟这个语义就行。

关于网络延迟,进程暂停

  对于 Kleppmann 设计的下图的场景,antirez 同样进行了反驳。
image.png

  antirez 先概括了 Redlock 锁获取的步骤:

image.png

  这个步骤大家可以去 Redlock 的原文档:redis.io/topics/dist… 看一看,本文开始也简单的提了一下。antirez 认为,OK,你说的 GC,网络延迟什么的,我都同意,咱们讨论下这个延迟(下面用它指代 GC 暂停,进程暂停,网络延迟等)发生在获取分布式锁的哪个步骤吧。

  如果这个延迟发生在步骤 1-3,那我在步骤 4 会检查获得锁的时间是不是超过了锁的有效时间,如果超过了,这个锁不会加成功。所以步骤 1-3 之间的延迟是没问题的。

  如果延迟在步骤 3 之后发生,那又回到 Kleppmann 说的第一个问题了。antirez 强调,这个问题不单单是 Redlock 有的,所有的分布式锁都有这个问题,并且 Kleppmann 的 token 方法并不实际(退一万步,即使实际,Redlock 也可以完成这种功能)。

是否持久化

  此外,由于 Kleppmann 还提到了一个 Redis 实例挂掉之后的延迟重启也依赖于时间的正确性,antirez 也对此进行了反驳,再次强调这只需要保证 Redis 等待一段特定的时间即可。

  antirez 还提到这个延迟重启只是个可选项,你当然采用每个操作都 aof 持久化的方式,但这种延迟重启完全不需要磁盘操作(没有备份),这带来了更高的性能。(关于为何延迟重启可以看 Redlock 官方文档,这里不再赘述)。

小结

  总结下 antirez 的回应:

  • 对于 Kleppmann 提到的 Redlock 无法产生 fencing token 从而无法解决长时间的进程暂停导致的两个客户端持有同一把锁,antirez 认为:fencing token 基于一个能力强大的存储系统,如果你的存储系统如此强大,为何还需要分布式锁?此外,antirez 指出,即使需要 fencing token,Redlock 现有的机制也足以支持此功能
  • 对于 Kleppmann 提到的 Redlock 依赖于系统的时间正确的假设的问题,antirez 指出 Redlock 只依赖于不同计算机在一个误差范围内计算时间的流逝,这是可能达到的,Redlock 文档原文是
    The algorithm relies on the assumption that while there is no synchronized clock across the processes, still the local time in every process flows approximately at the same rate, with an error which is small compared to the auto-release time of the lock.
  • 对于时钟跳跃问题,antirez 认为通过一些工程上的操作,可以避免此问题;同时他也认同应该使用单调时钟 API。
    实际上 Redlock 文档里也有一个考虑了时钟漂移的计算第一个失效的锁的最小存活时间的公式:MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT,只不过这里面的CLOCK_DRIFT,并没有说怎么计算。
  • 对于 Kleppmann 所说的由于网络延迟,进程暂停导致的客户端得到一把早已过期的锁,antirez 表示如果此暂停发生在获得 Redlock 的步骤 4 之前,那么客户端根本无法获得此锁;如果在步骤 4 后发生了暂停,那只要是有自动过期功能的分布式锁都有此问题,这又回到了 Kleppmann 所说的第一个问题

个人的一些理解

  下面谈谈我个人的一些理解。

一些疑问

  这个辩论我也有一些自己的疑问:

  • 首先就是这个 fencing token,这需要你的共享资源可以检查 token,这个虽然说的很轻描淡写, Kleppmann 原话是:But this is not particularly hard, once you know the trick.,但是是不是真的这么简单呢?然后即使很简单,我觉得这样也不好,咱们学面向对象的设计都知道,类的设计有一个单一职责原则。这个共享资源我觉得提供一个数据存储检索功能就可以了,你让它还有这种认证,鉴别的功能,我感觉这个就不太好。起码这个资源承担了一些和本职工作完全无关的工作。
  • 然后就是 antirez 说的 Redlock 的随机字符串,可以起到同样的作用,且不说他说的 CAS 算法要如何实现,先看看 Redlock 的随机字符串,关于此随机字符串,在 Redlock 文档里是这么说的:
    What should this random string be? I assume it’s 20 bytes from /dev/urandom, but you can find cheaper ways to make it unique enough for your tasks.

  意思就是这个字符串其实没有固定的标准,可以自己选择算法。所以如果客户端实现 Redlock 算法时,选的随机算法随机性很差,其实是达不到效果的。

一些思考

  对于 Kleppmann 设计的这些场景,我觉得一个关键问题就是:客户端对于 Redis 服务端那边锁有没有过期是没有感知的。 甭管是某个 Redis 时钟跳跃导致它上面锁过期了,别人又能抢了,还是客户端进程暂停了,在 Redis 上的锁过期了,其它客户端抢到了之后再醒来,抑或是由于 GC 抢到了早已过期的锁,都是这个问题。

  其实都是分布式锁在不该释放的时候自动释放了,如果每个分布式锁都是由客户端自己做完操作再删,那就少了很多问题(所以这个过期时间的选择也是很重要的)。所以如果有个锁续期机制,只要客户端操作还没完成,就让它继续持有锁,应该可以缓解这个问题。像 Redisson,好像就有锁续期机制。这个其实 Redlock 文档里有一节也有提到:

image.png

  不过如果有这个机制,那 Redlock 算法又会变得更复杂,越复杂就越容易出错;另外如果极端情况下,一个客户端一直 GC,你还得一直给它续期,也会导致其它客户端一直抢不到锁(或者再加一个锁续期时判断客户端是不是处于进程暂停中,那这又更复杂了)。

总结

  本文主要对 Kleppmann 和 antirez 针对 Redlock 算法是否安全的一次网上辩论进行了整理,由于本人对分布式系统所知甚少,故如果有一些理解得不好的地方,还请大家不吝赐教。