Redis dict詳解

dict,又稱字典(dictionary)或映射(map),是集合的一種;這種集合中每個元素都是KV鍵值對。
字典dict在各編程語言中都有體現,面向對象的編程語言如C++、Java中都稱其爲Map。

Redis的KV存儲結構

Redis內存數據庫,最底層是一個redisDb;

redisDb 整體使用 dict字典 來存儲鍵值對KV;
字典中的每一項,使用dictEntry ,代表KV鍵值;類似於HashMap中的鍵值對Entry。

why dict/map?

dict是一種用於維護key和value映射關係的數據結構,與很多編程語言中的Map類似。
爲什麼dict/map 這麼受歡迎呢?
因爲dict/map實現了key和value的映射,通過key查詢value是效率非常高的操作,時間複雜度是O©,C是常數,在沒有衝突/碰撞的情況下,可以達到O(1)。

dict本質上是爲了解決算法中的查找問題(Searching),一般查找問題的解法分爲兩個大類:一個是基於各種平衡樹,一個是基於哈希表。

  • 平衡樹,如二叉搜索樹、紅黑樹,使用的是“二分思想”;
    如果需要實現排序,則可使用平衡樹,如:用大頂堆實現TreeMap;
  • 哈希表,如Java中的Map,Python中的字典dict,使用的是“映射思想”;

我們平常使用的各種Map或dict,大都是基於哈希表實現的。在不要求數據有序存儲,且能保持較低的哈希值衝突概率的前提下,基於哈希表的查找性能能做到非常高效,接近O(1),而且容易實現。

Redis dict的應用

字典dict 在 Redis 中的應用廣泛, 使用頻率可以說和 SDS 以及雙端鏈表不相上下, 基本上各個功能模塊都有用到字典的地方。

其中, 字典dict的主要用途有以下兩個:

  • 實現數據庫鍵空間(key space);
  • 用作 hash 鍵的底層實現之一;

以下兩個小節分別介紹這兩種用途。


Redis數據庫鍵空間(key space)

Redis 是一個鍵值對數據庫服務器,服務器中每個數據庫都由 redisDB 結構表示(默認16個庫)。其中,redisDB 結構的 dict 字典保存了數據庫中所有的鍵值對,這個字典被稱爲鍵空間(key space)。

可以認爲,Redis默認16個庫,這16個庫在各自的鍵空間(key space)中;其實就通過鍵空間(key space)實現了隔離。而鍵空間(key space)底層是dict實現的。

鍵空間(key space)除了實現了16個庫的隔離,還能基於鍵空間通知(Keyspace Notifications) 實現某些事件的訂閱通知,如某個key過期的時間,某個key的value變更事件。

鍵空間通知(Keyspace Notifications),是因爲鍵空間(key space)實現了16個庫的隔離,而我們執行Redis命令最終都是落在其中一個庫上,當有事件發生在某個庫上時,該庫對應的鍵空間(key space)就能基於pub/sub發佈訂閱,實現事件“廣播”。

鍵空間(key space),詳細分析,可參見:Redis鍵空間通知(Keyspace Notifications)

dict 用作 hash 鍵的底層實現

Redis 的 hash 鍵使用以下兩種數據結構作爲底層實現:

  • 壓縮列表ziplist ;
  • 字典dict;

因爲壓縮列表 比字典更節省內存,所以程序在創建新 Hash 鍵時,默認使用壓縮列表作爲底層實現, 當有需要時,纔會將底層實現從壓縮列表轉換到字典。
ziplist 是爲 Redis 節約內存而開發的、非常節省內存的雙向鏈表,深入學習可移步Redis的list

壓縮鏈表轉成字典(ziplist->dict)的條件

同時滿足以下兩個條件,hash 鍵纔會使用ziplist:
1、key和value 長度都小於64
2、鍵值對數小於512

該配置 在redis.conf

hash-max-ziplist-entries 512
hash-max-ziplist-value 64

如何實現字典dict/映射

dict,又稱字典(dictionary)或映射(map),是集合的一種;這種集合中每個元素都是KV鍵值對。
它是根據關鍵字值(key)而直接進行訪問KV鍵值對的數據結構。也就是說,它通過把關鍵字值映射到一個位置來訪問記錄,以加快查找的速度。這個映射函數稱爲哈希函數(也稱爲散列函數)。
因此通常我們稱字典dict,也叫哈希表。

映射過程,通常使用hash算法實現,因此也稱映射過程爲哈希化,存放記錄的數組叫做散列表、或hash表。

哈希化之後難免會產生一個問題,那就是對不同的關鍵字,可能得到同一個散列地址,即不同的key散列到同一個數組下標,這種現象稱爲衝突,那麼我們該如何去處理衝突呢?
最常用的就是鏈地址法,也常被稱爲拉鍊法,就是在衝突的下標處,維護一個鏈表,所有映射到該下標的記錄,都添加到該鏈表上。

Redis字典dict如何實現的?

Redis字典dict,也是採用哈希表,本質就是數組+鏈表。
也是衆多編程語言實現Map的首選方式,如Java中的HashMap。

Redis字典dict 的底層實現,其實和Java中的ConcurrentHashMap思想非常相似。
就是用數組+鏈表實現了分佈式哈希表。當不同的關鍵字、散列到數組相同的位置,就拉鍊,用鏈表維護衝突的記錄。當衝突記錄越來越多、鏈表越來越長,遍歷列表的效率就會降低,此時需要考慮將鏈表的長度變短

將鏈表的長度變短,一個最直接有效的方式就是擴容數組。將數組+鏈表結構中的數組擴容,數組變長、對應數組下標就增多了;將原數組中所有非空的索引下標、搬運到擴容後的新數組,經過重新散列,自然就把衝突的鏈表變短了。

如果你對Java的HashMap或ConcurrentHashMap 底層實現原理比較瞭解,那麼對Redis字典dict的底層實現,也能很快上手。

dict.h 給出了這個字典dict的定義:

/*
 * 字典
 *
 * 每個字典使用兩個哈希表,用於實現漸進式 rehash
 */
typedef struct dict {

    // 特定於類型的處理函數
    dictType *type;

    // 類型處理函數的私有數據
    void *privdata;

    // 哈希表(2 個)
    dictht ht[2];

    // 記錄 rehash 進度的標誌,值爲 -1 表示 rehash 未進行
    int rehashidx;

    // 當前正在運作的安全迭代器數量
    int iterators;

} dict;

typedef struct dictType {
    unsigned int (*hashFunction)(const void *key);
    void *(*keyDup)(void *privdata, const void *key);
    void *(*valDup)(void *privdata, const void *obj);
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    void (*keyDestructor)(void *privdata, void *key);
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

結合上面的代碼,可以很清楚地看出dict的結構。一個dict由如下若干項組成:

  • dictType *type;一個指向dictType結構的指針(type)。它通過自定義的方式使得dict的key和value能夠存儲任何類型的數據。
  • void *privdata;一個私有數據指針(privdata)。由調用者在創建dict的時候傳進來。
  • dictht ht[2];兩個哈希表(ht[2])。只有在rehash的過程中,ht[0]和ht[1]才都有效。而在平常情況下,只有ht[0]有效,ht[1]裏面沒有任何數據。上圖表示的就是rehash進行到中間某一步時的情況。
  • int rehashidx;當前rehash索引(rehashidx)。如果rehashidx = -1,表示當前沒有在rehash過程中;否則,表示當前正在進行rehash,且它的值記錄了當前rehash進行到哪一步了。
  • int iterators;當前正在進行遍歷的iterator的個數。這不是我們現在討論的重點,暫時忽略。

dictType結構包含若干函數指針,用於dict的調用者對涉及key和value的各種操作進行自定義。這些操作包含:

  • hashFunction,對key進行哈希值計算的哈希算法。
  • keyDup和valDup,分別定義key和value的拷貝函數,用於在需要的時候對key和value進行深拷貝,而不僅僅是傳遞對象指針。
  • keyCompare,定義兩個key的比較操作,在根據key進行查找時會用到。
  • keyDestructor和valDestructor,分別定義對key和value的析構函數。
    私有數據指針(privdata)就是在dictType的某些操作被調用時會傳回給調用者。

dictht(dict hash table)哈希表

dictht 是字典 dict 哈希表的縮寫,即dict hash table。
dict.h/dictht 類型定義:

/*
 * 哈希表
 */
typedef struct dictht {

    // 哈希表節點指針數組(俗稱桶,bucket)
    dictEntry **table;

    // 指針數組的大小
    unsigned long size;

    // 指針數組的長度掩碼,用於計算索引值
    unsigned long sizemask;

    // 哈希表現有的節點數量
    unsigned long used;

} dictht;


/*
 * 哈希表節點
 */
typedef struct dictEntry {
    // 鍵
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 鏈往後繼節點
    struct dictEntry *next;
} dictEntry;

dictht 定義一個哈希表的結構,包括以下部分:

  • 一個dictEntry指針數組(table)。key的哈希值最終映射到這個數組的某個位置上(對應一個bucket)。如果多個key映射到同一個位置,就發生了衝突,那麼就拉出一個dictEntry鏈表。
  • size:標識dictEntry指針數組的長度。它總是2的指數次冪。
  • sizemask:用於將哈希值映射到table的位置索引。它的值等於(size-1),比如7, 15, 31, 63,等等,也就是用二進制表示的各個bit全1的數字。每個key先經過hashFunction計算得到一個哈希值,然後計算(哈希值 & sizemask)得到在table上的位置。相當於計算取餘(哈希值 % size)。
  • used:記錄dict中現有的數據個數。它與size的比值就是裝載因子。這個比值越大,哈希值衝突概率越高。

Redis dictht的負載因子
我們知道當HashMap中由於Hash衝突(負載因子)超過某個閾值時,出於鏈表性能的考慮、會進行擴容,Redis dict也是一樣。

一個dictht 哈希表裏,核心就是一個dictEntry數組,同時用size記錄了數組大小,用used記錄了所有記錄數。

dictht的負載因子,就是used與size的比值,也稱裝載因子(load factor)。這個比值越大,哈希值衝突概率越高。當比值[默認]超過5,會強制進行rehash。


dictEntry結構中包含k, v和指向鏈表下一項的next指針。k是void指針,這意味着它可以指向任何類型。v是個union,當它的值是uint64_t、int64_t或double類型時,就不再需要額外的存儲,這有利於減少內存碎片。當然,v也可以是void指針,以便能存儲任何類型的數據。

next 指向另一個 dictEntry 結構, 多個 dictEntry 可以通過 next 指針串連成鏈表, 從這裏可以看出, dictht 使用鏈地址法來處理鍵碰撞: 當多個不同的鍵擁有相同的哈希值時,哈希表用一個鏈表將這些鍵連接起來。

下圖展示了一個由 dictht 和數個 dictEntry 組成的哈希表例子:

如果再加上之前列出的 dict 類型,那麼整個字典結構可以表示如下:

在上圖給出的示例中, 只使用了 0 號哈希表ht[0],且rehashidx=-1表明字典未進行 rehash。
什麼是rehash,下文會詳細展開。

Redis dict使用的哈希算法

前面提到,一個kv鍵值對,添加到哈希表時,需要用一個映射函數將key散列到一個具體的數組下標。

Redis 目前使用兩種不同的哈希算法:
1、MurmurHash2
是種32 bit 算法:這種算法的分佈率和速度都非常好;Murmur哈希算法最大的特點是碰撞率低,計算速度快。Google的Guava庫包含最新的Murmur3。
具體信息請參考 MurmurHash 的主頁: http://code.google.com/p/smhasher/ 。
2、基於 djb 算法實現的一個大小寫無關散列算法:具體信息請參考 http://www.cse.yorku.ca/~oz/hash.html 。

使用哪種算法取決於具體應用所處理的數據:

  • 命令表以及 Lua 腳本緩存都用到了算法 2 。
  • 算法 1 的應用則更加廣泛:數據庫、集羣、哈希鍵、阻塞操作等功能都用到了這個算法。

Redis dict各種操作

以下是用於處理 dict 的各種 API , 它們的作用及相應的算法複雜度:

操作 函數 算法複雜度
創建一個新字典 dictCreate O(1)
添加新鍵值對到字典 dictAdd O(1)
添加或更新給定鍵的值 dictReplace O(1)
在字典中查找給定鍵所在的節點 dictFind O(1)
在字典中查找給定鍵的值 dictFetchValue O(1)
從字典中隨機返回一個節點 dictGetRandomKey O(1)
根據給定鍵,刪除字典中的鍵值對 dictDelete O(1)
清空並釋放字典 dictRelease O(N)
清空並重置(但不釋放)字典 dictEmpty O(N)
縮小字典 dictResize O(N)
擴大字典 dictExpand O(N)
對字典進行給定步數的 rehash dictRehash O(N)
在給定毫秒內,對字典進行rehash dictRehashMilliseconds O(N)

下面,會對一些關鍵步驟進行詳細講解。

dict的創建(dictCreate)

創建dict
dict *d = dictCreate(&hash_type, NULL);

dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));

    _dictInit(d,type,privDataPtr);
    return d;
}

int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    _dictReset(&d->ht[0]);
    _dictReset(&d->ht[1]);
    d->type = type;
    d->privdata = privDataPtr;
    d->rehashidx = -1;
    d->iterators = 0;
    return DICT_OK;
}

static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

dictCreate爲dict的數據結構分配空間併爲各個變量賦初值。其中兩個哈希表ht[0]和ht[1]起始都沒有分配空間,table指針都賦爲NULL。這意味着要等第一個數據插入時纔會真正分配空間。

  • ht[0]->table 的空間分配將在第一次往字典添加鍵值對時進行;
  • ht[1]->table 的空間分配將在 rehash 開始時進行;

添加新鍵值對到字典(dictAdd)

根據字典所處的狀態, 將給定的鍵值對添加到字典可能會引起一系列複雜的操作:

  • 如果字典爲未初始化(即字典的 0 號哈希表的 table 屬性爲空),則程序需要對 0 號哈希表進行初始化;
  • 如果在插入時發生了鍵碰撞,則程序需要處理碰撞;
  • 如果插入新元素,使得字典滿足了 rehash 條件,則需要啓動相應的 rehash 程序;
    當程序處理完以上三種情況之後,新的鍵值對纔會被真正地添加到字典上。

dictAdd函數是調用 dictAddRaw實現的:

/* 將Key插入哈希表 */
dictEntry *dictAddRaw(dict *d, void *key) 
{ 
    int index; 
    dictEntry *entry; 
    dictht *ht; 
 
    if (dictIsRehashing(d)) _dictRehashStep(d);  // 如果哈希表在rehashing,則執行單步rehash
 
    /* 調用_dictKeyIndex() 檢查鍵是否存在,如果存在則返回NULL */ 
    if ((index = _dictKeyIndex(d, key)) == -1) 
        return NULL; 
 

    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; 
    entry = zmalloc(sizeof(*entry));   // 爲新增的節點分配內存
    entry->next = ht->table[index];  //  將節點插入鏈表表頭
    ht->table[index] = entry;   // 更新節點和桶信息
    ht->used++;    //  更新ht
 
    /* 設置新節點的鍵 */ 
    dictSetKey(d, entry, key); 
    return entry; 
}

整個添加流程可以用下圖表示:

在接下來的三節中, 我們將分別看到,添加操作如何在以下三種情況中執行:
1、字典爲空;
2、添加新鍵值對時發生碰撞處理;
3、添加新鍵值對時觸發了 rehash 操作;

添加新元素時table爲空

當第一次往空字典裏添加鍵值對時, 程序會根據 dict.h/DICT_HT_INITIAL_SIZE 裏指定的大小爲 d->ht[0]->table 分配空間 (在目前的版本中, DICT_HT_INITIAL_SIZE 的值爲 4 )。

以下是字典空白時的樣子:

以下是往空白字典添加了第一個鍵值對之後的樣子:

添加新鍵值對時發生碰撞

在哈希表實現中, 當兩個不同的鍵擁有相同的哈希值時, 稱這兩個鍵發生碰撞(collision), 而哈希表實現必須想辦法對碰撞進行處理。

字典哈希表所使用的碰撞解決方法被稱之爲鏈地址法: 這種方法使用鏈表將多個哈希值相同的節點串連在一起, 從而解決衝突問題。

假設現在有一個帶有三個節點的哈希表,如下圖:

對於一個新的鍵值對 key4 和 value4 , 如果 key4 的哈希值和 key1 的哈希值相同, 那麼它們將在哈希表的 0 號索引上發生碰撞。

通過將 key4-value4 和 key1-value1 兩個鍵值對用鏈表連接起來, 就可以解決碰撞的問題:

添加新鍵值對時觸發了 rehash

對於使用鏈地址法來解決碰撞問題的哈希表 dictht 來說, 哈希表的性能取決於大小(size屬性)與保存節點數量(used屬性)之間的比率:

哈希表的大小與節點數量,比率在 1:1 時,哈希表的性能最好;
如果節點數量比哈希表的大小要大很多的話,那麼哈希表就會退化成多個鏈表,哈希表本身的性能優勢便不復存在;
舉個例子, 下面這個哈希表, 平均每次失敗查找只需要訪問 1 個節點(非空節點訪問 2 次,空節點訪問 1 次):

而下面這個哈希表, 平均每次失敗查找需要訪問 5 個節點:

爲了在字典的鍵值對不斷增多的情況下保持良好的性能, 字典需要對所使用的哈希表(ht[0])進行 rehash 操作: 在不修改任何鍵值對的情況下,對哈希表進行擴容, 儘量將比率維持在 1:1 左右。

dictAdd 在每次向字典添加新鍵值對之前, 都會對哈希表 ht[0] 進行檢查, 對於 ht[0] 的 size 和 used 屬性, 如果它們之間的比率 ratio = used / size 滿足以下任何一個條件的話,rehash 過程就會被觸發:

  • 自然 rehash : ratio >= 1 ,且變量 dict_can_resize 爲true。
  • 強制 rehash : ratio 大於變量 dict_force_resize_ratio (目前版本中, dict_force_resize_ratio 的值爲 5 )。

什麼時候 dict_can_resize 會爲false?
在前面介紹字典的應用時也說到過, 數據庫就是字典, 數據庫裏的哈希類型鍵也是字典, 當 Redis 使用子進程對數據庫執行後臺持久化任務時(比如執行 BGSAVE 或 BGREWRITEAOF 時), 爲了最大化地利用系統的 copy on write 機制, 程序會暫時將 dict_can_resize 設爲false, 避免執行自然 rehash , 從而減少程序對內存的觸碰(touch)。

當持久化任務完成之後, dict_can_resize 會重新被設爲true。

另一方面, 當字典滿足了強制 rehash 的條件時, 即使 dict_can_resize 不爲true(有 BGSAVE 或 BGREWRITEAOF 正在執行), 這個字典一樣會被 rehash 。

Rehash 執行過程

字典的 rehash 操作實際上就是執行以下任務:
1、創建一個比 ht[0]->table 更大的 ht[1]->table ;
2、將 ht[0]->table 中的所有鍵值對遷移到 ht[1]->table ;
3、將原有 ht[0] 的數據清空,並將 ht[1] 替換爲新的 ht[0] ;
經過以上步驟之後, 程序就在不改變原有鍵值對數據的基礎上, 增大了哈希表的大小。

dict的rehash 本質就是擴容,就是將數組+鏈表結構中的數組擴容;
這個過程,需要開闢一個更大空間的數組,將老數組中每個非空索引的bucket,搬運到新數組;搬運完成後再釋放老數組的空間。

作爲例子, 以下四個小節展示了一次對哈希表進行 rehash 的完整過程。

1. 開始 rehash
這個階段有兩個事情要做:

  • 設置字典的 rehashidx 爲 0 ,標識着 rehash 的開始;
  • 爲 ht[1]->table 分配空間,大小至少爲 ht[0]->used 的兩倍;

這時的字典是這個樣子:

2. Rehash 進行中
在這個階段, ht[0]->table 的節點會被逐漸遷移到 ht[1]->table , 因爲 rehash 是分多次進行的(細節在下一節解釋), 字典的 rehashidx 變量會記錄 rehash 進行到 ht[0] 的哪個索引位置上。
注意除了節點的移動外, 字典的 rehashidx 、 ht[0]->used 和 ht[1]->used 三個屬性也產生了變化。

3. 節點遷移完畢
到了這個階段,所有的節點都已經從 ht[0] 遷移到 ht[1] 了:

4. Rehash 完畢
在 rehash 的最後階段,程序會執行以下工作:

  • 釋放 ht[0] 的空間;
  • 用 ht[1] 來代替 ht[0] ,使原來的 ht[1] 成爲新的 ht[0] ;
  • 創建一個新的空哈希表,並將它設置爲 ht[1] ;
  • 將字典的 rehashidx 屬性設置爲 -1 ,標識 rehash 已停止;
    以下是字典 rehash 完畢之後的樣子:

incremental rehashing 增量/漸進式rehash

在上一節,我們瞭解了字典的 rehash 過程, 需要特別指出的是, rehash 並不是在觸發之後,馬上就執行直到完成; 而是分多次、漸進式地完成的。

rehash會產生的問題
1、rehash的過程,會使用兩個哈希表,創建了一個更大空間的ht[1],此時會造成內存陡增;
2、rehash的過程,可能涉及大量KV鍵值對dictEntry的搬運,耗時較長;
如果這個 rehash 過程必須將所有鍵值對遷移完畢之後纔將結果返回給用戶, 這樣的處理方式將不滿足Redis高效響應的特性。

rehash會產生的問題,主要層面就是內存佔用陡增、和處理耗時長的問題,基於這兩點,還會帶來其他影響。


爲了解決這些問題, Redis 使用了incremental rehashing,是一種 增量/漸進式的 rehash 方式: 通過將 rehash 分散到多個步驟中進行, 從而避免了集中式的計算/節點遷移。

dictAdd 添加鍵值對到dict,檢查到需要進行rehash時,會將dict.rehashidx 設置爲 0 ,標識着 rehash 的開始;
後續請求,在執行add、delete、find操作時,都會判斷dict是否正在rehash,如果是,就執行_dictRehashStep()函數,進行增量rehash。

每次執行 _dictRehashStep , 會將ht[0]->table 哈希表第一個不爲空的索引上的所有節點就會全部遷移到 ht[1]->table 。

也就是在某次dictAdd 添加鍵值對時,觸發了rehash;後續add、delete、find命令在執行前都會檢查,如果dict正在rehash,就先不急去執行自己的命令,先去幫忙搬運一個bucket;
搬運完一個bucket,再執行add、delete、find命令 原有處理邏輯。

ps:實際上incremental rehashing增量/漸進式rehash,只解決了第二個:耗時長的問題,將集中式的節點遷移分攤到多步進行,ht[1]佔用的雙倍多內存,還一直佔用。

下面我們通過dict的查找(dictFind)來看漸進式rehash過程;

dict的查找(dictFind)

dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    unsigned int h, idx, table;

    if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
    if (dictIsRehashing(d)) _dictRehashStep(d);// 如果哈希表在rehashing,則執行單步rehash
    h = dictHashKey(d, key);
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key))
                return he;
            he = he->next;
        }
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}

上述dictFind的源碼,根據dict當前是否正在rehash,依次做了這麼幾件事:

  • 如果當前正在進行rehash,那麼將rehash過程向前推進一步(即調用_dictRehashStep)。實際上,除了查找,插入和刪除也都會觸發這一動作。這就將rehash過程分散到各個查找、插入和刪除操作中去了,而不是集中在某一個操作中一次性做完。
  • 計算key的哈希值(調用dictHashKey,裏面的實現會調用前面提到的hashFunction)。
  • 先在第一個哈希表ht[0]上進行查找。在table數組上定位到哈希值對應的位置(如前所述,通過哈希值與sizemask進行按位與),然後在對應的dictEntry鏈表上進行查找。查找的時候需要對key進行比較,這時候調用dictCompareKeys,它裏面的實現會調用到前面提到的keyCompare。如果找到就返回該項。否則,進行下一步。
  • 判斷當前是否在rehash,如果沒有,那麼在ht[0]上的查找結果就是最終結果(沒找到,返回NULL)。否則,在ht[1]上進行查找(過程與上一步相同)。
    下面我們有必要看一下增量式rehash的_dictRehashStep的實現。
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

/* 
 * Note that a rehashing step consists in moving a bucket (that may have more
 * than one key as we use chaining) from the old to the new hash table, however
 * since part of the hash table may be composed of empty spaces, it is not
 * guaranteed that this function will rehash even a single bucket, since it
 * will visit at max N*10 empty buckets in total, otherwise the amount of
 * work it does would be unbound and the function may block for a long time. 
 */
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht[0].table[d->rehashidx];
        /* Move all the keys in this bucket from the old to the new hash HT */
        while(de) {
            unsigned int h;

            nextde = de->next;
            /* Get the index in the new hash table */
            h = dictHashKey(d, de->key) & d->ht[1].sizemask;
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }

    /* Check if we already rehashed the whole table... */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }

    /* More to rehash... */
    return 1;
}

根據dictRehash函數的註釋,rehash的單位是bucket,也就是從老哈希表dictht中找到第一個非空的下標,要把該下標整個鏈表搬運到新數組。

如果遍歷老數組,訪問的 前N*10 個都是空bucket,則不再繼續往下尋找。

dictAdd、dictDelete、dictFind在rehash過程中的特殊性

在哈希表進行 rehash 時, 字典還會採取一些特別的措施, 確保 rehash 順利、正確地進行:

  • 因爲在 rehash 時,字典會同時使用兩個哈希表,所以在這期間的所有查找dictFind、刪除dictDelete等操作,除了在 ht[0] 上進行,還需要在 ht[1] 上進行。
  • 在執行添加操作dictAdd時,新的節點會直接添加到 ht[1] 而不是 ht[0] ,這樣保證 ht[0] 的節點數量在整個 rehash 過程中都只減不增。

dict的縮容

上面關於 rehash 的章節描述了通過 rehash 對字典進行擴展(expand)的情況, 如果哈希表的可用節點數比已用節點數大很多的話, 那麼也可以通過對哈希表進行 rehash 來收縮(shrink)字典。

收縮 rehash 和上面展示的擴展 rehash 的操作幾乎一樣,執行以下步驟:

  • 創建一個比 ht[0]->table 小的 ht[1]->table ;
  • 將 ht[0]->table 中的所有鍵值對遷移到 ht[1]->table ;
  • 將原有 ht[0] 的數據清空,並將 ht[1] 替換爲新的 ht[0] ;

小結

Redis的dict最顯著的一個特點,就在於它的rehash。它採用了一種稱爲增量式(incremental rehashing)的rehash方法,在需要擴容時避免一次性對所有key進行rehash,而是將rehash操作分散到對於dict的各個增刪改查的操作中去。
這種方法能做到每次只對一小部分key進行rehash,而每次rehash之間不影響dict的操作。dict之所以這樣設計,是爲了避免rehash期間單個請求的響應時間劇烈增加,這與前面提到的“快速響應時間”的設計原則是相符的。

  • Redis的dict也是使用數組+鏈表實現;
  • 當衝突增加、鏈表增長,也是採用rehash(數組擴容)來將鏈表變短;
  • dict數組擴容,也是按2的指數次冪,使用位運算,替代求餘操作,計算更快;
  • 漸進式rehash,其實是輔助式的;不是讓觸發rehash的一個人搬運完所有dictEntry,而是讓後來者一起參與搬運。

福利: 關注公衆號,回覆“Redis源碼”,即可獲得 Redis 3.0源碼註釋版~

本文首發於公衆號 架構道與術(ToBeArchitecturer),歡迎關注、學習更多幹貨~

發佈了208 篇原創文章 · 獲贊 609 · 訪問量 114萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章