Redis中的資料結構

張浮生發表於2018-09-10

1. 底層資料結構, 與Redis Value Type之間的關係

對於Redis的使用者來說, Redis作為Key-Value型的記憶體資料庫, 其Value有多種型別.

  1. String
  2. Hash
  3. List
  4. Set
  5. ZSet

這些Value的型別, 只是"Redis的使用者認為的, Value儲存資料的方式". 而在具體實現上, 各個Type的Value到底如何儲存, 這對於Redis的使用者來說是不公開的.

舉個粟子: 使用下面的命令建立一個Key-Value

$ SET "Hello" "World"

對於Redis的使用者來說, Hello這個Key, 對應的Value是String型別, 其值為五個ASCII字元組成的二進位制資料. 但具體在底層實現上, 這五個位元組是如何儲存的, 是不對使用者公開的. 即, Value的Type, 只是表象, 具體資料在記憶體中以何種資料結構存放, 這對於使用者來說是不必要了解的.

Redis對使用者暴露了五種Value Type, 其底層實現的資料結構有8種, 分別是:

  1. SDS - simple synamic string - 支援自動動態擴容的位元組陣列
  2. list - 平平無奇的連結串列
  3. dict - 使用雙雜湊表實現的, 支援平滑擴容的字典
  4. zskiplist - 附加了後向指標的跳躍表
  5. intset - 用於儲存整數數值集合的自有結構
  6. ziplist - 一種實現上類似於TLV, 但比TLV複雜的, 用於儲存任意資料的有序序列的資料結構
  7. quicklist - 一種以ziplist作為結點的雙連結串列結構, 實現的非常苟
  8. zipmap - 一種用於在小規模場合使用的輕量級字典結構

而銜接"底層資料結構"與"Value Type"的橋樑的, 則是Redis實現的另外一種資料結構: redisObject. Redis中的Key與Value在表層都是一個redisObject例項, 故該結構有所謂的"型別", 即是ValueType. 對於每一種Value Type型別的redisObject, 其底層至少支援兩種不同的底層資料結構來實現. 以應對在不同的應用場景中, Redis的執行效率, 或記憶體佔用.

2. 底層資料結構

2.1 SDS - simple dynamic string

這是一種用於儲存二進位制資料的一種結構, 具有動態擴容的特點. 其實現位於src/sds.hsrc/sds.c中, 其關鍵定義如下:

typedef char *sds;

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

SDS的總體概覽如下圖:

sds

其中sdshdr是頭部, buf是真實儲存使用者資料的地方. 另外注意, 從命名上能看出來, 這個資料結構除了能儲存二進位制資料, 顯然是用於設計作為字串使用的, 所以在buf中, 使用者資料後總跟著一個\0. 即圖中 "資料" + "\0" 是為所謂的buf

SDS有五種不同的頭部. 其中sdshdr5實際並未使用到. 所以實際上有四種不同的頭部, 分別如下:

sdshdr

  1. len分別以uint8, uint16, uint32, uint64表示使用者資料的長度(不包括末尾的\0)
  2. alloc分別以uint8, uint16, uint32, uint64表示整個SDS, 除過頭部與末尾的\0, 剩餘的位元組數.
  3. flag始終為一位元組, 以低三位標示著頭部的型別, 高5位未使用.

當在程式中持有一個SDS例項時, 直接持有的是資料區的頭指標, 這樣做的用意是: 通過這個指標, 向前偏一個位元組, 就能取到flag, 通過判斷flag低三位的值, 能迅速判斷: 頭部的型別, 已用位元組數, 總位元組數, 剩餘位元組數. 這也是為什麼sds型別即是char *指標型別別名的原因.

建立一個SDS例項有三個介面, 分別是:

// 建立一個不含資料的sds: 
//  頭部    3位元組 sdshdr8
//  資料區  0位元組
//  末尾    \0 佔一位元組
sds sdsempty(void);
// 帶資料建立一個sds:
//  頭部    按initlen的值, 選擇最小的頭部型別
//  資料區  從入參指標init處開始, 拷貝initlen個位元組
//  末尾    \0 佔一位元組
sds sdsnewlen(const void *init, size_t initlen);
// 帶資料建立一個sds:
//  頭部    按strlen(init)的值, 選擇最小的頭部型別
//  資料區  入參指向的字串中的所有字元, 不包括末尾 \0
//  末尾    \0 佔一位元組
sds sdsnew(const char *init);
  1. 所有建立sds例項的介面, 都不會額外分配預留記憶體空間
  2. sdsnewlen用於帶二進位制資料建立sds例項, sdsnew用於帶字串建立sds例項. 介面返回的sds可以直接傳入libc中的字串輸出函式中進行操作, 由於無論其中儲存的是使用者的二進位制資料, 還是字串, 其末尾都帶一個\0, 所以至少呼叫libc中的字串輸出函式是安全的.

在對SDS中的資料進行修改時, 若剩餘空間不足, 會呼叫sdsMakeRoomFor函式用於擴容空間, 這是一個很低階的API, 通常情況下不應當由SDS的使用者直接呼叫. 其實現中核心的幾行如下:

sds sdsMakeRoomFor(sds s, size_t addlen) {
    ...
    /* Return ASAP if there is enough space left. */
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    newlen = (len+addlen);
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    ...
}

可以看到, 在擴充空間時

  1. 先保證至少有addlen可用
  2. 然後再進一步擴充, 在總體佔用空間不超過閾值SDS_MAC_PREALLOC時, 申請空間再翻一倍. 若總體空間已經超過了閾值, 則步進增長SDS_MAC_PREALLOC. 這個閾值的預設值為 1024 * 1024

SDS也提供了介面用於移除所有未使用的記憶體空間. sdsRemoveFreeSpace, 該介面沒有間接的被任何SDS其它介面呼叫, 即預設情況下, SDS不會自動回收預留空間. 在SDS的使用者需要節省記憶體時, 由使用者自行呼叫:

sds sdsRemoveFreeSpace(sds s);

總結:

  1. SDS除了是某些Value Type的底層實現, 也被大量使用在Redis內部, 用於替代C-Style字串. 所以預設的建立SDS例項介面, 不分配額外的預留空間. 因為多數字符串在程式執行期間是不變的. 而對於變更資料區的API, 其內部則是呼叫了 sdsMakeRoomFor, 每一次擴充空間, 都會預留大量的空間. 這樣做的考量是: 如果一個SDS例項中的資料被變更了, 那麼很有可能會在後續發生多次變更.
  2. SDS的API內部不負責清除未使用的閒置記憶體空間, 因為內部API無法判斷這樣做的合適時機. 即便是在運算元據區的時候導致資料區佔用記憶體減少時, 內部API也不會清除閒置內在空間. 清除閒置記憶體空間責任應當由SDS的使用者自行擔當.
  3. 用SDS替代C-Style字串時, 由於其頭部額外儲存了資料區的長度資訊, 所以字串的求長操作時間複雜度為O(1)

2.2 list

這是普通的連結串列實現, 連結串列結點不直接持有資料, 而是通過void *指標來間接的指向資料. 其實現位於 src/adlist.hsrc/adlist.c中, 關鍵定義如下:

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

typedef struct listIter {
    listNode *next;
    int direction;
} listIter;

typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned long len;
} list;

其記憶體佈局如下圖所示:

list

這是一個平平無奇的連結串列的實現. list在Redis除了作為一些Value Type的底層實現外, 還廣泛用於Redis的其它功能實現中, 作為一種資料結構工具使用. 在list的實現中, 除了基本的連結串列定義外, 還額外增加了:

  1. 迭代器listIter的定義, 與相關介面的實現.
  2. 由於list中的連結串列結點本身並不直接持有資料, 而是通過value欄位, 以void *指標的形式間接持有, 所以資料的生命週期並不完全與連結串列及其結點一致. 這給了list的使用者相當大的靈活性. 比如可以多個結點持有同一份資料的地址. 但與此同時, 在對連結串列進行銷燬, 結點複製以及查詢匹配時, 就需要list的使用者將相關的函式指標賦值於list.dup, list.free, list.match欄位.

2.3 dict

dict是Redis底層資料結構中實現最為複雜的一個資料結構, 其功能類似於C++標準庫中的std::unordered_map, 其實現位於 src/dict.hsrc/dict.c中, 其關鍵定義如下:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

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;

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

/* If safe is set to 1 this is a safe iterator, that means, you can call
 * dictAdd, dictFind, and other functions against the dictionary even while
 * iterating. Otherwise it is a non safe iterator, and only dictNext()
 * should be called while iterating. */
typedef struct dictIterator {
    dict *d;
    long index;
    int table, safe;
    dictEntry *entry, *nextEntry;
    /* unsafe iterator fingerprint for misuse detection. */
    long long fingerprint;
} dictIterator;

其記憶體佈局如下所示:

dict

  1. dict中儲存的鍵值對, 是通過dictEntry這個結構間接持有的, k通過指標間接持有鍵, v通過指標間接持有值. 注意, 若值是整數值的話, 是直接儲存在v欄位中的, 而不是間接持有. 同時next指標用於指向, 在bucket索引值衝突時, 以鏈式方式解決衝突, 指向同索引的下一個dictEntry結構.
  2. 傳統的雜湊表實現, 是一塊連續空間的順序表, 表中元素即是結點. 在dictht.table中, 結點本身是散佈在記憶體中的, 順序表中儲存的是dictEntry的指標
  3. 雜湊表即是dictht結構, 其通過table欄位間接的持有順序表形式的bucket, bucket的容量儲存在size欄位中, 為了加速將雜湊值轉化為bucket中的陣列索引, 引入了sizemask欄位, 計算指定鍵在雜湊表中的索引時, 執行的操作類似於dict->type->hashFunction(鍵) & dict->ht[x].sizemask. 從這裡也可以看出來, bucket的容量適宜於為2的冪次, 這樣計算出的索引值能覆蓋到所有bucket索引位.
  4. dict即為字典. 其中type欄位中儲存的是本字典使用到的各種函式指標, 包括雜湊函式, 鍵與值的複製函式, 釋放函式, 以及鍵的比較函式. privdata是用於儲存使用者自定義資料. 這樣, 字典的使用者可以最大化的自定義字典的實現, 通過自定義各種函式實現, 以及可以附帶私有資料, 保證了字典有很大的調優空間.
  5. 字典為了支援平滑擴容, 定義了ht[2]這個陣列欄位. 其用意是這樣的:
    1. 一般情況下, 字典dict僅持有一個雜湊表dictht的例項, 即整個字典由一個bucket實現.
    2. 隨著插入操作, bucket中出現衝突的概率會越來越大, 當字典中儲存的結點數目, 與bucket陣列長度的比值達到一個閾值(1:1)時, 字典為了緩解效能下降, 就需要擴容
    3. 擴容的操作是平滑的, 即在擴容時, 字典會持有兩個dictht的例項, ht[0]指向舊雜湊表, ht[1]指向擴容後的新雜湊表. 平滑擴容的重點在於兩個策略:
      1. 後續每一次的插入, 替換, 查詢操作, 都插入到ht[1]指向的雜湊表中
      2. 每一次插入, 替換, 查詢操作執行時, 會將舊錶ht[0]中的一個bucket索引位持有的結點連結串列, 遷移到ht[1]中去. 遷移的進度儲存在rehashidx這個欄位中.在舊錶中由於衝突而被連結在同一索引位上的結點, 遷移到新表後, 可能會散佈在多個新表索引中去.
      3. 當遷移完成後, ht[0]指向的舊錶會被釋放, 之後會將新表的持有權轉交給ht[0], 再重置ht[1]指向NULL
  6. 這種平滑擴容的優點有兩個:
    1. 平滑擴容過程中, 所有結點的實際資料, 即dict->ht[0]->table[rehashindex]->kdict->ht[0]->table[rehashindex]->v分別指向的實際資料, 記憶體地址都不會變化. 沒有發生鍵資料與值資料的拷貝或移動, 擴容整個過程僅是各種指標的操作. 速度非常快
    2. 擴容操作是步進式的, 這保證任何一次插入操作都是順暢的, dict的使用者是無感知的. 若擴容是一次性的, 當新舊bucket容量特別大時, 遷移所有結點必然會導致耗時陡增.

除了字典本身的實現外, 其中還順帶實現了一個迭代器, 這個迭代器中有欄位safe以標示該迭代器是"安全迭代器"還是"非安全迭代器", 所謂的安全與否, 指是的這種場景:
設想在執行迭代器的過程中, 字典正處於平滑擴容的過程中. 在平滑擴容的過程中時, 舊錶一個索引位上的, 由衝突而鏈起來的多個結點, 遷移到新表後, 可能會散佈到新表的多個索引位上. 且新的索引位的值可能比舊的索引位要低.

遍歷操作的重點是, 保證在迭代器遍歷操作開始時, 字典中持有的所有結點, 都會被遍歷到. 而若在遍歷過程中, 一個未遍歷的結點, 從舊錶遷移到新表後, 索引值減小了, 那麼就可能會導致這個結點在遍歷過程中被遺漏.

所以, 所謂的"安全"迭代器, 其在內部實現時: 在迭代過程中, 若字典正處於平滑擴容過程, 則暫停結點遷移, 直至迭代器執行結束. 這樣雖然不能保證在迭代過程中插入的結點會被遍歷到, 但至少保證在迭代起始時, 字典中持有的所有結點都會被遍歷到.

這也是為什麼dict結構中有一個iterators欄位的原因: 該欄位記錄了執行於該字典上的安全迭代器的數目. 若該數目不為0, 字典是不會繼續進行結點遷移平滑擴容的.

下面是字典的擴容操作中的核心程式碼, 我們以插入操作引起的擴容為例:

先是插入操作的外部邏輯:

  1. 如果插入時, 字典正處於平滑擴容過程中, 那麼無論本次插入是否成功, 先遷移一個bucket索引中的結點至新表
  2. 在計算新插入結點鍵的bucket索引值時, 內部會探測雜湊表是否需要擴容(若當前不在平滑擴容過程中)
int dictAdd(dict *d, void *key, void *val)
{
    dictEntry *entry = dictAddRaw(d,key,NULL);          // 呼叫dictAddRaw

    if (!entry) return DICT_ERR;
    dictSetVal(d, entry, val);
    return DICT_OK;
}

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;

    if (dictIsRehashing(d)) _dictRehashStep(d); // 若在平滑擴容過程中, 先步進遷移一個bucket索引

    /* Get the index of the new element, or -1 if
     * the element already exists. */

    // 在計算鍵在bucket中的索引值時, 內部會檢查是否需要擴容
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL;

    /* Allocate the memory and store the new entry.
     * Insert the element in top, with the assumption that in a database
     * system it is more likely that recently added entries are accessed
     * more frequently. */
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    entry = zmalloc(sizeof(*entry));
    entry->next = ht->table[index];
    ht->table[index] = entry;
    ht->used++;

    /* Set the hash entry fields. */
    dictSetKey(d, entry, key);
    return entry;
}

下面是計算bucket索引值的函式, 內部會探測該雜湊表是否需要擴容, 如果需要擴容(結點數目與bucket陣列長度比例達到1:1), 就使字典進入平滑擴容過程:

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

    /* Expand the hash table if needed */
    if (_dictExpandIfNeeded(d) == DICT_ERR) // 探測是否需要擴容, 如果需要, 則開始擴容
        return -1;
    for (table = 0; table <= 1; table++) {
        idx = hash & d->ht[table].sizemask;
        /* Search if this slot does not already contain the given key */
        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;
        }
        if (!dictIsRehashing(d)) break;
    }
    return idx;
}

/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
    /* Incremental rehashing already in progress. Return. */
    if (dictIsRehashing(d)) return DICT_OK; // 如果正在擴容過程中, 則什麼也不做

    /* If the hash table is empty expand it to the initial size. */
    // 若字典中本無元素, 則初始化字典, 初始化時的bucket陣列長度為4
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    /* If we reached the 1:1 ratio, and we are allowed to resize the hash
     * table (global setting) or we should avoid it but the ratio between
     * elements/buckets is over the "safe" threshold, we resize doubling
     * the number of buckets. */
    // 若字典中元素的個數與bucket陣列長度比值大於1:1時, 則呼叫dictExpand進入平滑擴容狀態
    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;
}

int dictExpand(dict *d, unsigned long size)
{
    dictht n; /* the new hash table */  // 新建一個dictht結構
    unsigned long realsize = _dictNextPower(size);  

    /* the size is invalid if it is smaller than the number of
     * elements already inside the hash table */
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

    /* Rehashing to the same table size is not useful. */
    if (realsize == d->ht[0].size) return DICT_ERR;

    /* Allocate the new hash table and initialize all pointers to NULL */
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));// 初始化dictht下的table, 即bucket陣列
    n.used = 0;

    /* Is this the first initialization? If so it's not really a rehashing
     * we just set the first hash table so that it can accept keys. */
    // 若是新字典初始化, 直接把dictht結構掛在ht[0]中
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }

    // 否則, 把新dictht結構掛在ht[1]中, 並開啟平滑擴容(置rehashidx為0, 字典處於非擴容狀態時, 該欄位值為-1)
    /* Prepare a second hash table for incremental rehashing */
    d->ht[1] = n;
    d->rehashidx = 0;
    return DICT_OK;
}

下面是平滑擴容的實現:

static void _dictRehashStep(dict *d) {
    // 若字典上還執行著安全迭代器, 則不遷移結點
    // 否則每次遷移一箇舊bucket索引上的所有結點
    if (d->iterators == 0) dictRehash(d,1); 
}

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);
        // 在舊bucket中, 找到下一個非空的索引位
        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 */
        // 把所有結點遷移到新bucket中去
        while(de) {
            uint64_t 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... */
    // 檢查是否舊錶中的所有結點都被遷移到了新表
    // 如果是, 則置先釋放原舊bucket陣列, 再置ht[1]為ht[0]
    // 最後再置rehashidx=-1, 以示字典不處於平滑擴容狀態
    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;
}

總結:

  1. 字典的實現很複雜, 主要是實現了平滑擴容邏輯
  2. 使用者資料均是以指標形式間接由dictEntry結構持有, 故在平滑擴容過程中, 不涉及使用者資料的拷貝
  3. 有安全迭代器可用, 安全迭代器保證, 在迭代起始時, 字典中的所有結點, 都會被迭代到, 即使在迭代過程中對字典有插入操作
  4. 字典內部使用的預設雜湊函式其實也非常有講究, 不過限於篇幅, 這裡不展開講. 並且字典的實現給了使用者非常大的靈活性(dictType結構與dict.privdata欄位), 對於一些特定場合使用的鍵資料, 使用者可以自行選擇更高效更特定化的雜湊函式

2.4 zskiplist

zskiplist是Redis實現的一種特殊的跳躍表. 跳躍表是一種基於線性表實現簡單的搜尋結構, 其最大的特點就是: 實現簡單, 效能能逼近各種搜尋樹結構. 血統純正的跳躍表的介紹在維基百科中即可查閱. 在Redis中, 在原版跳躍表的基礎上, 進行了一些小改動, 即是現在要介紹的zskiplist結構.

其定義在src/server.h中, 如下:

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

其記憶體佈局如下圖:

zskiplist

zskiplist的核心設計要點為:

  1. 頭結點不持有任何資料, 且其level[]的長度為32
  2. 每個結點, 除了持有資料的ele欄位, 還有一個欄位score, 其標示著結點的得分, 結點之間憑藉得分來判斷先後順序, 跳躍表中的結點按結點的得分升序排列.
  3. 每個結點持有一個backward指標, 這是原版跳躍表中所沒有的. 該指標指向結點的前一個緊鄰結點.
  4. 每個結點中最多持有32個zskiplistLevel結構. 實際數量在結點建立時, 按冪次定律隨機生成(不超過32). 每個zskiplistLevel中有兩個欄位.
    1. forward欄位指向比自己得分高的某個結點(不一定是緊鄰的), 並且, 若當前zskiplistLevel例項在level[]中的索引為X, 則其forward欄位指向的結點, 其level[]欄位的容量至少是X+1. 這也是上圖中, 為什麼forward指標總是畫的水平的原因.
    2. span欄位代表forward欄位指向的結點, 距離當前結點的距離. 緊鄰的兩個結點之間的距離定義為1.
  5. zskiplist中持有欄位level, 用以記錄所有結點(除過頭結點外), level[]陣列最長的長度.

跳躍表主要用於, 在給定一個分值的情況下, 查詢與該分值最接近的結點. 搜尋時, 虛擬碼如下:

int level = zskiplist->level - 1;
zskiplistNode p = zskiplist->head;

while(1 && p)
{
    zskiplistNode q = (p->level)[level]->forward:
    if(q->score > 分值)
    {
        if(level > 0)
        {
            level--;
        }
        else
        {
            return :
                q為整個跳躍表中, 分值大於指定分值的第一個結點
                q->backward為整個跳躍表中, 分值小於或等於指定分值的最後一個結點
        }
    }
    else
    {
        p = q;
    }
}

跳躍表的實現比較簡單, 最複雜的操作即是插入與刪除結點, 需要仔細處理鄰近結點的所有level[]中的所有zskiplistLevel結點中的forwardspan的值的變更.

另外, 關於新建立的結點, 其level[]陣列長度的隨機演算法, 在介面zslInsert的實現中, 核心程式碼片斷如下:

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    //...

    level = zslRandomLevel();   // 隨機生成新結點的, level[]陣列的長度
        if (level > zsl->level) {   
        // 若生成的新結點的level[]陣列的長度比當前表中所有結點的level[]的長度都大
        // 那麼頭結點中需要新增幾個指向該結點的指標
        // 並重新整理ziplist中的level欄位
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        zsl->level = level;
    }
    x = zslCreateNode(level,score,ele); // 建立新結點
    //... 執行插入操作
}

// 按冪次定律生成小於32的隨機數的函式
// 巨集 ZSKIPLIST_MAXLEVEL 的定義為32, 巨集 ZSKIPLIST_P 被設定為 0.25
// 即 
//      level == 1的概率為 75%
//      level == 2的概率為 75% * 25%
//      level == 3的概率為 75% * 25% * 25%
//      ...
//      level == 31的概率為 0.75 * 0.25^30
//      而
//      level == 32的概率為 0.75 * sum(i = 31 ~ +INF){ 0.25^i }
int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

2.5 intset

這是一個用於儲存在序的整數的資料結構, 也底層資料結構中最簡單的一個, 其定義與實現在src/intest.hsrc/intset.c中, 關鍵定義如下:

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

inset結構中的encoding的取值有三個, 分別是巨集INTSET_ENC_INT16, INTSET_ENC_INT32, INTSET_ENC_INT64. length代表其中儲存的整數的個數, contents指向實際儲存數值的連續記憶體區域. 其記憶體佈局如下圖所示:

intset

  1. intset中各欄位, 包括contents中儲存的數值, 都是以主機序(小端位元組序)儲存的. 這意味著Redis若執行在PPC這樣的大端位元組序的機器上時, 存取資料都會有額外的位元組序轉換開銷
  2. encoding == INTSET_ENC_INT16時, contents中以int16_t的形式儲存著數值. 類似的, 當encoding == INTSET_ENC_INT32時, contents中以int32_t的形式儲存著數值.
  3. 但凡有一個數值元素的值超過了int32_t的取值範圍, 整個intset都要進行升級, 即所有的數值都需要以int64_t的形式儲存. 顯然升級的開銷是很大的.
  4. intset中的數值是以升序排列儲存的, 插入與刪除的複雜度均為O(n). 查詢使用二分法, 複雜度為O(log_2(n))
  5. intset的程式碼實現中, 不預留空間, 即每一次插入操作都會呼叫zrealloc介面重新分配記憶體. 每一次刪除也會呼叫zrealloc介面縮減佔用的記憶體. 省是省了, 但記憶體操作的時間開銷上升了.
  6. intset的編碼方式一經升級, 不會再降級.

總之, intset適合於如下資料的儲存:

  1. 所有資料都位於一個穩定的取值範圍中. 比如均位於int16_tint32_t的取值範圍中
  2. 資料穩定, 插入刪除操作不頻繁. 能接受O(lgn)級別的查詢開銷

2.6 ziplist

ziplist是Redis底層資料結構中, 最苟的一個結構. 它的設計宗旨就是: 省記憶體, 從牙縫裡省記憶體. 設計思路和TLV一致, 但為了從牙縫裡節省記憶體, 做了很多額外工作.

ziplist的記憶體佈局與intset一樣: 就是一塊連續的記憶體空間. 但區域劃分比較複雜, 概覽如下圖:

ziplist_overall

  1. intset一樣, ziplist中的所有值都是以小端序儲存的
  2. zlbytes欄位的型別是uint32_t, 這個欄位中儲存的是整個ziplist所佔用的記憶體的位元組數
  3. zltail欄位的型別是uint32_t, 它指的是ziplist中最後一個entry的偏移量. 用於快速定位最後一個entry, 以快速完成pop等操作
  4. zllen欄位的型別是uint16_t, 它指的是整個ziplitentry的數量. 這個值只佔16位, 所以蛋疼的地方就來了: 如果ziplistentry的數目小於65535, 那麼該欄位中儲存的就是實際entry的值. 若等於或超過65535, 那麼該欄位的值固定為65535, 但實際數量需要一個個entry的去遍歷所有entry才能得到.
  5. zlend是一個終止位元組, 其值為全F, 即0xff. ziplist保證任何情況下, 一個entry的首位元組都不會是255

在畫圖展示entry的記憶體佈局之前, 先講一下entry中都儲存了哪些資訊:

  1. 每個entry中儲存了它前一個entry所佔用的位元組數. 這樣支援ziplist反向遍歷.
  2. 每個entry用單獨的一塊區域, 儲存著當前結點的型別: 所謂的型別, 包括當前結點儲存的資料是什麼(二進位制, 還是數值), 如何編碼(如果是數值, 數值如何儲存, 如果是二進位制資料, 二進位制資料的長度)
  3. 最後就是真實的資料了

entry的記憶體佈局如下所示:

ziplist_entry_1

prevlen即是"前一個entry所佔用的位元組數", 它本身是一個變長欄位, 規約如下:

  1. 若前一個entry佔用的位元組數小於 254, 則prevlen欄位佔一位元組
  2. 若前一個entry佔用的位元組數等於或大於 254, 則prevlen欄位佔五位元組: 第一個位元組值為 254, 即0xfe, 另外四個位元組, 以uint32_t儲存著值.

encoding欄位的規約就複雜了許多

  1. 若資料是二進位制資料, 且二進位制資料長度小於64位元組(不包括64), 那麼encoding佔一位元組. 在這一位元組中, 高兩位值固定為0, 低六位值以無符號整數的形式儲存著二進位制資料的長度. 即 00xxxxxx, 其中低六位bitxxxxxx是用二進位制儲存的資料長度.
  2. 若資料是二進位制資料, 且二進位制資料長度大於或等於64位元組, 但小於16384(不包括16384)位元組, 那麼encoding佔用兩個位元組. 在這兩個位元組16位中, 第一個位元組的高兩位固定為01, 剩餘的14個位, 以小端序無符號整數的形式儲存著二進位制資料的長度, 即 01xxxxxx, yyyyyyyy, 其中yyyyyyyy是高八位, xxxxxx是低六位.
  3. 若資料是二進位制資料, 且二進位制資料的長度大於或等於16384位元組, 但小於2^32-1位元組, 則encoding佔用五個位元組. 第一個位元組是固定值10000000, 剩餘四個位元組, 按小端序uint32_t的形式儲存著二進位制資料的長度. 這也是ziplist能儲存的二進位制資料的最大長度, 超過2^32-1位元組的二進位制資料, ziplist無法儲存.
  4. 若資料是整數值, 則encodingdata的規約如下:
    1. 首先, 所有儲存數值的entry, 其encoding都僅佔用一個位元組. 並且最高兩位均是11
    2. 若數值取值範圍位於[0, 12]中, 則encodingdata擠在同一個位元組中. 即為1111 0001~1111 1101, 高四位是固定值, 低四位的值從00011101, 分別代表 0 ~ 12這十五個數值
    3. 若數值取值範圍位於[-128, -1] [13, 127]中, 則encoding == 0b 1111 1110. 數值儲存在緊鄰的下一個位元組, 以int8_t形式編碼
    4. 若數值取值範圍位於[-32768, -129] [128, 32767]中, 則encoding == 0b 1100 0000. 數值儲存在緊鄰的後兩個位元組中, 以小端序int16_t形式編碼
    5. 若數值取值範圍位於[-8388608, -32769] [32768, 8388607]中, 則encoding == 0b 1111 0000. 數值儲存在緊鄰的後三個位元組中, 以小端序儲存, 佔用三個位元組.
    6. 若數值取值範圍位於[-2^31, -8388609] [8388608, 2^31 - 1]中, 則encoding == 0b 1101 0000. 數值儲存在緊鄰的後四個位元組中, 以小端序int32_t形式編碼
    7. 若數值取值均不在上述範圍, 但位於int64_t所能表達的範圍內, 則encoding == 0b 1110 0000, 數值儲存在緊鄰的後八個位元組中, 以小端序int64_t形式編碼

在大規模數值儲存中, ziplist幾乎不浪費記憶體空間, 其苟的程式到達了位元組級別, 甚至對於[0, 12]區間的數值, 連data裡的那一個位元組也要省下來. 顯然, ziplist是一種特別節省記憶體的資料結構, 但它的缺點也十分明顯:

  1. intset一樣, ziplist也不預留記憶體空間, 並且在移除結點後, 也是立即縮容, 這代表每次寫操作都會進行記憶體分配操作.
  2. ziplist最蛋疼的一個問題是: 結點如果擴容, 導致結點佔用的記憶體增長, 並且超過254位元組的話, 可能會導致鏈式反應: 其後一個結點的entry.prevlen需要從一位元組擴容至五位元組. 最壞情況下, 第一個結點的擴容, 會導致整個ziplist表中的後續所有結點的entry.prevlen欄位擴容. 雖然這個記憶體重分配的操作依然只會發生一次, 但程式碼中的時間複雜度是o(N)級別, 因為鏈式擴容只能一步一步的計算. 但這種情況的概率十分的小, 一般情況下鏈式擴容能連鎖反映五六次就很不幸了. 之所以說這是一個蛋疼問題, 是因為, 這樣的壞場景下, 其實時間複雜度並不高: 依次計算每個entry新的空間佔用, 也就是o(N), 總體佔用計算出來後, 只執行一次記憶體重分配, 與對應的memmove操作, 就可以了. 蛋疼說的是: 程式碼特別難寫, 難讀. 下面放一段處理插入結點時處理鏈式反應的程式碼片斷, 大家自行感受一下:
unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
    size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen;
    unsigned int prevlensize, prevlen = 0;
    size_t offset;
    int nextdiff = 0;
    unsigned char encoding = 0;
    long long value = 123456789; /* initialized to avoid warning. Using a value
                                    that is easy to see if for some reason
                                    we use it uninitialized. */
    zlentry tail;

    /* Find out prevlen for the entry that is inserted. */
    if (p[0] != ZIP_END) {
        ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
    } else {
        unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl);
        if (ptail[0] != ZIP_END) {
            prevlen = zipRawEntryLength(ptail);
        }
    }

    /* See if the entry can be encoded */
    if (zipTryEncoding(s,slen,&value,&encoding)) {
        /* 'encoding' is set to the appropriate integer encoding */
        reqlen = zipIntSize(encoding);
    } else {
        /* 'encoding' is untouched, however zipStoreEntryEncoding will use the
         * string length to figure out how to encode it. */
        reqlen = slen;
    }
    /* We need space for both the length of the previous entry and
     * the length of the payload. */
    reqlen += zipStorePrevEntryLength(NULL,prevlen);
    reqlen += zipStoreEntryEncoding(NULL,encoding,slen);

    /* When the insert position is not equal to the tail, we need to
     * make sure that the next entry can hold this entry's length in
     * its prevlen field. */
    int forcelarge = 0;
    nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
    if (nextdiff == -4 && reqlen < 4) {
        nextdiff = 0;
        forcelarge = 1;
    }

    /* Store offset because a realloc may change the address of zl. */
    offset = p-zl;
    zl = ziplistResize(zl,curlen+reqlen+nextdiff);
    p = zl+offset;

    /* Apply memory move when necessary and update tail offset. */
    if (p[0] != ZIP_END) {
        /* Subtract one because of the ZIP_END bytes */
        memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);

        /* Encode this entry's raw length in the next entry. */
        if (forcelarge)
            zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
        else
            zipStorePrevEntryLength(p+reqlen,reqlen);

        /* Update offset for tail */
        ZIPLIST_TAIL_OFFSET(zl) =
            intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);

        /* When the tail contains more than one entry, we need to take
         * "nextdiff" in account as well. Otherwise, a change in the
         * size of prevlen doesn't have an effect on the *tail* offset. */
        zipEntry(p+reqlen, &tail);
        if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
            ZIPLIST_TAIL_OFFSET(zl) =
                intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
        }
    } else {
        /* This element will be the new tail. */
        ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
    }

    /* When nextdiff != 0, the raw length of the next entry has changed, so
     * we need to cascade the update throughout the ziplist */
    if (nextdiff != 0) {
        offset = p-zl;
        zl = __ziplistCascadeUpdate(zl,p+reqlen);
        p = zl+offset;
    }

    /* Write the entry */
    p += zipStorePrevEntryLength(p,prevlen);
    p += zipStoreEntryEncoding(p,encoding,slen);
    if (ZIP_IS_STR(encoding)) {
        memcpy(p,s,slen);
    } else {
        zipSaveInteger(p,value,encoding);
    }
    ZIPLIST_INCR_LENGTH(zl,1);
    return zl;
}

unsigned char *__ziplistCascadeUpdate(unsigned char *zl, unsigned char *p) {
    size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), rawlen, rawlensize;
    size_t offset, noffset, extra;
    unsigned char *np;
    zlentry cur, next;

    while (p[0] != ZIP_END) {
        zipEntry(p, &cur);
        rawlen = cur.headersize + cur.len;
        rawlensize = zipStorePrevEntryLength(NULL,rawlen);

        /* Abort if there is no next entry. */
        if (p[rawlen] == ZIP_END) break;
        zipEntry(p+rawlen, &next);

        /* Abort when "prevlen" has not changed. */
        if (next.prevrawlen == rawlen) break;

        if (next.prevrawlensize < rawlensize) {
            /* The "prevlen" field of "next" needs more bytes to hold
             * the raw length of "cur". */
            offset = p-zl;
            extra = rawlensize-next.prevrawlensize;
            zl = ziplistResize(zl,curlen+extra);
            p = zl+offset;

            /* Current pointer and offset for next element. */
            np = p+rawlen;
            noffset = np-zl;

            /* Update tail offset when next element is not the tail element. */
            if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) {
                ZIPLIST_TAIL_OFFSET(zl) =
                    intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
            }

            /* Move the tail to the back. */
            memmove(np+rawlensize,
                np+next.prevrawlensize,
                curlen-noffset-next.prevrawlensize-1);
            zipStorePrevEntryLength(np,rawlen);

            /* Advance the cursor */
            p += rawlen;
            curlen += extra;
        } else {
            if (next.prevrawlensize > rawlensize) {
                /* This would result in shrinking, which we want to avoid.
                 * So, set "rawlen" in the available bytes. */
                zipStorePrevEntryLengthLarge(p+rawlen,rawlen);
            } else {
                zipStorePrevEntryLength(p+rawlen,rawlen);
            }

            /* Stop here, as the raw length of "next" has not changed. */
            break;
        }
    }
    return zl;
}

這種程式碼的特點就是: 最好由作者去維護, 最好一次性寫對. 因為讀起來真的費勁, 改起來也很費勁.

2.7 quicklist

如果說ziplist是整個Redis中為了節省記憶體, 而寫的最苟的資料結構, 那麼稱quicklist就是在最苟的基礎上, 再苟了一層. 這個結構是Redis在3.2版本後新加的, 在3.2版本之前, 我們可以講, dict是最複雜的底層資料結構, ziplist是最苟的底層資料結構. 在3.2版本之後, 這兩個記錄被雙雙重新整理了.

這是一種, 以ziplist為結點的, 雙端連結串列結構. 巨集觀上, quicklist是一個連結串列, 微觀上, 連結串列中的每個結點都是一個ziplist.

它的定義與實現分別在src/quicklist.hsrc/quicklist.c中, 其中關鍵定義如下:

/* Node, quicklist, and Iterator are the only data structures used currently. */

/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
 * We use bit fields keep the quicklistNode at 32 bytes.
 * count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
 * encoding: 2 bits, RAW=1, LZF=2.
 * container: 2 bits, NONE=1, ZIPLIST=2.
 * recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
 * attempted_compress: 1 bit, boolean, used for verifying during testing.
 * extra: 12 bits, free for future use; pads out the remainder of 32 bits */
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

/* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
 * 'sz' is byte length of 'compressed' field.
 * 'compressed' is LZF data with total (compressed) length 'sz'
 * NOTE: uncompressed length is stored in quicklistNode->sz.
 * When quicklistNode->zl is compressed, node->zl points to a quicklistLZF */
typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

/* quicklist is a 40 byte struct (on 64-bit systems) describing a quicklist.
 * 'count' is the number of total entries.
 * 'len' is the number of quicklist nodes.
 * 'compress' is: -1 if compression disabled, otherwise it's the number
 *                of quicklistNodes to leave uncompressed at ends of quicklist.
 * 'fill' is the user-requested (or default) fill factor. */
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : 16;              /* fill factor for individual nodes */
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;

typedef struct quicklistIter {
    const quicklist *quicklist;
    quicklistNode *current;
    unsigned char *zi;
    long offset; /* offset in current ziplist */
    int direction;
} quicklistIter;

typedef struct quicklistEntry {
    const quicklist *quicklist;
    quicklistNode *node;
    unsigned char *zi;
    unsigned char *value;
    long long longval;
    unsigned int sz;
    int offset;
} quicklistEntry;

這裡定義了五個結構體:

  1. quicklistNode, 巨集觀上, quicklist是一個連結串列, 這個結構描述的就是連結串列中的結點. 它通過zl欄位持有底層的ziplist. 簡單來講, 它描述了一個ziplist例項
  2. quicklistLZF, ziplist是一段連續的記憶體, 用LZ4演算法壓縮後, 就可以包裝成一個quicklistLZF結構. 是否壓縮quicklist中的每個ziplist例項是一個可配置項. 若這個配置項是開啟的, 那麼quicklistNode.zl欄位指向的就不是一個ziplist例項, 而是一個壓縮後的quicklistLZF例項
  3. quicklist. 這就是一個雙連結串列的定義. head, tail分別指向頭尾指標. len代表連結串列中的結點. count指的是整個quicklist中的所有ziplist中的entry的數目. fill欄位影響著每個連結串列結點中ziplist的最大佔用空間, compress影響著是否要對每個ziplist以LZ4演算法進行進一步壓縮以更節省記憶體空間.
  4. quicklistIter是一個迭代器
  5. quicklistEntry是對ziplist中的entry概念的封裝. quicklist作為一個封裝良好的資料結構, 不希望使用者感知到其內部的實現, 所以需要把ziplist.entry的概念重新包裝一下.

quicklist的記憶體佈局圖如下所示:

quicklist

下面是有關quicklist的更多額外資訊:

  1. quicklist.fill的值影響著每個連結串列結點中, ziplist的長度.
    1. 當數值為負數時, 代表以位元組數限制單個ziplist的最大長度. 具體為:
      1. -1 不超過4kb
      2. -2 不超過 8kb
      3. -3 不超過 16kb
      4. -4 不超過 32kb
      5. -5 不超過 64kb
    2. 當數值為正數時, 代表以entry數目限制單個ziplist的長度. 值即為數目. 由於該欄位僅佔16位, 所以以entry數目限制ziplist的容量時, 最大值為2^15個
  2. quicklist.compress的值影響著quicklistNode.zl欄位指向的是原生的ziplist, 還是經過壓縮包裝後的quicklistLZF
    1. 0 表示不壓縮, zl欄位直接指向ziplist
    2. 1 表示quicklist的連結串列頭尾結點不壓縮, 其餘結點的zl欄位指向的是經過壓縮後的quicklistLZF
    3. 2 表示quicklist的連結串列頭兩個, 與末兩個結點不壓縮, 其餘結點的zl欄位指向的是經過壓縮後的quicklistLZF
    4. 以此類推, 最大值為2^16
  3. quicklistNode.encoding欄位, 以指示本連結串列結點所持有的ziplist是否經過了壓縮. 1代表未壓縮, 持有的是原生的ziplist, 2代表壓縮過
  4. quicklistNode.container欄位指示的是每個連結串列結點所持有的資料型別是什麼. 預設的實現是ziplist, 對應的該欄位的值是2, 目前Redis沒有提供其它實現. 所以實際上, 該欄位的值恆為2
  5. quicklistNode.recompress欄位指示的是當前結點所持有的ziplist是否經過了解壓. 如果該欄位為1即代表之前被解壓過, 且需要在下一次操作時重新壓縮.

quicklist的具體實現程式碼篇幅很長, 這裡就不貼程式碼片斷了, 從記憶體佈局上也能看出來, 由於每個結點持有的ziplist是有上限長度的, 所以在與操作時要考慮的分支情況比較多. 想想都蛋疼.

quicklist有自己的優點, 也有缺點, 對於使用者來說, 其使用體驗類似於線性資料結構, list作為最傳統的雙連結串列, 結點通過指標持有資料, 指標欄位會耗費大量記憶體. ziplist解決了耗費記憶體這個問題. 但引入了新的問題: 每次寫操作整個ziplist的記憶體都需要重分配. quicklist在兩者之間做了一個平衡. 並且使用者可以通過自定義quicklist.fill, 根據實際業務情況, 經驗主義調參.

2.8 zipmap

dict作為字典結構, 優點很多, 擴充套件性強悍, 支援平滑擴容等等, 但對於字典中的鍵值均為二進位制資料, 且長度都很小時, dict的中的一坨指標會浪費不少記憶體, 因此Redis又實現了一個輕量級的字典, 即為zipmap.

zipmap適合使用的場合是:

  1. 鍵值對量不大, 單個鍵, 單個值長度小
  2. 鍵值均是二進位制資料, 而不是複合結構或複雜結構. dict支援各種巢狀, 字典本身並不持有資料, 而僅持有資料的指標. 但zipmap是直接持有資料的.

zipmap的定義與實現在src/zipmap.hsrc/zipmap.c兩個檔案中, 其定義與實現均未定義任何struct結構體, 因為zipmap的記憶體佈局就是一塊連續的記憶體空間. 其記憶體佈局如下所示:

zipmap

  1. zipmap起始的第一個位元組儲存的是zipmap中鍵值對的個數. 如果鍵值對的個數大於254的話, 那麼這個位元組的值就是固定值254, 真實的鍵值對個數需要遍歷才能獲得.
  2. zipmap的最後一個位元組是固定值0xFF
  3. zipmap中的每一個鍵值對, 稱為一個entry, 其記憶體佔用如上圖, 分別六部分:
    1. len_of_key, 一位元組或五位元組. 儲存的是鍵的二進位制長度. 如果長度小於254, 則用1位元組儲存, 否則用五個位元組儲存, 第一個位元組的值固定為0xFE, 後四個位元組以小端序uint32_t型別儲存著鍵的二進位制長度.
    2. key_data為鍵的資料
    3. len_of_val, 一位元組或五位元組, 儲存的是值的二進位制長度. 編碼方式同len_of_key
    4. len_of_free, 固定值1位元組, 儲存的是entry中未使用的空間的位元組數. 未使用的空間即為圖中的free, 它一般是由於鍵值對中的值被替換髮生的. 比如, 鍵值對hello <-> word被修改為hello <-> w後, 就空了四個位元組的閒置空間
    5. val_data, 為值的資料
    6. free, 為閒置空間. 由於len_of_free的值最大隻能是254, 所以如果值的變更導致閒置空間大於254的話, zipmap就會回收記憶體空間.

3. 膠水層 redisObject

銜接底層資料結構, 與五種Value Type之間的橋樑就是redisObject這個結構. 該結構的關鍵定義如下(位於src/server.h中):

/*-----------------------------------------------------------------------------
 * Data types
 *----------------------------------------------------------------------------*/

/* A redis object, that is a type able to hold a string / list / set */

/* The actual Redis Object */
#define OBJ_STRING 0
#define OBJ_LIST 1
#define OBJ_SET 2
#define OBJ_ZSET 3
#define OBJ_HASH 4

/* Objects encoding. Some kind of objects like Strings and Hashes can be
 * internally represented in multiple ways. The 'encoding' field of the object
 * is set to one of this fields for this object. */
#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3  /* Encoded as zipmap */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */

#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */
#define LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */

#define OBJ_SHARED_REFCOUNT INT_MAX
typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;

redisObject的記憶體佈局如下:

redisObject

從定義上來看, redisObject有:

  1. 與Value Type一致的Object Type, 即type欄位
  2. 特定的Object Encoding, 即encoding欄位, 表明物件底層使用的資料結構型別
  3. 記錄最末一次訪問時間的lru欄位
  4. 引用計數refcount
  5. 指向底層資料結構例項的ptr欄位

redisObject的通用操作API如下:

API 功能
char *strEncoding(int encoding) 返回各種編碼的可讀字串表達
void decrRefCount(robj *o); 引用計數-1. 若減後引用計數會降為0, 則會自動呼叫 freeXXXObject函式釋放物件
void decrRefCountVoid(void *o); 功能同decrRefCount, 只不過接收的是void * 型引數
void incrRefCount(robj *o); 引用計數+1
robj *makeObjectShared(robj *o); 將物件置為"全域性共享物件", 所謂的"全域性只讀共享物件", 有以下特徵
0. 內部引用計數為 INT_MAX
0. 引用計數操作函式對其不起作用
0. 多純種共享讀是安全的, 不需要加鎖
0. 禁止寫操作
robj *resetRefCount(robj *obj); 將引用計數置為0, 但不會呼叫freeXXXObject函式釋放物件
robj *createObject(int type, void *ptr); 建立一個物件, 物件型別由引數指定, 物件底層編碼指定為RAW, 底層資料由引數提供, 物件引用計數為1.
並初始化lru欄位. 若伺服器採用LRU演算法, 則置該欄位的值為當前分鐘級別的一個時間戳. 若伺服器採用LFU演算法, 則置為一個計數值.
unsigned long long estimateObjectIdleTime(robj *o) 獲取一個物件未被訪問的時間, 單位為毫秒.
由於redisObjectlru欄位有24位, 並不是無限長, 所以有迴圈溢位的風險, 當發生迴圈溢位時(即當前LRU時鐘計數比物件中的lru欄位小), 那麼該函式始終保守認為迴圈溢位只發生了一次

3.1 字串物件

字串物件支援三種編碼方式: INT, RAW, EMBSTR, 三種方式的記憶體佈局分別如下:

stringObject

字串物件的相關介面如下:

分類 API名 功能
建立介面 robj *createEmbeddedStringObject(const char *ptr,size_t len) 建立一個編碼為EMBSTR的字串物件.
即底層使用SDS, 且SDS與RedisObject位於同一塊連續記憶體上
-- robj *createRawStringObject(const char *ptr,size_t len) 建立一個編碼為RAW的字串物件.
即底層使用SDS, 且SDS由RedisObject間接持有
內部是先用入參建立一個SDS, 然後用這個SDS再去呼叫createObject
-- robj *createStringObject(const char *ptr,size_t len) 建立一個字串物件.
len引數的值小於或等於OBJ_ENCODING_EMBSTR_SIZE_LIMIT時, 編碼方式為EMBSTR, 否則為RAW
內部是通過呼叫createRawStringObjectcreateEmbeddedStringObject來建立不同編碼的字串物件的
-- robj *createStringObjectFromLongLong(long long value) 根據整數值, 建立一個字串物件.
若可複用全域性共享字串物件池中的物件, 則會盡量複用. 否則以最節省記憶體的原則, 來決定物件的編碼
-- robj *createStringObjectFromLongDouble(long double value,int humanfriendly) 根據浮點數值, 建立一個字串物件
其中引數humanfriendly不為0, 則字串以小數形式表達. 否則以exp計數法表達.根據字串表達的長短, 編碼可能是RAW, 或EMBSTR
釋放介面 void freeStringObject(robj *o) 釋放字串物件.
若字串物件底層使用SDS, 則呼叫sdsfree釋放這個SDS.
否則什麼也不做
讀寫介面 robj *dupStringObject(const robj *o) 建立一個字串物件的深拷貝副本. 不影響原字串物件的引用計數.
建立的副本與原字串毫無關聯
-- int isSdsRepresentableAsLongLong(sds s,long long *llval) 判斷SDS字串是否是一個取值在long long數值範圍內的數值的字串表達. 如果是, 就把相應的數值置在出參中
內部呼叫的是string2ll來判斷

嚴格來講這不應該算是RedisObject的介面函式, 而應當算是SDS的介面函式"
-- int isObjectRepresentableAsLongLong(robj *o,long long *llval) 判斷字串物件是否是一個取值在long long數值範圍內的數值的字串表達. 如果是, 就把相應的數值置在出參中.
-- robj *tryObjectEncoding(robj *o) 嘗試縮減這個字串物件的記憶體佔用.

策略為:
如果字串物件代表的是一個位於long取值範圍內的數值, 則嘗試返回全域性共享字串物件池裡的等價物件. 若由於伺服器配置等原因不成功, 則嘗試將物件編碼改為INT
如果以上都不成功, 則嘗試將物件的編碼改為EMBSTR
若以上都不成功, 則在物件的編碼為RAW的狀態下, 至少呼叫sdsRemoveFreeSpace來移除掉內部SDS中, 閒置的記憶體空間
-- robj *getDecodedObject(robj *o) 返回字串物件的一個淺拷貝.
在編碼為RAWEMBSTR時, 底層資料引用計數+1, 返回一個共享控制程式碼
在編碼為INT時, 返回一個編碼為RAWEMBSTR的新副本的控制程式碼. 新舊物件之間無關
-- size_t stringObjectLen(robj *o) 返回字串物件中的字元個數
-- int getDoubleFromObject(const robj *o,double *target) 從字串物件中解析出數值, 相容整數值
-- int getLongLongFromObject(robj *o,long long *target) 從字串物件中解析出整數值, 不相容浮點數值
-- int getLongDoubleFromObject(robj *o,long double *target) 從字串物件中解析出數值, 相容整數值
-- int compareStringObjects(robj *a, robj *b) 二進位制比較兩個字串物件. 若有字串物件使用的是INT編碼, 則先會把ptr中的數值轉化為字串表達, 然後再去比較
-- int collateStringObjects(robj *a, robj *b) 底層呼叫strcoll去比較兩個字串物件. 比較的大小結果受LC_LOCALE的影響
-- int equalStringObjects(robj *a, robj *b) 字串判等
-- #define sdsEncodedObject(objptr) 巨集, 判斷字串物件的內部是否為SDS實現. 即編碼為RAWEMBSTR

3.2 雜湊物件

雜湊物件的底層實現有兩種, 一種是dict, 一種是ziplist. 分別對應編碼HTZIPLIST. 而之前介紹的zipmap這種結構, 雖然也是一種輕量級的字典結構, 且縱使在原始碼中有相應的編碼巨集值, 但遺憾的是, 至Redis 4.0.10, 目前雜湊物件的底層編碼仍然只有ziplistdict兩種

dict自不必說, 本身就是字典型別, 儲存鍵值對的. 用ziplist作為底層資料結構時, 是將鍵值對以<key1><value1><key2><value2>...<keyn><valuen>這樣的形式儲存在ziplist中的. 兩種編碼記憶體佈局分別如下:

hashObject

上圖中不嚴謹的地方有:

  1. ziplist中每個entry, 除了鍵與值本身的二進位制資料, 還包括其它欄位, 圖中沒有畫出來
  2. dict底層可能持有兩個dictht例項
  3. 沒有畫出dict的雜湊衝突

需要注意的是: 當採用HT編碼, 即使用dict作為雜湊物件的底層資料結構時, 鍵與值均是以sds的形式儲存的.

雜湊物件的相關介面如下:

分類 API名 功能
建立介面 robj *createHashObject(void) 建立一個空雜湊物件
底層編碼使用ZIPLIST, 即底層使用ziplist
釋放介面 void freeHashObject(robj *o) 釋放雜湊物件
若雜湊物件底層使用的是dict, 則呼叫dictRelease釋放這個dict
若雜湊物件底層使用的是ziplist, 則直接釋放掉這個ziplist佔用的連續記憶體空間
編碼轉換介面 void hashTypeConvertZiplist(robj *o, int enc) 將雜湊物件的編碼從ZIPLIST轉換為HT, 即底層實現從ziplist轉為dict
-- void hashTypeConvert(robj *o, int enc) 轉換雜湊物件的編碼.
雖然介面設計的好像可以在底層編碼之間互相轉換, 但實際上這個介面的實現, 目前僅支援從ZIPLIST轉向HT
-- void hashTypeTryConversion(robj *o,robj **argv,int start,int end) o是一個雜湊物件. argv是其它物件的陣列.(最好是字串物件, 且為SDS實現)
這個函式會檢查argv陣列中, 從startend之間的所有物件, 如果這些物件中, 但凡有一個物件是字串物件, 且長度超過了用ziplist實現雜湊物件時, ziplist的限長
那麼o這個雜湊物件的編碼就會從ZIPLIST轉為HT
讀寫介面 int hashTypeSet(robj *o,sds field,sds value,int flags) 向雜湊物件寫入一個鍵值對.
在底層編碼為HT時, flag將影響插入鍵值對時的具體行為. flag可有標誌位 HASH_SET_TAKE_VALUEHASH_SET_TAKE_FIELD, 若對應位置1, 代表鍵與值直接引用引數值. 否則代表要呼叫sdsdup介面拷貝鍵與值.
在底層編碼為ZIPLIST時, 鍵與值必然會被拷貝
-- int hashTypeExists(robj *o, sds field) 查詢指定鍵在雜湊物件中是否存在
-- unsigned long hashTypeLength(const robj *o) 查詢雜湊物件中的鍵值對總數
-- int hashTypeGetFromZiplist(robj *o, sds field,unsigned char **vstr,unsigned int *vlen,long long *vll) 從編碼為ZIPLIST的雜湊物件中, 取出一個鍵對應的值.
鍵從field傳入, 當值為數值型別時, 值以*vll傳出, 當值為二進位制型別時, 值以*vstr*vlen傳出
-- sds hashTypeGetFromHashTable(robj *o, sds field) 從編碼為HT的雜湊物件中, 取出一個鍵對應的值.
鍵從field傳入, 值以返回值傳出. 若值不存在, 返回NULL"
-- "int hashTypeGetValue(robj *o,sds field,unsigned char **vstr,unsigned int *vlen,long long *vll) 取出雜湊物件中指定鍵對應的值. 若值是數值型別, 則以*vll傳出, 否則以*vstr*vlen傳出
-- robj *hashTypeGetValueObject(robj *o, sds field) 取出雜湊物件中指定鍵對應的值, 幷包裝成RedisObject返回. 返回的物件為字串物件
-- size_t hashTypeGetValueLength(robj *o, sds field) 取出雜湊物件中指定鍵對應的值的長度
-- int hashTypeDelete(robj *o, sds field) 刪除雜湊物件中的一個鍵值對. 鍵不存在時返回0, 成功刪除返回1
迭代器介面 hashTypeIterator *hashTypeInitIterator(robj *subject) 在指定雜湊物件上建立一個迭代器
-- void hashTypeReleaseIterator(hashTypeIterator *hi) 釋放雜湊物件的迭代器
-- int hashTypeNext(hashTypeIterator *hi) 讓雜湊迭代器步進一步
-- void hashTypeCurrentFromZiplist(hashTypeIterator *hi,int what,unsigned char **vstr,unsigned int *vlen,long long *vll) 取出雜湊物件迭代器當前指向的鍵 或值. 當what傳入OBJ_HASH_KEY時, 取的是鍵, 否則取的是值.
注意, 該函式僅在雜湊物件的編碼為ZIPLIST時才能正確執行
-- sds hashTypeCurrentFromHashTable(hashTypeIterator *hi,int what) 取出雜湊物件迭代器當前指向的鍵 或值. 當what傳入OBJ_HASH_KEY時, 取的是鍵, 否則取的是值.
注意, 該函式僅在雜湊物件的編碼為HT時才能正確執行
-- void hashTypeCurrentObject(hashTypeIterator *hi,int what,unsigned char **vstr,unsigned int *vlen,long long *vll) 取出雜湊物件迭代器當前指向的鍵或值. 當what傳入OBJ_HASH_KEY時, 取的是鍵, 否則取的是值.
-- sds hashTypeCurrentObjectNewSds(hashTypeIterator *hi,int what) 取出雜湊物件迭代器當前指向的鍵或值. 且把鍵或值以一個全新的SDS字串返回. 當what傳入OBJ_HASH_KEY時, 取的是鍵, 否則取的是值.

3.3 列表物件

列表物件的底層實現, 歷史上是有兩種的, 分別是ziplistlist, 但截止Redis 4.0.10版本, 所有的列表物件API都不再支援除去quicklist之外的任何底層實現. 也就是說, 目前(Redis 4.0.10), 列表物件支援的底層實現實質上只有一種, 即是quicklist.

列表物件的建立API依然支援從ziplist的例項建立一個列表物件, 即你可以建立一個底層編碼為ZIPLIST的列表物件, 但如果用該列表物件去呼叫任何其它列表物件的API, 都會導致panic. 在使用之前, 你只能再次呼叫相關的底層編碼轉換介面, 將這個列表物件的底層編碼轉換為QUICKLIST.

並且遺憾的是, LINKEDLIST這種編碼, 即底層為list的列表, 被徹底淘汰了. 也就是說, 截止目前(Redis 4.0.10), Redis定義的10個物件編碼方式巨集名中, 有兩個被完全閒置了, 分別是: OBJ_ENCODING_ZIPMAPOBJ_ENCODING_LINKEDLIST. 從Redis的演進歷史上來看, 前者是後續可能會得到支援的編碼值, 後者則應該是被徹底淘汰了.

列表物件的記憶體佈局如下圖所示:

listObject

列表物件的API介面如下:

分類 API名 功能
建立介面 robj *createQuicklistObject(void) 建立一個列表物件. 內部編碼為QUICKLIST
即內部使用quicklist實現的列表物件
-- robj *createZiplistObject(void) 建立一個列表物件. 內部編碼為ZIPLIST
即內部使用ziplist實現的列表物件
釋放介面 void freeListObject(robj *o) 釋放一個列表物件
編碼轉換介面 void listTypeConvert(robj *subject, int enc) 轉換列表物件的內部編碼.
雖然介面設計的好你可以在底層編碼之間互相轉換, 但實際上這個介面的實現, 目前僅支援從ZIPLIST轉換為QUICKLIST
並且蛋疼的是, 4.0.10這個版本中, 所有的列表物件操作API內部實現都僅支援編碼方式為QUICKLIST的列表物件, 其它編碼方式會panic.
所以目前為止, 這個API的唯一作用, 就是配合createZiplistObject介面, 來使用一個ziplist建立一個內部編碼為QUICKLIST的列表物件.
讀寫介面 void listTypePush(robj *subject,robj *value,int where) 向列表物件中新增一個資料.
where引數的值控制是在頭部新增, 還是尾部新增.
where可選的值為LIST_HEAD, LIST_TAIL
-- robj *listTypePop(robj *subject,int where) 從列表物件的頭部或尾部取出一個資料.
取出的資料通過被包裝成字串物件後返回. 具體取出位置通過引數where控制
-- unsigned long listTypeLength(const robj *subject) 獲取列表物件中儲存的資料的個數
-- void listTypeInsert(listTypeEntry *entry,robj *value, int where) 將字串物件中的資料插入到列表物件的頭部或尾部.
插入過程中不會拷貝字串物件持有的資料本身. 但會縮減字串物件的引用計數.
-- int listTypeEqual(listTypeEntry *entry, robj *o) 判斷字串物件o與列表物件中指定位置上儲存的資料是否相同.
-- robj *listTypeGet(listTypeEntry *entry) 獲取列表物件中指定位置的資料.
位置資訊通過entry傳入, 這是一個入參. 資料將拷貝一份後通過SDS形式返回
迭代器介面 listTypeIterator *listTypeInitIterator(robj *subject,long index,unsigned char direction) 建立一個列表物件迭代器
-- void listTypeReleaseIterator(listTypeIterator *li) 釋放一個列表物件迭代器
-- int listTypeNext( listTypeIterator *li, listTypeEntry *entry) 讓列表物件迭代器步進一步, 並將步進之前迭代器所指向的資料儲存在entry
-- void listTypeDelete( listTypeIterator *iter, listTypeEntry *entry) 刪除列表迭代器當前指向的列表物件中儲存的資料.
被刪除的資料通過entry返回

3.4 集合物件

集合物件的底層實現有兩種, 分別是intsetdict. 分別對應編碼巨集中的INTSETHT. 顯然當使用intset作為底層實現的資料結構時, 集合中儲存的只能是數值資料, 且必須是整數. 而當使用dict作為集合物件的底層實現時, 是將資料全部儲存於dict的鍵中, 值欄位閒置不用.

集合物件的記憶體佈局如下圖所示:

setObject

集合物件的API介面如下:

分類 API名 功能
建立介面 robj *createSetObject(void) 建立一個空集合物件.
底層編碼使用HT, 即底層使用dict
-- robj *createIntsetObject(void) 建立一個空集合物件.
底層編碼使用INTSET, 即底層使用intset
-- robj *setTypeCreate(sds value) 建立一個空集合物件.
注意入參雖然攜帶了一個資料, 但這個資料並不會儲存在集合中
這個資料只起到決定編碼方式的作用, 若這個資料是數值的字串表達, 則底層編碼則為INTSET, 否則為HT
釋放介面 void freeSetObject(robj *o) 釋放集合物件.
若集合物件底層使用的是dict, 則呼叫dictRelease釋放這個dict
若集合物件底層使用的是intset, 則直接釋放這個intset佔用的連續記憶體
編碼轉換介面 void setTypeConvert(robj *setobj, int enc) 轉換集合物件的內部編碼
雖然介面設計的好你可以在底層編碼之間互相轉換, 但實際上這個介面的實現, 目前僅支援從INTSET轉換為HT
讀寫介面 int setTypeAdd(robj *subject, sds value) 向集合物件中寫入一個資料
-- int setTypeRemove(robj *setobj, sds value) 刪除集合物件中的一個資料
-- int setTypeIsMember(robj *subject, sds value) 判斷指定資料是否在集合物件中
-- int setTypeRandomElement(robj *setobj, sds *sdsele, int64_t *llele) 從集合物件中, 隨機選出一個資料, 將其資料通過出參返回.
若資料是數值型別, 則從*llele返回, 否則, 從*sdsele返回.
注意該介面若取得二進位制資料, 則*sdsele是直接引用集合內的資料, 而不是拷貝一份
-- unsigned long setTypeSize(const robj *subject) 返回集合中資料的個數
迭代器介面 setTypeIterator *setTypeInitIterator(robj *subject) 建立一個集合物件迭代器
-- void setTypeReleaseIterator(setTypeIterator *si) 釋放集合物件迭代器
-- int setTypeNext( setTypeIterator *si, sds *sdsele, int64_t *llele) 讓集合迭代器步進一步, 並從出參中返回步進前迭代器所指向的資料.
若資料是數值型別, 則從*llele返回, 否則, 從*sdsele返回
注意該介面若取得二進位制資料, 則*sdsele是直接引用集合內的資料, 而不是拷貝一份
-- sds setTypeNextObject(setTypeIterator *si) 讓集合迭代器步進一步, 並把步進前所指向的資料, 拷貝一份, 構造成一個新的SDS, 作為返回值返回

3.5 有序集合物件

有序集合的底層實現依然有兩種, 一種是使用ziplist作為底層實現, 另外一種比較特殊, 底層使用了兩種資料結構: dictskiplist. 前者對應的編碼值巨集為ZIPLIST, 後者對應的編碼值巨集為SKIPLIST

使用ziplist來實現在序集合很容易理解, 只需要在ziplist這個資料結構的基礎上做好排序與去重就可以了. 使用zskiplist來實現有序集合也很容易理解, Redis中實現的這個跳躍表似乎天然就是為了實現有序集合物件而實現的, 那麼為什麼還要輔助一個dict例項呢? 我們先看來有序集合物件在這兩種編碼方式下的記憶體佈局, 然後再做解釋:

首先是編碼為ZIPLIST時, 有序集合的記憶體佈局如下:

zsetObject_ZIPLIST

然後是編碼為SKIPLIST時, 有序集合的記憶體佈局如下:

zsetObject_SKIPLIST

在使用dictskiplist實現有序集合時, 跳躍表負責按分數索引, 字典負責按資料索引. 跳躍表按分數來索引, 查詢時間複雜度為O(lgn). 字典按資料索引時, 查詢時間複雜度為O(1). 設想如果沒有字典, 如果想按資料查分數, 就必須進行遍歷. 兩套底層資料結構均只作為索引使用, 即不直接持有資料本身. 資料被封裝在SDS中, 由跳躍表與字典共同持有. 而資料的分數則由跳躍表結點直接持有(double型別資料), 由字典間接持有.

有序集合物件的API介面如下:

分類 API名 功能
建立介面 robj *createZsetObject(void) 建立一個有序集合物件
預設內部編碼為SKIPLIST, 即內部使用zskiplist與dict來實現有序集合
-- robj *createZsetZiplistObject(void) 建立一個有序集合物件
指定內部編碼為ZIPLIST, 即內部使用ziplist來實現有序集合
釋放介面 void freeZsetObject(robj *o) 釋放一個有序集合物件
編碼轉換介面 void zsetConvert(robj *zobj, int encoding) 轉換有序集合物件的內部編碼
可以在ZIPLISTSKIPLIST兩種編碼間轉換
-- void zsetConvertToZiplistIfNeeded(robj *zobj, size_t maxelelen) 判斷當前有序集合物件是否有必要將底層編碼轉換為ZIPLIST, 如果有必要, 就執行轉換
讀寫介面 int zsetScore(robj *zobj, sds member, double *score) 獲取有序集合中, 指定資料的得分.
資料由member引數攜帶, 通過二進位制判等的方式匹配
-- int zsetAdd( robj *zobj, double score, sds ele, int *flags, double *newscore) 向有序集合中新增資料, 或更新已存在的資料的得分.
flag是一個in-out引數, 其作為入參, 控制函式的具體行為, 其作為出參, 報告函式執行的結果.

作為入參時, *flags的語義如下:
ZADD_INCR 遞增已存在的資料的得分. 如果資料不存在, 則新增資料, 並設定得分. 且若newscore != NULL, 執行操作後, 資料的得分還會賦值給*newscore
ZADD_NX 僅當資料不存在時, 執行新增資料並設定得分, 否則什麼也不做
ZADD_XX 僅當資料存在時, 執行重置資料得分. 否則什麼也不做

作為出參, *flags的語義如下:
ZADD_NAN 資料的得分不是一個數值, 代表內部出現的異常
ZADD_ADDED 新資料已經新增至集合中
ZADD_UPDATED 資料的得分已經更新
ZADD_NOP 函式什麼也沒做
-- int zsetDel(robj *zobj, sds ele) 從有序集合中移除一個資料
-- long zsetRank(robj *zobj, sds ele, int reverse) 獲取有序集合中, 指定資料的排名.
reverse==0, 排名以得分升序排列. 否則排名以得分降序排列.
第一個資料的排名為0, 而不是1
-- unsigned int zsetLength(const robj *zobj) 獲取有序集合物件中儲存的資料個數

相關文章