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

1,890 阅读14分钟

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

在很多不同进程必须以相互排斥的方式竞争分片资源的情况下,分布式锁是非常有用的原始功能。

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

实现

现在已经有很多的基于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同时获取了锁,违反了安全性)

当然如果你允许两个客户端同时持有锁,那么这种方案是可行的。否则的话建议使用官方推荐的分布式锁方案。

单点的正确实现

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

为了获得锁,可以这么做:

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脚本来扩展锁定,如果密钥存在且其值仍然是获取锁定时客户端分配的随机值。 如果能够将锁扩展到大多数实例,并且在有效时间内,基本上使用的算法与获取锁时使用的算法非常相似。

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