因为不一样的进程都必须在排他的方式操做共享资源,分布式锁在不少环境中是很是有用的基础组件。node
有不少库和博客都介绍了怎么用Redis来实现分布式锁管理器,可是每一个库都有不一样的设计理念,不少只是用了很小的一个方向,相比于略微复杂一点的设计都不能保证。算法
如今我尝试提供一个更权威的算法来实现Redis分布式锁。咱们提出一个算法,名叫Redlock,咱们相信它是比通常单例实现方式更加安全地实现了分布式锁管理器。咱们但愿Redis社区可以分析它,提供反馈,而且把它做为一个实现更加复杂和可供选择的设计的一个出发点。api
尽管已经有了10个以上Redlock的独立实现,咱们不知道哪一个依赖这个算法,我仍是认为把个人笔记分享出来是颇有意义的。因为Redis已经在不少别的地方被屡次提到了,因此我不许备在这里讨论他了。数组
在我准备详细讲述Redlock以前,我想说我十分喜欢Redis,并且以前也成功地在生产环境中应用了。我认为若是你想在服务器之间共享一些生命期比较短,类似而且快速变化的数据,并且对于偶尔无论什么缘由的丢失数据不太敏感的话,那么我建议你使用他。好比,一个好的用法是保留每一个IP地址的请求数,和不一样IP的用户ID。安全
然而,Redis也开始向着须要保证强一致性和持久化需求的数据管理的领域进军。这一点困惑住我了,由于Redis起初是否是为了这个目的设计的。能够论证,分布式锁就是这些领域中的一种。让咱们详细去检验。服务器
你用那个锁来作什么?网络
锁的目的是保证在不一样的节点间执行相同的任务,只有一个成功。这个任务多是向一个共享的存储系统中写入数据,进行某些计算,调用一些外部的API接口,或者别的。在较高层次分析,在分布式应用中有两个缘由是你想让锁去完成的:效率或者正确性。为了区别这些状况,你能够试着想象锁失败会致使什么后果:多线程
效率:并发
加锁能够不用你去把一件事作两次(好比一些复杂计算)。若是锁失败了,两个节点最终作了同一件事,结果就会在花销上有略微的增长或者一些不适当的。异步
正确性:
加锁阻止了线程的不一样步和系统状态的混乱。若是锁失败而且两个节点在同一份数据上同步工做,结果就将致使产生一个损坏的文件,数据丢失,永久不一致。一个病人服用了错误剂量的药物,或者其余一些比较严重的问题。
二者都是获取锁时会遇到的情形,可是你必须很是清楚地分辨出你正在处理的是哪一种。
我认为若是你只是出于保证效率来使用锁,那么使用Redlock而带来的花销和复杂度会令你望而却步。运行五个Redis服务而且花费大量时间去检查是否获取到你的锁。你最好不要仅仅使用一个Redis实例,以确保在宕机状况下能够经过异步复制的方式将数据复制到从节点。
若是你使用了单个Redis实例,当这个节点忽然断电或者其余什么故障发生的时候就会释放锁。可是若是你只是将锁用于优化效率的话,宕机也不会常常发生,那就没大毛病。这个“没多大毛病”的情景是Redis的一个亮点。至少若是你依赖单个Redis节点,每一个进入系统的人都是看到相同的,这只用于极少数的情形。
另一个方面,Redlock算法,重大选举和5个节点的复制,看起来更加适合正确性的选择。我认为在下面这些情形中都不大适合这个目的。本文的余下部分咱们主要讨论你的锁在分布式事务中怎样保证正确性的,若是两个不一样节点并发地持有同一个锁,这将是一个严重的bug。
使用锁保护资源
让咱们先不讨论Redlock的特别之处,让咱们先看下一个分布式锁一般是怎么用的吧。记住,分布式系统中的锁不一样于多线程应用中的mutex(互斥锁)。他比那个要来的复杂,归因于不一样的节点和网络会以不一样的方式失败而产生的问题。
举个例子,假如说你有一个系统,他的客户端须要更新共享存储(如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持续的时间超过释放的过时时间,客户端不会认为它过时了,它会继续运行,并产生不安全的状态更改。
这个bug不只仅停留在理论:HBase常常有这种问题。一般GC是很短的。可是“stop the world”GC有时候会持续几分钟——固然要比释放过时长的多。甚至所谓的线程垃圾收集器,好比HotSpot JVM的CMS不能彻底运行在并行条件下。甚至她们须要反复地stop the world。
你不可以在将数据写回到存储以前在锁过时期间加入检查来修复这个问题。记住GC能够在任什么时候候阻断一个运行中的线程,包括回最大限度形成不便的点。
若是你会由于本身的程序在运行阶段不会发生长时间的GC停留而沾沾自喜,别高兴太早,由于仍然会有别的缘由致使中止。可能你的进程想要去读取一个还没有写入内存的地址数据,它就会获得一个错误的页面,停下来直到页面从磁盘中加载出来。若是你的磁盘确实是EBS(快存储),读取一个无心而可变的数据致使Amazon的同步网络请求阻塞。可能有不少进程抢占CPU,你命中了你的任务树中的一个黑色节点(算法基于红黑树)。可能一些时间发送给进程SIGSTOP。这样的话你的进程游可能会挂掉。
假如你仍旧不相信进程会中止的事实,请换位思考下,文件写请求在到达存储服务以前会在网络中会发生延迟。报文相似广域网和IP都会果断延迟发送包,在GitHub的设计中,网络包延迟大约是90秒。这意味着一个应用进程会发生写请求,它会在当释放过时一分钟以后到达存储服务器。
甚至在一个管理良好的网络中,这种事情也会发生。你无法对延迟作一些假设,也是为何上面的代码基本上是不安全的,无论你用了怎样的锁服务。
使用栅栏让锁变得安全
这个问题解决起来也比较简单:你须要在每一个向存储服务的写请求中加入栅栏token。在这篇文章中,一个栅栏token既是当客户端请求锁时递增的数字。下面这张图说明了这点:
client1须要释放和获取编号为33的token,可是以后它进入了一个长时间的停滞,而后释放超期。Client2须要释放锁,获取了编号为34的token,而后发送它的写请求道存储服务商,包括34token。而后,client1恢复活跃而且发送它的写请求到存储服务商,包括33token。然而,存储服务器记录了34token,因此拒绝了33token。
注意到这样须要存储服务器作一个动态的检查tokens的操做,阻止往回写的token。可是当你知道了这个套路以后就以为不是特别的难。提供的锁服务产生了严格递增的tokens,这样使得锁变的安全。好比说,若是你把ZooKeeper看成锁服务来用,你可使用zxid或者znode版本号看成栅栏token,这样你就作的很好了。
然而,这样致使你用Redlock时遇到一个大问题:它没有产生栅栏tokens的能力。这个算法不能产生确保每时每刻提供给客户端锁的数组。这意味着尽管这个算法时很是不错的,可是使用它不是很安全,由于你不能阻止客户端之间的运行条件。当一个客户端停滞或者包延迟的情形。
若是某人修改Redlock算法来产生栅栏tokens我也不会感到很稀奇。这个惟一的随机数值不能提供须要的单调性。仅仅保证在Redis节点上计数器是不充分的,由于这个节点有可能挂掉。保证在不一样节点上的计数器都正常代表他们可能不是同步的。因此你可能须要一个一致性算法来产生栅栏tokens。
花时间去解决一致性
事实上Redlock在产生栅栏tokens的时候老是失败也称为它不该该应用在对锁的正确性要求很高的业务场景中。可是有更须要讨论的问题存在着。
在学术文献中,这种算法最具实践系统模型时不可靠失败检测的异步模型。英文字面上讲,就是这个算法对时间不敏感:进程可能会随意中止一段时间,包也会随机性地发生网络延时,锁会失败——尽管如此这个算法仍是但愿去作正确的事。基于咱们上面所述,仍是有很是有理由的证据的。
算法使用锁的惟一愿望事产生延迟,避免节点宕机致使的无限时的等待。可是延迟事不精确的:只是由于一个请求延迟,不意味着其余节点会挂掉——在网络中产生大的延迟倒还好,也许是本地时钟出问题了。当用来作失败检测的时候,延迟是断定出错的。
记得Redis使用getTimeOfDay api,而不是monotonic clock,去检查过时的keys。getTimeOfDay的帮助页明确说明了它返回给在系统时间上是不连续跳跃的——意味着,他会时隔几分钟忽然跳跃变化,或者及时地跳回来。如此,若是系统时钟在作奇怪的事情,他会比想象更快或者更慢更容易发生Redis的key的延迟。
异步模型中的算法不是一个大问题:这些算法通畅证实他们的安全属性常常维持住,没有产生时间假定。只有存活的属性依赖延迟或者其余失败检测。用英文直译的话就是若是系统延迟随时发生,算法的表现就会不好,可是算法永远不会形成错误的论断。
然而,Redlock不是这样的。他依赖于大量的时间假定:他却包全部Redis节点可以在过时前维持keys很长一段时间;在过时延迟面前网络延迟是个小case;进程的中止时长也比过时持续的时间要短的多。
经过坏的延迟摧毁Redlock
让咱们看一些代表了Redlock是依赖于时间假设的例子。假设系统有5个Redis节点和两个客户端。若是在一个Redis节点上的时钟跳过了会发生什么?
1 client 1在节点A、B、C上加了锁。因为网络缘由,D和E没有可以到达。
2 在C节点上的时钟跳过了,形成锁过时。
3 Client 2在节点C、D、E上加了锁。因为网络缘由,A和B不能到达。
4 Client 1和2都认为他们获取了锁。
一个相似的问题也会发生。当C在持久化锁道磁盘以前发生宕机,而后马上重启。因为这个缘由,Redlock官方文档推荐崩溃节点至少在长期持有锁的过程当中延迟启动。可是当一个理所固然的计算时间时候发生了延迟重启,若是时钟跳太久失败。
好的,你可能认为时钟抖动是难以想象的。由于你确定很是确信正确地配置了NTP去调节是种。这种情形下,让咱们看下一个进程的中止是怎么样形成这个算法失败的例子:
1 Client 1请求了节点A,B,C,D,E上的锁
2 当Client 1上的响应在争夺资源,client 1将进行stop-the-world GC。
3 锁在全部的Redis节点上过时。
4 Client 2在A,B,C,D,E上获取锁。
5 Client 1完成GC,收到来自Redis节点上的响应代表他获取锁成功了。
6 Client 1和2如今都确信他们获取了锁。
注意尽管Redis是用C写的,这样就没有GC,任何客户端发生GC停留的系统都会有这个问题。你只有组织Client 1在Client 2获取锁以后去作任何事情才能保证线程安全。好比使用上述的栅栏方法。
一个很长的网络延迟会产生和进程中止同样的效果。他可能受限于你的TCP用户延迟-若是你让延迟比Redis的带宽还算。可能延迟的网络包能够忽略,可是咱们不得不仔细研究和思考TCP是怎么样实现来确保正确发送包的。固然,由于有延迟咱们回归到时间精确性的问题上来。
Redlock同步假定
这些例子展现了Redlock只有在你肯定一个同步系统模型的时候才能正确地工做-就是有下面这些属性的系统:
绑定的网路延迟
绑定的进程中止
绑定的时钟错误
注意同步模型不意味着绝对同步的时钟:他代表你嘉定一个周知的,已经解决了网络延迟绑定,中止和时钟抖动。Redlock嘉定延迟,中止和抖动都是小问题;若是时间问题和时间的持久性同样规模的话,算法就失效了。
固然在数据中心环境下,时间嘉定将适合不少场合-这被叫作半同步系统。可是他有足够的好吗?时间嘉定一旦失败,Redlock就会将他的安全的属性透明化,好比,容许在另外一个以前发布到一个客户端的节点就过时了。若是你认为你的时钟是可靠的,“大多数时间”不充分-你须要让她老是正确的。
为大多数版系统环境制定一个同步系统模型是不安全的。保证提醒你本身关于Github的90秒包延迟时间。
另外一个方面,一个为半同步系统设计的一致性算法有机会工做。Raft,Viewstamped复制,Zab和Paxos在策略中都落实了。这样的算法是用来远离全部的时间假定的。这是困难的:保证网络是临时的,进程和时钟比他们本身还要确信。可是关于分布式系统的凌乱的认知,你不得不特别关心你的假定。