Redis底層數據結構之hash
hash是日常開發過程中使用Redis的一個數據結構,其底層實現方式有兩種,如下所示。一種是zipList,這種是當hash結構的V值較小的時候使用的編碼方式。這個已經在上一篇文章中介紹過了。這篇文章主要講解一下另外一種實現方式,字典dict,當hash結構的V值較大時采用的編碼方式。
dict
這里又要開始鞭屍C語言了,字典dict作為一種常用的數據結構,C語言內部並不具備,因而Redis的開發人員自己設計和開發了Redis中的dict結構,其定義如下:
typedf struct dict{
dictType *type;//類型特定函數,包括一些自定義函數,這些函數使得key和
//value能夠存儲
void *private;//私有數據
dictht ht[2];//兩張hash表
int rehashidx;//rehash索引,字典沒有進行rehash時,此值為-1
unsigned long iterators; //正在迭代的迭代器數量
}dict;
type和private這兩個屬性是為了實現字典多態而設置的,當字典中存放着不同類型的值,對應的一些復制,比較函數也不一樣,這兩個屬性配合起來可以實現多態的方法調用;ht[2],兩個hash表rehashidx,這是一個輔助變量,用於記錄rehash過程的進度,以及是否正在進行rehash等信息,當此值為-1時,表示該dict此時沒有rehash過程iterators,記錄此時dict有幾個迭代器正在進行遍歷過程
dictht
由上面可以看出,dict本質上是對哈希表dictht的一個簡單封裝,dictht的定義如下所示:
typedf struct dictht{
dictEntry **table;//存儲數據的數組 二維
unsigned long size;//數組的大小
unsigned long sizemask;//哈希表的大小的掩碼,用於計算索引值,總是等於
//size-1
unsigned long used;//// 哈希表中中元素個數
}dictht;
table是一個dictEntry類型的數組,用於真正存儲數據;size表示table這個數組的大小;sizemask用於計算索引位置,且總是等於size-1;used表示dictht中已有的節點數量,其示意圖如下所示:

dictEntry
上面分析dictht時說到,真正存儲數據的結構是dictEntry數組,其結構定義如下:
typedf struct dictEntry{
void *key;//鍵
union{
void val;
unit64_t u64;
int64_t s64;
double d;
}v;//值
struct dictEntry *next;//指向下一個節點的指針
}dictEntry;
其示意圖如下所示:

最后整個dict的結構示意圖如上所示:
上圖是一個沒有處於rehash狀態下的字典dict,整個dict中有兩個哈希表dictht,其中一個哈希表存儲數據,另一個哈希表為空。
擴容與縮容
當哈希表中元素數量逐漸增加時,此時產生hash沖突的概率逐漸增大,且由於dict也是采用拉鏈法解決hash沖突的,隨着hash沖突概率上升,鏈表會越來越長,這就會導致查找效率下降。相反,當元素不斷減少時,元素占用dict的空間就越少,出於對內存的極致利用,此時就需要進行縮容操作。
既然說到擴容和縮容,熟悉Java集合的小伙伴是不是想到了什么。不錯,那就是負載因子。負載因子一般用於描述集合當前被填充的程度。在Redis的字典dict中,負責因子=哈希表中已保存節點數量/哈希表的大小,即:
load factor = ht[0].used / ht[0].size
Redis中,三條關於擴容和縮容的規則:
- 沒有執行BGSAVE和BGREWRITEAOF指令的情況下,哈希表的負載因子大於等於1時進行擴容;
- 正在執行BGSAVE和BGREWRITEAOF指令的情況下,哈希表的負載因大於等於5時進行擴容;
- 負載因子小於0.1時,
Redis自動開始對哈希表進行收縮操作;
其中,擴容和縮容的數量大小也有一定的規則:
- 擴容:擴容后的
dictEntry數組數量為第一個大於等於ht[0].used * 2的2^n; - 縮容:縮容后的
dictEntry數組數量為第一個大於等於ht[0].used的2^n;
rehash
與Java中的HashMap類似,當Redis中的dict進行擴容或者縮容,會發生reHash過程。Java中HashMap的rehash過程如下:新建一個哈希表,一次性將當前所有節點進行rehash然后復制到新哈希表相應的位置上,之后釋放掉原有的hash表,而持有新的表,這個過程是一個時間復雜度為O(n)的操作。而對於單線程的Redis而言很難承受這么高時間復雜度的操作,因而其rehash的過程有所不同,使用的是一種稱之為漸進式rehash的方式,一點一點地進行搬遷。其過程如下:
- 假設當前數據在
dictht[0]中,那么首先為dictht[1]分配足夠的空間,如果是擴容,則dictht[1]大小就按照擴容規則設置;如果是縮減,則dictht[1]大小就按照縮減規則進行設置; - 在字典
dict中維護一個變量,rehashidx=0,表示rehash正式開始; rehash進行期間,每次對字典執行添加、刪除、查找或者更新操作時,程序除了執行指定的操作以外,還會順帶將dictht[0]哈希表在rehashidx索引上的所有鍵值對rehash到dictht[1],當一次rehash工作完成之后,程序將rehashidx屬性的值+1。同時在serverCron中調用rehash相關函數,在1ms的時間內,進行rehash處理,每次僅處理少量的轉移任務(100個元素);- 隨着字典操作的不斷執行,最終在某個時間點上,
dictht[0]的所有鍵值對都會被rehash至dictht[1],這時程序將rehashidx屬性的值設為-1,表示rehash操作已完成;
上述就是Redis中dict的漸進式rehash過程,但在這個過程會存在兩個明顯問題。第一,第三步說了,每次對字典執行增刪改查時才會觸發rehash過程,萬一某一時間段並沒有任何請求命令呢?此時應該怎么辦?第二,在維護兩個dictht的時候,此時哈希表如何正常對外提供服務?
Redis的設計人員在設計時就已經考慮到了這兩個問題。對於第一個問題,Redis在有一個定時器,會定時去判斷rehash是否完成,如果沒有完成,則繼續進行rehash。定時函數如下所示:
// 服務器定時任務
void databaseCron() {
...
if (server.activerehashing) {
for (j = 0; j < dbs_per_call; j++) {
int work_done = incrementallyRehash(rehash_db);//rehash方法
if (work_done) {
/* If the function did some work, stop here, we'll do
* more at the next cron loop. */
break;
} else {
/* If this db didn't need rehash, we'll try the next one. */
rehash_db++;
rehash_db %= server.dbnum;
}
}
}
}
對於第二個問題,對於添加操作,會將新的數據直接添加到dictht[1]上面,這樣就可以保證dictht[0]上的數量只減少不增加。而對於刪除、更改、查詢操作,會直接在dictht[0]上進行,尤其是這三個操作,都會涉及到查詢,當在dictht[0]上查詢不到時,會接着去dictht[1]上查找,如果再找不到,則表明不存在該K-V值。
漸進式rehash的優缺點
優點:采用了分而治之的思想,將 rehash 操作分散到每一個對該哈希表的操作上以及定時函數上,避免了集中式 rehash 帶來的性能壓力;
缺點:在 rehash 的時間內,需要保存兩個 hash 表,對內存的占用稍大,而且如果在 redis 服務器本來內存滿了的時候,突然進行 rehash 會造成大量的 key 被拋棄;
思考題
為什么擴容的時候要考慮BIGSAVE的影響,而縮容時不需要?
BIGSAVE時,dict要是進行擴容,則此時就需要為dictht[1]分配內存,若是dictht[0]的數據量很大時,就會占用更多系統內存,造成內存頁過多分離,所以為了避免系統耗費更多的開銷去回收內存,此時最好不要進行擴容;- 縮容時,結合縮容的條件,此時負載因子<0.1,說明此時
dict中數據很少,就算為dictht[1]分配內存,也消耗不了多少資源;
總結

參考
Redis深度歷險:核心原理和應用實踐
Redis系列(六)底層數據結構之字典
redis哈希表的rehash分析
圖解redis五種數據結構底層實現
