Redis分布式锁

Redis分布式锁

分布式锁是许多环境中非常有用的原语,其中不同的进程必须以相互排斥的方式与共享资源一起运行。

有许多图书馆和博客文章描述了如何使用Redis实现DLM(分布式锁管理器),但是每个库都使用不同的方法,并且许多使用一种简单的方法,与稍微更复杂的方法相比可以实现更低的保证设计。

这个页面试图提供一个更规范的算法来实现与Redis的分布式锁。我们提出一种称为Redlock的算法,它实现了一种DLM,我们认为它比香草单实例方法更安全。我们希望社区能够对其进行分析,提供反馈,并将其用作实施或更复杂或替代设计的起点。

实施

在描述算法之前,这里有一些可用于参考的可用实现的链接。

安全生活保障

我们将使用三个属性来建模我们的设计,从我们的角度来看,是以有效的方式使用分布式锁所需的最低保证。

  1. 安全属性:相互排斥。在任何给定的时刻,只有一个客户端可以持有锁。
  2. 活力财产A:无死锁 最终,即使锁定资源的客户端崩溃或获得分区,始终可以获取锁定。
  3. 活力属性B:容错。只要大部分Redis节点都启动,客户端就可以获取和释放锁。

为什么基于故障转移的实现是不够的

要了解我们想要改进的内容,我们来分析大多数基于Redis的分布式锁库的当前状态。

使用Redis锁定资源的最简单方法是在实例中创建一个密钥。关键是通常在有限的时间内使用Redis到期功能创建,以便最终将其释放(我们列表中的属性2)。当客户端需要释放资源时,会删除该密钥。

表面上这样做很好,但是有一个问题:这是我们的架构中的一个失败点。如果Redis主人失败会怎么样?好吧,让我们添加一个奴隶!如果主机不可用,请使用它。这不幸是不可行的。通过这样做,我们不能实现我们的互斥的安全属性,因为Redis复制是异步的。

这种模式有明显的竞争条件:

  1. 客户端A获取主机中的锁。
  2. 主机在写入密钥之前发生故障,并将其传送到从站。
  3. 奴隶被提拔为主人。
  4. 客户端B获取对同一资源的锁定A已经拥有锁。安全隐患!

有时完全可以在特殊情况下,如失败时,多个客户端可以同时锁住锁。如果是这种情况,您可以使用基于复制的解决方案。否则我们建议实施本文档中描述的解决方案。

使用单个实例正确实现

在尝试克服上述单一实例设置的限制之前,让我们在这种简单的情况下检查如何正确执行,因为这实际上是一个可行的解决方案,在应用程序中,不时有竞争条件是可以接受的,并且因为锁定单个实例是我们将用于此处描述的分布式算法的基础。

要获得锁,路要走如下:

    SET resource_name my_random_value NX PX 30000

该命令将仅在不存在(NX选项)的情况下设置密钥,并且超时时间为30000毫秒(PX选项)。键被设置为值“我的随机值”。该值在所有客户端和所有锁定请求中必须是唯一的。

基本上使用随机值以便以安全的方式释放锁,并且使用一个脚本来告诉Redis:只有当它存在并且存储在密钥上的值正好是我期望的值时才删除该键。这是由以下Lua脚本完成的:

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

这是为了避免删除由另一个客户端创建的锁重要。例如,客户端可以获取锁定,在某些操作中被阻塞的时间长于锁定有效时间(密钥到期的时间),然后删除已被其他客户端获取的锁定。使用DEL是不安全的,因为客户端可能会删除另一个客户端的锁定。使用上述脚本,而不是每个锁都使用随机字符串“签名”,因此只有当客户端尝试将其删除时,该锁才会被删除。

这个随机字符串应该是什么?我假设它是/ dev / urandom的20个字节,但是你可以找到更便宜的方法来使其独特,足以满足你的任务。例如,安全的选择是使用/ dev / urandom种子RC4,并从中生成伪随机流。一个更简单的解决方案是使用unix时间与微秒分辨率的组合,将其与客户端ID连接起来,它不是那么安全,但可能在大多数环境中的任务。

我们用作关键时间的时间被称为“锁定有效期”。无论是自动发布时间还是客户端为了执行所需的操作,在另一个客户端可以再次获取锁定之前,客户端都有时间,而不会在技术上违反互斥保证,这仅限于给定的窗口从获得锁定的时刻开始。

所以现在我们有一个很好的方式来获取和释放锁。系统,关于由单一的,始终可用的实例组成的非分布式系统的推理是安全的。我们将这个概念扩展到一个我们没有这种保证的分布式系统。

Redlock算法

在分布式版本的算法中,我们假设我们有N Redis主控。那些节点是完全独立的,所以我们不使用复制或任何其他隐式协调系统。我们已经描述了如何在一个实例中安全地获取和释放锁。我们认为算法将使用此方法在单个实例中获取和释放锁。在我们的例子中,我们设置N = 5,这是一个合理的值,所以我们需要在不同的计算机或虚拟机上运行5个Redis主人,以确保它们以最独立的方式失败。

为了获取锁定,客户端执行以下操作:

  1. 它以毫秒为单位获取当前时间。
  2. 它尝试在所有N个实例中顺序获取锁,在所有实例中使用相同的密钥名称和随机值。在步骤2中,当在每个实例中设置锁定时,客户端使用与总锁定自动释放时间相比较小的超时,以获取它。例如,如果自动释放时间为10秒,超时可能在〜5-50毫秒范围内。这样可以防止客户端长时间阻止与Redis节点进行通话:如果一个实例不可用,我们应该尝试尽快与下一个实例通话。
  3. 客户端计算通过从当前时间中减去步骤1中获得的时间戳来获取锁定所需的时间。当且仅当客户端能够在大多数实例中获取锁定(至少3)时, ,并且获取锁定的总时间小于锁定有效时间,则认为该锁被获取。
  4. 如果锁被获取,则其有效时间被认为是初始有效时间减去经过的时间,如步骤3中所计算的。
  5. 如果客户端由于某种原因(无法锁定N / 2 + 1个实例或有效时间为负)而无法获取锁定,则会尝试解锁所有实例(即使是相信它不是能够锁定)。

算法是否异步?

该算法依赖于这样的假设:虽然在整个过程中没有同步时钟,但是每个进程中的本地时间仍然以相同的速率大致流动,并且与锁的自动释放时间相比较小。这个假设非常类似于真实世界的计算机:每台计算机都有一个本地时钟,我们通常可以依靠不同的计算机来实现一个小的时钟漂移。

在这一点上,我们需要更好地指定我们的互斥规则:只要持有锁的客户端将在锁定有效时间内终止其工作(如步骤3中获得的),减去一段时间(仅几毫秒)以补偿进程之间的时钟漂移)。

有关需要绑定时钟漂移的类似系统的更多信息,本文是一个有趣的参考:租赁:分布式文件缓存一致性的高效容错机制

重试失败

当客户端无法获取锁定时,应该在随机延迟之后再次尝试重新尝试重新同步多个客户端尝试同时获取同一资源的锁定(这可能会导致大脑失败的情况,胜利)。客户端尝试获取大多数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没有客户端应该能够重新获得锁。因此,只有当锁定多数的时间大于TTL时间时,多个客户端才能同时锁定N / 2 + 1个实例(“时间”是步骤2的结束),使锁定无效。

您能否提供正式的安全证明,指出现有的类似的算法,还是发现错误?这将非常感激。

生活论证

系统活动基于三个主要特点:

  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脚本来扩展锁定,如果该键存在并且其值仍然是获取锁时客户端分配的随机值。

如果客户端能够将锁扩展到大多数实例,并且在有效时间内(基本上使用的算法与获取锁定时使用的算法基本相同),客户端应该仅考虑重新获取的锁。

然而,这并不技术地改变算法,所以锁定重新获取尝试的最大数目应受到限制,否则其中一个活动属性被违反。

posted @ 2017-04-06 14:28  杨浪  阅读(371)  评论(0编辑  收藏  举报