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