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

續上一篇文章 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原理

首先咱們知道,這個迭代操做有下面幾個地方須要注意:

  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, 我稍做改動某些原做者筆誤!核心不變

相關文章
相關標籤/搜索