深度解密如何基于 Redis 实现分布式锁
作者:@万明珠
喜欢这篇文章的话,就点个关注吧,会持续分享高质量Python文章,以及其它相关内容。
楔子
锁是多线程编程中的一个重要概念,它是保证多线程并发时顺利执行的关键。我们通常所说的锁是指程序中的锁,也就是单机锁,比如 Python threading 模块里面的 Lock 等等。因此锁主要用于并发控制,保证一项资源在任何时候只能被一个线程使用,如果其它线程也要使用同样的资源,必须排队等待上一个线程使用完。
但很明显,单机锁要求使用范围必须局限在一个进程当中,如果换做是多个进程,需要同时操作一个共享资源,如何互斥呢?例如,现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程如果需要修改 MySQL 中的同一行记录时,该怎么做呢?
显然为了避免操作乱序导致数据错误,我们需要引入「分布式锁」来解决这个问题。
想要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上申请「加锁」。而这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。
显然这个外部系统,可以是关系型数据库, Redis, ZooKeeper, etcd 等等,本次我们就来介绍如何基于 Redis 实现分布式锁。
如何实现分布式锁
Redis 如果想实现分布式锁,那么它必须要有互斥的能力,显然单线程的 Redis 是可以满足的。它的实现思路是使用 setnx(set if not exists),该命令的特点是只有 key 不存在时才会设置成功,如果 key 存在则会设置失败。所以当一个应用调用 setnx 成功时,则表明此锁创建成功,否则代表这个锁已经被占用、创建失败。
我们举个例子,假设有两个客户端,首先客户端 1 申请加锁,加锁成功:
127.0.0.1:6379> SETNX lock 1
(integer) 1 # 客户端 1,加锁成功
然后客户端 2 也申请加锁,但因为后到达,所以加锁失败:
127.0.0.1:6379> SETNX lock 1
(integer) 0 # 客户端 2,加锁失败
此时加锁成功的客户端,就可以去操作「共享资源」了,例如修改 MySQL 的某一行数据,或者调用一个 API 请求。但需要注意的是,操作完成后还要及时释放锁,给后来者让出操作共享资源的机会。如果锁不释放,那么其它进程就永远都没有机会操作共享资源了。
而释放锁也很简单,直接使用 DEL 命令将 key 删除即可:
127.0.0.1:6379> DEL lock # 释放锁
(integer) 1
这样其它客户端就可以继续创建锁了。
所以分布式锁只是一个抽象的概念,并不是什么具体的数据结构。因为操作共享资源的多个进程之间是没有任何关系的,为了能让它们彼此互斥,就需要借助一个第三方系统,比如 Redis。多个进程同时连接 Redis,然后创建一个名称相同的 key,谁创建成功了,那么我们就认为谁拿到了分布式锁。
但这个过程要求 Redis 能够实现互斥,多个进程只能有一个创建成功,因此 setnx 命令就是一个绝佳的选择。设置成功时返回 True,那么进程就知道自己拿到了锁,于是会去操作共享资源;如果返回 False,就知道这个 key 已经被其它进程设置了,换言之就是分布式锁已经被别人取走了。
从逻辑上来讲,单机锁和分布式锁是类似的:
但从实现上来讲,两者是不同的,分布式锁需要借助一个具有互斥功能的第三方组件。
不过目前这个设计还是存在问题的,首先获得锁的进程在资源操作完毕之后,必须通过 DEL 命令把锁释放掉,否则其它进程就永远没有机会操作共享资源了。那么问题来了,如果在执行 DEL 释放锁之前,程序挂掉了怎么办,比如出现异常、节点宕机,都可以导致程序挂掉。
显然如果是这种情况,那么就出现了死锁,因为锁永远不会被释放。要如何解决呢?
如何解决死锁
一个容易想到的方案就是,在设置 key 的时候,同时绑定一个过期时间,比如 30 秒。
# 加锁
127.0.0.1:6379> SETNX lock 1
(integer) 1
# 10s后自动过期
127.0.0.1:6379> EXPIRE lock 30
(integer) 1
这样即使程序出现崩溃,也不用担心,因为 30 秒超时时间一过,这个锁会自动解除,因此不会出现死锁的情况了。但真的就是万事大吉了吗?可以看到 setnx 和 expire 是两条独立的命令,故存在原子性的问题,比如 setnx 成功之后,因为网络原因导致 expire 执行失败、或者因为客户端异常崩溃导致 expire 压根没有执行。
因此这两条命令如果不能保证是原子操作(全部成功),仍有潜在的风险导致过期时间设置失败,进而发生「死锁」问题。那么我们如何能保证这两条命令同时成功呢?
在早期要解决此问题,需要引入额外的类库,但这样就增加了使用的成本。因此在 Redis 2.6.12 时将 set 命令进行了扩展,从而解决了此问题。
- set key value ex 30:设置 key 的同时指定 30s 的过期时间;
- set key value nx:key 不存在进行设置,存在则设置失败,等价于 setnx;
- set key value xx:key 存在进行设置,不存在则设置失败,没有 setxx;
- set key value nx|xx ex 30:相当于将 setnx/setxx 和 expire 组合在一起;
# 设置成功,ex 30 和 nx 谁先谁后都可以
127.0.0.1:6379> set lock 1 ex 30 nx
OK
# 在锁被占用的情况下,设置失败
127.0.0.1:6379> set lock 1 ex 30 nx
(nil)
# 30s 过后,锁失效,设置成功
127.0.0.1:6379> set lock 1 ex 30 nx
OK
这样我们就可以使用 set 命令来设置分布式锁,并同时设置超时时间了,因为整体就是一条 set 命令,可以保证原子性。
分布式锁的超时问题
使用 set 命令之后好像所有问题都解决了,然而真相却没那么简单。使用 set 命令只解决创建锁的问题,但释放锁还存在一些问题。
例如我们设置锁的最大超时时间是 30s,但业务处理使用了 35s,这就会导致原有的业务还未执行完成,锁就被释放了。这时候第二个客户端进程就会拿到锁,于是就会出现两个客户端同时操作共享资源,从而造成一系列问题。
所以不管是什么组件,只要是和设置超时时间相关的,基本都是很难评估的。谁也说不准,操作一个共享资源究竟要花费多长时间。即使把超时时间设置长一些,也只能缓解,却无法彻底根治。而且如果超时时间设置长了的话,比如 60s,但如果客户端 10s 就执行完了,那么就会额外多出 50s 的等待时间,这对服务的执行效率也不友好。
因此即便设置了过期时间,也会导致问题(业务的执行时间超过了分布式锁的过期时间)。但除此之外,该问题还会导致另一个问题:锁被误删。
假设锁的时间是 30s,进程 1 执行了 35s,因此进程 2 会在过了30s、锁被自动释放之后,重新获取锁。于是从 30s 到 35s 这个过程期间,两个进程会同时操作共享资源。然后在 35s 时,进程 1 执行完毕,而执行完毕后要释放锁,但此时进程1 释放的是进程 2 创建的锁(被误删)。
注意:不同的进程在执行 SET 和 DEL 命令的时候,key 一定是相同的,因为锁只能有一把。
所以接下来的重点就是如何把过期时间不好评估和锁被误删这两个问题给解决掉?
如何设置过期时间
过期时间不好评估的话,我们可以换一种方式,首先大致估算一个时间,然后开启一个守护线程,定时去检测这个锁的失效时间。如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续租」,重新设置过期时间。
这样就避免了两个进程同时获得锁的问题。
锁被误删
锁被误删的解决方案也很简单,在使用 set 命令创建锁时,将 value 设置为只有当前进程才知道的值,比如设置一个 UUID。每次在删除之前先判断 value 是不是自己设置的 UUID,如果是的话,再删除,这样就避免了锁被误删的问题。
# 锁的 VALUE 设置为只有自己知道的 UUID
127.0.0.1:6379> SET lock $uuid EX 30 NX
OK
同时启动一个守护线程自动检测过期时间,如果时间快到了但操作还没有完成,则自动续租。之后,在释放锁时,要先判断这把锁是否是自己持有,伪代码可以这么写:
# 锁是自己的,才释放
if client.get("lock") == $uuid:
client.delete("lock")
但这里释放锁使用的是 GET + DEL 两条命令,这又遇到前面说的原子性问题了。
- 1)客户端 1 执行 GET,判断锁是自己的;
- 2)客户端 2 执行了 SET 命令,强制获取到锁;
- 3)客户端 1 执行 DEL,却释放了客户端 2 的锁;
由此可见,这两个命令还是必须要原子执行才行。怎样才能原子执行呢?答案是通过 Lua 脚本。我们可以把 GET + DEL 这两个操作组合起来,放在一个 Lua 脚本里,让 Redis 来执行。因为 Redis 处理每一个请求都是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成。这样一来,GET + DEL 之间就不会插入其它命令了。
安全释放锁的 Lua 脚本如下:
-- 判断锁是自己的,才释放
if redis.call("GET", KEYS[1]) == ARGV[1]
then
return redis.call("DEL", KEYS[1])
else
return 0
end
关于 Redis 中如何嵌入 lua 脚本,我们以后会说。
主从复制带来的锁重复问题
如果我们能够保证 Redis 所在节点不宕掉,那么采用 Redis 实现分布式锁就是完美的,但显然我们无法保证这一点。所以实际在使用 Redis 时,一般会采用主从复制模式,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。
那么问题来了,当「主从发生切换」时,这个分布锁还安全吗?显然是不安全的,我们不妨想一下这样的场景:
- 1)客户端 1 在主库上执行 SET 命令,加锁成功;
- 2)此时,主库异常宕机,SET 设置的锁还未同步到从库上,因为主从复制是异步的;
- 3)从库被哨兵提升为新主库,这个锁在新的主库上就丢失了。换句话说,锁对应的 key 没有同步过来,这就意味着客户端 2 会 SET 成功,也会获得分布式锁,那么此时就有了两把分布式锁;
所以当 Redis 采用主从复制时,分布式锁还是会受到影响的,或者说用 Redis 实现的分布式锁在当前这种极端场景下是不 ok 的。如果从 CAP 的角度来理解的话,因为分布式锁要求组件是 CP 模型,但 Redis 是一个 AP 模型,所以极端条件下 Redis 是不适合的。
不过很多公司还是会拿 Redis 实现分布式锁,因为 Redis 组件在项目中太常用了,并且用它来实现分布式锁也很简单。虽然在极端场景下(Redis 主库挂掉,数据同步之前从库提升为主库)可能会有问题,但毕竟发生的概率还是很低,很多公司可以接受这一点。这里值得一提的是,阿里也是用 Redis 实现的分布式锁,但它没有使用主从集群,而是只用单个节点的 Redis 实现分布式锁。这个节点什么也不做,只用于实现分布式锁的 Redis,这样的话 Redis 服务就不会因为内存不足、CPU 负载过高等原因挂掉。
然后是网络、断电问题,阿里会给该节点配置多块网卡、多块电源,只要有一块能够工作,那么该节点就能正常工作,除非所有的网卡和电源同时宕掉,但很明显这概率是非常低的。所以阿里是通过这种手段来保证分布式锁服务可用,可以说简单粗暴,虽然是解决办法,但很明显需要钱来维持,因为它要求你首先要有一个自己的机房。
因此一般公司不会采用阿里这种做法,为了保证 Redis 服务的高可用,还是采用主从复制的模式。那么问题来了,如果真的遇到上面这种极端条件,要如何解决它呢?为此,Redis 的作者提出了一种解决方案,就是我们经常听到的 Redlock(红锁)。
Redlock 是怎么实现分布式锁的
下面来看一下 Redis 作者提出的 Redlock 方案,是如何解决主从切换后,锁失效问题的。
Redlock 的方案基于 2 个前提:
不再需要部署从库和哨兵实例,只部署主库
但主库要部署多个,官方推荐至少 5 个实例
也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,就是一个个孤立的实例。
那么 Redlock 具体如何使用呢?整体的流程是这样的,一共分为 5 步:
1) 客户端先获取「当前时间戳 T1」
2) 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
3) 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
4) 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
5) 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
可以仔细理解一下这 5 步,非常重要,后续会根据这个流程,剖析各种可能导致锁失效的问题假设。其实也不难理解,总共有以下几个重点:客户端在多个 Redis 实例上申请加锁;必须保证大多数节点加锁成功;大多数节点加锁的总耗时,要小于锁设置的过期时间;释放锁,要向全部节点发起释放锁请求。
好,明白了 Redlock 的流程,我们来看 Redlock 为什么要这么做。
1)为什么要在多个实例上加锁?
本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。
2)为什么大多数加锁成功,才算成功?
多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。在分布式系统中,总会出现「故障节点」,所以在谈论分布式系统问题时,需要考虑在不会影响整个系统的「正确性」的前提下,故障节点最多达到多少个。
这是一个分布式系统「容错」问题,这个问题的结论是:即使存在「故障」节点,但只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。
3) 为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久。而且因为是网络请求,网络情况又很复杂,有可能存在「延迟、丢包、超时」等情况发生,并且网络请求越多,异常发生的概率就越大。所以即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。
4) 为什么释放锁,要操作所有节点?
在某一个 Redis 节点加锁时,可能因为「网络原因」导致误以为加锁失败。例如客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,但这把锁其实已经在 Redis 上加锁成功了。所以释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。
Redlock 的争论谁对谁错?
Redis 作者把这个方案一经提出,就马上受到业界著名的分布式系统专家的质疑。这个专家叫 Martin,是英国剑桥大学的一名分布式系统研究员,在此之前他曾是软件工程师和企业家,从事大规模数据基础设施相关的工作,还经常在大会做演讲,写博客,写书,也是开源贡献者。
他马上写了篇文章,质疑这个 Redlock 的算法模型是有问题的,并对分布式锁的设计,提出了自己的看法。之后,Redis 作者 Antirez 面对质疑,不甘示弱,也写了一篇文章,反驳了对方的观点,并详细剖析了 Redlock 算法模型的更多设计细节。并且关于这个问题的争论,在当时互联网上也引起了非常激烈的讨论。
二人思路清晰,论据充分,这是一场高手过招,也是分布式系统领域非常好的一次思想的碰撞。双方都是分布式系统领域的专家,却对同一个问题提出很多相反的论断,究竟是怎么回事?
分布式专家 Martin 对于 Relock 的质疑
在他的文章中,主要阐述了 4 个论点:
1) 分布式锁的目的是什么?
Martin 表示,你必须先清楚你在使用分布式锁的目的是什么?他认为有两个目的。
第一,效率。使用分布式锁的互斥能力,是避免不必要地做同样的两次工作(例如一些昂贵的计算任务)。如果锁失效,并不会带来「恶性」的后果,例如发了 2 次邮件等,无伤大雅。
第二,正确性。使用锁用来防止并发进程互相干扰。如果锁失效,会造成多个进程同时操作同一条数据,产生的后果是数据严重错误、永久性不一致、数据丢失等恶性问题,就像给患者服用了重复剂量的药物,后果很严重。
他认为,如果你是为了前者(效率),那么使用单机版 Redis 就可以了,即使偶尔发生锁失效(宕机、主从切换),都不会产生严重的后果。而使用 Redlock 太重了,没必要。而如果是为了正确性,Martin 认为 Redlock 根本达不到安全性的要求,也依旧存在锁失效的问题!
2) 锁在分布式系统中会遇到的问题
Martin 表示,一个分布式系统,更像一个复杂的「野兽」,存在着你想不到的各种异常情况。这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC。
N:Network Delay,网络延迟
P:Process Pause,进程暂停(GC)
C:Clock Drift,时钟漂移
Martin 用一个进程暂停(GC)的例子,指出了 Redlock 安全性问题:
1) 客户端 1 请求锁定节点 A、B、C、D、E
2) 客户端 1 的拿到锁后,进入 GC(时间比较久)
3) 所有 Redis 节点上的锁都过期了
4) 客户端 2 获取到了 A、B、C、D、E 上的锁
5) 客户端 1 的 GC 结束,认为成功获取锁
6) 客户端 2 也认为获取到了锁,发生「冲突」
Martin 认为,GC 可能发生在程序的任意时刻,而且执行时间是不可控的。
注:即使是使用没有 GC 的编程语言,在发生网络延迟、时钟漂移时,也都有可能导致 Redlock 出现问题,这里 Martin 只是拿 GC 举例。
3) 假设时钟正确的是不合理的
又或者,当多个 Redis 节点「时钟」发生问题时,也会导致 Redlock 锁失效。
1) 客户端 1 获取节点 A、B、C 上的锁,但由于网络问题,无法访问 D 和 E
2) 节点 C 上的时钟「向前跳跃」,导致锁到期
3) 客户端 2 获取节点 C、D、E 上的锁,由于网络问题,无法访问 A 和 B
4) 客户端 1 和 2 现在都相信它们持有了锁(冲突)
Martin 觉得,Redlock 必须「强依赖」多个节点的时钟是保持同步的,一旦有节点时钟发生错误,那这个算法模型就失效了。另外,即使 C 不是时钟跳跃,而是「崩溃后立即重启」,也会发生类似的问题。
Martin 继续阐述,机器的时钟发生错误,是很有可能发生的:
系统管理员「手动修改」了机器时钟
机器时钟在同步 NTP 时间时,发生了大的「跳跃」
总之 Martin 认为,Redlock 的算法是建立在「同步模型」基础上的,有大量资料研究表明,同步模型的假设,在分布式系统中是有问题的。在混乱的分布式系统的中,你不能假设系统时钟就是对的,所以,你必须非常小心你的假设。
4) 提出 fecing token 的方案,保证正确性
相对应的,Martin 提出一种被叫作 fecing token 的方案,保证分布式锁的正确性。这个模型流程如下:
1) 客户端在获取锁时,锁服务可以提供一个「递增」的 token
2) 客户端拿着这个 token 去操作共享资源
3) 共享资源可以根据 token 拒绝「后来者」的请求
这样一来,无论 NPC 哪种异常情况发生,都可以保证分布式锁的安全性,因为它是建立在「异步模型」上的。而 Redlock 无法提供类似 fecing token 的方案,所以它无法保证安全性。
他还表示,一个好的分布式锁,无论 NPC 怎么发生,可以不在规定时间内给出结果,但并不会给出一个错误的结果。也就是只会影响到锁的「性能」(或称之为活性),而不会影响它的「正确性」。
Martin 的结论:
1) Redlock 不伦不类:它对于效率来讲,Redlock 比较重,没必要这么做,而对于正确性来说,Redlock 是不够安全的
2) 时钟假设不合理:该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效
3) 无法保证正确性:Redlock 不能提供类似 fencing token 的方案,所以解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper
好了,以上就是 Martin 反对使用 Redlock 的观点,看起来有理有据,那么下面我们来看 Redis 作者 Antirez 是如何反驳的。
Redis 作者 Antirez 的反驳
在 Redis 作者的文章中,重点有 3 个。
1)解释时钟问题
首先,Redis 作者一眼就看穿了对方提出的最为核心的问题:时钟问题。Redis 作者表示,Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,允许有「误差」。例如要计时 5s,但实际可能记了 4.5s,之后又记了 5.5s,有一定误差,但只要不超过「误差范围」锁失效时间即可,这种对于时钟的精度要求并不是很高,而且这也符合现实环境。
对于对方提到的「时钟修改」问题,Redis 作者反驳到:
1)手动修改时钟:不要这么做就好了,否则你直接修改 Raft 日志,那 Raft 也会无法工作...
2)时钟跳跃:通过「恰当的运维」,保证机器时钟不会大幅度跳跃(每次通过微小的调整来完成),实际上这是可以做到的
为什么 Redis 作者优先解释时钟问题?因为在后面的反驳过程中,需要依赖这个基础做进一步解释。
2)解释网络延迟、GC 问题
之后,Redis 作者对于对方提出的,网络延迟、进程 GC 可能导致 Redlock 失效的问题,也做了反驳,这里可以再回到上面看看 Martin 提出的问题假设。而 Redis 作者反驳到,这个假设其实是有问题的,Redlock 是可以保证锁安全的。这是怎么回事呢?还记得前面介绍 Redlock 流程的那 5 步吗?这里再拿过来复习一下。
1) 客户端先获取「当前时间戳 T1」
2) 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
3) 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
4) 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
5) 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
注意,重点是 1-3,在步骤 3,加锁成功后为什么要重新获取「当前时间戳 T2」?还用 T2 - T1 的时间,与锁的过期时间做比较?
Redis 作者强调:如果在 1-3 发生了网络延迟、进程 GC 等耗时长的异常情况,那在第 3 步 T2 - T1,是可以检测出来的,如果超出了锁设置的过期时间,那这时就认为加锁会失败,之后释放所有节点的锁就好了!此外 Redis 作者继续论述,如果对方认为,发生网络延迟、进程 GC 是在步骤 3 之后,也就是客户端确认拿到了锁,去操作共享资源的途中发生了问题,导致锁失效,那这「不止是 Redlock 的问题,任何其它锁服务例如 Zookeeper,都有类似的问题,这不在讨论范畴内」。
这里举个例子解释一下:
1) 客户端通过 Redlock 成功获取到锁(通过了大多数节点加锁成功、加锁耗时检查逻辑)
2) 客户端开始操作共享资源,此时发生网络延迟、进程 GC 等耗时很长的情况
3) 此时,锁过期自动释放
4) 客户端开始操作 MySQL(此时的锁可能会被别人拿到,锁失效)
Redis 作者这里的结论就是:
客户端在拿到锁之前,无论经历什么耗时长问题,Redlock 都能够在第 3 步检测出来
客户端在拿到锁之后,发生 NPC,那 Redlock、Zookeeper 都无能为力
所以,Redis 作者认为 Redlock 在保证时钟正确的基础上,是可以保证正确性的。
3) 质疑 fencing token 机制
Redis 作者对于对方提出的 fecing token 机制,也提出了质疑,主要分为 2 个问题,这里不是很好理解,可以多仔细想想。
第一:这个方案必须要求要操作的「共享资源服务器」有拒绝「旧 token」的能力。例如,要操作 MySQL,从锁服务拿到一个递增数字的 token,然后客户端要带着这个 token 去改 MySQL 的某一行,这就需要利用 MySQL 的「事物隔离性」来做。
-- 两个客户端必须利用事物和隔离性达到目的
-- 注意 token 的判断条件
UPDATE table T SET val = $new_val WHERE id = $id AND current_token < $token
但如果操作的不是 MySQL 呢?例如向磁盘上写一个文件,或发起一个 HTTP 请求,那这个方案就无能为力了,这对要操作的资源服务器,提出了更高的要求。也就是说,大部分要操作的资源服务器,都是没有这种互斥能力的。再者,既然资源服务器都有了「互斥」能力,那还要分布式锁干什么?所以,Redis 作者认为这个方案是站不住脚的。
第二:退一步讲,即使 Redlock 没有提供 fecing token 的能力,但 Redlock 已经提供了随机值(就是前面讲的 UUID),利用这个随机值,也可以达到与 fecing token 同样的效果。而具体做法 Redis 作者并没有说,这里根据查阅到的资料简单说一下。
1) 客户端使用 Redlock 拿到锁
2) 客户端在操作共享资源之前,先把这个锁的 VALUE,在要操作的共享资源上做标记
3) 客户端处理业务逻辑,最后,在修改共享资源时,判断这个标记是否与之前一样,一样才修改(类似 CAS 的思路)
还是以 MySQL 为例,举个例子就是这样的:
1) 客户端使用 Redlock 拿到锁
2) 客户端要修改 MySQL 表中的某一行数据之前,先把锁的 VALUE 更新到这一行的某个字段中(这里假设为 current_token 字段)
3) 客户端处理业务逻辑
4) 客户端修改 MySQL 的这一行数据,把 VALUE 当做 WHERE 条件,再修改
UPDATE table T SET val = $new_val WHERE id = $id AND current_token = $redlock_value
可见,这种方案依赖 MySQL 的事物机制,也达到对方提到的 fecing token 一样的效果。但这里还有个小问题,是网友参与问题讨论时提出的:两个客户端通过这种方案,先「标记」再「检查+修改」共享资源,那这两个客户端的操作顺序无法保证啊?
而用 Martin 提到的 fecing token,因为这个 token 是单调递增的数字,资源服务器可以拒绝小的 token 请求,保证了操作的「顺序性」。Redis 作者对这问题做了不同的解释,我觉得很有道理,他解释道:分布式锁的本质是为了「互斥」,只要能保证两个客户端在并发时,一个成功,一个失败就好了,不需要关心「顺序性」。
前面 Martin 的质疑中,一直很关心这个顺序性问题,但 Redis 的作者的看法却不同。
综上,Redis 作者的结论:
1)作者同意对方关于「时钟跳跃」对 Redlock 的影响,但认为时钟跳跃是可以避免的,取决于基础设施和运维
2)Redlock 在设计时,充分考虑了 NPC 问题,在 Redlock 步骤 3 之前出现 NPC,可以保证锁的正确性,但在步骤 3 之后发生 NPC,不止是 Redlock 有问题,其它分布式锁服务同样也有问题,所以不在讨论范畴内
是不是觉得很有意思?在分布式系统中,一个小小的锁,居然可能会遇到这么多问题场景,影响它的安全性!不知道你看完双方的观点,更赞同哪一方的说法呢?
好,讲完了双方对于 Redis 分布锁的争论,你可能也注意到了,Martin 在他的文章中,推荐使用 Zookeeper 实现分布式锁,认为它更安全,确实如此吗?
基于 Zookeeper 的锁安全吗?
如果你有了解过 Zookeeper,基于它实现的分布式锁是这样的:
1)客户端 1 和 2 都尝试创建「临时节点」,例如 /lock
2)假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败
3)客户端 1 操作共享资源
4)客户端 1 删除 /lock 节点,释放锁
你应该也看到了,Zookeeper 不像 Redis 那样,需要考虑锁的过期时间问题,它是采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。而且,如果客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放。
不错,没有锁过期的烦恼,还能在异常时自动释放锁,是不是觉得很完美?其实不然。思考一下,客户端 1 创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?原因就在于客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持连接。但如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。
同样地,基于此问题,我们也讨论一下 GC 问题对 Zookeeper 的锁有何影响:
1) 客户端 1 创建临时节点 /lock 成功,拿到了锁
2) 客户端 1 发生长时间 GC
3) 客户端 1 无法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」
4) 客户端 2 创建临时节点 /lock 成功,拿到了锁
5) 客户端 1 GC 结束,它仍然认为自己持有锁(冲突)
可见,即使是使用 Zookeeper,也无法保证进程 GC、网络延迟异常场景下的安全性。这就是前面 Redis 作者在反驳的文章中提到的:如果客户端已经拿到了锁,但客户端与锁服务器发生「失联」(例如 GC),那不止 Redlock 有问题,其它锁服务都有类似的问题,Zookeeper 也是一样!
所以,这里我们就能得出结论了:一个分布式锁,在极端情况下,不一定是安全的。如果你的业务数据非常敏感,在使用分布式锁时,一定要注意这个问题,不能假设分布式锁 100% 安全。好,现在我们来总结一下 Zookeeper 在使用分布式锁时的优劣。
Zookeeper 的优势:
1) 不需要考虑锁的过期时间
2) watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁
Zookeeper 的劣势:
1) 性能不如 Redis
2) 部署和运维成本高
3) 客户端与 Zookeeper 的长时间失联,锁被释放问题
对分布式锁的一些理解
1)到底要不要用 Redlock?
前面也分析了,Redlock 只有建立在「时钟正确」的前提下,才能正常工作,如果你可以保证这个前提,那么可以拿来使用。但保证时钟正确,我认为并不是你想的那么简单就能做到的。
- 第一:从硬件角度来说,时钟发生偏移是时有发生,无法避免。例如,CPU 温度、机器负载、芯片材料都有可能导致时钟发生偏移。
- 第二:从人位角度来说,运维暴力修改时钟的情况也会发生,进而影响了系统的正确性,所以人为错误也是很难完全避免的。
所以,我对 Redlock 的个人看法是,尽量不用它,而且它的性能不如单机版 Redis,部署成本也高,我还是会优先考虑使用主从 + 哨兵的模式实现分布式锁。那正确性如何保证呢?之前不是说主节点挂掉可能会产生第二把锁吗?下面第二点给你答案。
2) 如何正确使用分布式锁?
在分析 Martin 观点时,他提到了 fecing token 的方案,给我很大的启发,虽然这种方案有很大的局限性,但对于保证「正确性」的场景,是一个非常好的思路。所以,我们可以把这两者结合起来用:
1)使用分布式锁,在上层完成「互斥」目的,虽然极端情况下锁会失效,但它可以最大程度把并发请求阻挡在最上层,减轻操作资源层的压力
2)但对于要求数据绝对正确的业务,在资源层一定要做好「兜底」,设计思路可以借鉴 fecing token 的方案来做
两种思路结合,我认为对于大多数业务场景,已经可以满足要求了。
小结
本文介绍了锁和分布式锁的概念,锁其实就是用来保证同一时刻只有一个程序可以去操作某一个资源,以此来保证数据在并发时不出问题。
使用 Redis 实现分布式锁不能用 setnx 命令,因为它可能会带来死锁的问题,因此我们可以使用 Redis 2.6.12 中支持多参数的 set 命令来申请锁。但它会涉及业务执行时间超过锁的超时时间,带来线程安全和锁误删的问题。而这两个问题,可以通过守护线程定时续租、以及设置唯一标识来解决。
最后就是主从复制带来的问题,因为 Redis 是 AP 模型,而分布式锁要求的是 CP 模型,所以在极端场景下 Redis 会出问题。换句话说,使用 Redis 做分布式锁,99% 的情况下都是 ok 的。于是 Redis 作者又整出来一个 Redlock,但我觉得太重了,没必要使用。
Martin 更推荐使用 Zookeeper 实现分布式锁,它是 CP 模型,当然啦,出于性能考虑,更推荐 etcd。
本文转载自:
- 《水滴与银弹》
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器