Redis源码剖析-Dict遍历算法
目录
先贴一下整体的代码
unsigned long dictScan(dict *d,
unsigned long v,
dictScanFunction *fn,
dictScanBucketFunction* bucketfn,
void *privdata)
{
dictht *t0, *t1;
const dictEntry *de, *next;
unsigned long m0, m1;
// 跳过空字典
if (dictSize(d) == 0) return 0;
// 如果没有rehash,迭代一个哈希表的字典
if (!dictIsRehashing(d)) {
t0 = &(d->ht[0]);
m0 = t0->sizemask;
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
de = t0->table[v & m0];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
// 迭代有两个哈希表的字典
} else {
t0 = &d->ht[0];
t1 = &d->ht[1];
/* Make sure t0 is the smaller and t1 is the bigger table */
// 确保 t0 比 t1 要小
if (t0->size > t1->size) {
t0 = &d->ht[1];
t1 = &d->ht[0];
}
m0 = t0->sizemask;
m1 = t1->sizemask;
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
de = t0->table[v & m0];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
/* Iterate over indices in larger table that are the expansion
* of the index pointed to by the cursor in the smaller table */
do {
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
de = t1->table[v & m1];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
/* Increment bits not covered by the smaller mask */
v = (((v | m0) + 1) & ~m0) | (v & m0);
/* Continue while bits covered by mask difference is non-zero */
} while (v & (m0 ^ m1));
}
/* Set unmasked bits so incrementing the reversed cursor
* operates on the masked bits of the smaller table */
v |= ~m0;
/* Increment the reverse cursor */
v = rev(v);
v++;
v = rev(v);
return v;
}
Redis的dict结构中,有一个遍历dict的函数,因为该遍历函数的算法比较特别,值得单独拿出来研究一下。
首先考虑最简单的情况, 如果一个dict是稳定,即没有扩大缩小,也没有正好处于rehash的过程中,那么这种情况下的遍历是最简单的, 只需要按照索引值顺序遍历第一个hash表ht[0]就行。
但是如果本次索引的遍历的时候,跟上一个索引遍历的时候,dict已经经过了扩大或者缩小,或者dict正好处于rehash的过程中的时候,遍历过程就变复杂了。
如果仍旧采用顺序遍历索引的策略,考虑dict经过缩小的情况。如果hash表一共有8个slot,经过扩大变成了4个slot,在计算索引的时候,都是hash值同mask=size-1做与操作。所以如果某个key的hash值最后原来是7的话,在slot=8的时候,应该放到索引为7的slot,但是缩小到4个slot的时候,将被放在slot=3。可以想见,这种情况下,会有大量的key被漏掉。同样,如果经过了扩大,也会有大量的key被重复遍历。
dict的反向二进制位遍历
Redis的遍历方式采用了反向的二进制位遍历。那么什么是反向二进制位遍历呢。按照正常的遍历逻辑,遍历的顺序是按照0->1->2->3->4….
但是dictScan采用的顺序,以8个slot为例,是0->4->2->6->1->5->3->7。看着貌似没有规律,但是其实是从高位开始,向低位进位的一种遍历方式,用二进制更能直观的看出来
000 --> 100 --> 010 --> 110 --> 001 --> 101 --> 011 --> 111 --> 000
那么用这种方式遍历会有什么好处呢,可以按照dict不同状态的示例来说明
1、当dict稳定的时候
这种情况下,从上面的说明能看出,反向二进制遍历同正向二进制遍历一样,能够遍历到所有的索引,并且不会有重复或遗漏
2、当dict扩大的时候
假设dict的slot由8个扩大到16个,首先列出两种状态下的遍历顺序:
000 --> 100 --> 010 --> 110 --> 001 --> 101 --> 011 --> 111 --> 000
0000 --> 1000 --> 0100 --> 1100 --> 0010 --> 1010 --> 0110 --> 1110 --> 0001 --> 1001 --> 0101 --> 1101 --> 0011 --> 1011 --> 0111 --> 1111 --> 0000
假设在slot=8的时候已经遍历完了010的索引,下一次就需要遍历110的索引。此时dict发生了rehash操作,slot从8个扩展到了16个,
在slot=16的情况下,就需要遍历0110的索引。 此时,在slot=8的时候遍历过的所以值为000,100,010,对应到slot=16的情况下,分别是0000,1000, 0100,1100, 0010, 1010, 正好是所以址0110的前序所以, 这样的话,整个遍历流程,不会造成遍历的重复或者遗漏。
3、当dict缩小的时候
假设在slot=16的是,已经遍历完了0110的索引,下一个就要遍历1110的索引,此时dict缩小到了slot=8 。此前已经遍历过的所有索引分别为 0000, 1000, 0100, 1100, 0010, 1010, 0110, 对应到slot=8的情况下,分别落到索引000,100,010,110。
此时需要从110的所以开始遍历,但是由于此前在slot=16的时候已经遍历过的0110的索引值也是落到110的索引,所以会造成一部分的key被重复遍历。
原哈希表长度为x,缩小后长度为y,则最多会有x/y – 1个原bucket的节点会被重复迭代。比如由16缩小为8,则最多就有1个bucket节点会重复迭代,要是由32缩小为8,则最多会有3个。
但是,也有可能,正好不重复遍历,比如slot=16的时候遍历到1010,即将遍历0110的时候dict缩小的时候,就不会产生重复。
反向二进制算法保证了dict不会遗漏元素,同时在dict缩小的时候,保证了比较小的元素重复。
4、正好进行rehash的时候
当dict正好在进行rehash的时候,当前遍历的索引里边可能数据不全,因为有一部分已经rehash到新表中去了。所以为了不漏掉元素,采取的措施是同时遍历两个hash表的对应索引。
但是rehash的过程中,不管扩大还是缩小,两张表的索引都是不同的,如何对应呢。 方法就是先比较两张hash表的大小,先遍历较小的hash表,遍历完之后,找到大表中所有对应的索引值,全部依次遍历。
举个例子,暂且不管dict当前是在扩大还是缩小,两张hash表的大小肯定是不同的。假设一张表的slot=8,另一张表的slot=32, 两个mask分别为111和11111。 假设当前的hash值同111做与操作之后为010,那么小表中010的索引遍历之后,需要遍历大表的00010,01010, 10010和11010的索引,然后将两者的索引一块返回。
v = (((v | m0) + 1) & ~m0) | (v & m0);
那么如何保证大表对应的所有索引都能遍历到呢。代码中可以看到这么一条语句,分析一下。 v | m0 将v的低位全部置为1, (v | m0) + 1则是将v的高位加1,之后再 & ~m0 将所有低位置0, v & m0 就是将v的低位提取出来放到之前的数值之后。 这样就能够: 保持v的低位不变,高位持续加1,遍历所有扩展后的slot
那么高位什么时候停止加1呢,就是while终止的条件
while (v & (m0 ^ m1))
由于m0和m1都是全为1的mask,假设m0=111, m1=11111, 那么m0 ^ m1 = 11000, 所以我们能看到当v的高位没有1了,其实就是说到头了,循环就会终止。
总结
Redis的dictScan通过反向二进制位的遍历顺序,既能防止漏掉遍历元素,也能在必须要重复遍历元素的时候,减少重复元素的个数。 同时在处理rehash操作时的遍历的时候,通过各种位操作的结合,使得rehash过程中不会漏掉元素。