用Redis实现分布式锁

写在前面:
文章写的确实不错, 很多关于分布式锁的细节,看完这篇文章瞬间就明晰了, 但是翻译水平确实有限, 难免有谬误, 望诸位看客海涵, 同时推荐有能力的同学去阅读原文

一个基于 Redis 的分布式锁设计模式

分布式锁在那些需要对资源进行独占访问的场景里起着至关重要的作用

现在已经有很多的开源框架和文章来告诉我们该如何利用 Redis 实现一个分布式锁管理器 DLM(Distributed Lock Manager), 但是实现的方式五花八门,而且大部分使用的方法都比较简单, 无法保证可靠性, 其实我们只需要采用稍微复杂一点的设计就可以大幅提高可靠性

现在我们将用一个公认更加成熟的算法来实现一个分布式锁。 我们提出了一个算法, 我们称它为"Redlock", 它实现了一个比 vanilla 单点方法更安全的分布式锁管理器。 我们希望大家能测试一下它, 给我们提供反馈, 并且以此为基础来实现更复杂的设计模式。

安全性和系统活力保证

我们将我们的设计总结出三个属性, 在我们看来, 这些是分布式锁系统有效运转的最低保证。

安全: 任何时间点,只有一个客户端可以持针对某一特定资源的锁

活力 A: 无死锁。 任何客户端在任何时候都可以获取锁, 即使某些持有锁的客户端挂掉或者出现网络隔离

活力 B: 容错, 只要超过半数的 Redis 节点存活, 客户端就可以获取和释放锁

为什么主备形式的实现方式不能满足需求?

为了让大家明白我们改进了什么, 让我们来分析一下当前大部分以 Redis 为基础的分布式锁的问题

用 Redis 来当锁, 最简单的方式就是在 Redis 实例中创建一个 key, 创建 key 的时候通常会给与这个 key 一个存活时间, 这通过 Redis 的过期机制就可以实现, 最终这个锁会被释放掉(我们列出的属性 2)。当客户端需要手动释放这个锁时, 只需要删除这个 key 就好

表面上看这一切很不错, 但是这里有一个问题: 这将成为我们整个架构中的一个单点。 如果这个 Redis 实例挂掉了怎么办? 你可能会说, 添加一个备用节点! 如果主节点挂掉了那就让备用节点顶上。非常可惜,这行不通。 如果这么做我们将无法保证我们前面提到的安全性(同一时间只有一个客户端持有锁), 因为 Redis 的实例间的数据同步是异步的。

如果这么做将会出现竞锁的情况:

客户端 A 从主节点那里获得了锁。

主节点在将数据同步到备用节点之前挂掉了

备用节点此时变成了主节点

客户端 B 此时获取同一资源的锁。冲突发生

有时候这无所谓, 在某些特定场景下,比如在有节点失效的情况下,多个客户端可以同时持有相同资源的锁。 如果你能接受这种情况, 那使用主备方案没什么问题。否则, 我们建议你采用我们接下来的方案。

单实例情况下正确实现

在克服我们上面提到的单点问题之前, 让我们先来看一下如何正确实现单实例模式下的锁, 因为如果我们可以接受竞锁,这也是一种可行方案, 而且单实例锁的实现也是我们接下来要讲的方案的基础

获取锁,我们只需要执行下面的命令:

SET resource_name my_random_value NX PX 30000

上面的命令会在 resource_name 不存在的情况下设置 resource_name 和 my_random_value(NX 参数), 同时设置 30000 毫秒的时间限制。这里的 my_random_value 必须要做到对所有客户端和获取锁的请求来说都是唯一的

之所以要让这个值唯一,是因为我们需要用它来保证锁的安全释放, 下面的脚本告诉 Redis, 只有当 key 值存在且 value 与我们提供的值相同的情况下才能删除该 key

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

这对于防止误删其他客户端创建的锁很关键。 比如一个客户端获取了锁, 但是它对资源的操作超过了 key 的存活时长, 这时锁因为超时被释放了, 等它完成工作再释放这个锁的时候,它会释放掉别人获取的锁(因为对于某一资源,锁的 key 是相同的)。所以简单的 DEL 操作是无法保证锁安全释放的, 通过给每个锁(key)一个唯一的随机值,再利用上面的脚本来进行锁的释放,可以保证客户端只能释放它所获取的锁

那这个随机值该怎么取呢?我们可以每次取/dev/urandom 前 20 字节的, 当然, 你还可以通过其他更简单的方法来获取,只要保证在你的业务中唯一即可。例如, 把/dev/urandom 的值拿出来作为 RC4 的初始化种子, 然后用它来生成伪随机数。一个更简单的办法是利用 UNIX 时间戳精确到毫秒级别,将时间戳与客户端 id 进行拼合, 这不是绝对安全,但是对于大多数应用场景足够了。

锁的有效时长实际就是我们赋予 key 值的存活时长。 它即是自动释放的时长限制,同时也是客户端对资源进行操作的时间限制。

现在我们完成了锁的设计, 该系统由一个实例组成,如果该实例不会挂掉,那这个系统就可以认为是安全的。现在让我们把该设计扩展成一个分布式的系统。

Redlock 算法

在分布式的版本中,我们假设有 N 个 Redis 的主节点. 这些节点是完全独立的, 不会使用任何备用节点或者其他同步系统. 在前面我们已经描述了如何在单一节点上获取和释放锁, 在分布式版本中我们依然会沿用这一方式。在我们的例子中,我们把 N 设为 5, 这是一个比较合理的数字, 我们需要分别在 5 台物理机或者虚拟机上运行 5 个 Redis 的主节点, 以此保证如果他们其中的某几台挂掉,不会影响到其他节点的运行

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

获取当前时间, 精确到毫秒

用相同 key 和随机 value 依次向这 N 台实例发送获取锁的请求。在这个过程中, 客户端需要对请求加上超时限制, 该超时限制要远远小于锁的存活时间。假设自动释放时长为 10 秒, 那超时限制可以大约为 5 到 50 毫秒。这样可以防止客户端卡死在与挂掉的节点通讯上。如果某个节点没有响应, 我们应该尽快的去请求下一节点。

客户端在请求锁的时候需要计算当前已经流逝的时间, 这个可以通过用当前时间减去步骤 1 获取到的时间来得到。只有客户端成功从半数以上的实例(在本例中为 3)获取成功, 且获取的总耗时小于锁的有效时长, 才被认为是成功获取了锁。

如果锁成功获取了, 那么它实际的有效时长则为初始有效时长减去获取锁过程中的时间消耗, 就像步骤 3 中计算的那样。

如果客户端因为某种原因获取锁失败了(不管是没有成功获取半数还是实际有效时长变成了负数), 它都要在所有的节点上释放锁, 不管该节点是不是获取成功

该算法是异步的吗?

这个算法建立在一个假设之上, 那就是整个获取过程中没有可用的同步时钟, 且每个获取过程中, 本地时间都在以相同的速率更新(这地方有点拗口, 我的理解是, 我们没有一个所有节点和客户端都认可的同步时钟可供参考, 但是整个获取锁的过程中因为我们都是在同一个客户端上进行操作,所以时长是可信的, 因为一台机器的时钟不会无缘无故的变快或者变慢太多, 所以时间点不可信,但是时长可信), 所以本地时间相对于自动释放时间的误差可以忽略不计。该假设比较符合现实中的计算机系统: 每台计算机都有一个本地时钟并且我们通常依赖于不同计算机之间的时钟漂移差(clock drift 我也不知道该怎么翻译,应该指的是不同的两个时钟之间的走速差异)不大

现在是时候进一步解释一下我们的互斥独占规则了: 互斥性只有在客户端获取到锁且在锁的有效时长之内完成它的工作才能得到保证, 这个有效时长是指的从步骤 3 中计算得到的时长然后减去几毫秒(补偿不同获取过程之间的 clock drift)得到的时长

如果想进一步了解 clock drift, 建议读一下这篇文章

重试

当客户端无法获取到锁, 它应该在一段随机延迟之后重试, 之所以加随机延迟是为了能防止多个客户端同时获取一个锁的情况发生(这有可能会导致脑裂情况的发生). 客户端越快的获取到半数以上, 脑裂发生的时间窗口也就越小, 需要重试的可能性也就越小, 所以理想的客户端应当使用多路复用同时向 N 个节点发送 SET 命令。

在这里要强调一下, 如果客户端没有成功的获取半数以上, 一定要尽快的释放掉那些已经获取的锁,只有这样才不需要等待锁超时释放之后再进行再次获取(但是如果出现了网络隔离,客户端与已获取到锁的节点之间无法进行通讯,那这种等待超时释放的惩罚就不可避免了)

锁的释放

释放锁的操作比较简单, 而且该操作可以对任意节点执行,不管客户端是否真的在该节点获取到锁

安全扩展

这个算法安全吗?让我们检查一下不同场景下会发生什么

我们先从客户端成功获取半数以上锁的情况开始。 所有成功获取的节点都会保存一个有效时长相同的 key。但是设定这些 key 的时间点不同,所以这些 key 会在不同的时间失效。但是如果第一个 key 设置在最晚 T1(我们在与第一个节点通信之前获取的时间点)失效, 最后一个 key 被设置在最晚 T2(我们从最后一台服务器收到响应的时间)时间点失效, 我们可以确定这些 key 里面第一个失效的至少可以存在 MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT。 因为所有其他的 key 的失效时间都将晚于这个时间点, 所以我们可以认为这些 key 被同时 set,其有效时长不小于 MIN_VALIDITY

当有客户端获取了半数以上的锁,其他客户端将无法获取该锁, 因为 N/2+1 个 SET NX 成功操作此时是无法达成的(因为已经至少有 N/2+1 个 SET NX 操作成功了), 所以如果一个全面锁被获取了, 是不可能重复获取该锁的

然而我们还有个问题要解决, 那就是在多个客户端同时获取同一个锁的时候,不能有同时成功

如果一个客户端获取了半数以上的锁, 但是获取过程用时接近甚至长于锁的最大有效时长(基本可以认为就是我们设置的 TTL), 我们认为该锁是无效的,并且会释放该锁, 所以我们只需要考虑那些获取所消耗的时长小于锁的有效时长的情况, 此时, 就像上文我们提到的, 在 MIN_VALIDITY 时长内, 没有客户端可以重新获取该锁。 多个客户端在同一时间获取同一个锁并且能够同时获得 N/2+1 个锁的情况,只有获取锁的时间消耗长于锁的有效时长的时候才会出现, 而在这种情况下获得的锁是无效的

活力扩展

系统的活力主要由以下三个方面决定:

锁的自动释放(key 过期): 最终锁被释放变成可获取的状态

大多数情况下,客户端会在没有成功获取到全面锁的情况下去释放已获取的部分锁,如果成功获取,客户端也会在完成自己的工作后主动释放锁, 所以我们基本上不需要去等待锁过期来重新获取

当客户端需要进行重试的时候,它会等待一个比获取半数以上锁所需的时间稍长的时长, 以此来尽可能降低脑裂情况发生的可能性

但是如果发生网络隔离,我们就必须等待与锁的有效时长相等的时间了, 所以如果发生了持续的网络隔离, 我们要付出的代价将是不确定的。 这会在客户端获取了锁,但是在释放锁之前发生了网络隔离的情况下发生

简单来说,如果有一直持续的网络隔离, 系统也会变得持续的不可用

性能, 故障恢复和 fsync

许多用户用 redis 来做锁系统,是为了获取更好的性能, 这包括更短的获取和释放延迟和更多的每秒获取释放次数。为了符合这一需求, 我们在与这 N 个节点通讯的时候采用了多路复用来减少延迟(设置 socket 为非阻塞模式, 一次发送所有的命令, 稍后再一次读取所有的命令, 假设客户端与所有节点之间的 RTT 是相同的)

However there is another consideration around persistence if we want to target a crash-recovery system model.
但是如果我们对宕机恢复有需求的话我们就需要在持久化上做文章了

为了明确问题, 首先假设我们没有对 Redis 节点做任何的持久化配置。客户端获取了 5 个当中的 3 个部分锁。这 3 个节点当中的某一个此时突然重启了, 这时候我们可以获取的节点又变成了 3 个(客户端没有获取到的那 2 个加上重启的这个), 其他客户端又可以获得整体锁了, 这显然破坏了锁的独占性。

如果我们开启 AOF 持久化,情况会改善不少。例如我们可以通过关机重启来升级服务器。因为 Redis 的过期机制是通过语意实现的(我的理解是过期参数会转化成过期时间点保存下来)所以即使关机了该机制还是可以起效的, 现在我们所有的需求都得到了满足。但是这是建立在正常关机的基础之上的, 如果是突然断电会怎么样?Redis 的默认配置是每秒通过 fsync 同步到磁盘, 这导致我们设置的 key 有可能在重启之后丢失。理论上, 如果想让我们的锁无视任何情况的重启,我们需要在持久化配置中开启 fsync=always, 但是这种额外的同步负担将会大大降低 Redis 的性能

其实情况没有一开始看上去的那么糟糕。只要重启的服务器不参与当前活跃的锁(作者的意思应该是已经获取的全面锁和正在获取的部分锁以及涉及的全面锁)的事务,那么算法的安全性就不会受到影响。 详细点说就是服务器重启的那一刻已经活跃的所有的锁,在服务器重启后应当只被那些已获取成功的节点管理,而不是重启的这台。

为了达到这一点我们需要节点重启后至少要保持在比锁的最长有效时长还要长的时间里处于不可访问的状态。这段时长是所有在该节点重启之前就已经活跃的锁自动释放的最长时长。(这段英文表达起来比较绕,其实很简单, 假如说我们设置的锁的最长有效时长是 10s, 那我们服务器重启后就需要等待 11s, 这样哪怕有一个锁是服务器重启的那一时间点获取成功的, 我们的服务器重启的又非常快,假设不需要时间, 那在经过 11s 的等待后该锁也已经被释放了)

采用这种延时上线的方法我们就不需要任何的持久化手段来维持系统的安全了。但这也引入了另一个不可避免的代价。 举个例子, 如果半数的节点挂掉了, 整个系统将会变得完全不可用,持续时间为一个锁的有效时长(这里完全意味着在这段时间里没有任何资源可以被上锁)

让算法变得更可靠: 延长锁

如果客户端的工作可以拆分成若干个小步骤, 那可以采用较小的有效时长, 同时给算法添加一个锁延长机制。 简单点说就是, 如果工作过程中锁的有效时长剩余不多了, 客户端可以发送一个 Lua 脚本到所有的节点, 申请延长锁的有效时长,当然前提是这个锁还没有被超时释放掉而且所有者确实是当前客户端(key 存在且 value 的数值对的上)

客户端只有在锁的有效时长内且半数以上的节点同意延期的情况下才能认为这个锁是可以被重新获取的(延期的算法跟获取的算法基本上是一样的)

但是算法的根本不能变, 所以锁的延期次数要被限制, 否则会影响系统的活性

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
使用Redis实现分布式的方法是通过使用setnx命令进行上,del命令进行释放,以及expire命令设置的过期时间。当一个进程或线程需要获取时,它会尝试执行setnx命令将一个特定的键设置为1(表示被获取),如果设置成功,则表示获取到了,如果设置失败,则表示已被其他进程或线程获取。当进程或线程完成任务后,可以使用del命令将该键删除,从而释放。为了防止死被一直持有,还可以使用expire命令设置的过期时间,确保即使没有被主动释放,也能在一定时间后自动过期。这样就实现了基于Redis分布式。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [Redis实现分布式](https://blog.csdn.net/m0_52884709/article/details/127697133)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] - *2* [分布式实现(一)Redis篇](https://blog.csdn.net/lans_g/article/details/126118046)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值