分布式锁:RedLock 你这锁也不包熟啊!

3,023 阅读12分钟

「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。

前言

RedLock算法是Redis作者提出基于Redis在分布式锁的一种实现。在RedLock提出之后,就有一位分布式领域的研究大牛Martin在Github上批评RedLock,本文就分布式锁的实现以及RedLock为什么被喷来带大家探究一下分布式锁究竟是个什么东西。

在介绍RedLock之前,先来看看传统的单机锁和分布式锁的比较,还有常见的分布式锁实现方案。

单机锁 vs 分布式锁

当我们的业务数据流量上来了之后,系统的架构就会从单机集中式系统升级位分布式架构。在单机系统高并发的情况下,我们直接使用Java内置的锁比如Synchronize或者是ReentrantLock就可以实现业务需求。

这类锁属于单机锁,对于单机架构来说是完全够用的。但并不适用于在分布式架构中。用户请求通过负载均衡设备打在每个服务上面的,单机锁只能够限制打入到当前机器的请求,并不能限制整个分布式集群

image.png

在分布式环境下,如果我们想要并发严格控制资源,那么就需要用到分布式锁

常见分布式锁实现

常见的分布式锁实现有基于RedisMysqlZookeeper的。归根到底是因为这些中间件可以提供共享资源的一个能力。相当于小时候看见同学之间打架,都会去找老师来协调。

Mysql

唯一性约束

原理:Mysql会对唯一性索引进行唯一性校验,插入的时候如果不符合唯一性要求,就会直接报错。

可以利用插入主键的唯一性来进行校验,除了主键也可以使用唯一索引来进行分布式锁的实现,原理是一样的。

  • 如果我们可以插入这条数据,则代表我成功地获得了锁
  • 当我们执行完了业务逻辑之后,再进行删除这条数据,代表锁释放即可。

如果已经插入了这条数据,那么其他人是无法进行再次插入,会报错,代表锁已经被人获取了。

注意:这里插入的数据必须是大家都一样的。比如插入一个商品id:10086,大家都去尝试插入 10086.

Zookeeper

基于使用Zookeeper实现分布式锁相信大家都非常熟悉,他是基于Zookeeper中的临时顺序节点来实现的。

临时顺序性节点 + ZAB协议

原理:Zookeeper中的ZNode有持久性节点和临时节点,临时节点的生命周期跟客户端的Session是绑定的。当客户端失去连接之后,所创建的临时节点也会全部删除。

锁的获取:就是全部客户端去Zookeeper创建同一个节点,看谁成功创建,谁就获得了锁。

锁的释放:客户端自行删除节点,或者客户端和服务器超时之后强制释放。

Zookeeper是一个CP中间件,采用的是ZAB(Zookeeper Atomic Broadcast)分布式一致性协议。所以他可以很好的控制并发情况下节点创建的唯一性。

Zookeeper是一个非常可靠的分布式锁实现方案,缺点就是会引入第三方的中间件使得系统架构会变重。需要根据自己的业务数据量来判断是否上Zookeeper。

Redis

基于Redis的实现,也是非常常见的一种解决方案。因为一个系统可能没有Zookeeper,可能没有消息中间件,但是Redis缓存肯定会有(或者是其他缓存中间件Memcache等)。

Key的唯一性

一种实现方案是基于Key的唯一性。也就是setNx,那条指令。

原理:setNx 就是 set if not Existed (存入Key如果没有存在的话)

一般我们都会携带超时时间,避免释放锁的时候出现故障导致Key一直存活在Redis里面无法再次进行锁的获取。

缺点:只能使用于单Redis实例,不支持Redis集群。 并且如果锁所在的Redis实例挂掉了之后,别的客户端就可以趁机而入进行锁的获取,但是已经拿到锁的客户端无法感知。

image.png

客户端并发地去获取锁,只能跑到1个Redis实例去进行锁的获取。不能说我客户端A在Redis_1中成功获得了锁.然后你客户端B在Redis_2设置了锁,你也说自己成功获得了锁。

注意: 还需要考虑许多细节,比如锁续命,锁只能释放自己的锁等细节问题。感兴趣的可以看作者之前的文章介绍 Redis&Redisson 关于分布式锁中看门狗机制 源码理解 超卖问题 - 掘金 (juejin.cn)。这里就不赘述。

那有没有能够支持Redis集群的锁呢?现在Redis基本都是集群架构来抗并发压力了。答案其实是有的RedLock

RedLock

分析问题:之所以简单的设置Key不能满足于Redis集群应用,主要的原因在于锁仅存在于单个实例中。现在介绍今天的主角RedLock

RedLock算法原理

image.png

  1. 获取当前的时间戳
  2. 使用同一个Key且带有超时时间,向Redis集群发出锁获取请求,并且给客户端也设置一个超时时间,防止Redis实例挂掉了之后客户端还傻傻等待。(客户端超时时间要比Key的超时时间要短)
  3. 超过半数实例获取锁成功并且没有超过客户端超时时间(根据步骤1计算),那么就是视为客户端成功获取锁。
  4. 如果获取锁失败,比如没有得到半数客户端加锁成功或者是超时了就视为获取锁失败,客户端需要向全部Redis实例发送解锁请求(del)。

算法相对比较简单,里面包含了半数的思想,半数这个概念在分布式数据一致性中出现的频率非常高,比如Paxos、ZAB都有他的身影。

RedLock是否真的能彻底解决分布式锁的问题呢?

正常情况下,是可以解决分布式问题的。

image.png

当某个客户端通过超半数的实例获取锁成功之后,那么其他客户端就不可能再通过超半数获取到锁。但是面对极端情况下,RedLock可能就不包熟了!

极端场景:加锁的节点宕机

image.png

原本ClientA通过RedLock加锁成功在Redis_1Redis_2Redis_3实例上成功加锁!但过了一段时间后,Redis_3节点宕机掉后重启加入集群,但加锁的数据没了,此时被ClientB趁虚而入,在Redis_3Redis_4Redis_5节点成功超半数加锁,那么ClientA和ClientB同时持有锁,这个锁就不包熟了!

解决办法:

  • 持久化数据,使用AOF方式来存储数据,尽可能地保存全部锁的数据,当节点宕机之后也能保证重启之后锁依然在Redis中。AOF同步策略中,有每秒同步每次同步。设置位每秒同步,每次进行写操作的时候都会写日志,就是效率优点低。
  • 延迟启动。光时靠持久化数据还不够,必须估计到数据还没有持久化到磁盘后就宕机的情况。此时我们可以采取延迟启动。Redis宕机之后不要立即重启,而是要等分布式锁中最长的Key的TTL(超时时间)过了之后再启动,保证全部Key都被强制解锁了。但这种方案需要用一个东西来存储每个分布式锁的TTL时间。

极端场景:客户端无法感知锁超时

image.png

国外的一个知名分布式领域研究大牛Martin就在Github上喷过RedLock的缺点。他提出了以下的观点:

  • 由于Key在Redis中具有超时自动释放的机制,在客户端的锁无法感知自己的锁失效了。
  • RedLock太过于依赖时间。

上图是Martin指出的一种情况。

  1. 客户端通过RedLock加锁成功后,就执行自己的业务逻辑。
  2. 客户端恰巧执行垃圾回收,GC中的STW(stop the world),机制会导致客户端阻塞一段时间。
  3. 当客户端醒过来后,锁已经在Redis中失效了,然后被Client2趁虚而入,Client2加锁成功。此时Client1、Client2同时持有锁,导致资源不安全。

其实不止提到的STW(stop the world)情况,很多时间都会出现阻塞客户端的情况。比如:网络通信原因等等。

那Client1他也不知道自己锁失效了阿,这也不能怪人家Client1。那么能不能解决这个问题呢?Martin也提出了相对应的解决方案fencing机制

fencing机制

image.png

fencing我百度了发现是击剑🤺的意思,还挺有趣的!

Martin认为给每一个锁都加入一个标志ID,这个标志ID是单调递增的。越晚生成的锁就越小。

回到刚刚的场景下,Client1拿到了锁并且这个锁的ID是369。而Client2是后来拿到锁的,所以他生成的锁的ID是370(比369要大)。那么在他们进行写入操作的时候,只允许最大的那个锁所在的客户端有效即可!

聪明的小伙伴可能读到这里就能联想到了某个跟fencing非常相似的机制,没错就是Zookeeper中的ZXID全局事务ID。归根到底就是乐观锁的一个实现。

Redis的作者反怼了Martin,既然你都能保证共享变量的数据同步问题,那么你也不需要引入锁的这个机制呀?

具体的说就是,既然你能保证每个客户端知道自己锁的ID,并且知道自己的锁ID在整个集群中是否是有效的(最大的),那么你同样可以让需要上锁的资源,知道自己被谁持有

而且我个人觉得:Martin提到,“在写入数据的时候进行判断,自己是不是最大的锁ID”,这个实现起来就非常地困难。

假如又是Client1和Client2。Client2还没拿到锁,此时Client1在进行最后一步fencing的检查,发现自己是最高的,正准备进行写数据。Client此时拿到了锁。哦豁!又是一把锁两个客户端持有!

RedLock过于依赖时钟

在分布式架构中,其中的一个特点是:缺乏全局时钟

即使在现实世界中,每个地方的时间都会有不同,根据经纬度的不同,会划分分区(东8区)。分布式的机器在地理的维度上分布是自由的。那么不可避免机器上的时钟的不同。

即便是用NTP(时钟同步服务器)也不能保证时钟的可靠。比如出现时钟回拨时钟飘逸等问题。关于时钟回拨,是雪花算法中遇到的一个问题。感兴趣的可以去看看,雪花算法是如何解决时钟回拨的,这里提一句,雪花算法并不能保证严格的递增,原因就在于分布式每台机子的时间不能严格同步)。

下面一起复习一下RedLock算法:

  1. 获取当前的时间戳
  2. 使用同一个Key且带有超时时间,向Redis集群发出锁获取请求,并且给客户端也设置一个超时时间,防止Redis实例挂掉了之后客户端还傻傻等待。(客户端超时时间要比Key的超时时间要短)
  3. 超过半数实例获取锁成功并且没有超过客户端超时时间(根据步骤1计算),那么就是视为客户端成功获取锁。
  4. 如果获取锁失败,比如没有得到半数客户端加锁成功或者是超时了就视为获取锁失败,客户端需要向全部Redis实例发送解锁请求(del)。

我们来看看RedLcok哪里用到了时间

  • 在第3步骤,我们需要获取当前时间戳,然后再对第1步中获取的时间戳相减,curTime - startTime,然后再与客户端超时时间相比较,看是否超时。

  • Key存放在Redis中也是依靠时间来过期的。

image.png

如果出现了时钟回拨问题,Redis_3时间往前跳了,导致锁在Redis_3这里提前释放了,然后又被ClientB趁虚而入了。直接芭比Q。

Martin批评RedLock算法太过于依赖时间,大概意思就是:强调一个好的算法,不管时间维度上出现问题,还是网络通信上出现了问题,算法可以没有立刻得到正确的答案,但算法会在未来的时间内给出正确的答案而并非是错误的答案

一听就有最终一致性那味儿!

总结

  • RedLock算法有2个非常大的问题。1、客户端无法感知锁失效。2、RedLock过于依赖时钟。
  • 如果数据一致性要求比较严格,那么建议用Zookeeper实现分布式锁。

我们确实看到了RedLock算法在分布式环境下,确实有他的不足之处。但是我个人认为在CAP定理里,Redis是AP架构的中间件,对于锁这种偏向于保证C的问题上,确实不适合而且也没必要。要是真的数据对一致性要求很高,那么就用CP的中间件如Zookeeper。

就拿缓存和数据库一致性来说,尽管我们都知道Cache-aside Pattern(旁路缓存模式)有几率会出现数据不一致行。即便是延迟双删也不能完全保证。但是也会采用这个方案,原因就在于并不是很多业务达到需要用Canal那种严格一致性的中间件。

还是那句话,脱离了业务谈技术就是耍流氓