Redis 分布式锁

分布式锁在许多环境中得到应用,例如不同的进程必须以互斥的方式对共享资源进行操作。
有许多库和博客文章描述了如何使用Redis实现DLM(分布式锁管理器),但是每个库都使用不同的方法,而且许多库使用简单的方法,与使用稍微复杂一点的设计相比,安全性更低。

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

实现

在描述算法实现之前,这里有一些已经可用的实现连接,可作为参考。

  • Redlock-rb (Ruby 语言实现). 还有一个Redlock-rb 分支,其添加实现了简单的分布式和更多其他功能。
  • Redlock-py(Python 语言实现 ).
  • Aioredlock (异步IO Python 实现).
  • Redlock-php (PHP implementation语言实现).
  • PHPRedisMutex (深度 PHP 语言实现) cheprasov/php-redis-lock (PHP library for locks)
  • Redsync (Go语言实现).
  • Redisson (Java 语言实现).
  • Redis::DistLock (Perl 语言实现).
  • Redlock-cpp (C++ 语言实现).
  • Redlock-cs (C#/.NET 语言实现).
  • RedLock.net (C#/.NET 语言实现). 包括异步和锁拓展支持.
  • ScarletLock (C# .NET 语言实现:可配置数据库)
  • Redlock4Net (C# .NET 语言实现)
  • node-redlock (NodeJS 语言实现). 包括锁拓展支持。

安全和活性保证

我们打算从三个性能方面模型化我们的设计,我们的观点是,以有效的方式最小保证分布式锁的实现。

  • 安全性,互斥原则。在任何给定的环境下,只能有一个用户持有锁。
  • 存活性A:死锁释放。事实上,锁是永远可能被获取到的,即使客户已经锁定了资源或者分区。
  • 存活性B:容错。只要redis的主节点在运行,客户端就可以获取锁和释放锁。

为什么基于错误回滚的实现还是不够?

要理解我们想要改善什么,让我们首先分析下大部分基于Redis分布式锁库的事物状态。

锁定一个资源的最简单的方法是在一个实例中创建一个key,这个key通常被限定了存活时间,应用redis的过期属性,因此最终key会被释放(对应性能2),当客户端需要释放资源时,可以删除key。

表面上看,这会工作很好,但是存在一个问题:就是在我们的结构中的单点失败。如果主redis挂了,会发生什么呢?好吧,让我们建立一个slave redis,当主redis不可用的时候,代替它工作。不幸的是,这并不可行,若是这么做了,我们无法实现安全性里的互斥原则,因为redis响应是异步的。

改原型的有一个明显的竞态条件:

  1. 客户端A从主redis里获取锁
  2. 主redis在把key传给slave之前挂了
  3. slave升级为主redis
  4. 客户端B再次获取A应持有的锁,违反了安全互斥原则!!!

有时,在特殊情况下(比如故障期间),多个客户机可以同时持有锁,这是完全没问题的。如果是这种情况,可以使用基于复制的解决方案。另外,我们建议实现本文中描述的解决方案。

单例模式的正确实现

在试图克服上述限制单一实例设定之前,我们先检查一下怎样正确的建立一个单例模式,在这个示例中,因此这实际上是一个可行的解决方案的应用程序中不时竞态条件是可以接受的,因为锁定单个实例是我们使用这里描述的分布式算法的基础。

获取锁的做法是:

 SET resource_name my_random_value NX PX 30000

这个命令会创建一个key仅当这个key不存在时,过期时间是30000毫秒,key对应的value值是“my_random_value”。在所有客户端调用和锁请求时这个value值必须是唯一的,基础上,利用随机值来保证释放锁是一个安全的方式。写一个脚本告诉redis:删除key如果key存在,并且key里存储的值是我想要的值,这可以通过下面的Lua脚本实现:

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

避免删除其他客户端创建的key很重要,例如,一个客户端获取了锁,在一些操作中阻塞时间比锁验证时间还长(key已经过期了),然后移除锁,但是这个锁已经被其他客户端获取到了。由于客户端删除的可能是其他客户端的锁,因此仅仅用DEL是不安全的,应用上面的脚本,每个锁都被标记了一个随机的字符串,因此只有当它仍然是客户端设置的值时,才会尝试删除锁。

我们应该怎样定义这个随机的字符串呢?我用一个20字节的随机字符,当然你可以在你的任务中用更简便的/dev/urandom方式保证它的唯一性。例如,一个安全的选择是用/dev/urandom播种RC4,并从中生成一个伪随机流。一种更简单的解决方案是结合使用unix时间和微秒分辨率,并将其与客户端ID连接起来,这样做不太安全,但在大多数环境中足以完成任务。

key的存活时间,被称为锁“有效时间”,它既能自动减时,既能客户端持有的时间用来演示操作获取在另一个客户端再次获取锁之前,没有违反互斥技术保证,它只是在获取锁的时候限制了时间窗口的给定。

因此现在我们有了一个很好的方式去获取和释放锁。该系统是安全的,它推理出一个由单个、总是可用的实例组成的非分布式系统。让我们将这个概念扩展到一个分布式系统,在这个系统中我们没有这样的保证。

RedLock算法介绍

在分布式版本的算法中,我们假设有N个Redis master,这些节点是完全独立的,所以我们不使用复制或其他隐式协调系统。我们已经描述了在单例模式下怎样安全的获取和释放锁,我们想当然地认为算法将使用此方法在单个实例中获取和释放锁。在我们的示例中,设定N=5,一个很合理的值,因此我们需要启动5个redis在不同的服务器或者虚拟机中,以确保他们可以在完全独立的情境下失败。

为了获取锁,客户端进行下列操作:

  1. 获取毫秒级的当前时间,
  2. 它尝试在所有N个实例中顺序获取锁,在所有实例中使用相同的键名和随机值,在步骤2中,当在每个实例中设置锁时,客户端使用一个超时,这个超时与自动释放锁的总时间相比很小,以便获取锁。例如,自动释放时间是10s,timeout时间应该在5~50毫秒之间,这样做防止了客户端尝试与一个已经关闭的redis节点会话的长期阻塞。如果一个实例不可用,我们应该尽快与下一个实例进行对话。
  3. 客户端通过从当前时间减去第1步中获得的时间戳来计算获得锁所需的时间,当且仅当客户端能够在大多数实例(至少3个)中获取锁,并且获取锁所需的总时间小于锁的有效时间时,该锁就被认为已被获取。
  4. 如果获取了锁,则其有效性时间被认为是初始有效性时间减去经过的时间,如步骤3中计算的那样。
  5. 如果客户端由于某种原因未能获得锁(它无法锁定N/2+1个实例,或者有效性时间过期),它将尝试解锁所有实例(甚至是它认为无法锁定的实例)。

算法异步么?

该算法基于这样的假设:虽然进程之间没有同步时钟,但每个进程的本地时间流动速度大致相同,与锁的自动释放时间相比,误差很小。这个假设与现实世界的计算机非常相似:每台计算机都有一个本地时钟,我们通常可以依靠不同的计算机来获得一个很小的时钟漂移。
在这一点上我们需要更好的指定互斥规则:这是保证只要客户持有的锁锁有效期内将终止其工作时间(在步骤3中获得)减去一段时间(几毫秒为了弥补不同进程之间的时钟漂移)。

有关需要限制时钟漂移的类似系统的更多信息,这篇文章就是一个有趣的参考:Lease:一种高效的容错机制,用于分布式文件缓存一致性。

失败重试机制

当一个客户端无法获得锁时,它应该在一个随机延迟之后再次尝试,以实现去同步,即避免多个客户端试图在同一时间获取同一资源的锁(这可能会导致没有人获胜)。当然,一个客户机在大多数Redis实例中越快试图获得锁,窗口的大脑分割条件就越小(需要重试),所以理想的客户端应该使用多路复用机制试着在同一时间发送Set命令到N个实例中。

值得强调的这点很重要,即当客户端未能获得多数的锁,尽快释放(部分)获取了的锁,所以不需要等待key到期为了再次获取锁(然而如果网络分区发生故障,客户端不再能够与Redis实例通讯,那就只能等待密钥过期)。

释放锁

释放锁是简单的,关联到所有实例的锁释放,不管客户端是否相信它能够成功地锁定给定的实例。

安全参数

算法安全吗?我们可以尝试理解一下在不同的场景中发生了什么。

开始我们假设实例可以在集群中获取锁,所有的实例都将包含一个存活相同时间的key。然而,key的设置是在不同的时间,因此key的过期也是在不同的时间,但是如果第一个key的最差设置时间是T1(我们简单点得认为这个时间在与第一个server链接的时间之前),最后一个key的最差设置时间是T2(我们从最后一个server取得回复的时间),可以确定的是第一个key的过期时间是: MIN_VALIDITY(最小有效时间)=TTL-(T2-T1)-CLOCK_DRIFT(时钟漂移),其他的key值随后过期,因此我们可以确定的是,所有的key值至少在这个时间点会被同时设定。

在key值得时间设定期间,其他的客户端是无法获取key的,由于N/2+1 SET NX 操作不能成功如果N/2+1 个keys值已经存在。因此如果一个锁应被获取,在同一时间是不能再次被获取的(保证了互斥原则)。

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

如果一个客户端锁定大部分实例的时间等于或者大于锁的最大有效时间,将会考虑锁失效并且释放实例,因此我们仅需要考虑如下情境:客户端锁定大部分实例,并且小于有效时间。在这种情境下,如上面已经描述的参数MIN_VALIDITY,没有客户端可以再次获取锁。因此,只有当锁定大多数实例的时间大于TTL时间时,多个客户机才能同时锁定N/2+1个实例(第2步的末尾是“time”),从而使锁定无效。

您是否能够提供安全性的正式证明、指出现有的类似算法或发现缺陷?非常感谢。

活性参数

系统的活性基于3个主要特性:

  • 自动释放锁(当锁过期时):最终keys可以再次被锁定
  • 通常的,事实上客户端会协助删除不可达的锁,或者当锁被获取并且工作时间终止时,让它看起来像我们不需要必须等待keys的过期去重新获取锁。
  • 事实上,当一个客户端需要重试一个锁时,它等待的时间比获取大多数锁所需的时间要长,为了在资源争用期间不太可能出现大脑分裂的情况。

但是,在网络分区上,我们付出的可用性代价等于TTL时间,因此,如果存在连续分区,我们可以无限期地付出这个代价。每当客户端获得锁并在能够删除锁之前被分割时,就会发生这种情况。

基本上,如果有无限连续的网络分区,系统可能会在无限长的时间内不可用。

性能、崩溃恢复和数据同步

阅读原文:https://redis.io/topics/distlock

你可能感兴趣的:(Redis)