跟著大彬讀原始碼 - Redis 8 - 物件編碼之字典

北國丶風光發表於2019-08-05

字典,是一種用於儲存鍵值對的抽象資料結構。由於 C 語言沒有內建字典這種資料結構,因此 Redis 構建了自己的字典實現。

在 Redis 中,就是使用字典來實現資料庫底層的。對資料庫的 CURD 操作也是構建在對字典的操作之上。

除了用來表示資料庫之外,字典還是雜湊鍵的底層實現之一。當一個雜湊鍵包含的鍵值對比較多,又或者鍵值對中的元素都是比較長的字串時,Redis 就會適應字典作為雜湊鍵的底層實現。

1 字典的實現

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

1.1 雜湊表

Redis 字典所使用的雜湊表結構:

typedef struct dictht {
    dictEntry **table;      // 雜湊表陣列
    unsigned long size;     // 雜湊表大小
    unsigned long sizemask; // 雜湊表大小掩碼,用來計算索引
    unsigned long used;     // 雜湊表現有節點的數量
} dictht;
  • table 屬性是一個陣列。陣列中的每個元素都是一個指向 dictEntry 結構的指標,每個 dictEntry 結構儲存著一個鍵值對。
  • size 屬性記錄了雜湊表的大小,也即是 table 陣列的大小。
  • used 屬性記錄了雜湊表目前已有節點(鍵值對)的數量。
  • sizemask 屬性的值總數等於 size-1,這個屬性和雜湊值一起決定一個鍵應該被放到 table 陣列中哪個索引上。

圖 1 展示了一個大小為 4 的空雜湊表。

大小為4的空雜湊表

1.2 雜湊表節點

雜湊表節點使用 dictEntry 結構表示,每個 dictEntry 結構中都儲存著一個鍵值對:

typedef struct dictEntry {
    void *key;              // 鍵
    union {
        void *val;          // 值型別之指標
        uint64_t u64;       // 值型別之無符號整型
        int64_t s64;        // 值型別之有符號整型
        double d;           // 值型別之浮點型
    } v;                    // 值
    struct dictEntry *next; // 指向下個雜湊表節點,形成連結串列
} dictEntry;
  • key 屬性儲存著鍵,而 v 屬性則儲存著值。
  • next 屬性是指向另一個雜湊表節點的指標。這個指標可以將多個雜湊值相同的鍵值對連線在一起,以此來解決鍵衝突的問題。

圖 2 展示了通過 next 指標,將兩個索引相同的鍵 k1 和 k0 連線在一起的情況。

連線在一起的鍵 k1 和 k0

1.3 字典

字典的結構:

typedef struct dict {
    dictType *type; // 型別特定函式
    void *privdata; // 私有資料
    dictht ht[2];   // 雜湊表(兩個)
    long rehashidx; // 記錄 rehash 進度的標誌。值為 -1 表示 rehash 未進行
    int iterators;  // 當前正在迭代的迭代器數
} dict;

dictType 的結構如下:

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;

type 屬性和 privdata 屬性是針對不同型別的鍵值對,為建立多型字典而設定的。其中:

  • type 屬性是一個指向 dictType 結構的指標,每個 dictType 結構儲存了一簇用於操作特定型別鍵值對的函式。Redis 會為用途不用的字典設定不同的型別特定函式。
  • privdata 屬性儲存了需要傳給那些型別特定函式的可選引數。

而 ht 屬性是一個包含兩個雜湊表的陣列。一般情況下,字典只使用 ht[0],只有在對 ht[0] 進行 rehash 時才會使用 ht[1]。

rehashidx 屬性,它記錄了 rehash 目前的進度,如果當前沒有進行 rehash,它的值為 -1。至於什麼是 rehash,別急,後面會詳細說明。

圖 3 是沒有進行 rehash 的字典:

沒有進行 rehash 的字典

2 插入演算法

當在字典中新增一個新的鍵值對時,Redis 會先根據鍵值對的鍵計算出雜湊值和索引值,然後再根據索引值,將包含新鍵值對的雜湊表節點放到雜湊表陣列指定的索引上。具體演算法如下:

# 使用字典設定的雜湊函式,計算 key 的雜湊值
hash = dict->type->hashFunction(key);
# 使用雜湊表的 sizemask 屬性和雜湊值,計算出索引值
# 根據不同情況,使用 ht[0] 或 ht[1]
index = hash & dict[x].sizemask;

圖 4 - 空字典
如圖 4,如果把鍵值對 [k0, v0] 新增到字典中,插入順序如下:

hash = dict-type->hashFunction(k0);
index = hash & dict->ht[0].sizemask; # 8 & 3 = 0

計算得出,[k0, v0] 鍵值對應該被放在雜湊表陣列索引為 0 的位置上,如圖 5:

圖 5 - 新增 k0-v0 後的字典

2.1 鍵衝突

當有兩個或以上數量的鍵被分配到了雜湊表陣列的同一個索引上面時,我們認為這些鍵發生了建衝突

Redis 的雜湊表使用鏈地址法來解決建衝突。每個雜湊表節點都有一個 next 指標,多個雜湊表節點可以用 next 指標構成一個單向連結串列,被分配到同一個索引的多個節點用 next 指標連結成一個單向連結串列。

舉個栗子,假設我們要把 [k2, v2] 鍵值對新增到圖 6 所示的雜湊表中,並且計算得出 k2 的索引值為 2,和 k1 衝突,因此,這裡就用 next 指標將 k2 和 k1 所在的節點連線起來,如圖 7。

圖 6 - 一個包含兩個鍵值對的雜湊表

圖 7 - 使用連結串列解決 k2 和 k1 衝突

3 rehash 與 漸進式 rehash

隨著對字典的操作,雜湊表報錯的鍵值對會逐漸增多或者減少,為了讓雜湊表的負載因子維持在一個合理的範圍之內,當雜湊表報錯的鍵值對數量太多或者太少時,程式需要對雜湊表進行相應的擴容或收縮。這個擴容或收縮的過程,我們稱之為 rehash。

對於負載因子,可以通過以下公式計算得出:

# 負載因子 = 雜湊表已儲存節點數量 / 雜湊表大小
load_factor = ht[0].used / ht[0].size;

3.1 雜湊表的擴容與收縮

擴容

對於雜湊表的擴容,原始碼如下:

if (d->ht[0].used >= d->ht[0].size &&
    (dict_can_resize ||
     d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
    return dictExpand(d, d->ht[0].used*2);
}

當以下條件被滿足時,程式會自動開始對雜湊表執行擴充套件操作:

  • 伺服器當前沒有進行 rehash;
  • 雜湊表已儲存節點數量大於雜湊表大小;
  • dict_can_resize 引數為 1,或者負載因子大於設定的比率(預設為 5);

收縮

雜湊表的收縮,原始碼如下:

int htNeedsResize(dict *dict) {
    long long size, used;
    size = dictSlots(dict); // ht[2] 兩個雜湊表的大小之和
    used = dictSize(dict);  // ht[2] 兩個雜湊表已儲存節點數量之和
    # DICT_HT_INITIAL_SIZE 預設為 4,HASHTABLE_MIN_FILL 預設為 10。
    return (size > DICT_HT_INITIAL_SIZE &&
            (used*100/size < HASHTABLE_MIN_FILL));
}
void tryResizeHashTables(int dbid) {
    if (htNeedsResize(server.db[dbid].dict))
        dictResize(server.db[dbid].dict);
    if (htNeedsResize(server.db[dbid].expires))
        dictResize(server.db[dbid].expires);
}

當 ht[] 雜湊表的大小之和大於 DICT_HT_INITIAL_SIZE(預設 4),且已儲存節點數量與總大小之比小於 4,HASHTABLE_MIN_FILL(預設 10,也就是 10%),會對雜湊表進行收縮操作。

3.2 rehash

擴容和收縮雜湊表都是通過執行 rehash 操作來完成,雜湊表執行 rehash 的步驟如下:

  1. 為字典的 ht[1] 雜湊表分配空間,這個雜湊表的空間大小取決於要執行的操作,以及 ht[0] 當前包含的鍵值對數量。
    1. 如果執行的是擴容操作,那麼 ht[1] 的大小為**第一個大於等於 ht[0].usedx2 的 2^n。
    2. 如果執行的是收縮操作,那麼 ht[1] 的大小為第一個大於等於 ht[0].used 的 2^n。
  2. 將儲存在 ht[0] 中的所有鍵值對 rehash 到 ht[1] 上面:rehash 指的是重新計算鍵的雜湊值和索引值,然後將鍵值對都遷移到 ht[1] 雜湊表的指定位置上。
  3. 當 ht[0] 包含的所有鍵值對都遷移到 ht[1] 後,此時 ht[0] 變成空表,釋放 ht[0],將 ht[1] 設定為 ht[0],並在 ht[1] 新建立一個空白雜湊表,為下一次 rehash 做準備。

示例:

圖 8 - 將要執行 rehash 的字典
假設程式要對圖 8 所示字典的 ht[0] 進行擴充套件操作,那麼程式將執行以下步驟:
1)ht[0].used 當前的值為 4,那麼 4*2 = 8,而 2^3 恰好是第一個大於等於 8 的,2 的 n 次方。所以程式會將 ht[1] 雜湊表的大小設定為 8。圖 9 是 ht[1] 在分配空間之後的字典。

圖 9 - 為字典的 ht1 雜湊表分配空間

2)將 ht[0] 包含的四個鍵值對都 rehash 到 ht[1],如圖 10。

圖 10 - ht0 所有鍵值對都遷移到 ht1

3)釋放 ht[0],並將 ht[1] 設定為 ht[0],然後為 ht[1] 分配一個空白雜湊表。如圖 11:

圖 11 - 完成 rehash 之後的欄位

至此,對雜湊表的擴容操作執行完畢,程式成功將雜湊表的大小從原來的 4 改為了 8。

3.3 漸進式 rehash

對於 Redis 的 rehash 而言,並不是一次性、集中式的完成,而是分多次、漸進式地完成,所以也叫漸進式 rehash

之所以採用漸進式的方式,其實也很好理解。當雜湊表裡儲存了大量的鍵值對,要一次性的將所有鍵值對全部 rehash 到 ht[1] 裡,很可能會導致伺服器在一段時間內只能進行 rehash,不能對外提供服務。

因此,為了避免 rehash 對伺服器效能造成影響,Redis 分多次、漸進式的將 ht[0] 裡面的鍵值對 rehash 到 ht[1]。

漸進式 rehash 就用到了索引計數器變數 rehashidx,詳細步驟如下:

  1. 為 ht[1] 分配空間,讓字典同時持有 ht[0] 和 ht[1] 兩個雜湊表。
  2. 在欄位中維持一個索引計數器變數 rehashidx,並將它的值設定為 0,表示開始 rehash。
  3. 在 rehash 期間,每次對字典執行 CURD 操作時,程式除了執行指定的操作外,還會將 ht[0] 雜湊表在 rehashidx 索引上的所有鍵值對移動到 ht[1],當 rehash 完成後,程式將 rehashidx 的值加一。
  4. 隨著不斷操作字典,最終在某個時間點上,ht[0] 的所有鍵值對都會被 rehash 到 ht[1],這時程式將 rehashidx 屬性的值設為 -1,表示 rehash 已完成。

漸進式 rehash 才有分而治之的方式,將 rehash 鍵值對所需要的計算工作均攤到對字典的 CURD 操作上,從而避免了集中式 rehash 帶來的問題。

此外,字典在進行 rehash 時,刪除、查詢、更新等操作會在兩個雜湊表上進行。例如,在字典張查詢一個鍵,程式會現在 ht[0] 裡面進行查詢,如果沒找到,再去 ht[1] 上查詢。

要注意的是,新增的鍵值對一律只儲存在 ht[1] 裡,不在對 ht[0] 進行任何新增操作,保證了 ht[0] 包含的鍵值對數量只減不增,隨著 rehash 操作最終變成空表。

圖 12 至 圖 17 展示了一次完整的漸進式 rehash 過程:

1)未進行 rehash 的字典

圖 12 - 未進行 rehash 的字典

2) rehash 索引 0 上的鍵值對

圖 13 - rehash 索引 0 上的鍵值對

3)rehash 索引 1 上的鍵值對

圖 14 - rehash 索引 1 上的鍵值對

4)rehash 索引 2 上的鍵值對

圖 15 - rehash 索引 2 上的鍵值對

5)rehash 索引 3 上的鍵值對

圖 16 - rehash 索引 3 上的鍵值對

6)rehash 執行完畢

圖 17 - rehash 執行完畢

總結

  1. 欄位被廣泛用於實現 Redis 的各種功能,其中包括資料庫和雜湊鍵。
  2. Redis 中的字典使用雜湊表作為底層實現,每個字典帶有兩個雜湊表,一個平時使用,一個僅在 rehash 時使用。
  3. 雜湊表使用鏈地址法來解決鍵衝突,被分配到同一個索引上的多個鍵值對會連線成一個單向連結串列。
  4. 在對雜湊表進行擴容或收縮操作時,使用漸進式完成 rehash。

相關文章