[翻译]基于redis的分布式锁

本篇翻译自【redis.io/topics/dist…php

在不少不一样进程必须以相互排斥的方式竞争分片资源的状况下,分布式锁是很是有用的原始功能。node

有不少的实现和博客都描述了如何基于Redis来实现分布式锁管理器(DLM,Distributed Lock Manager)。有的使用了不一样的途径,可是大多都是使用相同的简单方案,与复杂的设计相比,下面这种官方的方案用更低的保证度来实现分布式锁。官方把它称做是更加规范的分布式锁的实现方案,也就是所谓的RedLock。redis

实现

如今已经有不少的基于Redis的锁实现,好比:算法

  • Redlock-rb (Ruby).
  • Redlock-py (Python).
  • Aioredlock (Asyncio Python).
  • Redlock-php (PHP).
  • PHPRedisMutex (further PHP)
  • cheprasov/php-redis-lock (PHP)
  • Redsync.go (Go).
  • Redisson (Java).
  • Redis::DistLock (Perl).
  • Redlock-cpp (C++).
  • Redlock-cs (C#/.NET).
  • RedLock.net (C#/.NET).
  • ScarletLock (C# .NET使用可配置的数据库存储来实现的)
  • node-redlock (NodeJS).

保证更加安全和灵活

RedLock设计有三个原则,这三个原则在RedLock的设计者看来是有效的分布式锁的最低要求。数据库

  • 安全属性:保证互斥,任什么时候候都只有一个客户端持有锁
  • 效率属性1:不会死锁。即便锁定资源的客户端崩溃或者被隔离分区,也要可以得到锁。
  • 效率属性2:容错。只要大多数节点还在运行,那么客户端就能继续得到和释放锁

基于故障转移(failover-based)的方案是不够的

想理解官方的分布式锁方案原理,就得了解现有的分布式锁的实现方式。安全

最简单的方式是在单实例中使用Redis锁住一个建立的key,这个key一般是有存活时间限制的,使用的是redis的expires特性,因此这种锁最终都会释放(知足效率属性2)。当客户端须要释放资源,就删除这个key。服务器

很容易理解的方案,可是有个问题。这是单点架构,若是Redis的master节点挂了,会发生什么呢?固然你可能会使用添加slave的方法来解决这个问题。可是这是无效的,由于这种状况下没法保证互斥。缘由是Redis的副本复制是异步的。网络

这个模型有明显的竞争条件:架构

  • 1.客户端A的锁在master中
  • 2.在master向slave同步传输数据的时候master崩溃了
  • 3.slave升级成为master
  • 4.客户端B在获取相同资源的锁时能够正常获取(客户端A和B同时获取了锁,违反了安全性)

固然若是你容许两个客户端同时持有锁,那么这种方案是可行的。不然的话建议使用官方推荐的分布式锁方案。dom

单点的正确实现

在尝试克服上述单实例的限制以前,这里会用一个简单的例子来检查如何完成。 在频繁的竞争条件的应用中,这也是被接收的方案。由于在单实例中的锁也是咱们使用分布式算法的基础。

为了得到锁,能够这么作:

SET resource_name my_random_value NX PX 30000
复制代码

这个命令会在一个值不存在的状况下设置一个key(NX),这个key会存在30000毫秒(PX)。这个key设置了一个值“myrandomvalue”。这个值针对全部客户端和锁必须是惟一的。随机数是以安全方式分发锁的的基础,这经过脚本传达给redis:若是存在这个key,那么就移除这个key,而后保存这个value则是我指望的效果。若是我使用Lua脚本实现,将会是:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
复制代码

为了不移除其余客户端建立的锁,这是很是必要的。好比一个客户端得到了锁,而后在一个相似操做中阻塞了很长时间,而这段时间超过了合法的时间(在这段时间中key已通过期了),随后移除这个被其余的客户得到的锁。对于一个客户端来讲只是用删除操做也许是删除了其余客户端所持有的锁。使用上面的脚本,那么每一个锁都是随机字符串签名的。因此只有在这个这个客户端尝试删除锁时,这个锁才会被删除。

和这个随机的字符串是怎么产生的呢?我假设这个随机字符串是从/dev/urandom中取得20个byte,可是你也能用简易的方法生成惟一的字符串。好比使用/dev/urandom做为RC4的随机种子,而后生成一个伪随机流。一个更简单的方法是使用一个unix的毫秒数和客户端id的组合,但这是不安全的,可是能适应大多数环境。

咱们开始设置这个key的存活时间,叫作“锁的生效时间”。这个时间既是锁的自动释放时间,也是其余客户端可以再次得到锁以前已经得到锁的客户端可以执行操做的时间。这种状况没有在技术上违反互斥保证,只是限制了得到锁的时间窗口。

因此咱们如今有个不错的方法来得到和释放锁。如今这个非分布式的单点系统,老是可用的,而且安全的。让咱们将这种概念扩展到无保护的分布式系统中。

Redlock算法

在分布式算法中,咱们假设有N个redis的master。这些node相互独立,因此我不用复制,也不用任何作任何隐式的协同操做。如今已经在单节点中定义了如何得到和释放锁。咱们使用算法保证了咱们在单节点中锁的得到与释放不会冲突。在咱们的例子中,咱们假设N等于5,这是个合理的值,因此咱们须要在不一样的机器上运行5台redis,这也是为了尽量保证节点相互独立。

为了得到锁,客户端须要作以下操做:

  • 1.得到当前时间的毫秒数
  • 2.使用相同的key和不一样的随机字符串做为value,尝试在N个节点中顺序得到锁。在步骤2中,当在一个节点中设置了锁,那么客户端为了能得到他,将会使用一个总的锁定时间相比较小的时间做为超时时间。好比自动释放的时间是10秒,那么超时时间为5-50毫秒。这主要是为了防止在节点down了后,长时间尝试得到锁时的阻塞。若是节点不可用,咱们应该尝试尽快与下一个节点交互。
  • 3.客户端经过从当前时间中减去在步骤1中得到的时间戳来计算获取锁定所需的时间。当且仅当客户端可以在大多数实例中获取锁定时(至少3个)而且获取锁定所通过的总时间小于锁定有效时间,认为锁定被获取。
  • 4.若是得到了锁,则其有效时间被认为是初始有效时间减去通过的时间,如步骤3中计算的。
  • 5.若是客户端因为某种缘由(没法锁定N / 2 + 1实例或有效时间为负)没法获取锁定,它将尝试解锁全部实例(即便它认为不是可以锁定)。

算法是异步的吗?

该算法依赖于这样的假设:虽然跨过程没有同步时钟,但每一个进程中的本地时间仍然大体以相同的速率流动,其中错误与锁的自动释放时间相比较小。这个假设很是相似于真实世界的计算机:每台计算机都有一个本地时钟,咱们一般能够依靠不一样的计算机来得到较小的时钟漂移。

此时咱们须要更好地指定咱们的互斥规则:只要持有锁的客户端将在锁定有效时间内(在步骤3中得到)终止其工做,减去一些时间(仅几毫秒),它就获得保证为了补偿进程之间的时钟漂移)。 有关须要绑定时钟漂移的相似系统的更多信息,

这里有个有趣的参考:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency[dl.acm.org/citation.cf…]

重试失败

当客户端没法获取锁定时,它应该在随机延迟以后再次尝试,以尝试同步多个客户端,同时尝试获取同一资源的锁定(这可能会致使大脑分裂状况,由于无人可以选取成功)。此外,客户端尝试在大多数Redis实例中获取锁定的速度越快,分裂大脑条件的窗口越小(而且须要重试),所以理想状况下客户端应尝试将SET命令发送到N个实例同时使用多路复用。

值得强调的是,对于未能得到大多数锁定的客户来讲,尽快释放(部分)获取的锁定是多么重要,所以无需等待密钥到期以便再次获取锁定(可是,若是发生网络分区且客户端没法再与Redis实例通讯,则在等待密钥到期时须要支付可用性惩罚。

释放锁

释放锁是很简单的,只需在全部实例中释放锁,不管客户端是否定为它可以成功锁定给定实例。

安全论点

算法安全吗?咱们能够尝试了解不一样场景中会发生什么。 首先让咱们假设客户端可以在大多数状况下获取锁。全部实例都将包含一个生存时间相同的密钥。可是,密钥设置在不一样的时间,所以密钥也将在不一样的时间到期。可是若是第一个密钥在时间T1设置为最差(咱们在联系第一个服务器以前采样的时间),而且最后一个密钥在时间T2(咱们从最后一个服务器得到回复的时间)设置为最差,咱们肯定在集合中到期的第一个密钥将至少存在MIN_VALIDITY = TTL-(T2-T1)-CLOCK_DRIFT。全部其余密钥将在稍后过时,所以咱们确信密钥将至少同时设置为此时间。

在那段时间里,若是设置了大多数密钥,则另外一个客户端将没法获取锁定,由于若是已存在N / 2 + 1密钥,则N / 2 + 1 SET NX操做将没法成功。 所以,若是得到锁定,则没法同时从新获取锁定(违反互斥属性)。

可是,咱们还但愿确保同时尝试获取锁的多个客户端不能同时成功。

若是客户端使用的时间接近或大于锁定最大有效时间(咱们基本上用于SET的TTL)锁定大多数实例,则会认为锁定无效并将解锁实例,所以咱们只须要考虑 客户端可以在小于有效时间的时间内锁定大多数实例的状况。 在这种状况下,对于上面已经表达的参数,对于MIN_VALIDITY,没有客户端应该可以从新获取锁。 所以,多个客户端将可以同时锁定N / 2 + 1个实例(“时间”为结束 步骤2)只有当锁定多数的时间大于TTL时间时,才使锁定无效。

争论点

系统活跃度基于三个主要特征:

  • 1.锁的自动释放(由于密钥到期):最终能够再次锁定密钥。
  • 2.一般状况下,客户一般会在未获取锁定时或在获取锁定而且工做终止时合做移除锁定,这使得咱们可能没必要等待密钥到期以从新获取锁。
  • 3.事实上,当客户端须要重试锁定时,它等待的时间比获取大多数锁定所需的时间要大得多,以便在资源争用期间几率地分裂大脑条件。

可是,咱们在网络分区上付出的可用性惩罚等于TTL时间,所以若是有连续分区,咱们能够无限期地付出此惩罚时间。每次客户端获取锁定并在可以删除锁定以前进行分区时,都会发生这种状况。 基本上若是有无限连续的网络分区,那么 系统可能没法在无限的时间内使用。

性能,崩溃恢复和fsync

使用Redis做为锁服务的许多用户在获取和释放锁的延迟以及每秒可执行的获取/释放操做的数量方面须要高性能。为了知足这一要求,与N个Redis服务器通讯以减小延迟的策略确定是多路复用(或者是妥协的多路复用,即将套接字置于非阻塞模式,发送全部命令,并读取全部命令稍后,假设客户端和每一个实例之间的RTT类似)。

可是,若是咱们想要定位崩溃恢复系统模型,还有另外一个考虑持久性的问题。

基本上要看到这里的问题,让咱们假设咱们根本没有持久性配置Redis。客户端在5个实例中的3个中获取锁定。从新启动客户端可以获取锁的实例之一,此时还有3个实例能够锁定同一资源, 而且另外一个客户能够再次锁定它,违反了锁定的安全性。

若是咱们启用AOF持久性,事情会有所改善。 例如,咱们能够经过发送SHUTDOWN并从新启动它来升级服务器。 由于Redis过时是在语义上实现的,因此当服务器关闭时,实际上时间仍然流逝了,咱们全部的需求都是实现的很好。 可是一切都很好,只要它是一个简洁的关闭。 若是停电呢? 若是默认状况下将Redis配置为每秒在磁盘上进行fsync,则从新启动后可能会丢失密钥。 理论上,若是咱们想要在任何类型的实例重启时保证锁定安全性,咱们须要在持久性设置中启用fsync = always。 这反过来将彻底破坏性能,沦落到传统上用于以安全方式实现分布式锁的CP系统的相同级别。 然而,事情比第一眼看起来更好。 基本上算法是安全的 只要在崩溃后实例从新启动时,它就会保留,它再也不参与任何当前活动的锁,所以当实例从新启动时,当前活动锁的集合都是经过锁定除从新加入实例以外的实例得到的系统。

为了保证这一点,咱们只须要在崩溃后建立一个实例,至少比咱们使用的最大TTL多一点,也就是说,实例崩溃时若是有获取全部锁所需的时间,则锁变为无效并自动释放。

使用延迟重启基本上能够实现安全性,即便没有任何可用的Redis持久性,但请注意,这可能转化为可用性惩罚。 例如,若是大多数实例崩溃,系统将变为全局不可用于TTL(这里全局意味着在此期间根本没有资源可锁定)。

使算法更可靠:扩展锁定

若是客户端执行的工做由小步骤组成,则默认状况下可使用较小的锁定有效时间,并扩展实现锁定扩展机制的算法。基本上,客户端若是在锁定有效性接近低值的状况下处于计算中间,则能够经过向全部扩展密钥的TTL的实例发送Lua脚原本扩展锁定,若是密钥存在且其值仍然是获取锁定时客户端分配的随机值。 若是可以将锁扩展到大多数实例,而且在有效时间内,基本上使用的算法与获取锁时使用的算法很是类似。

客户端应该只考虑从新获取的锁。 可是这在技术上不会更改算法,所以应限制锁定从新获取尝试的最大次数,不然会违反其中一个活动属性。

相关文章
相关标签/搜索