如何做分布式锁定
Martin Kleppmann于2016年2月8日发布。
作为本书研究的一部分,我在Redis网站上 遇到了一种称为Redlock的算法。该算法声称 在Redis的顶部实现容错的分布式锁(或更确切地说, 租约 [1]),并且该页面要求来自分布式系统人员的反馈。该算法本能地触发了我的脑海,因此我花了一些时间思考并编写这些注释。
由于Redlock已经有10多个独立的实现,而且我们不知道谁已经在依赖此算法,因此我认为值得公开分享我的笔记。我不会讨论Redis的其他方面,其中一些已经在其他地方受到了批评 。
在详细介绍Redlock之前,我要说我非常喜欢Redis,并且过去我已经在生产中成功使用了Redis。我认为这非常适合以下情况:您希望在服务器之间共享一些瞬时的,近似的,快速变化的数据,并且如果由于某种原因偶尔丢失这些数据也没什么大不了的。例如,一个好用例是维护每个IP地址的请求计数器(出于速率限制的目的)和每个用户ID的不同IP地址集(用于滥用检测)。
但是,Redis一直在逐步进入具有更高一致性和持久性期望的数据管理领域,这让我担心,因为这不是Redis的目标。可以说,分布式锁定是这些领域之一。让我们更详细地研究它。
您将那个锁用于什么用途?
锁定的目的是确保在可能尝试执行同一工作的多个节点中,只有一个实际上可以执行(一次至少一次)。这项工作可能是将一些数据写入共享存储系统,执行一些计算,调用某些外部API等。从高层次上讲,您可能想在分布式应用程序中锁定的原因有两个: 效率或正确性 [2]。为了区分这些情况,您可以询问锁定失败的情况:
- 效率:进行锁定可以避免不必要地重复执行两次相同的工作(例如,一些昂贵的计算)。如果锁定失败,并且两个节点最终完成相同的工作,则结果是成本略有增加(您最终向AWS支付的费用比原本多5美分)或带来的不便(例如,用户最终两次收到相同的电子邮件通知)。
- 正确性:进行锁定可以防止并发进程踩到彼此的脚趾并弄乱系统状态。如果锁定失败,并且两个节点同时处理同一数据,则结果将导致文件损坏,数据丢失,永久性不一致,向患者使用的药物剂量错误或其他一些严重问题。
两者都是想要锁定的有效情况,但是您需要非常清楚要处理的是哪一种。
我会争辩说,如果您仅出于提高效率的目的使用锁,则不必招致Redlock的成本和复杂性,无需运行5台Redis服务器并检查是否有大多数人可以获得您的锁。您最好只使用一个Redis实例,如果主实例崩溃,则可以异步复制到辅助实例。
如果您使用单个Redis实例,那么如果Redis节点突然断电或发生其他问题,您当然会丢掉一些锁。但是,如果您仅将锁用作效率优化,并且崩溃不会经常发生,那没什么大不了的。Redis大放异彩是这种“没什么大不了”的场景。至少如果您仅依靠单个Redis实例,那么查看系统的每个人都清楚锁是近似的,并且仅用于非关键目的。
另一方面,具有5个副本和多数投票的Redlock算法乍一看,似乎适用于锁定对于正确性很重要的情况。在以下各节中,我将争辩说它不适合该目的。对于本文的其余部分,我们将假定您的锁对于正确性很重要,并且如果两个不同的节点同时认为它们持有相同的锁,则这是一个严重的错误。
用锁保护资源
让我们暂时搁置一下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();
}
}
不幸的是,即使您拥有完善的锁定服务,上面的代码也已损坏。下图显示了如何以损坏的数据结束:
在此示例中,获取锁的客户端在持有锁的同时暂停了一段较长的时间–例如,因为垃圾收集器(GC)被踢了进来。锁具有超时(即,它是租约),即总是一个好主意(否则,崩溃的客户端最终可能永远持有一把锁,而永远不会释放它)。但是,如果GC暂停的持续时间长于租约到期期限,并且客户没有意识到它已过期,则可能会继续进行一些不安全的更改。
这个错误不是理论上的:HBase曾经有这个问题 [3,4]。通常,GC暂停时间很短,但是有时人们知道“世界停下来”的GC暂停时间可以持续 几分钟 [5] –肯定足够长,足以使租约到期。即使是像HotSpot JVM的CMS这样的所谓的“并发”垃圾收集器,也无法与应用程序代码完全并行运行-甚至他们需要时不时地停止运行[6]。
您不能通过在写回存储之前在锁定到期时插入检查来解决此问题。请记住,GC可以在任何时候暂停正在运行的线程,包括对您而言最大的麻烦(在最后一次检查和写入操作之间)。
而且,如果由于编程语言运行时没有长时间的GC暂停而感到自鸣得意,则还有许多其他原因导致您的进程可能被暂停。也许您的进程试图读取一个尚未加载到内存中的地址,所以它遇到了页面错误,并被暂停,直到从磁盘加载页面为止。也许您的磁盘实际上是EBS,所以读取变量会不经意间转变为通过Amazon拥塞网络发出的同步网络请求。也许还有许多其他竞争CPU的进程,而您在调度程序树中遇到了一个黑色节点。也许有人不小心将SIGSTOP发送到该进程。随你。您的过程将暂停。
如果您仍然不相信我有关进程暂停的信息,那么可以考虑在到达存储服务之前,网络中的文件写入请求可能会延迟。诸如以太网和IP之类的数据包网络可能会任意延迟数据包,而它们确实做到了 [7]:在GitHub的一次著名 事件中,数据包在网络中被延迟了大约90秒[8]。这意味着应用程序进程可以发送写请求,并且当租约已经到期时,它可能在一分钟后到达存储服务器。
即使在管理良好的网络中,也会发生这种情况。您根本无法对时间做出任何假设,这就是无论您使用哪种锁定服务,上述代码从根本上来说都不安全的原因。
用栅栏保护锁安全
解决此问题的方法实际上非常简单:您需要在对存储服务的每个写入请求中都包含隔离标记。在这种情况下,防护令牌只是每次客户端获取锁时都会增加(例如,通过锁服务增加)的数字。如下图所示:
客户端1获得了租约并获得了33的令牌,但随后进入了长暂停状态,租约到期。客户端2获得租约,获得令牌34(该数字始终在增加),然后将其写操作(包括令牌34)发送到存储服务。随后,客户端1恢复工作并将其写操作发送到存储服务,包括其令牌值33。但是,存储服务器记住它已经处理了具有较高令牌号的写入(34),因此它拒绝了具有令牌33的请求。
请注意,这要求存储服务器在检查令牌中发挥积极作用,并拒绝令牌已倒退的所有写操作。但是,一旦知道了窍门,这并不是特别困难。并且只要锁服务生成严格单调递增的令牌,就可以使锁安全。例如,如果您将ZooKeeper用作锁定服务,则可以将zxid
或znode版本号用作防护令牌,并且状态良好[3]。
但是,这使我们遇到了Redlock的第一个大问题:它没有任何生成防护令牌的工具。该算法不会产生任何保证每次客户端获取锁都会增加的数字。这意味着,即使该算法在其他方面是完美的,也将无法安全使用,因为在一个客户端暂停或其数据包被延迟的情况下,您无法防止客户端之间的竞争状态。
对于我来说,还不知道如何更改Redlock算法以开始生成隔离令牌。它使用的唯一随机值不能提供所需的单调性。仅在一个Redis节点上保留一个计数器是不够的,因为该节点可能会失败。在多个节点上保留计数器将意味着它们将不同步。您可能需要一个共识算法才能生成围栏令牌。(如果仅增加一个计数器很简单。)
用时间解决共识
Redlock无法生成隔离令牌的事实应该已经成为在正确性取决于锁定的情况下不使用它的充分理由。但是还有一些其他问题值得讨论。
在学术文献中,用于这种算法的最实用的系统模型是带有不可靠故障检测器的 异步模型 [9]。用简单的英语来说,这意味着该算法不对时序做任何假设:进程可能会暂停任意时间长度,数据包可能会在网络中被任意延迟,时钟可能会被任意错误-尽管如此,该算法仍有望正确执行事情。鉴于我们上面讨论的内容,这些是非常合理的假设。
算法可以使用时钟的唯一目的是生成超时,以避免在节点发生故障时永远等待。但是超时不必一定是准确的:仅仅是因为一个请求超时,并不意味着另一个节点肯定已关闭–也可能是网络中存在较大的延迟,或者您的本地时钟是错的。当用作故障检测器时,超时仅是某些错误的猜测。(如果可以的话,分布式算法将完全不使用时钟,但是共识变得不可能了 [10]。获取锁就像比较设置操作,需要达成共识 [11]。)
请注意,Redis 使用gettimeofday
而非单调时钟来确定密钥的到期时间。的手册页gettimeofday
明确指出,返回的时间会受到系统时间的不连续跳跃-也就是说,它可能突然向前跳跃几分钟,甚至跳回时间(例如,如果时钟由NTP步进,因为它与NTP服务器的差异太大,或者时钟是由管理员手动调整的)。因此,如果系统时钟做奇怪的事情,很容易发生Redis中的密钥过期比预期快得多或慢得多的情况。
对于异步模型中的算法来说,这不是一个大问题:这些算法通常可确保始终保持其安全性,而无需进行任何时序假设 [12]。仅活动属性取决于超时或其他故障检测器。用简单的英语来说,这意味着,即使系统中的时序无处不在(进程暂停,网络延迟,时钟向前和向后跳动),算法的性能也可能会陷入困境,但该算法永远不会错误的决定。
但是,Redlock不是这样的。它的安全性取决于许多时序假设:它假设所有Redis节点在过期前大约保持正确的时间长度;与有效期相比,网络延迟小;而且该过程的暂停比有效期短得多。
用不好的时机打破Redlock
让我们看一些示例来说明Redlock对时序假设的依赖。假设系统有五个Redis节点(A,B,C,D和E)和两个客户端(1和2)。如果Redis节点之一上的时钟向前跳怎么办?
- 客户端1获取节点A,B,C的锁定。由于网络问题,无法访问D和E。
- 节点C上的时钟向前跳,导致锁过期。
- 客户端2获取节点C,D,E的锁定。由于网络问题,无法访问A和B。
- 现在,客户1和2都认为他们持有该锁。
如果C在将锁保留到磁盘之前崩溃并立即重新启动,则可能会发生类似的问题。因此,Redlock文档建议将崩溃的节点的重新启动至少延迟最长寿命的锁的生存时间。但是,此重新启动延迟再次依赖于合理准确的时间度量,并且如果时钟跳变将失败。
好的,所以您可能认为时钟跳变是不现实的,因为您对正确配置NTP仅能摆正时钟非常有信心。在这种情况下,让我们看一个进程暂停可能导致算法失败的示例:
- 客户端1请求锁定节点A,B,C,D,E。
- 在发送对客户端1的响应时,客户端1进入了世界停止GC。
- 锁在所有Redis节点上失效。
- 客户端2获得对节点A,B,C,D,E的锁定。
- 客户端1完成GC,并接收来自Redis节点的响应,表明它已成功获取了锁(在进程暂停时它们已保存在客户端1的内核网络缓冲区中)。
- 现在,客户1和2都认为他们持有该锁。
请注意,即使Redis是用C编写的,因此没有GC,但这在这里也无济于事:任何客户端可能会遇到GC暂停的系统都存在此问题。您只能通过阻止客户端1在客户端2获得锁之后执行该锁下的任何操作来确保此安全,例如使用上述防护方法。
较长的网络延迟会产生与过程暂停相同的效果。这可能取决于您的TCP用户超时-如果您将超时时间大大短于Redis TTL,则可能会忽略延迟的网络数据包,但是为了确保这一点,我们必须详细研究TCP。另外,随着超时,我们又回到了时间测量的准确性!
Redlock的同步假设
这些示例表明,只有在假设使用同步系统模型(即具有以下属性的系统)的情况下,Redlock才能正常工作:
- 有界的网络延迟(您可以保证数据包始终在一定的最大延迟内到达),
- 有限的过程暂停(换句话说,严格的实时约束,通常只能在汽车安全气囊系统等中找到),并且
- 有限的时钟错误(用手指指您无法从错误的NTP服务器获取时间)。
请注意,同步模型并不意味着时钟完全同步:这意味着您假设网络延迟,暂停和时钟漂移的固定上限是已知的 [12]。Redlock假设相对于锁的生存时间而言,延迟,暂停和漂移都很小;如果计时问题变得与生存时间一样大,则该算法将失败。
在行为良好的数据中心环境中,大多数 时间都将满足时序假设–这被称为部分同步系统 [12]。但这足够好吗?一旦这些时间假设被打破,Redlock可能会违反其安全属性,例如,在一个客户过期之前将其授予租约。如果您依靠锁来确保正确性,那么“大多数时候”是不够的–您需要始终保持正确性。
有大量证据表明,对于大多数实际系统环境而言,假设同步系统模型并不安全[7,8]。持续90秒钟的数据包延迟提醒自己GitHub事件 。Redlock不可能在Jepsen测试中幸存下来。
另一方面,为部分同步的系统模型(或带有故障检测器的异步模型)设计的共识算法实际上有工作的机会。筏,带时间戳的复制,Zab和Paxos都属于此类。这种算法必须放弃所有时序假设。很难:假设网络,进程和时钟比实际更可靠。但是在混乱的分布式系统现实中,您必须非常谨慎地进行假设。
结论
我认为Redlock算法是一个错误的选择,因为它“既不是鱼也不是禽”:对于效率优化锁来说,它不必要地繁重且昂贵,但是对于正确性取决于锁的情况,它并不是足够安全。
特别是,该算法对时序和系统时钟进行了危险的假设(本质上是假设同步系统具有受限的网络延迟和操作的受限执行时间),并且如果不满足这些条件,就会违反安全性。而且,它缺乏用于生成隔离令牌的功能(可以保护系统免受网络或暂停进程中的长时间延迟)。
如果您仅在尽力而为的基础上需要锁(作为效率优化,而不是为了正确性),我建议您为Redis 坚持使用简单的单节点锁算法(不存在条件集的情况下获取锁,原子的delete-if-value-matches来释放锁),并在代码中非常清楚地证明锁只是近似的,有时可能会失败。不必理会由五个Redis节点组成的集群。
另一方面,如果您需要锁定以确保正确性,请不要使用Redlock。相反,请使用适当的共识系统,例如ZooKeeper,可能通过 实现锁定的Curator配方之一。(至少,使用具有合理事务保证的数据库。)并且请在锁定下的所有资源访问上强制使用防护令牌。
正如我在开始时所说的,Redis是一个很好的工具,如果您正确使用它。以上所有内容都不会削弱Redis用于其预期目的的有用性。萨尔瓦多(Salvatore)多年来一直致力于该项目,其成功理所应当。但是每种工具都有局限性,了解它们并据此计划很重要。
如果您想了解更多信息,我将在我的书的第8章和第9章中更详细地解释该主题,该书现在可以从O'Reilly的Early Release中获得。(以上图表摘自我的书。)为了学习如何使用ZooKeeper,我推荐Junqueira和Reed的书 [3]。为了更好地介绍分布式系统理论,我推荐Cachin,Guerraoui和Rodrigues的教科书 [13]。
感谢Kyle Kingsbury,Camille Fournier,Flavio Junqueira和 Salvatore Sanfilippo审阅本文草稿。当然,任何错误都是我的。
2016年2月9日更新: Redlock的原始作者 Salvatore对本文发表了反驳(另请参见 HN讨论)。他提出了一些意见,但我坚持我的结论。如果有时间,我可能会在后续帖子中进行详细说明,但请形成您自己的意见-请查阅以下参考资料,其中许多参考资料都经过了严格的学术同行评审(与我们的任何一篇博文均不同)。
参考文献
[1] Cary G Gray和David R Cheriton:“ 租赁:分布式文件缓存一致性的有效容错机制 ”,在第12届ACM操作系统原理研讨会(SOSP)上,1989年12月 。doi:10.1145 / 74850.74870
[2] Mike Burrows:“ 松散耦合分布式系统的Chubby锁服务 ”,在2006年11月举行的第七届USENIX操作系统设计和实现(OSDI)研讨会上。
[3] Flavio P Junqueira和Benjamin Reed: ZooKeeper:分布式过程协调。O'Reilly Media,2013年11月。ISBN:978-1-4493-6130-3
[4] EnisSöztutar:“ HBase和HDFS:了解HBase中的文件系统使用 ”,在HBaseCon上,2013年6月。
[5] Todd Lipcon:“ 避免使用带有MemStore本地分配缓冲区的Apache HBase中的完整GC:第1部分 ”,blog.cloudera.com,2011年2月24日。
[6]马丁·汤普森(Martin Thompson):“ Java垃圾收集已蒸馏 ”,mechanical-sympathy.blogspot.co.uk,2013年7月16日。
[7] Peter Bailis和Kyle Kingsbury:“ The Network is Reliable ”, ACM Queue,第12卷,第7期,2014年7月 。doi:10.1145 / 2639988.2639988
[8] Mark Imbriaco:“ 上周六停机,” github.com,2012年12月26日。
[9] Tushar Deepak Chandra和Sam Toueg:“ 可靠的分布式系统的不可靠故障检测器 ” ,ACM杂志,第43卷,第2期,第225-267页,1996年3月 。doi:10.1145 / 226643.226647
[10] Michael J Fischer,Nancy Lynch和Michael S Paterson:“ 不可能通过一个错误的过程达成分布式共识 ” ,ACM杂志,第32卷,第2期,第374-382页,1985年4月 。doi:10.1145 / 3149.214121
[11] Maurice P Herlihy:“无等待同步 ”, ACM Transactions on Programming Languages and Systems,第13卷,第1期,第124-149页,1991年1月 。doi:10.1145 / 114005.102808
[12]辛西娅·德沃克(Cynthia Dwork),南希·林奇(Nancy Lynch)和拉里·斯托克迈尔(Larry Stockmeyer):“ 存在部分同步的共识 ” ,《美国计算机学会》 ,第35卷,第2期,第288-323页,1988年4月 。doi:10.1145 / 42282.42283
[13] Christian Cachin,Rachid Guerraoui和LuísRodrigues: 可靠和安全的分布式编程简介,第二版。斯普林格,2011年2月。ISBN:978-3-642-15259-7, doi:10.1007 / 978-3-642-15260-3