【求锤得锤的故事】Redis锁从面试连环炮聊到神仙打架。
持续输出原创文章,点击蓝字关注我吧
这是why技术的第 38 篇原创文章
又到了一周一次的分享时间啦,老规矩,还是先荒腔走板的聊聊生活。
有上面的图是读大学的时候,一次自行车骑行途中队友抓拍的我的照片。拍照的地方,名字叫做牛背山,一个名字很 low,实际很美的地方。
那条上山的路很难骑,超级烂路和极度变态的陡坡。真是一种折磨,是对意志力的完全考验。
在我们几近崩溃,弹尽粮绝,离山顶还有近两个多小时的时候,一个卡车司机主动要求把我们免费带到山顶。我们拒绝了。
因为同行的星哥他说了一句话:“骑行牛背山这件事情,我这辈子只会做这一次。现在的情况还不是那么糟,如果我搭车上去了,之后想起来我会有遗憾的。所以我更情愿推车。”
第二天下午山上下了一场暴风雪。于是我们几个南方孩子在这少见的雪景中肆意打闹。打雪仗,堆雪人,滑雪……
晚上雪停了之后,我看到了让我终身难忘的场景。星空,美丽到让人想哭的星空!
后来,在人生路上的很多场景中,我都会想起星哥的那句话:这件事,我这辈子只会做这一次,我不想留下遗憾。还会想起那晚璀璨的、触手可及般的星空。
坚持不住的时候再坚持一下,确实是一种难能可贵的精神。
好了,说回文章。
背景铺垫
面试的时候,不管你的简历写没写 Redis,它基本上是一个绕不过的话题。
为了引出本文要讨论的关于 Redlock 的神仙打架的问题,我们就得先通过一个面试连环炮:
-
Redis 做分布式锁的时候有需要注意的问题?
-
如果是 Redis 是单点部署的,会带来什么问题?
-
那你准备怎么解决单点问题呢?
-
集群模式下,比如主从模式,有没有什么问题呢?
-
你知道 Redis 是怎么解决集群模式也不靠谱的问题的吗?
-
那你简单的介绍一下 Redlock 吧?
-
你觉得 Redlock 有什么问题呢?
很明显,上面是一个常规的面试连环套路题。中间还可以插入很多其他的 Redis 的考察点,我这里就不做扩展了。
单点的 Redis 做分布式锁不靠谱,导致了基于 Redis 集群模式的分布式锁解决方案的出现。
基于 Redis 集群模式的分布式锁解决方案还是不靠谱,Redis 的作者提出了 Redlock 的解决方案。
Redis 作者提出的 Redlock 的解决方案,另一位分布式系统的大神觉得它不靠谱,于是他们之间开始了 battle。
基于这场 battle,又引发了更多的讨论。
这场 battle 难分伯仲,没有最后的赢家。如果一定要选出谁是最大的赢家的话,那一定是吃瓜网友。因为对于吃瓜网友来说(比如我),可以从两位大神的交锋中学习到很多东西。
让你深刻的体会到: 看起来那么无懈可击的想法,细细推敲之下,并不是那么天衣无缝。
所以本文就按照下面的五个模块展开讲述。
先来一波劝退: 本文近1.2w字,谨慎观看。看不下去不要紧,拉到最后点个“在看”。就是对于我最大的鼓励。奥利给!
单点Redis
按照我的经验,当面试聊到 Redis 的时候,百分之 90 的朋友都会说: Redis在我们的项目中是用来做热点数据缓存的。
然后百分之百的面试官都会问:
Redis除了拿来做缓存,你还见过基于Redis的什么用法?
接下来百分之 80 的朋友都会说到:我们还用 Redis 做过分布式锁。
(当然, Redis 除了缓存、分布式锁之外还有非常非常多的奇技淫巧,不是本文重点,大家有兴趣的可以自己去了解一下。)
那么面试官就会接着说:
那你给我描述(或者写一下伪代码)基于Redis的加锁和释放锁的细节吧。
注意面试官这里说的是 加锁和释放锁的细节,魔鬼都在细节里。
问这个问题面试官无非是想要听到下面几个关键点:
关键点一:原子命令加锁。因为有的“年久失修”的文章中对于 Redis 的加锁操作是先set key,再设置 key 的过期时间。这样写的根本原因是在早期的 Redis 版本中并不支持原子命令加锁的操作。不是原子操作会带来什么问题,就不用我说了吧?如果你不知道,你先回去等通知吧。
而在 2.6.12 版本后,可以通过向 Redis 发送下面的命令,实现原子性的加锁操作:
SET key random_value NX PX 30000
关键点二:设置值的时候,放的是random_value。而不是你随便扔个“OK”进去。
先解释一下上面的命令中的几个参数的含义:
random_value:是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的。
NX:表示只有当要设置的 key 值不存在的时候才能 set 成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
PX 30000:表示这个锁有一个 30 秒的自动过期时间。当然,这里 30 秒只是一个例子,客户端可以选择合适的过期时间。
再解释一下为什么 value 需要设置为一个随机字符串。这也是第三个关键点。
关键点三:value 的值设置为随机数主要是为了更安全的释放锁,释放锁的时候需要检查 key 是否存在,且 key 对应的值是否和我指定的值一样,是一样的才能释放锁。所以可以看到这里有获取、判断、删除三个操作, 为了保障原子性,我们需要用 lua 脚本。
(基本上能答到这几个关键点,面试官也就会进入下一个问题了。常规热身送分题呀,朋友们,得记住了。)
集群模式
面试官就会接着问了:
经过刚刚的讨论,我们已经有较好的方法获取锁和释放锁。基于Redis单实例,假设这个单实例总是可用,这种方法已经足够安全。如果这个Redis节点挂掉了呢?
到这个问题其实可以直接聊到 Redlock 了。 但是你别慌啊,为了展示你丰富的知识储备(疯狂的刷题准备),你得先自己聊一聊 Redis 的集群 ,你可以这样去说:
为了避免节点挂掉导致的问题,我们可以采用Redis集群的方法来实现Redis的高可用。
Redis集群方式共有三种:主从模式,哨兵模式,cluster(集群)模式
其中主从模式会保证数据在从节点还有一份,但是主节点挂了之后,需要手动把从节点切换为主节点。它非常简单,但是在实际的生产环境中是很少使用的。
哨兵模式就是主从模式的升级版,该模式下会对响应异常的主节点进行主观下线或者客观下线的操作,并进行主从切换。它可以保证高可用。
cluster (集群)模式保证的是高并发,整个集群分担所有数据,不同的 key 会放到不同的 Redis 中。每个 Redis 对应一部分的槽。
(上面三种模式也是面试重点,可以说很多道道出来,由于不是本文重点就不详细描述了。主要表达的意思是你得在面试的时候遇到相关问题,需要展示自己是知道这些东西的,都是面试的套路。)
在上面描述的集群模式下还是会出现一个问题,由于节点之间是采用异步通信的方式。如果刚刚在 Master 节点上加了锁,但是数据还没被同步到 Salve。这时 Master 节点挂了,它上面的锁就没了,等新的 Master 出来后(主从模式的手动切换或者哨兵模式的一次 failover 的过程),就可以再次获取同样的锁,出现一把锁被拿到了两次的场景。
锁都被拿了两次了,也就不满足安全性了。 一个安全的锁,不管是不是分布式的,在任意一个时刻,都只有一个客户端持有。
Redlock简介
为了解决上面的问题,Redis 的作者提出了名为 Redlock 的算法。
在 Redis 的分布式环境中,我们假设有 N 个 Redis Master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。
前面已经描述了在单点 Redis 下,怎么安全地获取和释放锁,我们确保将在 N 个实例上使用此方法获取和释放锁。
在下面的示例中,我们假设有 5 个完全独立的 Redis Master 节点,他们分别运行在 5 台服务器中,可以保证他们不会同时宕机。
从官网上我们可以知道,一个客户端如果要获得锁,必须经过下面的五个步骤:
步骤描述来源:
http://redis.cn/topics/distlock.html
-
获取当前 Unix 时间,以毫秒为单位。
-
依次尝试从 N 个实例,使用相同的 key 和随机值获取锁。在步骤 2,当向 Redis 设置锁时, 客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间 。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个 Redis 实例。
-
客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
-
如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
-
如果因为某些原因,获取锁失败(没有在至少 N/2+1 个Redis实例取到锁或者取锁时间已经超过了有效时间), 客户端应该在所有的 Redis 实例上进行解锁 (即便某些 Redis 实例根本就没有加锁成功)。
通过上面的步骤我们可以知道,只要大多数的节点可以正常工作,就可以保证 Redlock 的正常工作。这样就可以解决前面单点 Redis 的情况下我们讨论的节点挂掉,由于异步通信,导致锁失效的问题。
但是,还是不能解决故障重启后带来的锁的安全性的问题。你想一下下面这个场景:
我们一共有 A、B、C 这三个节点。
-
客户端 1 在 A,B 上加锁成功。C 上加锁失败。
-
这时节点 B 崩溃重启了,但是由于持久化策略导致客户端 1 在 B 上的锁没有持久化下来。
-
客户端 2 发起申请同一把锁的操作,在 B,C 上加锁成功。
-
这个时候就又出现同一把锁,同时被客户端 1 和客户端 2 所持有了。
(接下来又得说一说Redis的持久化策略了,全是知识点啊,朋友们)
比如,Redis 的 AOF 持久化方式默认情况下是每秒写一次磁盘,即 fsync 操作,因此最坏的情况下可能丢失 1 秒的数据。
当然,你也可以设置成每次修改数据都进行 fsync 操作(fsync=always),但这会严重降低 Redis 的性能,违反了它的设计理念。(我也没见过这样用的,可能还是见的太少了吧。)
而且,你以为执行了 fsync 就不会丢失数据了?天真,真实的系统环境是复杂的,这都已经脱离 Redis 的范畴了。上升到服务器、系统问题了。
所以,根据墨菲定律,上面举的例子:由于节点重启引发的锁失效问题,总是有可能出现的。
为了解决这一问题, Redis 的作者又提出了延迟重启(delayed restarts ) 的概念。
意思就是说,一个节点崩溃后,不要立即重启它,而是等待一定的时间后再重启。等待的时间应该大于锁的过期时间(TTL)。这样做的目的是保证这个节点在重启前所参与的锁都过期。相当于把以前的帐勾销之后才能参与后面的加锁操作。
但是有个问题就是: 在等待的时间内,这个节点是不对外工作的。那么如果大多数节点都挂了,进入了等待。就会导致系统的不可用,因为系统在TTL时间内任何锁都将无法加锁成功。
Redlock 算法还有一个需要注意的点是它的释放锁操作。
释放锁的时候是要向所有节点发起释放锁的操作的。这样做的目的是为了解决有可能在加锁阶段,这个节点收到加锁请求了,也set成功了,但是由于返回给客户端的响应包丢了,导致客户端以为没有加锁成功。所有,释放锁的时候要向所有节点发起释放锁的操作。
你可以觉得这不是常规操作吗?
有的细节就是这样,说出来后觉得不过如此,但是有可能自己就是想不到这个点,导致问题的出现,所以我们才会说: 细节,魔鬼都在细节里。
好了,简介大概就说到这里,有兴趣的朋友可以再去看看官网,补充一下。
中文:http://redis.cn/topics/distlock.html
英文:https://redis.io/topics/distlock
好了,经过这么长,这么长的铺垫,我们终于可以进入到神仙打架环节。
神仙打架
神仙一:Redis 的作者 antirez 。有的朋友对英文名字不太敏感,所以后面我就叫他 卷发哥 吧。
神仙二:分布式领域专家 Martin Kleppmann,我们叫他 长发哥 吧。
看完上面两位神仙的照片,再看看我为了写这篇文章又日渐稀少的头发,我忍不住哭出声来。可能只有给我点赞,才能平复我的心情吧。
卷发哥在官网介绍 Redlock 页面的最后写到:如果你也是使用分布式系统的人员,你的观点和意见非常重要,欢迎和我们讨论。
于是,“求锤得锤”!这一锤,锤出了众多的吃瓜网友,其中不乏在相关领域的专业人士。
长发哥出锤
故事得从 2016年2月8号 长发哥发布的一篇文章《How to do distributed locking》说起:
文章地址:
http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
这一部分直接翻译过来就是:
作为本书(《数据密集型应用系统设计》)研究的一部分,我在Redis网站上 看到了一种称为Redlock的算法。该算法声称在Redis实现容错的分布式锁(或更确切地说, 租约),并且该页面要求来自分布式系统人员的反馈。这个算法让我产生了一些思考,因此我花了一些时间写了我的这篇文章。
由于Redlock已经有10多个独立的实现,而且我们不知道谁已经在依赖此算法,因此我认为值得公开分享我的笔记。我不会讨论Redis的其他方面,其中一些已经在其他地方受到了批评 。
你看这个文章,开头就是火药味十足: 你说要反馈,那我就给你反馈。而且你这个东西有其他问题,我也就不说了。 (其实作者在这篇文章中也说了,他很喜欢并且也在使用 Redis,只是他觉得这个 Redlock 算法是不严谨的)
长发哥主要围绕了下面的这张图进行了展开:
要是一眼没看明白,我再给你一个中文版的,来自长发哥于2017年出版的书《数据密集型应用系统设计》:
可以看到上面的图片中提到了申请租约、租约到期的关键词,租约其实就是可以理解为带超时时间的锁。
而在书中,这张图片的下面写的描述这样的,你咂摸咂摸:
拿 HBase 举例,其设计的目标是确保存储系统的文件一次只能由一个客户端访问,如果多个客户端试图同时写入该文件,文件就会被破坏。那么上面的图片解释起来就是:
-
客户端 1 先去申请锁,并且成功获取到锁。之后客户端进行了长时间的 GC 导致了 STW 的情况。
-
在 STW 期间,客户端 1 获取的锁的超时时间到了,锁也就失效了。
-
由于客户端 1 的锁已经过期失效了,所以客户端 2 去申请锁就可以成功获得锁。
-
客户端 2 开始写文件,并完成文件的写入。
-
客户端 1 从 STW 中恢复过来,他并不知道自己的锁过期了,还是会继续执行文件写入操作,导致客户端 2 写入的文件被破坏。而且可以看到,它没有满足锁在任意时刻只有一个客户端持有的原则,即没有满足互斥性。
书里面没有明说,但是你品一品,这里的锁服务难道不是在说 Redis?
有的朋友就会说了,那客户端 1 写入文件的时候,再判断一下自己的锁有没有过期不就可以了吗?
你可真是个小机灵鬼呢,那我问你,GC 可能是发生在任何时间的,万一 GC 发生在判断之后呢?
你继续怼我,如果客户端使用的是没有 GC 的语言呢?
GC 不是导致线程暂停的唯一原因啊,朋友们。发生这种情况的原因有很多的,你看看长发哥书里举的例子:
上面的内容总结起来,就是 就算锁 服务是正常的 ,但是由于锁是有持有 时间的,由于客户端阻塞、长时间的 GC 或者网络原因,导致共享资源被一个以上的客户端同时访问了。
其实上面长发哥在书里直接说了:这是不正确的实现。
你多品一品,上面的图是不是有点像由于 Redis 锁的过期时间设置的不合理,导致前一个任务还没执行完成,但是锁的时间到期了,后一个任务也申请到了锁。
对于这种场景,Redission 其实有自己的看门狗机制。但是不在这次 Redlock 的讨论范围内,所以这里就不描述了。
长发哥提出的解决方案是什么呢?
他称为:fencing token。
长发哥认为使用锁和租约机制来保护资源的并发访问时,必须确保因为异常原因,导致锁过期的那个节点不能影响其他正常的部分,要实现这一目标,可以采用一直相当简单的 fencing(栅栏)。
假设每次锁服务在授予锁或者租约时,还会同时返回一个 fencing 令牌,该令牌每次授予都会递增。
然后,要求客户端每次向存储系统发送写请求时,都必须包含所持有的 fencing 令牌。存储系统需要对令牌进行校验,发现如果已经处理过更高令牌的请求,则拒绝执行该请求。
比如下面的图片: