那个深夜,我登上了公司的服务器,在 redis 命令行里敲入 keys* 后,线上开始报警,服务瞬间被卡死。
图片来自 Pexels
我只能举起双手,焦急地等待几千万 key 被慢慢扫描,束手无策万念俱灰的时候,我收到了 Leader 的短信:你明天不用来上班了。
虽然上面是我的臆想,事实上很多公司的运维也会禁用这些命令,来防止开发出错。
但我在群里依然看到有同学在问“为什么 Redis 不能用 keys?我觉得挺好的呀”时,为了不让上面的情况发生,我决定写下这篇文章。
如何才能优雅地遍历 Redis?作为一种可以称为数据库的组件,这是多么理所应当的要求。
终于,Redis 在 2.8.0 版本新增了众望所归的 scan 操作,从此再也不用担心敲入了 keys*,然后等着定时炸弹的引爆。
学会使用 scan 并不困难,那么问题又来了,它是如何遍历的?当遍历过程中加入了新的 key,当遍历过程中发生了扩容,Redis 是如何解决的?
抱着深入学习的态度,以及为了能够将来在面试官面前谈笑风生,让我们一起来借此探索 Redis 的设计原理。
开门见山,首先让我们来总结一下 scan 的优缺点。
优点如下:
缺点如下:
所以 scan 是一个能够满足需求,但也不是完美无瑕的命令。下面来介绍一下原理,scan 到底是如何实现的?
scan,hscan 等命令主要都是借用了通用的 scan 操作函数:scanGenericCommand 。
scanGenericCommand 函数分为以下几步:
由于 Redis 设计,只有数据量比较小的时候才会保存为 ziplist 或者 intset,所以此处不会影响性能。
游标在保存为 hash 的时候发挥作用,具体入口函数为 dictScan,下文详细描述。
当迭代一个哈希表时,存在三种情况:
在这三种情况之下,sacn 是如何实现的?首先需要知道的前提是:Redis 中进行 rehash 扩容时会存在两个哈希表,ht[0] 与 ht[1],rehash 是渐进式的,即不会一次性完成。
新的键值对会存放到 ht[1] 中并且会逐步将 ht[0] 的数据转移到 ht[1]。全部 rehash 完毕后,ht[1] 赋值给 ht[0] 然后清空ht[1]。
模拟问题
①迭代过程中,没有进行 rehash
这个过程比较简单,一般来说只需要最简单粗暴的顺序迭代就可以了,这种情况下没什么好说的。
②迭代过程中,进行过 rehash
但是字典的大小是能够进行自动扩容的,我们不得不考虑以下两个问题:
第一,假如字典扩容了,变成 2 倍的长度,这种情况下,能够保证一定能遍历所有最初的 key,但是却会出现大量重复。
举个例子:比如当前的 key 数组大小是 4,后来变为 8 了。假如从下表 0,1,2,3 顺序扫描时,如果数组已经发生扩容。
那么前面的 0,1,2,3 slot 里面的数据会发生一部分迁移到对应的 4,5,6,7 slot 里面去,当扫描到 4,5,6,7 的 slot 时,无疑会出现值重复的情况。
需要知道的是,Redis 按如下方法计算一个当前 key 扩容后的 slot:hash(key)&(size-1)。
如图,当字典大小从 4 扩容到 8 时,原先在 0 slot 的数据会分散到 0(000) 与 4(100) 两个 slot,对应关系表如下:
第二, 如果字典缩小了,比如从 16 缩小到 8, 原先 scan 已经遍历了 0,1,2,3 ,如果数组已经缩小。
这样后来迭代停止在 7 号 slot,但是 8,9,10,11 这几个 slot 的数据会分别合并到 0,1,2,3 里面去,从而 scan 就没有扫描出这部分元素出来,无法保证可用性。
③迭代过程中,正在进行 rehash
上面考虑的情况是,在迭代过程的间隙中,rehash 已经完成。那么会不会出现迭代进行中,切换游标时,rehash 也正在进行?当然可能会发生。
如果返回游标 1 时正在进行 rehash,那么 ht[0](扩容之前的表)中的 slot1 中的部分数据可能已经 rehash 到 ht[1](扩容之后的表)中的 slot1 或者 slot4。
此时必须将 ht[0] 和 ht[1] 中的相应 slot 全部遍历,否则可能会有遗漏数据,但是这么做好像也非常麻烦。
解决方法
为了解决以上两个问题,Redis 使用了一种称为:reverse binary iteration 的算法。
源码如下:
unsignedlongdictScan(dict*d,unsignedlongv,dictScanFunction*fn,void*privdata){if(!dictIsRehashing(d)){t0=(d->ht[0]);m0=t0->sizemask;/*Emitentriesatcursor*/while(de){fn(privdata,de);de=de->next;}}else{m0=t0->sizemask;m1=t1->sizemask;de=t0->table[v&m0];while(de){fn(privdata,de);de=de->next;}do{de=t1->table[v&m1];while(de){fn(privdata,de);de=de->next;}v=(((v|m0)+1)&~m0)|(v&m0);}while(v&(m0^m1));}v|=~m0;v=rev(v);v++;v=rev(v);returnv;}
一起来理解下核心源码,第一个 if,else 主要通过 dictIsRehashing 这个函数来判断是否正在 rehash。
sizemask 指的是字典空间长度,假如长度为 16,那么 sizemask 的二进制为 00001111。m0 代表当前字典的长度,v 代表游标所在的索引值。
接下来关注这个片段:
v|=~m0;v=rev(v);v++;v=rev(v);
这段代码初看好像有点摸不着头脑,怎么多次在多次 rev?我们来看下在字典长度从 4 rehash 到 8 时,scan 是如何迭代的。
当字典长度为 4 时,m0 等于 4,二进制表示为 00000011,那么 ~m0 为 11111100,v 初始值为 0,那么 v |=~m0为11111100。
接下来看图:
可以看到,第一次 dictScan 后,游标从 0 变成了 2,四次遍历分别为 0→2→1→3,四个值都遍历到了。
在字典长度为 8 时,遍历情况如下:
遍历顺序为:0→4→2→6→1→5→3→7。
是不是察觉了什么?遍历顺序是不是顺序是一致的?如果还没发现,不妨再来看看字典长度为 16 时的遍历情况,以及三次顺序的对比:
让我们设想这么一个情况,字典的大小本身为 4,开始迭代,当游标刚迭代完 slot0 时,返回的下一个游标是 slot2。
此时发现字典的大小已经从 4 rehash 到 8,那么不妨继续从 size 为 8 的 hashtable 中 slot2 处继续迭代。
有人会说,不是把 slot4 遗漏掉了吗?注意之前所说的扩容方式:hash(key)&(size-1),slot0 和 slot4 的内容是相同的,巧妙地避开了重复,当然,更不会遗漏。
如果你看到这里,你可能会发出和我一样的感慨:我 X,这算法太牛 X 了。
没错,上面的算法是由 Pieter Noordhuis 设计实现的,Redis 之父 Salvatore Sanfilippo 对该算法的评价是“Hard to explain but awesome。”
字典扩大的情况没问题,那么缩小的情况呢?可以仿照着自己思考一下具体步骤。答案是可能会出现重复迭代,但是不会出现遗漏,也能够保证可用性。
迭代过程中,进行过 rehash 这种情况下的迭代已经比较完美地解决了,那么迭代过程中,正在进行 rehash 的情况是如何解决的呢?
我们继续看源码,之前提到过 dictIsRehashing 这个函数用来判断是否正在进行 rehash。
那么主要就是关注这段源码:
m0=t0->sizemask;m1=t1->sizemask;de=t0->table[v&m0];while(de){fn(privdata,de);de=de->next;}do{de=t1->table[v&m1];while(de){fn(privdata,de);de=de->next;}v=(((v|m0)+1)&~m0)|(v&m0);}while(v&(m0^m1));
m0 代表 rehash 前的字典长度,假设为 4,即 00000011,m1 代表 rehash 后的字典长度,假设为 8,即 00000111。
首先当前游标 &m0 可以得到较小字典中需要迭代的 slot 的索引,然后开始循环迭代。
然后开始较大字典的迭代,首先我们关注一下循环条件:
v&(m0^m1)
m0,m1 二者经过异或操作后的值为 00000100,可以看到只留下了最高位的值。
游标 v 与之做 & 操作,将其作为判断条件,即判断游标 v 在最高位是否还有值。
当高位为 0 时,说明较大字典已经迭代完毕。(因为较大字典的大小是较小字典的两倍,较大字典大小的最高位一定是 1)
到此为止,我们已经将 scan 的核心源码通读一遍了,相信很多其间的迷惑也随之解开。
不仅在写代码的时候更自信了,假如日后被面试官问起相关问题,那绝对可以趁机表现一番,想想还有点小激动。