使用Redis的分布式锁

哈Ha!



今天,我们提请您注意有关使用Redis实现分布式锁的复杂文章的翻译,并建议讨论作为主题的Redis的观点。从马丁Kleppman,这本书“高负荷笔者认为Redlock算法的分析应用”,给出了这里





分布式锁是非常有用的原语,用于许多环境中,其中不同的进程必须以互斥的方式在共享资源上工作。



有很多库,其中描述了如何使用Redis实现DLM(分布式锁定管理器),但是每个库都采用不同的方法,并且与较复杂的设计相比,所提供的保证较弱。



在本文中,我们将尝试描述一个条件规范的算法,该算法演示如何使用Redis实现分布式锁。我们将讨论一种称为Redlock的算法,它实现了分布式锁管理器,并且在我们看来,该算法比传统的单实例方法更安全。我们希望社区能够对其进行分析,提供反馈并将其用作实施更复杂或替代项目的起点。



实现



在继续描述算法之前,这里有一些现成实现的链接。它们可以用作参考。







安全性和可用性保证



我们将仅使用三个属性来对设计进行建模,我们认为这三个属性可提供有效使用分布式锁所需的最低保证。



  1. 安全属性:互斥。一次只能有一个客户端持有一把锁。
  2. 可访问性属性A:无死锁。最后,即使锁定资源的客户端发生故障或最终出现在另一个磁盘段中,您也始终可以获得锁。
  3. 辅助功能属性B:容错。当大多数Redis节点运行时,客户端能够获取和释放锁。




为什么在这种情况下基于故障转移的实现还不够?

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



使用Redis锁定资源的最简单方法是在实例上创建密钥。通常,密钥是在有限的生命周期内创建的,这是通过Redis中提供的expires功能实现的,因此迟早会释放此密钥(我们列表中的属性2)。当客户端需要释放资源时,它将删除密钥。



乍一看,这种解决方案似乎有效,但是存在一个问题:我们的体系结构中存在单点故障。如果主Redis实例失败,会发生什么?然后添加一个关注者!如果主机不可用,我们将使用它。不幸的是,此选项不可行。这样,我们将无法正确实现安全性所需的互斥属性,因为Redis中的复制是异步的。



显然,在这种模型中出现了竞争条件:

  1. 客户端A获得了对主服务器的锁定。
  2. 在将密钥的写入转移到从属设备之前,主设备发生故障。
  3. 追随者被提升为领导者。
  4. 客户端B获得对已经由A锁定的同一资源的锁定。安全违规!




有时在特殊情况下(例如故障),多个客户端可以同时持有一个锁,这是完全正常的。在这种情况下,您可以应用基于复制的解决方案。否则,我们建议本文中介绍的解决方案。



正确的单实例实现



在尝试克服上述单实例配置的缺点之前,让我们弄清楚如何处理这种简单情况,因为在竞争条件偶尔可接受的应用程序中,这实际上是可以接受的,但是还因为锁定单个实例是此处描述的分布式算法的基础。



要获取锁,请执行以下操作:



SET resource_name my_random_value NX PX 30000



此命令仅在密钥不存在时才安装密钥(选项NX),密钥的到期日期为30,000毫秒(选项PX)。键设置为“ myrandomvalue”。此值在所有客户端和所有锁定请求中必须唯一。

基本上,随机值用于安全释放锁,脚本会告诉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相结合。它并不那么安全,但在大多数情况下也许处于挑战的水平。



我们用来衡量密钥生存期的时间称为“锁定到期时间”。此值既是自动释放锁定之后的时间,也是客户端必须完成操作之前另一个客户端可以依次锁定资源而不实际违反互斥保证的时间。这种保证仅限于从获取锁的那一刻开始的特定时间窗口。



因此,我们已经讨论了获取和释放锁的好方法。该系统(如果我们正在谈论由单个且始终可用的实例组成的未分配系统)是安全的。让我们将此概念扩展到我们没有这样的保证的分布式系统中。



重锁算法



该算法的分布式版本假设我们有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实例上的锁的速度越快,发生裂脑情况的窗口就越窄(并且所需的重试次数也就越少)。因此,理想情况下,客户端应尝试使用多路复用同时向N个实例发送SET命令。



这里值得强调的是,对于那些无法获取大部分锁的客户端来说,释放(部分地)获取的锁非常重要,这样,客户端就不必等待密钥过期日期就可以再次获取资源上的锁(即使发生网络碎片)并且客户端失去与Redis实例的联系,则必须在密钥预计过期时支付访问冲突罚款)。



释放锁



释放锁是一个简单的操作,需要您简单地解锁所有实例,而不管客户是否认为他们能够成功锁定特定实例。



安全注意事项



算法安全吗?让我们尝试想象一下在不同情况下会发生什么。



首先,让我们假设客户端能够在大多数实例上获得锁。每个实例将包含一个对每个人都具有相同生存期的密钥。但是,每个密钥都是在自己的时刻安装的,因此它们将在不同的时间到期。但是,如果第一个密钥的安装时间不低于T1(在与第一台服务器联系之前我们选择的时间),而最后一个密钥的安装时间不低于T2(从最后一台服务器收到响应的时间),那么我们确保将过期的集合中的第一个密钥至少持续使用MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT...所有其他密钥将在以后过期,因此我们可以确保所有密钥至少在这次同时有效。



在大多数键保持有效的时间内,另一个客户端将无法获取该锁,因为如果已经存在N / 2 + 1个键,则N / 2 + 1 SET NX操作将无法成功。因此,如果获取了锁,则不可能同时重新获取它(这将违反互斥的属性)。

但是,我们要确保同时尝试获取锁的许多客户端不能同时成功。



如果客户端已锁定大多数实例,并且花费的时间超过或超过此时间的最大锁定持续时间,则它将使锁定无效并取消阻止实例。因此,我们只需要考虑客户端能够在不到到期日期的情况下阻止大多数实例的情况。在这种情况下,对于以上参数,MIN_VALIDITY没有客户端应该能够及时重新获得锁定。因此,只有在锁定多数客户端的时间长于TTL时间(这会使锁定无效)时,多个客户端才能够在相同的时间量内锁定N / 2 + 1个实例(在阶段2结束时结束)。



您是否可以提供正式的安全性证明,指出现有的类似算法或在上面发现错误? 可用性



注意事项



系统可用性取决于三个主要特征:



  1. 自动释放锁定(随着密钥过期):最终,密钥将再次可用以用于锁定。
  2. 客户通常在未获得或已获得所需锁且工作完成时通过移开锁来互相帮助的事实;因此,很可能我们不必等待密钥到期就可以重新获取锁。
  3. 当客户需要重试以获取锁时,其等待时间要比获取大多数锁所花费的时间更长。这减少了争夺资源时大脑分裂的可能性。




但是,您必须为等于网络段中TTL时间的可用性降低而付出代价,因此,如果存在连续的网段,则此代价可能会变得不确定。每当客户端获取一个锁,然后在释放该段之前夹住另一个段,就会发生这种情况。



原则上,给定无限连续的网段,系统可以在无限长的时间内保持不可用状态。



性能,故障转移和fsync



许多人使用Redis是因为他们需要在获取和释放锁所需的延迟级别以及每秒可以执行的这种获取/释放操作的数量方面提供锁服务器的高性能。为了满足此要求,存在与N个Redis服务器的通信策略以减少延迟。这是一种多路复用策略(或穷人的多路复用,它使套接字处于非阻塞模式,发送所有命令,并在以后读取命令,假定客户端与每个实例之间的往返时间相似)。



的确,如果我们要创建一个在故障后能够可靠恢复的模型,还需要考虑长期的数据存储。



基本上,为了澄清这个问题,我们假设我们在配置Redis时根本没有长期数据存储。客户端设法阻止5个实例中的3个。客户端设法阻止的一个实例被重新启动,此刻,同一资源我们又可以阻止出现3个实例,而另一个客户端又可以阻止重新启动的实例,从而违反了安全属性,这意味着排他锁。



如果启用数据提前(AOF),情况将略有改善。例如,您可以通过发送SHUTDOWN命令然后重新启动服务器来升级服务器。由于Redis中的到期操作是通过语义方式实现的,即使关闭服务器电源,时间也会继续流逝,因此一切都可以满足我们的所有要求。只要可以确保正常关闭,就可以正常。停电该怎么办?如果Redis是默认配置的,并且fsync每秒钟在磁盘上同步一次,那么重新启动后很可能会丢失密钥。从理论上讲,如果我们要保证实例重新启动时的安全性,则必须启用fsync=always在长期数据存储设置中。这将完全破坏性能,达到传统上用于安全实施分布式锁的CP系统的水平。



但是这种情况总比让人眼前一亮。原则上,该算法保持安全性,因为当实例在失败后重新启动时,它不再参与任何当前活动的锁。



为确保这一点,我们只需要确保在发生故障后,该实例在一段时间内仍然不可用,该时间会略超过我们使用的最大TTL。因此,我们将等待失效时激活的所有键的过期和自动释放。



通过使用延迟重新启动,原则上可以在Redis中没有任何长期持久性的情况下实现安全性。但是请注意,这可能会导致可访问性损失。例如,如果大多数实例失败,则系统将在TTL时间内变为全局不可用(并且此时无法阻塞任何资源)。



增加算法的可用性:扩展锁



如果客户完成的工作很小,则可以缩短默认的锁到期时间并实现锁延长机制。基本上,如果客户端忙于计算,并且锁的到期值非常危险,那么可以在Lua中向所有实例发送脚本以扩展键的TTL(如果键仍然存在),并且在获取锁时其值仍然是随机获得的。



客户只有在有效期内成功锁定了大多数实例后,才应将其视为重新获得的锁定。



但是,从技术上讲,在这种情况下算法不会更改,因此应限制重复尝试获取锁的最大次数,否则将违反可访问性属性。



All Articles