1. 程式人生 > 實用技巧 >《閒扯Redis七》Redis字典結構的底層實現

《閒扯Redis七》Redis字典結構的底層實現


一、前言

上節《閒扯Redis六》Redis五種資料型別之Hash型 中說到 Hash(雜湊物件)的底層實現有:

1、ziplist 編碼的雜湊物件使用壓縮列表作為底層實現
2、hashtable 編碼的雜湊物件使用字典作為底層實現

原文解析

那麼第二種方式中的字典究竟是怎樣的一種結構呢?

字典, 又稱符號表(symbol table)、關聯陣列(associative array)或者對映(map), 是一種用於儲存鍵值對(key-value pair)的抽象資料結構。在字典中, 一個鍵(key)可以和一個值(value)進行關聯(或者說將鍵對映為值), 這些關聯的鍵和值就被稱為鍵值對。

字典中的每個鍵都是獨一無二的, 程式可以在字典中根據鍵查詢與之關聯的值, 或者通過鍵來更新值, 又或者根據鍵來刪除整個鍵值對, 等等。

二、實現分析

Redis 的字典採用雜湊表作為底層實現, 一個雜湊表裡面可以有多個雜湊表節點, 而每個雜湊表節點就儲存了字典中的一個鍵值對。所以咱們依次來分析一下雜湊表、雜湊表節點、以及字典的結構。

1.雜湊表結構

雜湊表結構定義 (dict.h/dictht):

typedef struct dictht {

    // 雜湊表陣列
    dictEntry **table;

    // 雜湊表大小
    unsigned long size;

    // 雜湊表大小掩碼,用於計算索引值
    // 總是等於 size - 1
    unsigned long sizemask;

    // 該雜湊表已有節點的數量
    unsigned long used;

} dictht;

描述

table 屬性是一個數組, 陣列中的每個元素都是一個指向 dict.h/dictEntry 結構的指標, 每個 dictEntry 結構儲存著一個鍵值對。

size 屬性記錄了雜湊表的大小, 也即是 table 陣列的大小, 而 used 屬性則記錄了雜湊表目前已有節點(鍵值對)的數量。

sizemask 屬性的值總是等於 size - 1 , 這個屬性和雜湊值一起決定一個鍵應該被放到 table 陣列的哪個索引上面。

結構圖解:一個空的雜湊表

2.雜湊表節點

一個雜湊表裡面可以有多個雜湊表節點,那麼每個雜湊表節點的結構以及多個雜湊表節點之間的儲存關係是怎麼樣的呢?

雜湊表節點結構定義

(dictEntry):

typedef struct dictEntry {

    // 鍵
    void *key;

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

    // 指向下個雜湊表節點,形成連結串列
    struct dictEntry *next;

} dictEntry;

描述

key 屬性儲存著鍵值對中的鍵, 而 v 屬性則儲存著鍵值對中的值, 其中鍵值對的值可以是一個指標, 

或者是一個 uint64_t 整數, 又或者是一個 int64_t 整數。

next 屬性是指向另一個雜湊表節點的指標, 這個指標可以將多個雜湊值相同的鍵值對連線在一次, 

以此來解決鍵衝突(collision)的問題。

結構圖解:多個雜湊值相同的鍵值對儲存結構,解決鍵衝突

3.字典結構實現

字典結構定義 (dict.h/dict):

typedef struct dict {

    // 型別特定函式
    dictType *type;

    // 私有資料
    void *privdata;

    // 雜湊表
    dictht ht[2];

    // rehash 索引
    // 當 rehash 不在進行時,值為 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */

} dict;

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

type 屬性是一個指向 dictType 結構的指標, 每個 dictType 結構儲存了一簇用於操作特定型別鍵值對的函式, 

Redis 會為用途不同的字典設定不同的型別特定函式。

privdata 屬性則儲存了需要傳給那些型別特定函式的可選引數。
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;

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

除了 ht[1] 之外, 另一個和 rehash 有關的屬性就是 rehashidx : 它記錄了 rehash 目前的進度, 如果目前沒有在進行 rehash , 那麼它的值為 -1 。

結構圖解:普通狀態下(沒有進行 rehash)的字典

三、雜湊表分析

1.雜湊演算法

當要將一個新的鍵值對新增到字典裡面時, 程式需要先根據鍵值對的鍵計算出雜湊值和索引值, 然後再根據索引值, 將包含新鍵值對的雜湊表節點放到雜湊表陣列的指定索引上面。

Redis 計算雜湊值和索引值的方法如下:


# 使用字典設定的雜湊函式,計算鍵 key 的雜湊值
hash = dict->type->hashFunction(key);

# 使用雜湊表的 sizemask 屬性和雜湊值,計算出索引值
# 根據情況不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;

如圖 4-4:

舉個例子, 對於圖 4-4 所示的字典來說, 如果我們要將一個鍵值對 k0 和 v0 新增到字典裡面, 那麼程式會先使用語句:

hash = dict->type->hashFunction(k0);

計算鍵 k0 的雜湊值。

假設計算得出的雜湊值為 8 , 那麼程式會繼續使用語句:

index = hash & dict->ht[0].sizemask = 8 & 3 = 0;

計算出鍵 k0 的索引值 0 , 這表示包含鍵值對 k0 和 v0 的節點應該被放置到雜湊表陣列的索引 0 位置上,
結構圖解:圖 4-5

2.鍵衝突解決

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

Redis 的雜湊表使用鏈地址法(separate chaining)來解決鍵衝突: 每個雜湊表節點都有一個 next 指標, 多個雜湊表節點可以用 next 指標構成一個單向連結串列, 被分配到同一個索引上的多個節點可以用這個單向連結串列連線起來, 這就解決了鍵衝突的問題。

舉個例子, 假設程式要將鍵值對 k2 和 v2 新增到圖 4-6 所示的雜湊表裡面, 並且計算得出 k2 的索引值為 2 , 那麼鍵 k1 和 k2 將產生衝突, 而解決衝突的辦法就是使用 next 指標將鍵 k2 和 k1 所在的節點連線起來。
結構圖解:圖 4-7


因為 dictEntry 節點組成的連結串列沒有指向連結串列表尾的指標, 所以為了速度考慮, 程式總是將新節點新增到連結串列的表頭位置(複雜度為 O(1)), 排在其他已有節點的前面。

四、要點總結

1.字典 ht 屬性是包含兩個雜湊表項的陣列,一般情況下, 字典只使用 ht[0], ht[1] 雜湊表只會在對 ht[0] 雜湊表進行 rehash (下節分析) 時使用

2.雜湊表使用鏈地址法(separate chaining)來解決鍵衝突

3.鍵值對新增到字典的過程, 先根據鍵值對的鍵計算出雜湊值和索引值, 然後再根據索引值, 將包含新鍵值對的雜湊表節點放到雜湊表陣列的指定索引上面