使用Redis实现分布式锁
本文是在Redis官方文档:https://redis.io/topics/distlock基础上进行翻译和完善。

分布式锁在很多环境中是一种非常有用的原语级别的并发控制设备,它能够为多进程提供一种基于共享资源的排他式工作方式。有许多的库和文章都在介绍如何使用Redis来实现分布式锁,但是每个库都用了不同的方式实现,并且实现方式或多或少都有些小问题。
这篇文档目的是介绍如何能够更好的使用Redis来实现分布式锁,并且提出了一个算法,叫做:Redlock,它能够实现我们认为更加安全可靠的分布式锁。同时也希望社区能够分析它,给出一些反馈。
正确的实现一个单节点Redis的分布式锁
设计分布式锁,至少要考虑的三个点:
- 安全属性(正确性):排他,任意时间一个客户端获取到锁;
- 活性A:不存在死锁(可用性),任何时刻都能够尝试去获取锁,总是获取到锁的客户端挂了;
- 活性B:失败容忍性(可用性),如果大多数的redis节点存在,就能提供锁服务。
为什么基于FailOver的实现存在问题
为了能够明白我们希望提升什么,我们分析一下现有基于Redis的分布式锁。
使用Redis来制作一个分布式锁的最简单方式是通过在一个Redis实例中创建一个KEY,一般这个KEY会设置一个TTL,使用了Redis数据过期的特性,而这将避免出现死锁。当客户端需要释放锁时,只需要删除这个KEY即可。
表面上看起来它能够正常工作,实际也是如此,但是这种方式存在一个问题:它是单点架构。如果Redis实例挂掉怎么办?这将会违反可用性的要求,我们可以通过Redis的主从模式集群来提升可用性,但很不幸,这并不奏效。因为Redis的主从复制是异步的,而当主Redis挂掉后,从Redis的数据KEY存在延迟(或少量丢失),这使得分布式锁的正确性受到影响。
这种方式会有明显的竞争问题:
- ClientA从主Redis获取到锁
- 主Redis崩溃,但KEY还在向从Redis同步中
- 从Redis晋升为主
- ClientB尝试获取锁,并获取到
该过程为违反了分布式锁的正确性。如果在这种不常出现的特定情况下,可以允许短时出现多个客户端在同一时刻占据一把锁的情况发生,那可以选择使用这种基于主从复制的Redis集群来提升可用性。一般在数据库一层或多或少都存在唯一约束的保护,所以在出现问题时,需要数据库承担一定的并发控制工作,这会在短时造成回滚(回滚数量多少视锁的竞争程度而定),需要使用者能够做好评估。否则我们建议按照文档中提到的解决方案来实现分布式锁。
单节点Redis分布式锁正确的实现方式
在介绍克服单点问题的方案之前,先来看一下在单个实例下,如何正确的实现一个分布式锁,事实上,这是一个可行的解决方案,而这种基于单个Redis节点的分布式锁实现方式将会演绎出多个Redis节点的分布式锁实现方式。
为了获取锁,客户端一般会使用如下命令:
SET resource_name my_random_value NX PX 30000
该命令通过NX
选项,确保只有在KEY,也就是resource_name
不存在的情况下才能设置,同时PX选项表示该KEY将会在30000
毫秒,也就是30
秒后过期,而my_random_value
需要在同一时刻做到所有的客户端唯一。
为什么要随机值,且需要保证在同一时刻所有客户端唯一,目的是能够保证安全(通过CAS方式)的释放锁,在Redis命令中,可以通过一段Lua脚本来实现,脚本如下:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
如果直接通过简单的DEL
命令删除KEY来释放锁,会出现其他获取到锁的客户端删除锁,导致分布式锁的正确性被破坏。通过Lua脚本的方式,保证只有客户端传递的值与当前Redis存储的KEY值相等时,才允许删除当前KEY,通过这种方式实现了CompareAndDelete,从而确保不会出现KEY被误删的情况。
如果使用阿里云的RDB缓存服务,可以不使用上述脚本,因为它提供了cad扩展命令。
这个随机字符串应该怎样怎样获得呢?可以通过使用/dev/urandom
产生的随机数据流,但也可以用其他更简单的方式来完成这个工作。比如:通过使用机器信息加上Unix毫秒时间戳的拼接来生成一个字符串,虽然它不一定可靠,但是对于这个场景是足够了。
我们设定的TTL时间可以被称为锁有效时间。它不仅是自动释放的时间,也是其他客户端能够获取到锁之前执行操作的最长时间。现在位置我们有了一个不错的获取和释放锁的方式。当前是一个单点Redis,如果它一直很可靠,该系统也能够正常运作。接下来我们会将这个概念延伸到分布式系统,通过多个Redis节点组成的分布式锁来提升可用性。
Redlock算法
该算法的分布式版本我们假设有N
个Redis主节点,这些节点都是相互独立的,不使用主从复制集群或其他隐含的服务发现系统。我们已经讨论过如何在单节点Redis的环境下进行正确的锁获取和释放操作。我们依旧采用这种方式在单个Redis节点上获取和释放锁,在接下来的算法示例中,假设N
为5
,这是一个合理的值,所以我们需要在不同的计算机或虚拟机上部署5
个Redis示例,理论上它们不会在同一时刻挂掉。
为了获取锁,客户端会按照如下的行为进行操作。
- 获取当前的时间(毫秒);
- 接下来针对
N
个Redis实例使用相同的KEY和随机值顺序获取锁。对于IO超时时间可以设置一个足够短的时间,比如:如果过期时间是10
秒,则超时时间可以设置在5
到50
毫秒的范围,目的是能够在Redis实例挂掉的时候能够快速反应,使得能够与下一个Redis尽快完成交互; - 如果客户端的耗时,从第一步到
N
个实例都调用完成,如果不超过锁的过期时间,并且在获取到锁的Redis实例数量大于等于3
个,则认为该客户端获取到了锁; - 如果锁获取到了,那么这个分布式锁的占用时间可以认为是初始的过期时间,比如:
10
秒,减去获取锁的耗时; - 如果客户端没有获取到锁,比如:获取到锁的实例数量是少数,获取在步骤3中的耗时已经超过了锁的过期时间,客户端将会选择释放所有节点的锁,纵使客户端它没有获取到对应节点的锁,这出于简单性的考虑。
该算法是异步的吗?
该算法假设在整个分布式系统中不存在同步时钟,但是依旧认为所有的计算节点拥有相近的时间,并且以基本一致的频率跳动。这个假设很像真实的计算机世界,每台计算机都有一个本地始终,我们各自依赖它,所幸它们之间的时间差很小,小到可以忽略。
在这点上我们可以在算法规则上作出一些调整,可以认为获取到锁的实例拥有锁的时间是在超时时间减去获取锁耗时的基础上,再减去若干毫秒用于补偿上述的时间差。
失败重试
当客户端无法获取到锁时,它应该尝试随机等待一下,然后再次尝试获取,由于是获取到多数的Redis实例,所以如果以相同频率再次获取,可能多个客户端会进入没有人能赢的尴尬局面。所以越快醒来的客户端,就更有机会获取到锁,同时客户端应该尝试使用多路复用将SET
命令同时发往N
个实例。
值得注意的是,如果客户端获取到少数的Redis实例锁,在获取分布式锁失败的前提下,释放获得的锁也是很重要的。因为它会使得对分布式锁的获取可以立刻继续,而不用等待到过期,但如果发生网络问题或者客户端与Redis实例断开链接,那就要承担这个影响。
释放锁
释放锁非常简单,只需要对所有Redis实例进行释放即可,不需要考虑当前客户端是否获取到锁。
正确性保证
这个算法的正确性有保证吗?我们尝试从多个不同场景来理解它。
在开始之前,我们假设客户端已经获取到了多数Redis实例的锁,这时,所有的多数Redis实例上都会具有一个KEY且设定了相同的TTL,也就是过期时间。但是这些KEY是在不同时间设置到实例上的,所以这些KEY也会在不同的时间过期。假设第一个设置的KEY在T1(发送命令给Redis实例前),最后一个设置的KEY在T2(Redis实例返回响应之后),我们可以肯定第一个KEY过期的时长至少在MIN_VALIDITY = TTL - (T2 - T1) - 时钟差
。我们在当前这个时刻设置了KEY,能够确保其他实例上的KEY一定比这个时长更晚之后才会过期。
在多数实例的KEY被设置后,其他的客户端将无法获取到锁,因为其他客户端无法完成设置多数实例的KEY,毕竟之前的KEY在多数的Redis实例上存在,这是满足锁的排他性的。
我们需要保证多个客户端不能同时获取到锁,也就是维护锁的排他性语义,这是确保正确性的基础。如果客户端获取到多数Redis实例的锁,花费的时间大于TTL,则考虑获取锁失败,并释放锁。因此我们仅需要考虑客户端在TTL之内获取到锁的情况。在这种情况下,MIN_VALIDITY内,其他客户端不能够获取到锁,在这个时间内,锁是安全的。
可靠性保证
系统的可用性主要基于以下三点:
- 通过过期时间来保证锁的自动释放,最终锁一定能够能够被获取;
- 事实上对于大多数释放锁的情况都是要么获取或者没有获取到锁,客户端主动的释放锁,而等待超时到自动释放的情况非常少;
- 客户端等待重试获取锁的时间要远比获取多数锁所花费的时间长,这样使得多个客户端出现无效竞争的几率很低。
但是在网络分区或者出现问题时,将会付出需要等待KEY过期时间才能继续获取锁的代价,这种使用KEY的过期时间来做到避免死锁的方式,其可靠性和网络环境的可靠性基本一致。
性能与故障恢复
许多将Redis用作锁服务器的用户在获取和释放锁的延迟以及每秒可以执行的获取/释放操作数量方面都需要高性能。为了满足这一要求,与N
个Redis节点的通信应该采用多路复用(即使用非阻塞IO的方式进行通信,将请求全部发出,然后等待返回)。
如果我们关注一个可以恢复的系统结构,那么还需要关注持久化。
可以看到的问题就是如果没有配置Redis的持久化。一个客户端获取到了多数(3/5)Redis的锁资源,但其中一个拥有锁资源的Redis实例被重启,此时另一个客户端发起获取锁的操作,就有概率形成多数,这使得锁的正确性没有得到保证。
如果我们启用AOF持久性,情况会有很大的改善。例如,我们可以通过发送SHUTDOWN
并重新启动服务器来升级服务器。由于Redis过期是在语义上实现的,因此服务器关闭时几乎时间仍然流逝,这不会产生任何问题。只要是正常的启停,一切都很好,但如果实例断电怎么办?如果默认情况下,Redis配置为每秒钟在磁盘上fsync
,重新启动后我们的部分KEY可能会丢失。当然我们为了要保证能够面对任何情况的实例重启行为,可以在持久化配置中开启fsync=always
,但这会极大的损伤性能,因为这和使用传统关系数据库来实现分布式锁差别不大了。
然而,事情比乍一看要好。只要实例在崩溃后重新启动,它就保留了算法安全性,因为它不再参与任何当前获取锁的活动,其只保留了旧有的锁信息。因此实例重新启动时的当前锁的集合都是通过锁定正在重启的Redis实例以外的实例获得的。
为了使上述的内容更加可靠,我可以将一个Redis实例的重启时间适当延长,长到超过我们设置的TTL,这样当它恢复后重新加入集群,所有在该实例上锁的KEY都已经过期,使之不回产生副作用。使用重启延迟的方式可以使得在没有足够Redis持久化保障的前提下,能够提供当前解法以足够的可靠性保障。但如果一个集群中的多数Redis在同一时刻都挂掉,这将会使整个系统陷入不可用的状态。