Redis核心原理與實踐–散列類型與字典結構實現原理

Redis散列類型可以存儲一組無序的鍵值對,它特別適用於存儲一個對象數據。

> HSET fruit name apple price 7.6 origin china
3
> HGET fruit price
"7.6"

本文分析Redis中散列類型以及其底層數據結構–字典的實現原理。

字典

Redis通常使用字典結構存儲用戶散列數據。
字典是Redis的重要數據結構。除了散列類型,Redis資料庫也使用了字典結構。
Redis使用Hash表實現字典結構。分析Hash表,我們通常關注以下幾個問題:
(1)使用什麼Hash演算法?
(2)Hash衝突如何解決?
(3)Hash表如何擴容?

提示:本章程式碼如無特別說明,均在dict.h、dict.c中。

定義

字典中鍵值對的定義如下:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;
  • key、v:鍵、值。
  • next:下一個鍵值對指針。可見Redis字典使用鏈表法解決Hash衝突的問題。

提示:C語言union關鍵字用於聲明共用體,共用體的所有屬性共用同一空間,同一時間只能儲存其中一個屬性值。也就是說,dictEntry.v可以存放val、u64、s64、d中的一個屬性值。使用sizeof函數計算共用體大小,結果不會小於共用體中最大的成員屬性大小。

字典中Hash表的定義如下:

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;
  • table:Hash表數組,負責存儲數據。
  • used:記錄存儲鍵值對的數量。
  • size:Hash表數組長度。

dictht的結構如圖3-1所示。
圖3-1

字典的定義如下:

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; 
    unsigned long iterators;
} dict;
  • type:指定操作數據的函數指針。
  • ht[2]:定義兩個Hash表用於實現字典擴容機制。通常場景下只使用ht[0],而在擴容時,會創建ht[1],並在操作數據時中逐步將ht[0]的數據移到ht[1]中。
  • rehashidx:下一次執行擴容單步操作要遷移的ht[0]Hash表數組索引,-1代表當前沒有進行擴容操作。
  • iterators:當前運行的迭代器數量,迭代器用於遍歷字典鍵值對。
    dictType定義了字典中用於操作數據的函數指針,這些函數負責實現數據複製、比較等操作。
typedef struct dictType {
    uint64_t (*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;

通過dictType指定操作數據的函數指針,字典就可以存放不同類型的數據了。但在一個字典中,鍵、值可以是不同的類型,但鍵必須類型相同,值也必須類型相同。
Redis為不同的字典定義了不同的dictType,如資料庫使用的server.c/dbDictType,散列類型使用的server.c/setDictType等。

操作分析

dictAddRaw函數可以在字典中插入或查找鍵:

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;
    // [1]
    if (dictIsRehashing(d)) _dictRehashStep(d);

    // [2]
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL;
    // [3]
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    // [4]
    entry = zmalloc(sizeof(*entry));
    entry->next = ht->table[index];
    ht->table[index] = entry;
    ht->used++;

    // [5]
    dictSetKey(d, entry, key);
    return entry;
}

參數說明:

  • existing:如果字典中已存在參數key,則將對應的dictEntry指針賦值給*existing,並返回null,否則返回創建的dictEntry。

【1】如果該字典正在擴容,則執行一次擴容單步操作。
【2】計算參數key的Hash表數組索引,返回-1,代表鍵已存在,這時dictAddRaw函數返回NULL,代表該鍵已存在。
【3】如果該字典正在擴容,則將新的dictEntry添加到ht[1]中,否則添加到ht[0]中。
【4】創建dictEntry,頭插到Hash表數組對應位置的鏈表中。Redis字典使用鏈表法解決Hash衝突,Hash表數組的元素都是鏈表。
【5】將鍵設置到dictEntry中。

dictAddRaw函數只會插入鍵,並不插入對應的值。可以使用返回的dictEntry插入值:

    entry = dictAddRaw(dict,mykey,NULL);
    if (entry != NULL) dictSetSignedIntegerVal(entry,1000);

Hash演算法
dictHashKey宏調用dictType.hashFunction函數計算鍵的Hash值:

#define dictHashKey(d, key) (d)->type->hashFunction(key)

Redis中字典基本都使用SipHash演算法(server.c/dbDictType、server.c/setDictType等dictType的hashFunction屬性指向的函數都使用了SipHash演算法)。該演算法能有效地防止Hash表碰撞攻擊,並提供不錯的性能。
Hash演算法涉及較多的數學知識,本書並不討論Hash演算法的原理及實現,讀者可以自行閱讀相關程式碼。

提示:Redis 4.0之前使用的Hash演算法是MurmurHash。即使輸入的鍵是有規律的,該演算法計算的結果依然有很好的離散性,並且計算速度非常快。Redis 4.0開始更換為SipHash演算法,應該是出於安全的考慮。

計算鍵的Hash值後,還需要計算鍵的Hash表數組索引:

static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing)
{
    unsigned long idx, table;
    dictEntry *he;
    if (existing) *existing = NULL;

    // [1]
    if (_dictExpandIfNeeded(d) == DICT_ERR)
        return -1;
    // [2]
    for (table = 0; table <= 1; table++) {
        idx = hash & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key)) {
                if (existing) *existing = he;
                return -1;
            }
            he = he->next;
        }
        // [3]
        if (!dictIsRehashing(d)) break;
    }
    return idx;
}

【1】根據需要進行擴容或初始化Hash表操作。
【2】遍歷ht[0]、ht[1],計算Hash表數組索引,並判斷Hash表中是否已存在參數key。若已存在,則將對應的dictEntry賦值給*existing。
【3】如果當前沒有進行擴容操作,則計算ht[0]索引後便退出,不需要計算ht[1]。

擴容

Redis使用了一種漸進式擴容方式,這樣設計,是因為Redis是單執行緒的。如果在一個操作內將ht[0]所有數據都遷移到ht[1],那麼可能會引起執行緒長期阻塞。所以,Redis字典擴容是在每次操作數據時都執行一次擴容單步操作,擴容單步操作即將ht[0].table[rehashidx]的數據遷移到ht[1]。等到ht[0]的所有數據都遷移到ht[1],便將ht[0]指向ht[1],完成擴容。
_dictExpandIfNeeded函數用於判斷Hash表是否需要擴容:

static int _dictExpandIfNeeded(dict *d)
{
    ...

    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);
    }
    return DICT_OK;
}

擴容需要滿足兩個條件:
(1)d->ht[0].used≥d->ht[0].size:Hash表存儲的鍵值對數量大於或等於Hash表數組的長度。
(2)開啟了dict_can_resize或者負載因子大於dict_force_resize_ratio。
d->ht[0].used/d->ht[0].size,即Hash表存儲的鍵值對數量/Hash表數組的長度,稱之為負載因子。dict_can_resize默認開啟,即負載因子等於1就擴容。負載因子等於1可能出現比較高的Hash衝突率,但這樣可以提高Hash表的記憶體使用率。dict_force_resize_ratio關閉時,必須等到負載因子等於5時才強制擴容。用戶不能通過配置關閉dict_force_resize_ratio,該值的開關與Redis持久化有關,等我們分析Redis持久化時再討論該值。

dictExpand函數開始擴容操作:

int dictExpand(dict *d, unsigned long size)
{
    ...
    // [1]
    dictht n; 
    unsigned long realsize = _dictNextPower(size);
    ...

    // [2]
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;

    // [3]
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }

    // [4]
    d->ht[1] = n;
    d->rehashidx = 0;
    return DICT_OK;
}

參數說明:

  • size:新Hash表數組長度。

【1】_dictNextPower函數會將size調整為2的n次冪。
【2】構建一個新的Hash表dictht。
【3】ht[0].table==NULL,代表字典的Hash表數組還沒有初始化,將新dictht賦值給ht[0],現在它就可以存儲數據了。這裡並不是擴容操作,而是字典第一次使用前的初始化操作。
【4】否則,將新dictht賦值給ht[1],並將rehashidx賦值為0。rehashidx代表下一次擴容單步操作要遷移的ht[0] Hash表數組索引。

為什麼要將size調整為2的n次冪呢?這樣是為了ht[1] Hash表數組長度是ht[0] Hash表數組長度的倍數,有利於ht[0]的數據均勻地遷移到ht[1]。
我們看一下鍵的Hash表數組索引計算方法:idx=hash&ht.sizemask,由於sizemask= size-1,計算方法等價於:idx=hash%(ht.size)
因此,假如ht[0].size為n,ht[1].size為2×n,對於ht[0]上的元素,ht[0].table[k]的數據,要不遷移到ht[1].table[k],要不遷移到ht[1].table[k+n]。這樣可以將ht[0].table中一個索引位的數據拆分到ht[1]的兩個索引位上。
圖3-2展示了一個簡單示例。
圖3-2

_dictRehashStep函數負責執行擴容單步操作,將ht[0]中一個索引位的數據遷移到ht[1]中。 dictAddRaw、dictGenericDelete、dictFind、dictGetRandomKey、dictGetSomeKeys等函數都會調用該函數,從而逐步將數據遷移到新的Hash表中。
_dictRehashStep調用dictRehash函數完成擴容單步操作:

int dictRehash(dict *d, int n) {
    int empty_visits = n*10; 
    // [1]
    if (!dictIsRehashing(d)) return 0;

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

        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        // [2]
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        // [3]
        de = d->ht[0].table[d->rehashidx];
        while(de) {
            uint64_t h;

            nextde = de->next;
            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++;
    }

    // [4]
    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;
    }
    return 1;
}

參數說明:

  • n:本次操作遷移的Hash數組索引的數量。

【1】如果字典當前並沒有進行擴容,則直接退出函數。
【2】從rehashidx開始,找到第一個非空索引位。
如果這裡查找的的空索引位的數量大於n×10,則直接返回。
【3】遍歷該索引位鏈表上所有的元素。
計算每個元素在ht[1]的Hash表數組中的索引,將元素移動到ht[1]中。
【4】ht[0].used==0,代表ht[0]的數據已經全部移到ht[1]中。
釋放ht[0].table,將ht[0]指針指向ht[1],並重置rehashidx、d->ht[1],擴容完成。

縮容

執行刪除操作後,Redis會檢查字典是否需要縮容,當Hash表長度大於4且負載因子小於0.1時,會執行縮容操作,以節省記憶體。縮容實際上也是通過dictExpand函數完成的,只是函數的第二個參數size是縮容後的大小。

dict常用的函數如表3-1所示。

函數 作用
dictAdd 插入鍵值對
dictReplace 替換或插入鍵值對
dictDelete 刪除鍵值對
dictFind 查找鍵值對
dictGetIterator 生成不安全迭代器,可以對字典進行修改
dictGetSafeIterator 生成安全迭代器,不可對字典進行修改
dictResize 字典縮容
dictExpand 字典擴容

編碼

散列類型有OBJ_ENCODING_HT和OBJ_ENCODING_ZIPLIST兩種編碼,分別使用dict、ziplist結構存儲數據(redisObject.ptr指向dict、ziplist結構)。Redis會優先使用ziplist存儲散列元素,使用一個ziplist節點存儲鍵,後驅節點存放值,查找時需要遍歷ziplist。使用dict存儲散列元素,字典的鍵和值都是sds類型。散列類型使用OBJ_ENCODING_ZIPLIST編碼,需滿足以下條件:
(1)散列中所有鍵或值的長度小於或等於server.hash_max_ziplist_value,該值可通過hash-max-ziplist-value配置項調整。
(2)散列中鍵值對的數量小於server.hash_max_ziplist_entries,該值可通過hash-max- ziplist-entries配置項調整。
散列類型的實現程式碼在t_hash.c中,讀者可以查看源碼了解更多實現細節。

資料庫

Redis是記憶體資料庫,內部定義了資料庫對象server.h/redisDb負責存儲數據,redisDb也使用了字典結構管理數據。

typedef struct redisDb {
    dict *dict;                 
    dict *expires;              
    dict *blocking_keys;        
    dict *ready_keys;           
    dict *watched_keys;         
    int id;                     
    ...
} redisDb;
  • dict:資料庫字典,該redisDb所有的數據都存儲在這裡。
  • expires:過期字典,存儲了Redis中所有設置了過期時間的鍵及其對應的過期時間,過期時間是long long類型的UNIX時間戳。
  • blocking_keys:處於阻塞狀態的鍵和相應的客戶端。
  • ready_keys:準備好數據後可以解除阻塞狀態的鍵和相應的客戶端。
  • watched_keys:被watch命令監控的鍵和相應客戶端。
  • id:資料庫ID標識。

Redis是一個鍵值對資料庫,全稱為Remote Dictionary Server(遠程字典服務),它本身就是一個字典服務。redisDb.dict字典中的鍵都是sds,值都是redisObject。這也是redisObject作用之一,它將所有的數據結構都封裝為redisObject結構,作為redisDb字典的值。
一個簡單的redisDb結構如圖3-3所示。
圖3-3

當我們需要操作Redis數據時,都需要從redisDb中找到該數據。
db.c中定義了hashTypeLookupWriteOrCreate、lookupKeyReadOrReply等函數,可以通過鍵找到redisDb.dict中對應的redisObject,這些函數都是通過調用dict API實現的,這裡不一一展示,感興趣的讀者可以自行閱讀程式碼。

總結:

  • Redis字典使用SipHash演算法計算Hash值,並使用鏈表法處理Hash衝突。
  • Redis字典使用漸進式擴容方式,在每次數據操作中都執行一次擴容單步操作,直到擴容完成。
  • 散列類型的編碼格式可以為OBJ_ENCODING_HT、OBJ_ENCODING_ZIPLIST。

本文內容摘自作者新書《Redis核心原理與實踐》,這本書深入地分析了Redis常用特性的內部機制與實現方式,大部分內容源自對Redis 6.0源碼的分析,並從中總結出設計思路、實現原理。通過閱讀本書,讀者可以快速、輕鬆地了解Redis的內部運行機制。
經過該書編輯同意,我會繼續在個人技術公眾號(binecy)發布書中部分章節內容,作為書的預覽內容,歡迎大家查閱,謝謝。

京東鏈接
豆瓣鏈接