續上一篇文章 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原理
首先我們知道,這個迭代操作有下面幾個地方需要注意:
- 字典大小不變的時候;
- 字典大小擴容的時候 ;
- 字典大小縮小的時候;
- 發生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了。具體的流程為:
- 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了。看下面的計算過程:
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, 我稍作改動某些原作者筆誤!核心不變