Redis Scan迭代器遍歷操作原理(二)


續上一篇文章 Redis Scan迭代器遍歷操作原理(一)–基礎 ,這里着重講一下dictScan函數的原理,其實也就是redis SCAN操作最有價值(也是最難懂的部分)。

關於這個算法的源頭,來自於githup這里:Add SCAN command #579,長篇的討論,確實難懂····建議看看這帖子,antirez 跟pietern 關於這個奇怪算法的討論···

這個算法的作者是:Pieter Noordhuis,作者稱其為:reverse binary iteration ,不知道我一對一翻譯為“反向二進制迭代器”可不可以,不過any way ··作者自己也沒有明確的證明其真假:

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的迭代器應該怎么設計,以及為什么不這么設計,而要這么設計·····

0.可用性 保證(Guarantees):

1.迭代結果可以重復;

2.整個迭代過程中,沒有變化(增加刪除)過的key必須出現在結果中;

redis的key是用hash存在的,key分布在數組的槽位內,下標從0到2^N,並且采用鏈表解決沖突。

hash會自動擴容或者縮小,並且每次 都是按2^N變化的。具體可以參閱:Redis源碼學習-Dict/hash 字典

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原理

首先我們知道,這個迭代操作有下面幾個地方需要注意:

  1. 字典大小不變的時候;
  2. 字典大小擴容的時候 ;
  3. 字典大小縮小的時候;
  4. 發生rehash的時候;

對於最簡單的時候,也就是沒有發生字典大小變化,那么最簡單了,按照redis現在的方式處理如下,然后再擴展到redis怎么處理變化的時候。

先貼一下代碼:

1 unsigned long dictScan(dict *d,
2                        unsigned long v,
3                        dictScanFunction *fn,
4                        void *privdata)
5 {
6     dictht *t0, *t1;
7     const dictEntry *de;
8     unsigned long m0, m1;
9  
10     if (dictSize(d) == 0) return 0;
11  
12     if (!dictIsRehashing(d)) {//沒有在做rehash,所以只有第一個表有數據的
13         t0 = &(d->ht[0]);
14         m0 = t0->sizemask;
15         //槽位大小-1,因為大小總是2^N,所以sizemask的二進制總是后面都為1,
16         //比如16個slot的字典,sizemask為00001111
17  
18         /* Emit entries at cursor */
19         de = t0->table[v & m0];//找到當前這個槽位,然后處理數據
20         while (de) {
21             fn(privdata, de);//將這個slot的鏈表數據全部入隊,准備返回給客戶端。
22             de = de->next;
23         }
24  
25     } else {
26         t0 = &d->ht[0];
27         t1 = &d->ht[1];
28  
29         /* Make sure t0 is the smaller and t1 is the bigger table */
30         if (t0->size > t1->size) {//將地位設置為
31             t0 = &d->ht[1];
32             t1 = &d->ht[0];
33         }
34  
35         m0 = t0->sizemask;
36         m1 = t1->sizemask;
37  
38         /* Emit entries at cursor */
39         de = t0->table[v & m0];//處理小一點的表。
40         while (de) {
41             fn(privdata, de);
42             de = de->next;
43         }
44  
45         /* Iterate over indices in larger table that are the expansion
46          * of the index pointed to by the cursor in the smaller table */
47         do {//掃描大點的表里面的槽位,注意這里是個循環,會將小表沒有覆蓋的slot全部掃描一次的
48             /* Emit entries at cursor */
49             de = t1->table[v & m1];
50             while (de) {
51                 fn(privdata, de);
52                 de = de->next;
53             }
54  
55             /* Increment bits not covered by the smaller mask */
56             //下面的意思是,還需要擴展小點的表,將其后綴固定,然后看高位可以怎么擴充。
57             //其實就是想掃描一下小表里面的元素可能會擴充到哪些地方,需要將那些地方處理一遍。
58             //后面的(v & m0)是保留v在小表里面的后綴。
59             //((v | m0) + 1) & ~m0) 是想給v的擴展部分的二進制位不斷的加1,來造成高位不斷增加的效果。
60             v = (((v | m0) + 1) & ~m0) | (v & m0);
61  
62             /* Continue while bits covered by mask difference is non-zero */
63         } while (v & (m0 ^ m1));//終止條件是 v的高位區別位沒有1了,其實就是說到頭了。
64     }
65  
66     /* Set unmasked bits so incrementing the reversed cursor
67      * operates on the masked bits of the smaller table */
68     v |= ~m0;
69     //按位取反,其實相當於v |= m0-1 , ~m0也就是11110000,
70     //這里相當於將v的不相干的高位全部置為1,待會再進行翻轉二進制位,然后加1,然后再轉回來
71  
72     /* Increment the reverse cursor */
73     v = rev(v);
74     v++;
75     v = rev(v);
76     //下面將v的每一位倒過來再加1,再倒回去,這是什么意思呢,
77     //其實就是要將有效二進制位里面的高位第一個0位設置置為1,因為現在是0嘛
78  
79     return v;
80 }

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 //v == 0 ,也就是0000 0000 , m0是size == 8時的掩碼,也就是0000 0111
2 v |= ~m0; //~m0按位取反,為1111 1000 , 跟v做或得到v的新值為  1111 1000
3 v = rev(v);//將V的每一位反過來,得到 0001 1111
4 v++; //這個是關鍵,加1,注意其效果,得到0010 0000 , 什么意思呢?對一個數加1,其實就是將這個數的低位的連續1變為0,然后將最低的一個0變為1,其實就是將最低的一個0變為1
5 v = rev(v);//再次反過來,得到了:0000 0100  , 十進制就是4 , 正好跟上面的吻合

這里來體味一下,上面反轉,然后加1,然后再反轉,整體效果其實就是想將有效位中,從高位開始的第一個0之上的1變為0,將第一個碰到的0變為1, 或者說嘗試將0變為1的slot。

更細致的說,上面的例子,是將0變為了1,效果就是scan的游標從0升為4,升到一個對應的高槽位去。下面來看一下從高槽位回到低位的過程,也就是將高位1設置會0,的過程:

1 //v == 4 ,也就是0000 0100 , m0是size == 8時的掩碼,也就是0000 0111
2 v |= ~m0; //~m0按位取反,為1111 1000 , 跟v做或得到v的新值為  1111 1100
3 v = rev(v);//將V的每一位反過來,得到 0011 1111
4 v++; //這個是關鍵,加1,注意其效果,得到0100 0000
5 v = rev(v);//再次反過來,得到了:0000 0010  , 十進制就是2

注意上面本來游標等於0000 0100 , 到最后的結果變為,從高位開始,第一個1變為了0,隨后的0變為了1. 其實就是說,從4,降到了2,也就是開始新的一個搭配。因為最高位已經嘗試過了,0->4是將最高位的0變為1的過程,現在應該輪到次高位了。

這種情況下既能夠保證未改動的key一定存在,並且只會存在一次

不太明白的話可以再一步步走一遍,在紙上寫一下整個計算過程,多幾次就清楚了。

1.當字典大小擴大的時候

這里假設變化之前,字典大小為8,后來擴大為16了。具體的流程為:

  1. scan 0 掃描,后來依次掃描了0,最后游標返回為4 ;
  2. 發生字典擴容以及rehashing,並且完成了;
  3. 客戶端發送scan 4的指令過來;

當前的情況如下:

原先0號下 鏈表的元素被分拆到了0或者8號新slot, 取決於對應key的hash值第4位為0還是1,;但這個在上面的第一步返回給客戶端了,所以后續的迭代是不需要返回的。

至於4號,此時scan 4, 那么redis會先將4的下標的鏈表元素返回給客戶端,然后計算下一個slot,注意此時的計算不一樣了,因為有效位掩碼不一樣了,多加了一位高位1. 因此這次返回的游標不再是2,而應該是12了。看下面的計算過程:

1 //v == 4 ,也就是0000 0100 , m0是size == 16時的掩碼了,所以就是0000 1111
2 v |= ~m0; //~m0按位取反,為1111 0000 , 跟v做或得到v的新值為  1111 0100
3 v = rev(v);//將V的每一位反過來,得到 0010 1111
4 v++; //這個是關鍵,加1,注意其效果,得到0011 0000 , 也就是講上面的0010 1111的后面所有的連續1換成0,第一個1換成1
5 v = rev(v);//再次反過來,得到了:0000 1100  , 十進制就是4+8 = 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是多少:

1 //v == 2 ,也就是0000 0010 , m0是size == 16時的掩碼了,所以就是0000 1111
2 v |= ~m0; //~m0按位取反,為1111 0000 , 跟v做或得到v的新值為  1111 0010
3 v = rev(v);//將V的每一位反過來,得到 0100 1111
4 v++; //這個是關鍵,加1,注意其效果,得到0101 0000 , 也就是講上面的0100 1111的后面所有的連續1換成0,第一個1換成1
5 v = rev(v);//再次反過來,得到了:0000 1010  , 十進制就是2+8 = 10.

由於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,里面對於大點的表的處理有一個比較關鍵的地方,如下代碼:

1 /* Iterate over indices in larger table that are the expansion
2  * of the index pointed to by the cursor in the smaller table */
3 do {//掃描大點的表里面的槽位,注意這里是個循環,會將小表沒有覆蓋的slot全部掃描一次的
4     /* Emit entries at cursor */
5     de = t1->table[v & m1];
6     while (de) {
7         fn(privdata, de);
8         de = de->next;
9     }
10  
11     /* Increment bits not covered by the smaller mask */
12     //下面的意思是,還需要擴展小點的表,將其后綴固定,然后看高位可以怎么擴充。
13     //其實就是想掃描一下小表里面的元素可能會擴充到哪些地方,需要將那些地方處理一遍。
14     //后面的(v & m0)是保留v在小表里面的后綴。
15     //((v | m0) + 1) & ~m0) 是想給v的擴展部分的二進制位不斷的加1,來造成高位不斷增加的效果。
16     v = (((v | m0) + 1) & ~m0) | (v & m0);
17  
18     /* Continue while bits covered by mask difference is non-zero */
19 } while (v & (m0 ^ m1));//終止條件是 v的高位區別位沒有1了,其實就是說到頭了。

上面的代碼是個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, 我稍作改動某些原作者筆誤!核心不變

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM