续上一篇文章 Redis Scan迭代器遍历操做原理(一)–基础 ,这里着重讲一下dictScan函数的原理,其实也就是redis SCAN操做最有价值(也是最难懂的部分)。html
关于这个算法的源头,来自于githup这里:Add SCAN command #579,长篇的讨论,确实难懂····建议看看这帖子,antirez 跟pietern 关于这个奇怪算法的讨论···git
这个算法的做者是:Pieter Noordhuis,做者称其为:reverse binary iteration ,不知道我一对一翻译为“反向二进制迭代器”可不能够,不过any way ··做者本身也没有明确的证实其真假:github
antirez: Hello @pietern! I’m starting to re-evaluate the idea of an iterator for Redis, and the first item in this task is definitely to understand better your pull request and implementation. I don’t understand exactly the implementation with the reversed bits counter…
I wonder if there is a way to make that more intuitive… so investing some more time into this, and if I fail I’ll just merge your code trying to augment it with more comments…
Hard to explain but awesome.
pietern: Although I don’t have a formal proof for these guarantees, I’m reasonably confident they hold. I worked through every hash table state (stable, grow, shrink) and it appears to work everywhere by means of the reverse binary iteration (for lack of a better word).redis
下面从零开始讲一下redis的迭代器应该怎么设计,以及为何不这么设计,而要这么设计·····算法
0.可用性 保证(Guarantees):
1.迭代结果能够重复;数组
2.整个迭代过程当中,没有变化(增长删除)过的key必须出如今结果中;数据结构
redis的key是用hash存在的,key分布在数组的槽位内,下标从0到2^N,而且采用链表解决冲突。app
hash会自动扩容或者缩小,而且每次 都是按2^N变化的。具体能够参阅:Redis源码学习-Dict/hash 字典。ide
1.最简单暴力的方法:顺序迭代:
这个简单,从0到2^N下标扫描一次,每次返回一个slot(槽位,也就是数组的一项,下同)或者多个slot的数据,这样实现很是简单,在不发生rehash的时候,这种方法没问题,可以完成前面的要求。,但有如下问题:
1.若是后来字典扩容了,好比2,4倍长度,那么可以保证必定能找出没变化的key,可是却会出现大量重复。函数
好比当前的key数组大小是8,后来变为16了,好比从0,1,2,3““顺序扫描,若是数组发生扩容,那么前面的0,1,2,3 slot里面的数据会发生一部分迁移到对应的8,9,10,11 slot里面去,而且这个量挺大;
2.若是字典缩小了,好比从16缩小到8, 原先scan已经遍历了0,1,2,3 ,而后发生缩小,这样后来迭代中止在7号slot,可是8,9,10,11这几个slot的数据会分别合并到0,1,2,3里面去,从而scan就没有扫描出这部分元素出来,没法保证可用性;
3.在发生rehashing的过程当中,这个确定有问题的。
2.中间的改进版本:
为了不上面第一种方法中第1个问题,也就是大量重复的问题,咱们能够改进为这样迭代扫描:若是字典大小为8, 那么扫描的时候,老是这么扫描:0,4, 1,5, 2,6, 3,7, 也就是访问完i 后,再访问i+2^(N-1), 这样若是已经访问过0,4, 1,5 了,当访问完2号slot以后,发生了扩容,变成了字典大小是16, 那么咱们不须要再次去访问8,9号了,缘由是8,9号里面的数据必定是从0和1里面迁移过去的。
但很惋惜,这样仍是没法解决字典缩小的时候没有访问问题,好比访问完0后,发生字典缩小,原来8号的数据迁移到了0号,而后按照算法,会去访问4号的。这样就会有问题。
2.redis的反向二进制位迭代器 原理:
首先从直观感受上,跟第二种方法相似的跳跃扫描,可是redis的方法更加完善。下面一步步的来介绍一下redis的SCAN原理
首先咱们知道,这个迭代操做有下面几个地方须要注意:
- 字典大小不变的时候;
- 字典大小扩容的时候 ;
- 字典大小缩小的时候;
- 发生rehash的时候;
对于最简单的时候,也就是没有发生字典大小变化,那么最简单了,按照redis如今的方式处理以下,而后再扩展到redis怎么处理变化的时候。
先贴一下代码:
1 |
unsigned long dictScan(dict *d, |
10 |
if (dictSize(d) == 0) return 0; |
12 |
if (!dictIsRehashing(d)) { |
19 |
de = t0->table[v & m0]; |
30 |
if (t0->size > t1->size) { |
39 |
de = t0->table[v & m0]; |
49 |
de = t1->table[v & m1]; |
60 |
v = (((v | m0) + 1) & ~m0) | (v & m0); |
63 |
} while (v & (m0 ^ m1)); |
0. 字典大小不变
假设字典大小为8,那么redis 的slot扫描顺序为:

细心的能够发现一个规律,就是能够两两分组,而且互相相差正好是8/2= 4。 对,这个是为了后面设计的。
咱们来看一下其二进制位的变化,以下,能够看出其两两的差别在于高位不同,算法会依次从高位开始尝试0和1的变化:

来讲一下它的好处,这种方法还能够这样描述:
依次从高位(有效位)开始,不断尝试将当前高位设置为1,而后变更更高位为不一样组合,以此来扫描整个字典数组。
这里咱们确定是必定可以扫描完整个数组的,不会漏。但其最大的好处在于,从高位扫描的时候,若是槽位是2^N个,扫描的临近的2个元素都是与2^(N-1)相关的就是说同模的,好比槽位8时,0%4 == 4%4, 1%4 == 5%4 , 所以想到其实hash的时候,跟模是很相关的。
好比当整个字典大小只有4的时候,一个元素计算出的整数为5, 那么计算他的hash值须要模4,也就是hash(n) == 5%4 == 1 , 元素存放在第1个槽位中。当字典扩容的时候,字典大小变为8, 此时计算hash的时候为5%8 == 5 , 该元素从1号slot迁移到了5号,1和5是对应的,咱们称之为同模或者对应。同模的槽位的元素最容易出现合并或者拆分了。所以在迭代的时候须要及时的扫描这些相关的槽位,这样就不会形成大面积的重复扫描。
咱们能够来走一遍代码,正常状况下,SCAN从0开始,假设字典大小为8,那么dictScan代码中字典确定不是在作rehashing,因此进入第一个if,直接将table[v & 8] 里面的链表节点返回给客户端。而后计算下一个scan的游标,计算代码以下:
这里来体味一下,上面反转,而后加1,而后再反转,总体效果其实就是想将有效位中,从高位开始的第一个0之上的1变为0,将第一个碰到的0变为1, 或者说尝试将0变为1的slot。
更细致的说,上面的例子,是将0变为了1,效果就是scan的游标从0升为4,升到一个对应的高槽位去。下面来看一下从高槽位回到低位的过程,也就是将高位1设置会0,的过程:
注意上面原本游标等于0000 0100 , 到最后的结果变为,从高位开始,第一个1变为了0,随后的0变为了1. 其实就是说,从4,降到了2,也就是开始新的一个搭配。由于最高位已经尝试过了,0->4是将最高位的0变为1的过程,如今应该轮到次高位了。
这种状况下既可以保证未改动的key必定存在,而且只会存在一次;
不太明白的话能够再一步步走一遍,在纸上写一下整个计算过程,多几回就清楚了。
1.当字典大小扩大的时候
这里假设变化以前,字典大小为8,后来扩大为16了。具体的流程为:
- scan 0 扫描,后来依次扫描了0,最后游标返回为4 ;
- 发生字典扩容以及rehashing,而且完成了;
- 客户端发送scan 4的指令过来;
当前的状况以下:

原先0号下 链表的元素被分拆到了0或者8号新slot, 取决于对应key的hash值第4位为0仍是1,;但这个在上面的第一步返回给客户端了,因此后续的迭代是不须要返回的。
至于4号,此时scan 4, 那么redis会先将4的下标的链表元素返回给客户端,而后计算下一个slot,注意此时的计算不同了,由于有效位掩码不同了,多加了一位高位1. 所以此次返回的游标再也不是2,而应该是12了。看下面的计算过程:
根据上面的计算,访问4以后,天然的就过分懂啊了8,而不是以前的12,由于以前的4号的数据迁移到了4或者8号,必须扫描迁移到8号的元素,不然就会出现漏掉的key。这种状况下,访问到的key不会多也不会小,由于原先访问的0如今分到了0和8,但已经访问过了,所以天然的从4号开始访问就好了。
这里再考虑一下第二种状况,若是扩容后,游标不是在4上,而是在2上,也就是在一个高位为0的上面,假设已经访问完了0,4,返回游标2,此时发生了扩容而且已经完成,size变为16了。此时0和4都不须要访问了。下一个访问2号,而且计算下一个slot是多少:
因为0,4号slot已经访问完毕,当前尚未访问的4号,也已经发生了迁移,有一部分高位为1的跑到了2+8 = 10 号slot 上面了。因此扫描完2后,须要天然的去迭代10号下标,不漏掉一个key。后续10号访问完成后,应该将是:6,而后14,一次继续就好了。跟上面的相似。
总结一下,对于字典大小扩大的状况,redis是是这样解决的:先访问n号slot,而后再访问n+2^N,由于这里面的元素其实都是从老的8个size的2号slot拆分到了2个slot,后面就须要访问这2个地方才行。正好这个算法支持这个。
这一点,redis scan保证了什么呢?保证了没有发生增删的操做的key必定可以找到;
在这种状况下,没变过的key必定可以返回,数据不会出现2次;
2.当字典大小缩小的时候:
其实字典缩小跟扩大相似,不过也有区别的。
字典大小缩小,也就是下降为原来的一半或者1/4····等等;假设咱们以前是16个slot,后来变为8个slot了。若是当前用户扫描过了0,8,4, 手里最新的游标为12的话,咱们来看一下图片:

因为咱们以前访问过了0和8,当字典缩小时, 原先的0和8的数据确定是放到了新的数组的0号位置上(去掉高位),这个咱们以前已经访问过了,因此不须要访问了的。
可是对于已经访问了原先的4号,而后发生了迁移,字典大小减小为8,原来的4和12 中12号下标的元素尚未访问,可是,当发生迁移后,12号的元素已经迁移到了新slot的4号位置上。那怎么可以保证不丢这个的数据呢?答案在代码中。
de = t0->table[v & m0]; 这个语句,老是跟当前的掩码进行按位求与,也就是只留那些有效位,原本scan 12发送过来,其v等于:0000 1100, m0此时应该是8,也就是0000 0111, 那么v&m0等于0000 0100, 也就是第四位的1被抹掉了,迁移到了4号,其实也就是说原先咱们已经访问了老数组的 0,8, 4号,其中4和12号是一组的,迁移缩小后,4和12都映射到了4号上面去了。接下来的scan 12虽然游标是12,可是截取有效位后,也就是访问的仍是4号;
这里就出现了重复的状况;从新访问4号,而后4号后根据以往的经验,4号后的访问,咱们不在须要访问8以上的key了,由于size只有8了。而且可以放心的是,像3,11, 2,10, 等这些一对一的尚未访问的数据,确定都会映射到了对应的8个槽位的对应元素里面。以后就当是一开始字典大小为8的dict的遍历工做。
总结一下当数组发生缩小的时候,会发生的事情:照样可以保证key没变更过的数据必定可以扫描出来返回; 另外因为要高位会合并到低位的slot里面,因此会发生重复,重复的数据是原先在4里面的全部数据。
3.在rehashing的过程当中
前面讨论的状况都是没有遇到在rehashing的过程当中,都是扩容或者缩小的时候都没有请求到来。这里来简单讨论一下发生rehashing的过程当中,接受到的SCAN该怎么处理;
redis处理这个情形的方法很简单:干脆就一次查找字典里面的2个表,一个临时扩容,一个就是主要的dict。 省得中间的状态基本没法维护;因此这种状况下,redis会先扫描数据项小一点的表,而后就扫描大的表,将其2份数据和在一块儿返回给客户端。这样简单粗暴,但绝对靠谱。这种状况下,是不会出现丢数据,和重复的状况的。
但从dictScan 函数里面能够看到,为了处理rehashing,里面对于大点的表的处理有一个比较关键的地方,以下代码:
5 |
de = t1->table[v & m1]; |
16 |
v = (((v | m0) + 1) & ~m0) | (v & m0); |
19 |
} while (v & (m0 ^ m1)); |
上面的代码是个do-while循环,终止条件是游标v与 m0和m1的不一样的位 之间没有相同的二进制位了。这里咱们知道m0和m1必定都是低位所有为1的,由于字典大小为2^N。这样m0^m1的异或结果就是m1的相对m0超过的高位部分,打个比方,第一个ht表的大小为8,第二个为64, 那么m0 == 0000 0111, m1 == 0011 1111 , m0^m1 的结果是: 0011 1000,以下图:

其实就是想扫描m1和m0相差的那些高位。可能有人不由会问,这个相差的高位不是只有1位么?其实不是的,rehashing的时候是可能2个表相差很大的。好比8 和64 。
上面do-while的前面部分是遍历第一个slot,小一点的。其实redis这里无论rehashing的方向,只管大小,反过来也是同样的。简化了逻辑;扫描完小一点的表后,须要将大一点的表进行扫描。
那么须要扫描哪些呢?答案是:全部可能从当前的小表的游标v所指的slot扩展迁移过去的slot,都须要扫描。好比当前的游标v等于0, 小表大小为8,大的表为64,那么须要扫描大表的这几个位置:0, 8, 16, 32。 缘由是由于可能t0(小表)里面的一部分元素已经发生了迁移,仅仅扫描t0不够,还要扫描哪些可能的迁移目的地(来源,同样的)。以下所示,t0到t1大小从8变化到64以后,原来在0号slot的元素可能会迁移到了0, 8, 16, 24,32这几个t1的slot中。因此咱们须要扫描这几个槽位,一次将其返回给客户端,省得夜长梦多,下次找不到地方了。

仔细观察能够发现,,他们都有个共同特色,从其二进制位中能够看出来:

也就是低位老是跟dictScan的参数v同样,高位从0开始不断加1 遍历,其实就是造成同模的效果,后缀同样,前缀不断变化加1,达到扫描全部可能的迁移slot,将其遍历返回给客户端。
这个遍历最主要的一行就是:
v = (((v | m0) + 1) & ~m0) | (v & m0);
下面简单分析一下它到底干了什么:
前面部分:(((v | m0) + 1) & ~m0) , v|m0就是将v的低位所有设置为1(这里所说的低位指t0的mask覆盖的位,高位指m1相对于m0独有的位。((v | m0) + 1)后面的+1 就是将(v | m0) 的值加1,也就是给v的高位部分加1。
后面的& ~m0效果就是去掉v的前面的二进制位。最后的(v & m0) 其实就是提取出v的低位部分。两边或起来,其实语义就是:保留v的低位,高位不断加1,赋值给v;这样V能带着低位不变,高位每次加1。高明!
这下清楚了,rehashing的时候会返回t0的槽位,以及t1里面全部可能发生迁移到的槽位。
总结
1. redis的SCAN操做可以保证 一直没变更过的元素必定可以在扫描结束的以前返回给客户端,这一点在不一样状况下均可以实现;
2. 当发生字典大小缩小的时候,若是接受到一个scan cursor, 游标位于高位为1的部分,那么会被有效位掩码给注释最高位,从而从从新读取以前已经访问过的元素,这种状况下回发生数据重复,但应该有限;
总体来看redis的SCAN操做是很不错的,可以在hash的数据结构里面提供比较稳定可靠的SCAN操做。
摘自博客:http://www.chenzhenianqing.cn/articles/1101.html, 我稍做改动某些原做者笔误!核心不变