查看原文
其他

面试时遇到「看门狗」脖子上挂着「时间轮」,我就问你怕不怕?

CSDN云计算 2020-10-29

The following article is from why技术 Author why技术

来源 | Why技术
封图 |  CSDN 下载于视觉中国

之前写了一篇文章,有一个小节中写到这样一段话:


于是就有读者来问了:老哥,看门狗介绍一下呗。面试的时候被问到了,没有回答上来。

听到这个问题我脑海里首先浮现出了几个问题:

  1. 你面试被问到,没有答上来,然后呢?
  2. 面试结束之后你没有进行面试的复盘吗?
  3. 对于自己没有回答上来的问题,没有去进行探索吗?

甚至你都忘记了当时你的面试题,只是看到我文章的时候,突然想起:哦,这题我之前遇到过,没有解决。

这个方式是不对的,朋友。


面试后的复盘非常重要

一次面试是一场技术的交锋,所以面试之后的复盘非常非常的重要,面试结束后的第一件事情就应该是回顾整个面试过程,看看在整个面试的过程中,哪些地方是自己知道但是没有说清楚的,哪些地方是自己应该知道但是确实不知道,需要去提升的。

然后立刻、马上、当即在手机标签或者随身笔记上记录下复盘后自己的总结出来的关键点。

这些关键点可以是表现的好的地方,但是更多的应该是需要提升的地方。
也许你也在网上看到过这个套路:面试的过程中有几个问题没有回答上来,最后面试官说你先回去等通知吧。于是面试结束后,你对于没有回答上来的问题进行了学习,然后把自己的学习总结发给面试官。面试官一看,哟,这小伙可以啊,学习能力还不错。
然后就真的通知你准备进行下一轮面试吧。

这招我没用过,但是这个套路,传递的思想就是:在自己领域范围内,不懂的问题,遇到了,你得主动去解决。

面试后的复盘,非常的重要。

复盘过程中的想法形成文字,保留下来,非常非常的重要。

形成文字了,还可以分享出去,帮助后来人。

好了,既然读者问了这个问题,我就稍微扩展一下,把我自己知道的都分享一下。


先看示例代码


Redisson 分布式锁可能大多数朋友都用过。先上个代码给大家看看是怎么用的。
看到这几行代码,你先别往下看,你先想一想,和你自己造的轮子比起来有什么非常明显不一样的地方?
我给大家分享一下我第一次用 Redission 做分布式锁的时候遇到的两个非常直观的疑问吧。

  1. value去哪里了?
  2. 过期时间去哪里了?

之前说过,如果是我们自己造轮子,基于 Redis 做分布式锁的话,需要向 Redis 发一条下面的命令:
SET key random_value NX PX 3000
而在我们上面的示例代码中,为什么只有 key 没有 value 呢?

我们知道 value 是必须要有的。还记得《求锤得锤之神仙打架》这篇文章里面说的,当面试官问:
你给我讲一讲基于Redis的加锁和释放锁的细节吧。
我们从三个关键点中去回答:
  1. 原子命令加锁。
  2. 设置值的时候,放的是random_value。
  3. value 的值设置为随机数主要是为了更安全的释放锁,释放锁的时候需要检查 key 是否存在,且 key 对应的值是否和我指定的值一样,是一样的才能释放锁。所以可以看到这里有获取、判断、删除三个操作,为了保障原子性,我们需要用 lua 脚本。

所以,这个 value 是非常重要的。

另外,第 3 步,释放锁的时候为什么需要 lua 脚本,也有读者问过,其实这事几句话就能说清楚,所以我在这里插播一下:

你看这三个操作:获取、判断、删除。

获取操作,只读不写,没有任何问题。问题就出在判断和删除之间。如果不是原子操作,出现了下面的情况:

  1. 线程 A 在判断了 value 是自己放进去的,在执行 key 删除操作之前,程序 GC 导致了 STW。
  2. STW 期间线程 A 的锁虽然没有执行删除操作,但是由于时间到期被 redis 释放了。
  3. STW 之后,在线程 A 执行删除操作之前,线程 B 加了同样 key 的锁。
  4. 结果你猜怎么着?线程 A 把线程 B 加的锁删除了。这就出问题了。
为什么 lua 脚本可以解决这个问题呢?因为 lua 脚本的执行是原子性的,再加上 Redis 执行命令是单线程的,所以在 lua 脚本执行完之前,其他的命令都得等着。就不会出现上面说的情况了。

第二个问题是过期时间去哪里了呢?

看上面的加锁代码,像是没有设置过期时间似的。

我们先说说没有过期时间的问题是什么。很明显嘛,容易造成死锁。

加锁操作的服务器,在没有执行释放锁操作之前,服务器崩了。

哦豁,喜提死锁一把。


Value 去哪了

对于这个问题,首先我们需要确定的是,value一定是有的。

当我们自己放 value 的时候,一般就是搞个随机值,往里面一塞就完事了。

另外,我见过网上有些分析 Redis 分布式锁的文章里面 value 直接扔个 OK 进去。前面我们说过,这是不对啊,朋友们。要注意辨别。

用 Redssion 时,我们知道这个 key 肯定是框架帮我们生成了。所以我们只需要去源码中验证我们的想法即可。

但是,先别慌,我们还有一个更加简单的验证方法:程序跑起来,然后去 Redis 里面看一眼不就完事了?


看了一眼后发现,不错哦,不仅验证了我们的想法,还有意外收获呢。

意外收获一:我们看到了 TTL:25 说明虽然我们没用设置过期时间,但是框架帮我们把过期时间设置好了。这部分在这一小节中先按下不表,等下一小节详细描述。

意外收获二:可以看到我们放进去的 why 是一个 Hash 类型。并不是我们常用的 String 类型。

很明显,key 是 UUID:1,这个 1 是什么含义呢?

为什么要用 Hash 类型,而不用 String 类型呢?

我们带着这两个疑问去看一眼源码。

注意本文中的 Redssion 的 Maven 版本为 3.12.3。

Redssion 的源码非常好 Debug,我建议你自己实际操作一遍。

首先 lock 操作会调用到这个方法:
org.redisson.RedissonLock#lock(long, java.util.concurrent.TimeUnit, boolean)
可以看到,在这里的时候,获取到的 thredId 就是 1。那 key 里面 UUID 后面拼接的 1。是不是就是这里来的呢?我们接着往下看。

再往前 Debug 三步就能到下面的这个位置:
org.redisson.RedissonLock#tryLockInnerAsync


到这里的 getLockName(threadId) 其实就是我们要找的东西:


你看,这一串东西,不就是我们刚刚看到的 UUID:1 吗?这个 1 就是线程ID。

什么?你问我为什么说这个 id 是 UUID?

直觉,程序猿的直觉告诉我,这就是个 UUID。但是我可以给你验证一下。
这个 id 的来源是下面这个接口:
org.redisson.connection.ConnectionManager
而该接口有 5 个实现类:
在创建 ConnectionManager 时,每个实现类的构造方法传的都是 UUID。

所以,我们可以下结论了:

使用 Redssion 做分布式锁,不需要明确指定 value ,框架会帮我们生成一个由 UUID 和 加锁操作的线程的 threadId 用冒号拼接起来的字符串。

毫无挑战甚至有点无聊的探索过程啊。(其实我想表达的是源码真的不难,不要抱有恐惧的心理,带着问题去看源码。)

但是别着急,这只是开胃菜。

对于第二个问题:为什么要用 Hash 类型,而不用 String 类型呢?

我们在下一节,寻找过期时间去哪里了的同时,寻找该问题的答案。

过期时间去哪了?

这个问题,我们从这段代码里面可以找到答案:
org.redisson.RedissonLock#tryLockInnerAsync

我们首先看一下这个方法对应的几个入参:


主要关注我框起来的部分:
  1. script:是要执行的 lua 脚本。
  2. keys:是 redis 中的 key。这里的 why 就是 KEYS[1]。
  3. params:是 lua 脚本的参数。这里的 30000 就是 ARVG[1]。UUID:thredId 就是 ARVG[2]。

所以这个过期时间我们也知道了,默认是 30000ms,即30s。

知道了上面三个参数的含义后,我们再来拆解这个 lua 脚本就很简单了,首先我们把他拆解为三部分:


第一部分:加锁

先看第一部分的加锁操作:


第4行,首先用 exists 判断了 KEYS[1] (即 why)是否存在。

如果不存在,则进入第 5 行,使用 hincrby 命令。hincrby 命令是干什么的知道吧?


之后进入第 6 行,对 KEY[1] 设置过期时间,30000ms。

然后,第7行,进行返回为 nil,结束。

这样,一个原子性的加锁操作就完成了。

到这里,我们就已经从源码的角度验证了:因为用的是 hincrby 命令,Redssion 做锁的时候 key 确实是一个 Hash 结构。

第二部分:重入

当第一部分的 if 分支判断 KEYS[1] 是存在的,则会进入到这个分支中:


由于 KEYS[1] 是一个 Hash 结构,所以第 13 行的意思是获取这个 KEYS[1] 中字段为 ARGV[2] 的数据,判断是否存在。

如果存在,则进入第 14 行代码,用 hincrby 命令对  ARGV[2] 字段进行加一操作。

然后第 15 行,没啥说的,就是重新设置过期时间为 30s。之后第 16 行,返回为 nil,结束。

所以,你在感受一下第 14 行代码的作用是什么?进入,然后加一,你联想到了什么?

看到这里的时候,解锁的 lua 脚本都不必看的,想也能想到,肯定是有一个减一的操作,然后减到 0,就释放这把锁。一会我们就去验证这个点。

所以,这里也就解释了为什么 Redssion 需要用 Hash 类型做锁。因为它支持可重入呀。

你用 String 类型,你怎么实现重入功能,来键盘给你,实现一个,让我学习一下?(其实也是可以的,就是有点背道而驰了。没意义。)

第三部分:返回


一行代码,问题不大。作用就是返回 KEY[1] 的剩余存活时间。

通过分析 lua 的这三部分,我们知道了:过期时间默认是 30s。当一个 key 加锁成功或者当一个锁重入成功后都会返回空,只有加锁失败的情况下会返回当前锁剩余的时间。

记住这个结论,我们在接下来的看门狗咋工作的这一小节中会用到这个返回值。

另外,写文章的时候我发现 Redssion 的最新版本 3.12.3 和之前的版本相比,加锁时的 lua 脚本有一个细微的差别,如下:


3.12.3 版本之前用的是 hset ,现在用的是 hincrby。所以导致第一部分和第二部分相似度有点高。看起来会有点容易迷糊。

你去网上找应该看到的都是说 hset 操作的。因为 3.12.3 版本刚刚发布一个月。


恭喜你,朋友,又学到了一个用不上的知识点。

看门狗咋工作的?

看到这一节的朋友们,辛苦了。在这一节,我们终于要看到看门狗长啥样了。
org.redisson.RedissonLock#tryAcquireAsync

这里的 ttlRemaining 就是经过 lua 脚本后返回的值。经过前面我们知道了,当加锁成功或者重入成功后会返回 null。进入这个方法:
org.redisson.RedissonLock#scheduleExpirationRenewal
这个方法,就是看门狗工作的片区了。

Debug之后,你会遇到这个方法:
org.redisson.RedissonLock#renewExpiration

很明显,从上面标注的数字可以看出来:
①:这是一个任务。
②:这任务需要执行的核心代码。
③:该任务每 internalLockLeaseTime/3ms 后执行一次。而  internalLockLeaseTime 默认为 30000。所以该任务每 10s 执行一次。

接着我们看一下 ② 里面执行的核心代码是什么:


这个 lua 脚本,先判断 UUID:threadId 是否存在,如果存在则把 key 的过期时间重新设置为 30s,这就是一次续命操作。

来,在做个小学二年的算法题:

应用题:key 默认的过期时间是 30s,每过 30s/3 的时候会去进行续命操作,那么每当 key 的 ttl(剩余时间)返回多少的时候,会进行续命操作?

答:由题干可知,30s/3 = 10s。于是得公式到:30s - 10s =20s。

所以,每当 key 的 ttl(剩余时间)为 20 的时候,则进行续命操作,重新将 key 的过期时间设置为默认时间 30s。

注意我上面一直强调的是默认时间 30s

因为这个时间是可以修改的,比如我们想要修改为 60s,就这样:


于是 internalLockLeaseTime 就变成了 60000 了:


那么附加题就来了。

附加题:阅读上面材料后,当默认时间被修改为 60s 后,那么每当 key 的 ttl(剩余时间) 返回多少的时候,会进行续命操作?

答:由题可得,时间每过 60s/3 = 20s 时,任务会被触发,看门狗进行工作。

所以,60s -20s =40s。每当 key 的 ttl 返回 40 时,会进行续命操作。

得学会变形,朋友们,明白吗?


接下来,我们看看这个 task 任务是怎么实现的。


可以看到,这个 Timeout 是 netty 包里面的类。

这个 task 任务是基于 netty 的时间轮做的。

面试官追问你:啥是时间轮?

你又不知道。那你接着往下看。

时间轮又是啥?

你听到了时间轮,你首先想到了啥?

听到这个词,就算你完全不知道时间轮,你也该想到,轮子嘛,不就是一个环嘛。

网上随便一搜,你就知道它确实长成了一个环状:


它的工作原理如下:

图片中的时间轮大小为 8 格,每格又指向一个保存着待执行任务的链表。

我们假设它每 1s 转一格,当前位于第 0 格,现在要添加一个 5s 后执行的任务,则0+5=5,在第5格的链表中添加一个任务节点即可,同时标识该节点round=0。

我们假设它每 1s 转一格,当前位于第 0 格,现在要添加一个 17s 后执行的任务,则(0+17)% 8 = 1,则在第 1 格添加一个节点指向任务,并标记round=2,时间轮每经过第 1 格后,对应的链表中的任务的 round 都会减 1 。则当时间轮第 3 次经过第 1 格时,会执行该任务。

需要注意的是时间轮每次只会执行round=0的任务。

知道了工作原理,我们再看看前面说的 Timeout 类,其实就是 HashedWheelTimer 里面 newTimeout 方法的返回:


前面我们分析了,在 Redssion 实现看门狗功能的时候,使用的是 newTimeout 方法。该方法三个入参:

  1. task,任务,对于 Redssion 看门狗功能来说,这个 task 就是把对应的 key 的过期时间重置,默认是 30s。
  2. delay,每隔多久执行一次,对于 Redssion 看门狗功能来说,这个 delay 就是 internalLockLeaseTime/3 算出来的值,默认是 10s。
  3. unit,时间单位。

其实,你发现了吗,这个时候我们已经脱离了 Redssion 进入 Netty 了

我们只需要告诉 newTimeout 方法,我们要每隔多少时间执行一次什么任务就行。

那我们为什么不自己写个更加简单的,易于理解的 Demo 来分析这个时间轮呢?

比如下面这样的:


上面的 Demo 应该是很好理解了。

到这里,我们知道了看门狗是基于定时任务实现的,而这个定时任务是基于 Netty 的时间轮实现的。

对于 HashedWheelTimer 的源码,开始我还想进行一个导读,写着写着去查阅资料的时候发现,这个链接里面的对于源码的解读已经很到位了,我索性把自己的写那部分删除了,大家有兴趣的可以去阅读一下:
https://www.jianshu.com/p/1eb1b7c67d63
另外,关于时间轮,还可以看一下 IBM 论坛里面的这篇文章《浅析 Linux 中的时间编程和实现原理》:
https://www.ibm.com/developerworks/cn/linux/1308_liuming_linuxtime3/index.html

解锁操作

还记得我们加锁操作的时候说的吗?
进入,然后加一,你联想到了什么? 

这不就是可重入锁吗! 

看到这里的时候,解锁的 lua 脚本都不必看的,想也能想到,肯定是有一个减一的操作,然后减到 0,就释放这把锁。一会我们就去验证这个点。
这一小节,我们就去验证这个点,请看下面的释放锁执行的 lua 脚本:


是不是里面有个 counter 的判断,如果减一后小于等于 0。就执行 del key 的操作。

解锁操作确实挺简单,主要是 del 之后执行了一个 publish 命令。你猜这里 publish 的是啥?

先猜再验证嘛,大胆假设,小心求证!

这里是基于 redis 的发布/订阅功能。解锁的时候发布了一个事件,你觉得通知的是什么玩意?

肯定是告诉别的线程,我这边锁用完了,你来获取吧。

别的线程是什么线程呢?

就是想要申请同一把锁的线程。

tryAcquire 的代码我们之前分析过,当 ttl 不为 null 时,只有一种情况,那就是加锁失败:


所以加锁失败的线程就执行了 subscribe 方法,完成了订阅。

这样,就和释放锁时的 publish 操作呼应上了。

接下来就只剩下一个问题没有解决了:怎么让看门狗知道不用续命了?

其实就是在执行完解锁的 lua 脚本之后,通过响应式编程,完成了 cancel 操作。


自此,我们的加锁、看门狗续命、解锁的一套操作就完成了。

补充说明,顺便打脸

在打脸之前,我先问个问题吧:看门狗什么情况下会失效?

别给我说宕机,宕机之后,由于线程没了,看门狗只是不续命了, redis 里面的 key 到期之后就删除了。

我问的失效是指什么时候完全就不启动?

答案是,调用 lock 方法的时候传进一个指定时间,这样如果指定时间之内没有调用 unLock 方法,该锁还是会被释放的。就像下面这样:
rLock.lock(5,TimeUnit.SECONDS);
该锁在 5s 之后就会自动释放了。不会进行续命操作!!!

对应的源码如下,注意看我写的注释:


所以,我想起很久之前我在群里说的这个,红框框起来的部分是错的:


明确指定了超时时间的时候,是不会启动看门狗机制。

自己打自己脸的事......

好爽啊,这事我经常干。

而且,读书人的事,这能叫打脸吗?这叫成长。
另外,这图画的挺好的,分享给大家:

图片来源:
https://juejin.im/post/5bf3f15851882526a643e207

还有一个读者提出的问题,续租的时候,是否需要进行次数的限制?


我觉得是不需要限制的,如果你的代码一直在进行续期操作,说明两种情况:

  1. 由于某种异常原因,导致你本次需要处理的数据比之前的多,所以,需要的时间更长,导致一直在进行续期操作。
  2. 你的代码有问题,导致了死循环,也就是死锁的出现,这个锅,Redssion 不背。

最后,还有一个问题,这锁安全吗,或者说你觉得会有什么问题?

什么?你不知道?
之前分享过的文章中说过了:


节点之间异步通信,会出现上面描述的情况。所以 Redis 推出的解决方案是啥?

RedLock。

其实后来有一天我突然想到, 如果从 CAP 的角度上去看 Redis 分布式锁问题,我觉得可能更好理解一点。


分布式锁的一致性要求 CP,但是 Redis 集群架构之间的异步通信满足的是 AP ,因此对不上呀,就是有问题的啊。 

但是为什么 Redis 做分布式锁还是那么流行呢?

可能是因为大多场景中可以容忍它的这个问题,也可能是使用者存在侥幸心理吧,或者说使用者就当个黑盒使用,根本不知道可能会出问题。

同时,欢迎所有开发者扫描下方二维码填写《开发者与AI大调查》,只需2分钟,即可收获价值299元的“ AI开发者万人大会”在线直播门票!
推荐阅读:真香,朕在看了!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存