Redis 物件內部組織結構 —— 字典

老錢發表於2018-09-01

我們知道一個大型的公司往往都具有複雜的組織結構,成百上千號員工,要做到大而不亂,就必須依靠合理的組織結構來優化內部的交流成本。Redis 內部也有組織結構,不同的是這個組織結構要維繫上億的物件,而不是幾百幾千。今天我來向大家呈現 Redis 如何來管理這上億的物件而不會混亂的。

圖片

Redis 的物件很多,但是物件的種類卻是有限的,目前一共只有7種物件。

#define OBJ_STRING 0    /* String object. */
#define OBJ_LIST 1      /* List object. */
#define OBJ_SET 2       /* Set object. */
#define OBJ_ZSET 3      /* Sorted set object. */
#define OBJ_HASH 4      /* Hash object. */
#define OBJ_MODULE 5    /* Module object. */
#define OBJ_STREAM 6    /* Stream object. */
複製程式碼

看到這裡,肯定會要很多人要舉手表示抗議!老錢啊,你這不對啊,HyperLogLog 哪裡去了?Geo 哪裡去了?

這個問題提的非常棒!其實這個問題是我在寫這篇文章的時候自己向自己提出的,我在問這個問題的時候,我也不知道為什麼,我只是隱約覺得上面這三種高階資料結構在Redis內部應該是混合使用了上面的基礎資料結構,也就是說他們是複合資料結構。但是我需要求證,於是我閱讀了一下原始碼證實了我的猜測。

HyperLogLog 和 Bitmap 一樣,使用的是一個普通的動態字串,而 Geo 使用的是 zset。還有一個奇妙的地方就是當你使用 pfadd 構造出來的計數器物件可以直接使用字串命令將它的內部全部顯示出來。

127.0.0.1:6379> pfadd codehole python java golang
(integer) 1
127.0.0.1:6379> get codehole
"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80C\x03\x84MK\x80P\xb8\x80^\xf3"
複製程式碼

同樣你也可以使用 zset 相關的指令將 geo 的內容顯示出來

127.0.0.1:6379> GEOADD city 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania"
(integer) 2
127.0.0.1:6379> zrange city 0 -1 withscores
1) "Palermo"
2) "3479099956230698"
3) "Catania"
4) "3479447370796909"
複製程式碼

這個問題算是回答完了,接下來我再提出一個問題,平時我們聽的「跳躍列表 skiplist」,「壓縮列表 ziplist」、「快速列表 quicklist」跟物件型別什麼關係?

為了回答這個問題,接下來要引入 Redis 的物件結構。Redis 所有的物件都有一個相同的「頭結構」,頭部結構中有一個指標指向各自不同的「體結構」。

typedef struct redisObject {
    unsigned type:4; // 物件型別
    unsigned encoding:4; // 物件編碼
    unsigned lru:24; // LRU時間戳
    int refcount; // 引用計數
    void *ptr; // 指向體結構的指標
} robj;
複製程式碼

我們注意到 type 欄位只有 4bit,最多隻能表示 16 個物件型別,這大概是為什麼物件型別要省著用的原因,太浪費了以後就不好擴充套件了。

我們還注意到有一個 encoding 欄位,它也是 4 個位,它代表的是物件的內部結構型別。Redis 為了節約記憶體,在集合物件比較小時,採用特殊結構進行儲存。比如hash物件在內部 key 很少 (size<512) 並且 value 值較短 (len<64) 的時候採用 ziplist 進行儲存,超過了這個數量就使用標準的 hashtable 儲存。

Type是對外統一介面是形象,Encoding是對內具體實現是骨肉。

我們翻翻原始碼來看看 encoding 都有哪些

#define OBJ_ENCODING_RAW 0  // 可修改的長字串
#define OBJ_ENCODING_INT 1 // 整型字串
#define OBJ_ENCODING_HT 2 // hashtable
#define OBJ_ENCODING_ZIPMAP 3 // 壓縮map,已經廢棄不用,改用ziplist
#define OBJ_ENCODING_LINKEDLIST 4 // 雙向連結串列,已廢棄不用,改用quicklist
#define OBJ_ENCODING_ZIPLIST 5 // 壓縮列表
#define OBJ_ENCODING_INTSET 6 // 整數集合,個數少全是整數的set
#define OBJ_ENCODING_SKIPLIST 7 // 跳躍列表,zset的標準內部結構
#define OBJ_ENCODING_EMBSTR 8 // 只讀短字串
#define OBJ_ENCODING_QUICKLIST 9 // 快速列表,儲存list
#define OBJ_ENCODING_STREAM 10 // 流
複製程式碼

看到這裡我開始有點當心,encoding 只有 4bit,但是已經用掉了 11 個值,以後要是擴充套件改怎麼辦?這個問題這裡就不好回答了,大家可以自己討論。

Type和Encoding的對應關係如下

1. string  ==> raw|embstr|int
2. list ==> quicklist
3. hash ==> ziplist|hashtable
4. set ==> intset|hashtable
5. zset ==> ziplist|skiplist
6. stream => stream
複製程式碼

接下來我們要開始深入內部結構了,將每一個結構都過一遍,限於篇幅,不能講的太詳細。

第一個我們要講的是字典,因為它太重要了,Redis 物件樹的主幹就是字典結構,key 是物件的名稱,value 是各種不同的物件,所有的物件都掛在一棵字典上。除了容納所有物件的主幹字典外,還有容納所有帶過期時間的物件的過期主幹字典,它的 key 是物件的名稱,value 是物件的過期時間戳。

typedef struct redisDb {
    dict *dict;
    dict *expires;
    ...
} redisDb;
複製程式碼

字典的 value 呈現出了多型性,它可以是一個單純的整數或者浮點數,也可以是一個物件,會有一個統一的物件頭,也就是前面的 redisObject 結構體,會根據 type 欄位和 encoding 欄位來決定 ptr 欄位指向的具體資料結構。我們來看一下字典的結構體程式碼定義

// dict
typedef struct dict {
    dictType *type; // 字典的介面實現,為字典帶來多型性
    void *privdata; // 儲存字典的附加資訊
    dictht ht[2]; // 注意這裡不是指向指標的陣列,為什麼?
    long rehashidx; // 漸進式rehash時記錄當前rehash的位置
    unsigned long iterators;
} dict;

// dict hashtable
typedef struct dictht {
    dictEntry **table; // 指向第一維陣列
    unsigned long size; // 陣列的長度
    unsigned long sizemask; // 用於快速hash定位 sizemask = size - 1
    unsigned long used; // 陣列中的元素個數
} dictht;

// 定義了字典功能的介面
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;

// key-value wrapper
typedef struct dictEntry {
    void *key;
    union {
        void *val; // sds|set|dict|zset|quicklist
        uint64_t u64; // 用於過期字典,val儲存過期時間戳
        int64_t s64; // Don't watch me!
        double d; // 用於zset,儲存score值
    } v;
    struct dictEntry *next;
} dictEntry;
複製程式碼

字典結構的內部實現是兩個 hashtable,為什麼是兩個 hashtable 呢,這個涉及到字典的漸進式擴容和所容,我們後再講。通常情況下,我們只會使用到ht[0],一個單純的 hashtable

圖片

我們看看 hashtable 的內部結構。hashtable 的結構和 Java 語言的 HashMap 初級版是一樣的,為什麼說初級版本呢,因為 Java8 對 HashMap 做了改造,在 hash 不均勻的時候做了複雜的優化處理,至於具體的優化方法,這裡我就不做詳細解釋了,感興趣可以搜尋相關資料。

我們看下字典的內部結構,它是一個二維的

圖片

查詢過程如下,為了方便閱讀,我仔細去掉了額外的需要考慮「漸進式遷移」的部分程式碼

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

    if (d->ht[0].used == 0) return NULL; /* dict is empty */
    h = dictHashKey(d, key); // 計算hash值
    idx = h & d->ht[0].sizemask; // 定位陣列位置
    he = d->ht[0].table[idx]; // 獲取連結串列表頭
    while(he) {
        if (key==he->key || dictCompareKeys(d, key, he->key))
            return he; // 找到了就返回
        he = he->next; // 找不到繼續遍歷
    }
    return NULL;
}
複製程式碼

其中 dictHashKey 和 dictCompareKeys 會分別呼叫相應字典的多型函式

#define dictHashKey(d, key) (d)->type->hashFunction(key)
#define dictCompareKeys(d, key1, key2) \
    (((d)->type->keyCompare) ? \
        (d)->type->keyCompare((d)->privdata, key1, key2) : \
        (key1) == (key2))
複製程式碼

需要注意到定位陣列用的是按位操作,這是因為字典的第一維陣列的長度都會 2^n 。對於 2^n 長度的陣列來說,對陣列長度的取模操作等價於按位操作

sizemask = size - 1;
idx = h & d->ht[0].sizemask ==> idx = h % d->ht[0].size
複製程式碼

我們在使用 Java 的 HashMap 時會當心如果物件的 hashcode 不均勻,會導致連結串列長度差別較大,個別連結串列會特別長,對效能就會產生較大影響。所以 Java8 對 HashMap 的連結串列進行了適當的改造,如果連結串列的長度超過 8,就會轉變成一顆紅黑樹,用於提升查詢效率。

那為什麼 Redis 不需要考慮這點呢?

這是因為 Java 的 HashMap 容納的 key 物件是不可控的,它可以是任意物件,如果物件的 hashCode 方法返回的數值不均勻就會帶來效能問題。

但是 Redis 的字典容納的 key 都是 sds 動態字串,它的 hashCode 是均勻的可控的,Redis的內建 hash(siphash) 演算法可以保證字串的 hash 值非常均勻。

接下來我們談談字典的擴容。

在 Java 的 HashMap 裡面,擴容是申請一個新的陣列,這個陣列是舊陣列的兩倍大小,然後一次性將舊陣列下面掛接的所有元素一次性全部遷移到新陣列中。如果字典中元素特別多,擴容會比較消耗計算資源,也就是通常所說的「卡頓」。

Redis 記憶體裡可以容納的物件會上億,這些物件是使用字典組織起來的。如果 Redis 字典的擴容策略和 Java 的 HashMap 一樣,這樣龐大的字典肯定也會遭遇「卡頓問題」。

Redis 為了解決這個問題,它使用了漸進式遷移策略。當字典需要擴容時,它會申請一個新的 hashtable 放在字典的 ht[1] 中,在遷移完成之前新舊兩個 hashtable 將會共存,也就是 ht[1] 和 ht[0] 兩個欄位值同時存在。

int dictExpand(dict *d, unsigned long size)
{
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

    dictht n;
    unsigned long realsize = _dictNextPower(size);

    if (realsize == d->ht[0].size) return DICT_ERR;

    // 分配一個新的hashtable
    n.size = realsize;
    n.sizemask = realsize - 1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
    
    // 如果是空字典的第一次擴容,那就掛到ht[0]上
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }

    // 掛在ht[1]上,準備進行漸進式遷移
    d->ht[1] = n;
    d->rehashidx = 0;
    return DICT_OK;
}
複製程式碼

在後續該字典的每個指令中,Redis都會將舊 hashtable 的一部分鍵值對遷移到新的 hashtable 中。目前漸進式遷移每次遷移 10 個槽位,也就是最多 10 個連結串列,平均一個連結串列的長度大約是 1。看到這裡我不禁要當心一個大型的字典需要漸進式遷移多少次才能完成。如果沒有了後續的讀寫操作,是不是就永遠無法遷移完成了呢?這個讀者可以繼續思考。

當 Redis 中積累了上億個物件時,這顆物件樹的主幹是一個字典,這個字典是非常大的,它也需要擴容。如果這個漸進式擴容的時間比較漫長,Redis 的每個指令都需要進行漸進式遷移,勢必會持續影響整體的效能,而且記憶體會長期處於一個比較高的冗餘狀態。

所以 Redis 對於這個主幹字典採取了定期主動遷移法,每隔 1ms 都會執行漸進式遷移,每次遷移不超過 1ms,以免導致正常的指令卡頓。

// 漸進式rehash,最多持續時間ms
int dictRehashMilliseconds(dict *d, int ms) {
    long long start = timeInMilliseconds();
    int rehashes = 0;

    // 每次執行100步(每步10個槽位),停下來看看時間,如果超出時間就中斷
    while(dictRehash(d,100)) {
        rehashes += 100;
        if (timeInMilliseconds()-start > ms) break;
    }
    return rehashes;
}

// 對指定db進行漸進式rehash
// 優先遷移所有物件的主幹字典,再考慮過期物件字典
int incrementallyRehash(int dbid) {
    if (dictIsRehashing(server.db[dbid].dict)) {
        dictRehashMilliseconds(server.db[dbid].dict,1);
        return 1;
    }
    
    if (dictIsRehashing(server.db[dbid].expires)) {
        dictRehashMilliseconds(server.db[dbid].expires,1);
        return 1;
    }
    return 0;
}
複製程式碼

同時如果 Redis 正在進行 bgsave 或者 bgaofrewrite 開啟子程式來執行持久化操作時,需要遍歷整顆物件樹。為了避免父子程式過多的頁面分離出來拉高整體記憶體佔用,在這兩條指令執行時,儘量不執行字典的擴容 dict_can_resize = false,除非字典已經特別擁擠,這個擁擠程度的閾值預設是 dict_force_resize_ratio = 5,也就是字典元素的個數相對第一維陣列的長度的比例。

static int _dictExpandIfNeeded(dict *d)
{
    // 如果正在執行漸進式rehash,那就暫時不要擴容
    if (dictIsRehashing(d)) return DICT_OK;

    // 如果是空字典,那就進行第一次擴容
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    // 綜合考慮字典的擁擠程度以及例項是否處於bgsave/bgaofrewrite
    // 來決定是否進行擴容
    if(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;
}
複製程式碼

有關字典的內容就講到這裡,下一篇我們繼續看看字典裡面容納的 key 。字典的 key 放的都是字串,所以下一篇我們要講的內容是字串的內部結構,敬請期待。

本文節選之掘金線上技術小冊《Redis 深度歷險》,對 Redis 感興趣請點選連線深入閱讀《Redis 深度歷險》

Redis 物件內部組織結構 —— 字典

Redis 物件內部組織結構 —— 字典

閱讀更多深度技術文章,掃一掃上面的二維碼關注微信公眾號「碼洞」

相關文章