建立对分布式锁的系统认知 - 从 Redlock 开始
https://xie.infoq.cn/article/50d9f8f82ba0f96d07b38032f
01 前言
这是一篇解析底层原理的文章,从 Redis 官方推荐的 Redlock 算法入手,帮助你建立对分布式锁的认知,并具备判断分布式锁方案优劣的理论基础。 通过对本文的学习,你将收获以下知识:
- 分布式锁的基本要求
- 评估简化版方案的现状
- 单实例 Redis 的锁方案
- Redlock 算法核心思想
- 科学的失败重试机制
- 性能、故障恢复和持久化
- 让算法更可靠:续期机制
什么是分布式锁?当不同的进程必须以互斥地方式访问同一个共享资源时,就要用到分布式锁。 当然,我更建议在工程实践时合理设计方案,避免用到锁,除非无法避免。 网上也有一些比较简单的设计方案,其可靠性往往得不到很好的保证。
很多语言都有 Redlock 分布式锁的实现,我们列举几个主流语言的实现:
- Ruby:Redlock-rb
- Python:Redlock-py、Pottery、Aioredlock
- PHP:Redlock-php、PHPRedisMutex、cheprasov/php-redis-lock、rtckit/react-redlock
- Go:RedSync
- Java:Redisson
- NodeJS:node-redlock
- C++:Redlock-cpp
02 分布式锁的基本要求
一个最小化、可有效使用的分布式锁至少需满足以下三个属性:
- 安全性:互斥。对于同一资源,在任何时刻,只有一个客户端可以持有锁。
- 活跃性 A:死锁释放。当持有锁的客户端发生崩溃等异常而不能释放锁时,锁最终也能被其它客户端获取到。
- 活跃性 B:容错。只要大多数(半数以上)Redis 节点处于启动状态,客户端就可以获取和释放锁。
不能满足以上三个属性,则不是一个合格的分布式锁方案,其可靠性不足以在生产环境使用。在选择分布式锁方案时要牢记这三点。
03 评估简化版方案的现状
比较简单的实现方式是在一个 Redis 实例中创建一个带有过期时间的 key,所以这个锁最终会被释放(满足活跃性 A 的要求)。 当客户端需要释放锁时,主动删掉这个 key 就可以了。
表面上看起来还不错,但存在一个问题:单点失败。大多数公司在使用 Redis 时会采用主从模式(主要指一主一从), 因为 master 到 slave 节点的数据复制是异步的,当 master 挂掉之后,互斥的安全性要求是无法得到满足。
具体分析如下:
- 客户端 A 在 master 节点中获取了锁。
- 对应的 key 在被复制到 slave 节点之前,master 节点挂了。
- slave 节点被提升为 master。
- 客户端 B 在新的 master 中获取到了 A 已经持有的相同资源的锁。违反了互斥的安全性要求!
所以,不要在主从模式的 Redis 环境中实现分布式锁,即便是后文中的 Redlock 方案也是一样,NO REPLICATION! 在选择一些开源类库时也需要考察其是否对有副本的情况进行了合理地处理。事实上很难处理,可以认为这是基于 Redis 方案的缺陷。
04 单实例 Redis 的锁方案
核心要点:
- key 不存在时设置 key,value 为全局唯一签名,一定时间后自动失效。
- 删除 key 时必须匹配签名。
既然主从的 Redis 环境不适合做分布式锁,那我们来看看只有一个实例的 Redis 环境怎么实现分布式锁。
设置一个当前不存在的 key,并使用随机值签名,配置过期时间。
只有当 resource_name 这个 key 不存在时,才设置 key。对应的值是一个全局唯一的随机数值,作为 key 的签名,并且这个 key 将会在 30000 毫秒后过期。
验证签名匹配后方可删除。
对应的 Lua 脚本如下:
只有签名值对应上时才允许删除,以此实现安全地释放锁,避免删掉其它客户端创建的锁。 在一些更加简陋的方案中是没有锁签名的,它们的可靠性就要更差一些了。
误删其它客户端的锁是不安全的,例如:
- 客户端 A 获取到一个锁。
- 客户端 A 长时间阻塞在某些操作上,阻塞的时间超过了锁的有效时间(通过 PX 参数设置的时间)。
- 操作完成后执行锁的 DEL,但这个锁已经过期并且被客户端 B 获取到。 DEL 操作导致客户端 B 的锁被误删,客户端 C 此时可以获取 B 锁持有的锁。 违反互斥的安全性要求!
我们使用全局唯一的随机值给锁进行签名,然后通过以上的脚本进行删除,就可以保证锁只能被创建者删除。 通常可以使用当前毫秒时间,拼接上客户端 id 作为锁的签名值。
给 key 指定的生存时间被称为“锁有效时间”,它既是锁自动释放的时间,也是客户端执行操作必需的时间,需要比最大执行时间更大一些,此时并不违反互斥的安全性要求。 但事实上我们无法保证每一次客户端操作的时间一定小于自动释放时间,就可能会出现操作还没有完成,锁已经自动过期,从而被其它客户端获取到。 这里需要配合锁的续期才能确保安全性,文章的最后一部分会讲解续期机制。
- 方案优点:相对简单易实现。
- 方案缺点:可用性不高,一旦唯一的 Redis 节点挂掉,系统将完全不可用。
不建议在可用性要求较高的场景中使用该方案!
05 Redlock 算法核心思想
核心要点:
- N 个独立节点,无副本,互不依赖(非集群),N 是奇数且 N≥3。
- 在有限的时间内,客户端在半数以上节点成功设置 key,则可以获取锁。
- 客户端需提前几毫秒完成工作,作为对时钟漂移的补偿。
单实例方案在面对单点故障时整个系统不可用,因此需要使用多实例来确保可用性,而单实例的方案无法直接套用在多实例环境,需要做一些改进。
假设有一个 N 个 Redis master 节点。这些节点是独立的、互不依赖的,没有使用主从复制或者其它的协调机制。当 N=5 时, 我们需要在不同的主机或者虚拟机上运行 5 个 Redis master 节点,以确保节点失效时独立失效,互不影响。
为了获取锁,客户端会执行以下操作:
- 获取当前毫秒时间。
- 尝试顺序地从所有实例中获取锁,在每个实例中都设置同样的 key 名称和随机值。在每个实例中设置锁时,客户端会有一个超时时间,这个时间比锁的有效时间更小。 比如,自动释放时间是 10 秒,则超时时间可以是 5~50 毫秒。这可以防止客户端尝试从已经失效的 Redis 节点获取锁而长时间被阻塞。 当一个 Redis 实例不可用时,要尽可能快地转移到下一个节点进行设置。
- 客户端会计算获取锁的过程消耗了多少时间。当且仅当客户端在半数以上的 Redis 实例中设置成功,且总耗时远小于锁的有效时间时,才会让锁最终成功被获取。
- 如果在某个 Redis 实例上设置成功,则会使用在步骤 1 中获取的时间,再加上已消耗的时间,作为过期时间。
- 如果设置锁失败了(未能成功锁定半数以上 Redis 实例或者有效时间是负的),将会尝试在所有 Redis 实例上解锁,删除对应的 key。
该算法依赖于一种假设:尽管进程之间没有同步时钟,但每个进程中的本地时间仍然以大致相同的速度流动,其误差与锁的自动释放时间相比是很小的。 这个假设是非常接近事实的:每台计算机都有一个本地时钟,通常这些计算机的时钟漂移是很小的,只有几毫秒。
基于这一点,只有当持有锁的客户端在锁有效时间达到之前完成工作,互斥性才会得到保证。提前的几毫秒用于补偿进程之间的时钟漂移。 这就跟木桶原理一样,较短的一块板子决定了最大蓄水量,最早的过期时间决定了锁的实际最大生存时长。
06 科学的失败重试机制
核心要点:
- 先延迟一个随机时间。
- 再使用指数退避法执行重试。
优秀的方案设计一定要充分考虑失败场景,即面向失败设计的思想!
当客户端不能成功获取到锁时,应该延迟一个随机时间后再重试。使用随机时间可以避免多个客户端同时争夺同一个资源的锁。 大量客户端同时发起重试请求的情况称为惊群效应(thundering herd),会过多消耗你的服务器资源。如果要对一个分布式锁方案进行压力测试,我必须关注的一个指标就是:重试次数/成功次数,这个指标越低越好。
如何减少重试几率和时间?
客户端尝试在半数以上 Redis 实例上加锁的速度越快,竞争的时间窗口就会越小,因此最理想的情况是客户端采用多路复用的方式,同时向 N 个实例发送 SET 命令。对于无法成功获取半数以上锁的客户端,要尽快释放已获取到的锁,不需要等待 key 自动过期,以确保锁可以尽快被再次请求。
当重试发生时的最佳策略:使用随机延迟+指数退避可有效地分散重试请求,削弱惊群效应的影响。
指数退避法(Exponential Backoff)
//retry=1 代表当前第 1 次重试,最大重试次数是 3。
//随着重试次数的增加,延迟时间越来越大。第 1 次重试的延迟时间是 20ms,第 3 次重试的延迟时间是 80ms。
long timemillsec = (long) (Math.pow(2, Math.min(retry, 3)) \* 10);
可以使用递归调用+定时器进行重试操作,每一次重试后都计算出下一次重试的延迟时间,达到最大重试次数而依然没有成功则放弃重试,业务客户端要有对应的失败处理。
07 性能、故障恢复与持久化
核心要点:
- 多路复用,更快完成加锁/解锁,提高性能。
- 实时 AOF 或者无持久化、延迟重启,确保互斥。
多路复用,为了更快
之所以选择 Redis 做分布式锁服务,是因为想获得较高的性能,每秒能够执行大量的加锁和解锁操作。 为了满足这个诉求,可以采用多路复用的方式,同时将所有命令发送到 N 个 Redis 节点上,并同时读取命令结果(假设所有 Redis 节点的响应时间是一致的)。
有持久化,实时 AOF-重启后数据不丢失,确保互斥。
如果我们想要系统具备故障恢复能力,就需要考虑 Redis 的持久化策略。
假设我们配置了 5 个没有任何持久化策略的 Redis 实例,来看看会有什么问题。 一个客户端在 3 个实例上成功设置了锁,其中一个实例被重启导致数据丢失,实际锁成功的节点只剩下 2 个(半数以下),此时其它客户端就能够获取到同一个资源的锁。 违反了互斥的安全性要求!
当开启 AOF 持久化后,情况会有很大改善。 例如:向 Redis Server 发送 SHUTDOWN 命令并重启,Redis 会先进行持久化然后再重启,重启后从 AOF 文件中恢复数据。 在 Server 关闭期间,锁的生存时间仍然在正常流逝,对锁的过期没有影响。 这种情况下,没有任何问题。
但如果是突然断电呢?假设 Redis 按照默认配置,每秒进行一次 AOF 文件的写盘,则有可能因为来不及写盘而丢失数据。 如果想在这种异常重启的情况下保证锁的安全性,就需要在持久化配置中把 fsync 设置为 always,实时写盘。 按照分布式系统的 CAP 理论,这是通过牺牲一定的可用性,保证了一致性和分区容错性。
无持久化,延迟重启-节点对应的锁全部过期自动失效,确保互斥。
如果一个 Redis 实例在崩溃后重启,而且该实例中所有的锁都不属于当前正在使用的锁(当前活动锁),则算法的安全性也是可以保证的。 我们只需要在 Redis 实例崩溃后,延迟一段时间再启动就可以。延迟的时间要比最大的锁生存时间大一些,这样该实例中的锁在重启后已经全部失效并且会被自动释放。
当 Redis 实例没有配置任何持久化时,使用延迟重启可以在任何一种重启的情况下确保锁的安全。 这也是一种牺牲可用性的方式,例如:当半数以上的 Redis 实例崩溃后,系统会变得完全不可用,持续的时间是最大锁生存时间,在这段时间里没有任何资源可以被锁定。
08 让算法更可靠:续期机制
核心要点:
- 进行基准测试,尽可能准确地预估客户端操作耗时,作为锁有效期的参考。
- 增加锁的续期机制,应对意外情况。
如果客户端操作的步骤很少,耗时很短,就可以使用更小的锁有效时间。 但无论预估地多么准确,都无法避免意外,建议使用锁的续期机制对算法进行扩展,以应对不确定性。 如果客户端正在执行操作,而锁的剩余生存时间已经很小,则可以通过 Lua 脚本对已存在的锁进行有效期延长。
09 总结
没有最完美的方案,只有最适合的。 基于 Redis 也不是分布式锁方案的唯一选项,还有基于 Zookeeper 的方案也可以考虑,最佳的方案是不需要分布式锁。
在实践中要考虑基础设施情况和业务要求,仔细权衡。 期望你可以通过这篇文章建立对分布式锁方案的评判标准,在实际的技术选型中能够对多种方案进行客观评价。