1周前 (03-03)  好文推荐 |   抢沙发  8 
文章评分 0 次,平均分 0.0

原文地址:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

Redis分布式锁Redlock的算法(https://redis.io/docs/latest/develop/use/patterns/distributed-locks/)声称在Redis之上实现了容错分布式锁(或者更确切地说,租赁)这个算法本能地在我脑海中敲响了警钟,所以我花了一些时间思考并写下了这些笔记。

由于Redlock已经有10多个独立的实现,我们不知道谁已经依赖这个算法,我认为值得公开分享我的笔记。我不会深入探讨Redis的其他方面,其中一些方面已经在其他地方受到了批评。

在我详细介绍Redlock之前,让我说我很喜欢Redis,过去我在生产中成功地使用过它。我认为这非常适合您想在服务器之间共享一些瞬态、近似、快速变化的数据的情况,如果您偶尔因任何原因丢失这些数据也没什么大不了的。例如,一个好的用例是维护每个IP地址的请求计数器(用于速率限制目的)和每个用户ID的不同IP地址集(用于滥用检测)。

然而,Redis已经逐渐进入数据管理领域,这些领域对一致性和持久性有更高的期望——这让我很担心,因为这不是Redis的设计初衷。可以说,分布式锁定就是其中之一。让我们更详细地研究一下。

你用那把锁干什么?

锁的目的是确保在可能尝试执行同一项工作的几个节点中,只有一个节点真正执行了这项工作(一次至少只有一个)。这项工作可能是将一些数据写入共享存储系统,执行一些计算,调用一些外部API,等等。从较高的层次上讲,您可能希望在分布式应用程序中使用锁有两个原因:效率或正确性。为了区分这些情况,您可以询问如果锁失败会发生什么:

  • 效率:使用锁可以避免不必要地重复相同的工作(例如一些昂贵的计算)。如果锁失败,两个节点最终完成了相同的工作,结果是成本略有增加(您最终向AWS支付的费用比其他情况多5美分)或带来了轻微的不便(例如,用户最终会收到两次相同的电子邮件通知)。
  • 正确性:使用锁可以防止并发进程踩到彼此的脚趾,扰乱系统的状态。如果锁失败,两个节点同时处理同一条数据,结果将是文件损坏、数据丢失、永久不一致、患者用药剂量错误或其他严重问题。
    这两种情况都是想要锁的有效情况,但你需要非常清楚你正在处理的是哪一种。

我认为,如果你只是为了提高效率而使用锁,那么就没有必要承担Redlock的成本和复杂性,运行5台Redis服务器并检查大多数人是否获得了锁。你最好只使用一个Redis实例,也许可以异步复制到辅助实例,以防主实例崩溃。

如果你使用一个Redis实例,当然,如果你的Redis节点突然断电,或者出现其他问题,你会掉一些锁。但是,如果你只是将锁用作效率优化,并且崩溃不会经常发生,那也没什么大不了的。这种“没什么大不了的”场景是Redis大放异彩的地方。至少如果你依赖于一个Redis实例,那么每个查看系统的人都很清楚,这些锁是近似的,只用于非关键目的。

另一方面,Redlock算法有5个副本和多数投票,乍一看,它似乎适用于锁定对正确性很重要的情况。我将在以下章节中论证它不适合这个目的。在本文的其余部分,我们将假设锁对正确性很重要,如果两个不同的节点同时认为它们持有相同的锁,这将是一个严重的错误。

用锁保护资源

让我们暂时抛开Redlock的细节,讨论一下分布式锁的一般使用方式(独立于所使用的特定锁定算法)。重要的是要记住,分布式系统中的锁与多线程应用程序中的互斥体不同。这是一个更复杂的问题,因为不同的节点和网络都可能以各种方式独立发生故障。

例如,假设您有一个应用程序,其中客户端需要更新共享存储(例如HDFS或S3)中的文件。客户端首先获取锁,然后读取文件,进行一些更改,将修改后的文件写回,最后释放锁。该锁可防止两个客户端同时执行此读-修改-写循环,这将导致更新丢失。代码可能看起来像这样:

// 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();
    }
}

不幸的是,即使你有一个完美的锁服务,上面的代码也会被破解。下图显示了如何最终导致数据损坏:

Redis分布式锁Redlock注意事项

在这个例子中,获取锁的客户端在持有锁的同时会暂停一段时间,例如因为垃圾收集器(GC)启动。锁有一个超时(即它是一个租约),这总是一个好主意(否则崩溃的客户端可能会永远持有锁,永远不会释放它)。但是,如果GC暂停持续的时间超过租约到期时间,并且客户没有意识到它已经到期,它可能会继续进行一些不安全的更改。

这个bug不是理论上的:HBase曾经有过这个问题(http://www.slideshare.net/enissoz/hbase-and-hdfs-understanding-filesystem-usage)。通常情况下,GC暂停时间很短,但“停止世界”GC暂停有时会持续几分钟,这肯定足够租约到期了。即使是所谓的“并发”垃圾收集器,如HotSpot JVM的CMS,也不能与应用程序代码完全并行运行——即使它们需要不时地停止运行。

在写回存储之前插入锁到期检查无法解决此问题。请记住,GC可以在任何时候暂停正在运行的线程,包括对您来说最不方便的点(在最后一次检查和写入操作之间)。

如果你因为你的编程语言运行库没有长时间的GC暂停而感到沾沾自喜,那么你的进程可能会暂停的原因还有很多。也许你的进程试图读取一个尚未加载到内存中的地址,所以它出现了页面错误,并被暂停,直到页面从磁盘加载。也许你的磁盘实际上是EBS,因此读取变量在不知不觉中变成了亚马逊拥塞网络上的同步网络请求。也许还有许多其他进程在争夺CPU,而你碰到了调度程序树中的一个黑色节点。也许有人不小心向进程发送了SIGSTOP。无论什么。您的进程将暂停。

如果您仍然不相信我关于进程暂停的说法,那么请考虑文件写入请求在到达存储服务之前可能会在网络中延迟。以太网和IP等数据包网络可能会任意延迟数据包,它们确实如此:在GitHub的一次著名事件中,数据包在网络中延迟了大约90秒。这意味着应用程序进程可能会发送写入请求,并可能在租约已过期的一分钟后到达存储服务器。

即使在管理良好的网络中,这种事情也可能发生。你根本无法对时间做出任何假设,这就是为什么无论你使用什么锁服务,上面的代码从根本上都是不安全的。

用边界检查来确保锁的安全

这个问题的修复实际上很简单:您需要在对存储服务的每个写入请求中包含一个边界令牌。在这种情况下,边界/围栏令牌只是一个数字,每次客户端获取锁时都会增加(例如,由锁服务递增)。如下图所示:

Redis分布式锁Redlock注意事项

客户端1获取租约并获得33的令牌,但随后它进入长时间暂停,租约到期。客户端2获取租约,获得令牌34(数字总是增加的),然后将其写入发送到存储服务,包括令牌34。稍后,客户端1恢复正常,并将其写入内容(包括其令牌值33)发送到存储服务。然而,存储服务器记住它已经处理了具有更高令牌编号(34)的写入,因此它拒绝了具有令牌33的请求。

请注意,这要求存储服务器在检查令牌方面发挥积极作用,并拒绝令牌倒退的任何写入。但一旦你知道诀窍,这并不特别难。如果锁服务生成严格单调递增的令牌,这将使锁安全。例如,如果你使用ZooKeeper作为锁服务,你可以使用zxid或znode版本号作为围栏令牌。

然而,这就引出了Redlock的第一个大问题:它没有任何生成边界检查的设施。该算法不会产生任何保证每次客户端获取锁时都会增加的数字。这意味着,即使算法在其他方面是完美的,使用它也不安全,因为在一个客户端暂停或其数据包延迟的情况下,你无法防止客户端之间的竞争情况。

我不清楚如何改变Redlock算法来开始生成边界令牌。它使用的唯一随机值不提供所需的单调性。仅仅在一个Redis节点上保留一个计数器是不够的,因为该节点可能会发生故障。将计数器保持在多个节点上意味着它们将不同步。很可能你需要一个共识算法来生成边界令牌。(要是递增计数器很简单就好了。)

利用时钟解决共识

Redlock未能生成围栏令牌的事实应该已经足以成为在正确性取决于锁的情况下不使用它的充分理由。但还有一些值得讨论的问题。

在学术文献中,这种算法最实用的系统模型是具有不可靠故障检测器的异步模型。简单地说,这意味着算法对时间没有任何假设:进程可能会暂停任意长度的时间,数据包可能会在网络中任意延迟,时钟可能会任意错误——尽管如此,算法仍被期望做正确的事情。鉴于我们上面讨论的内容,这些都是非常合理的假设。

算法可能使用时钟的唯一目的是生成超时,以避免在节点停机时永远等待。但是超时不一定是准确的:仅仅因为一个请求超时,并不意味着另一个节点肯定宕机了——也可能是网络中有很大的延迟,或者你的本地时钟错了。当用作故障检测器时,超时只是对出现问题的猜测。(如果可以的话,分布式算法将完全不需要时钟,但共识将变得不可能。获取锁就像比较和设置操作,需要共识。)

请注意,Redis使用gettimeofday而不是单调时钟来确定密钥的到期时间。gettimeofday的手册页明确指出,它返回的时间会受到系统时间的不连续跳跃的影响——也就是说,它可能会突然向前跳几分钟,甚至在时间上向后跳(例如,如果时钟因与NTP服务器相差太大而由NTP步进,或者如果时钟由管理员手动调整)。因此,如果系统时钟正在做奇怪的事情,很容易发生Redis中密钥的到期比预期快得多或慢得多的情况。

对于异步模型中的算法来说,这不是一个大问题:这些算法通常会确保其安全属性始终成立,而不会做出任何时间假设。只有活性属性依赖于超时或其他故障检测器。简单地说,这意味着即使系统中的定时到处都是(进程暂停、网络延迟、时钟前后跳),算法的性能可能会下降,但算法永远不会做出错误的决定。

然而,Redlock并非如此。它的安全性取决于很多时间假设:它假设所有Redis节点在到期前持有密钥的时间长度大致合适;与到期持续时间相比,网络延迟较小;并且该过程暂停比到期持续时间短得多。

用糟糕的时机打破僵局

让我们看一些例子来证明Redlock对时间假设的依赖。假设系统有五个Redis节点(A、B、C、D和E)和两个客户端(1和2)。如果Redis节点上的时钟向前跳,会发生什么?

  1. 客户端1在节点A、B、C上获取锁。由于网络问题,无法联系到D和E。
  2. 节点C上的时钟向前跳,导致锁过期。
  3. 客户端2在节点C、D、E上获取锁。由于网络问题,无法联系到a和B。
  4. 客户1和2现在都认为他们掌握了锁。

如果C在将锁持久化到磁盘之前崩溃,并立即重新启动,则可能会出现类似的问题。出于这个原因,Redlock文档建议将崩溃节点的重启延迟至少一段最长锁的生存时间。但这种重启延迟再次依赖于对时间的合理准确测量,如果时钟跳变,则会失败。

好吧,也许你认为时钟跳变是不现实的,因为你非常有信心正确配置NTP,只转换时钟。在这种情况下,让我们来看一个进程暂停如何导致算法失败的示例:

  1. 客户端1请求锁定节点A、B、C、D、E。
  2. 当对客户端1的响应正在进行中时,客户端1进入停止世界GC。
  3. 锁在所有Redis节点上过期。
  4. 客户端2获取节点A、B、C、D、E上的锁。
  5. 客户端1完成GC,并接收来自Redis节点的响应,表明它成功获取了锁(在进程暂停时,它们被保存在客户端1的内核网络缓冲区中)。
  6. 客户1和2现在都认为他们掌握了锁。

请注意,尽管Redis是用C编写的,因此没有GC,但这对我们没有帮助:任何客户端可能会遇到GC暂停的系统都有这个问题。您只能通过在客户端2获取锁后阻止客户端1在锁下执行任何操作来确保安全,例如使用上述围栏方法。

长时间的网络延迟会产生与进程暂停相同的效果。这可能取决于你的TCP用户超时时间——如果你让超时时间明显短于Redis TTL,那么延迟的网络数据包可能会被忽略,但我们必须详细研究TCP实现才能确定。此外,随着超时,我们再次回到了时间测量的准确性!

Redlock的同步性假设

这些示例表明,只有当您假设同步系统模型时,即具有以下属性的系统,Redlock才能正常工作:

  • 有界网络延迟(您可以保证数据包总是在某个保证的最大延迟内到达),
  • 有界过程暂停(换句话说,硬实时约束,通常只在汽车安全气囊系统等中发现),以及
  • 有界时钟错误(祈祷你的时间不是来自坏的NTP服务器)。

请注意,同步模型并不意味着时钟完全同步:它意味着你假设网络延迟、暂停和时钟漂移有一个已知的固定上限。Redlock假设延迟、暂停和漂移相对于锁的生存时间都很小;如果时间问题变得和生存时间一样大,算法就会失败。

在一个表现良好的数据中心环境中,大部分时间都会满足时间假设——这被称为部分同步系统。但这足够好吗?一旦这些时间假设被打破,Redlock可能会违反其安全属性,例如在另一个客户到期之前向一个客户授予租约。如果你依赖锁的正确性,“大多数时候”是不够的——你需要它总是正确的。

有大量证据表明,对于大多数实际系统环境,假设同步系统模型是不安全的。不断提醒自己GitHub事件中90秒的数据包延迟。Redlock不太可能在Jepsen测试中幸存下来。

另一方面,为部分同步系统模型(或带有故障检测器的异步模型)设计的共识算法实际上有可能奏效。Raft、Viewstamp Replication、Zab和Paxos都属于这一类。这样的算法必须放弃所有的时间假设。这很难:人们很容易认为网络、进程和时钟比实际更可靠。但在分布式系统的混乱现实中,你必须非常小心你的假设。

结论

我认为Redlock算法是一个糟糕的选择,因为它“既非鱼也非鸟”:它对于效率优化锁来说是不必要的重量级和昂贵的,但对于正确性取决于锁的情况来说,它不够安全。

特别是,该算法对定时和系统时钟做出了危险的假设(基本上假设一个具有有界网络延迟和有界操作执行时间的同步系统),如果不满足这些假设,则违反了安全属性。此外,它缺乏生成边界令牌的设施(保护系统免受网络或暂停进程中的长时间延迟)。

如果你只需要尽最大努力使用锁(作为效率优化,而不是为了正确性),我建议坚持使用Redis的简单单节点锁定算法(如果不存在条件集,则获取锁;如果值匹配,则进行原子删除以释放锁),并在代码中非常清楚地记录锁只是近似的,偶尔可能会失败。不要费心设置一个由五个Redis节点组成的集群。

另一方面,如果你需要锁来保证正确性,请不要使用Redlock。相反,请使用适当的共识系统,如ZooKeeper,可能是通过实现锁的Curator配方之一。(至少,使用具有合理事务保证的数据库。)请对锁下的所有资源访问强制使用边界令牌。

正如我在开始时所说的,如果你正确使用Redis,它是一个很好的工具。上述任何一点都不会降低Redis对其预期目的的有用性。Salvatore多年来一直致力于该项目,其成功是当之无愧的。但每种工具都有局限性,了解它们并相应地制定计划非常重要。

 

除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/3006.html

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册