Redis資料結構詳解(2)-redis中的字典dict

蘇易困發表於2022-03-28

前提知識?

字典,又被稱為符號表(symbol table)或對映(map),其實簡單地可以理解為鍵值對key-value

比如Java的常見集合類HashMap,就是用來儲存鍵值對的。

字典中的鍵(key)都是唯一的,由於這個特性,我們可以根據鍵(key)查詢到對應的值(value),又或者進行更新和刪除操作。

Redis資料結構詳解(2)-redis中的字典dict

 

字典dict的實現

Redis的字典使用了雜湊表作為底層實現,一個雜湊表裡面可以有多個雜湊表節點,每個節點也儲存了對應的鍵值對。

Redis的字典dict結構如下:

Redis資料結構詳解(2)-redis中的字典dict

typedef struct dict {
    //型別特定函式
    //是一個指向dictType結構的指標,可以使dict的key和value能夠儲存任何型別的資料
    dictType *type;
    
    //私有資料
    //私有資料指標,不是討論的重點,暫忽略
    void *privdata;
    
    //雜湊表
    dictht ht[2];
    
    //rehash 索引
    //當 rehash 不在進行時,值為 -1
    int rehashidx;
}

 

我們重點關注兩個屬性就可以:

  • ht 屬性:

可以看到ht屬性是一個 size為2dictht雜湊表陣列,在平常情況下,字典只用到 ht[0],ht[1] 只會在對 ht[0] 雜湊表進行rehash時才會用到。

  • rehashidx 屬性:

它記錄了rehash目前的進度,如果現在沒有進行rehash,那麼它的值為-1,可以理解為rehash狀態的標識。

 

下圖就是一個普通狀態下的字典:

Redis資料結構詳解(2)-redis中的字典dict

實際的資料在 ht[0] 中儲存;ht[1] 起輔助作用,只會在進行rehash時使用,具體作用包括rehash的內容我們會在後面進行詳細介紹。

 

Redis資料結構詳解(2)-redis中的字典dict

雜湊演算法定位索引

PS:如果你有HashMap的相關知識,知道如何計算索引值,那麼你可以跳過這一部分。

 

假如我們現在模擬將 hash值從0到5的雜湊表節點 放入 size為4的雜湊表陣列 中,也就是將包含鍵值對的雜湊表節點放在雜湊表陣列的指定索引上。

Redis資料結構詳解(2)-redis中的字典dict

對應索引的計算公式:

index = hash & ht[x].sizemask

 

看不懂沒關係,可以簡單的理解為hash值對雜湊表陣列的size值求餘;

比如上面 hash值為0的節點,0 % 4 = 0,所以放在索引0的位置上,

hash值為1的節點,1 % 4 = 1,所以放在索引1的位置上,

hash值為5的節點,5 % 4 = 1,也等於1,也會被分配在索引1的位置上,並且因為dictEntry節點組成的連結串列沒有指向連結串列表尾的指標,所以會將新節點新增在連結串列的表頭位置,排在已有節點的前面。

 

我們把上面索引相同從而形成連結串列的情況叫鍵衝突,而且因為形成了連結串列!那麼就意味著查詢等操作的複雜度變高了!

例如你要查詢hash=1的節點,你就只能先根據hash值找到索引為1的位置,然後找到hash=5的節點,再通過next指標才能找到最後的結果,也就意味著鍵衝突發生得越多,查詢等操作花費的時間也就更多。

Redis資料結構詳解(2)-redis中的字典dict

如果解決鍵衝突?rehash!

其實rehash操作很好理解,可以簡單地理解為雜湊表陣列擴容或收縮操作,即將原陣列的內容重新hash放在新的陣列裡

比如還是上面的資料,我們這次把它們放在 size等於8的雜湊表陣列 裡。

如下圖,此時size = 8,hash為5的鍵值對,重新計算索引:5 % 8 = 5,所以這次會放在索引5的位置上。

Redis資料結構詳解(2)-redis中的字典dict
那麼假如我們還要找hash=1的節點,因為沒有鍵衝突,自然也沒有連結串列,我們可以直接通過索引來找到對應節點。

可以看到,因為rehash運算元組擴容的緣故,鍵衝突的情況少了,進而我們可以更高效地進行查詢等操作。

 

觸發rehash操作的條件

首先我們先引入一個引數,叫做負載因子(load_factor),要注意的是:它與HashMap中的負載因子代表的含義不同;在HashMap裡負載因子loadFactor作為一個預設值為0.75f的常量存在,而在redis的dict這裡,它是一個會動態變化的引數,等於雜湊表的 used屬性值/size屬性值,也就是 實際節點數/雜湊表陣列大小。假如一個size為4的雜湊表有4個雜湊節點,那麼此時它的負載因子就是1;size為8的雜湊表有4個雜湊節點,那麼此時它的負載因子就是0.5。

 

滿足下面任一條件,程式就會對雜湊表進行rehash操作:

  • 擴容操作條件:
    • 伺服器目前沒有執行 BGSAVE 或者 BGREWRITEAOF 命令,負載因子大於等於1。
    • 伺服器目前正在執行 BGSAVE 或者 BGREWRITEAOF 命令,負載因子大於等於5。
  • 收縮操作條件:
    • 負載因子小於0.1時。

 

BGSAVE 和 BGREWRITEAOF 命令可以統一理解為redis的實現持久化的操作。

  • BGSAVE 表示通過fork一個子程式,讓其建立RDB檔案,父程式繼續處理命令請求。
  • BGREWRITEAOF 類似,不過是進行AOF檔案重寫。

 

漸進式rehash?rehash的過程是怎麼樣的?

首先我們知道redis是單執行緒,並且對效能的要求很高,但是rehash操作假如碰到了數量多的情況,比如需要遷移百萬、千萬的鍵值對,龐大的計算量可能會導致伺服器在一段時間裡掛掉!

為了避免rehash對伺服器效能造成影響,redis會分多次、漸進式地進行rehash,即漸進式rehash。

(可以理解粗略地理解為程式有空閒再來進行rehash操作,不影響其他命令的正常執行)

 

對雜湊表進行漸進式rehash的步驟如下:

  1. 首先為 ht[1] 雜湊表分配空間,size的大小取決於要執行的操作,以及 ht[0] 當前的節點數量(即ht[0]的used屬性值):
    • 擴充套件操作,ht[1]的size值為第一個大於等於ht[0].used屬性值乘以2的 2^n
    • 收縮操作,ht[1]的size值為第一個小於ht[0].used屬性值的 2^n

(有沒有很熟悉,其實跟Java中的HashMap、ConcurrentHashMap操作類似)

  1. 將雜湊表的rehashidx值從-1置為0,表示rehash工作開始。
  2. 節點轉移,重新計算鍵的hash值和索引值,再將節點放置到ht[1]雜湊表的對應索引位置上。
  1. 每次rehash工作完成後,程式會將rehashidx值加一。

(這裡的每次rehash就指漸進式rehash)

  1. 當ht[0]的所有節點都轉移到ht[1]之後,釋放ht[0],將ht[1]設定為ht[0],並在ht[1]新建立一個空白的hash表,等待下次rehash再用到。(其實就是資料轉移到ht[1]後,再恢復為 ht[0]儲存實際資料,ht[1]為空白表的狀態)
  2. 最後程式會將rehashidx的值重置為-1,代表rehash操作已結束。

 

 

進行漸進式rehash的時候會影響字典的其他操作嗎?

因為在進行漸進式rehash的時候,字典會同時用到ht[0]和ht[1]這兩個雜湊表,所以在這期間,字典的刪除(delete)、查詢(find)、更新(update)等操作會在兩個雜湊表進行;而進行新增操作時,會直接插入到ht[1]。

 

比如查詢一個鍵時,程式會先在ht[0]裡面查詢,沒找到的話再去ht[1]裡進行查詢。

 

搜資料的時候還看到好多評論,都對邏輯產生了疑問,還舉了例子說有問題,但我仔細看了下,其實都是忽略了刪除和更新都會在兩個雜湊表進行的前提條件。

 

Redis資料結構詳解(2)-redis中的字典dict

 

寫在最後的最後

我是蘇易困,大家也可以叫我易困,一名Java開發界的小學生,文章可能不是很優質,但一定會很用心。

 

距離上次更新都過去了好久,一是因為上海的疫情有點嚴重,一直沒靜下心來好好整理知識,還有就是發現自己得先很好地消化完知識才能夠整理出來,不然其實各方面收穫不大;所以後面也會自己先認真消化後再整理分享,不會追求速度,但會認真總結整理。

 

因為疫情要一直封到4月1號,我們小區還有1例陽性,更不知道到什麼時候了,每天早上也要定鬧鐘搶菜,但還搶不到,因為沒有綠葉菜的補給,我感覺已經得口腔潰瘍了,還好買到了維C泡騰片,感覺可以稍微緩緩。

 

疫情掰扯這麼多,其實我和大家一樣,我有想吃的美食,有想去的地方,更有馬上想見到的人,所以最後還是希望疫情能夠趕緊好起來~

 

Redis資料結構詳解(2)-redis中的字典dict

相關文章