主頁 > 後端開發 > redis續集

redis續集

2022-04-15 06:27:24 後端開發

SDS (Simple Dynamic String)是 Redis 最基礎的資料結構,直譯過來就是”簡單的動態字串“,Redis 自己實作了一個動態的字串,而不是直接使用了 C 語言中的字串,

sds 的資料結構:

struct sdshdr {   
// buf 中已占用空間的長度 int len; 
// buf 中剩余可用空間的長度 int free; 
// 資料空間 
char buf[];
    
}

所以一個 SDS 的就如下圖:

sds

所以我們看到,sds 包含3個引數,buf 的長度 len,buf 的剩余長度,以及buf,

為什么這么設計呢?

  • 可以直接獲取字串長度,
    C 語言中,獲取字串的長度需要用指標遍歷字串,時間復雜度為 O(n),而 SDS 的長度,直接從len 獲取復雜度為 O(1),

  • 杜絕緩沖區溢位,
    由于C 語言不記錄字串長度,如果增加一個字符傳的長度,如果沒有注意就可能溢位,覆寫了緊挨著這個字符的資料,對于SDS 而言增加字串長度需要驗證 free的長度,如果free 不夠就會擴容整個 buf,防止溢位,

  • 減少修改字串長度時造成的記憶體再次分配,
    redis 作為高性能的記憶體資料庫,需要較高的相應速度,字串也很大概率的頻繁修改, SDS 通過未使用空間這個引數,將字串的長度和底層buf的長度之間的額關系解除了,buf的長度也不是字串的長度,基于這個分設計 SDS 實作了空間的預分配和惰性釋放,

    1. 預分配
      如果對 SDS 修改后,如果 len 小于 1MB 那 len = 2 * len + 1byte, 這個 1 是用于保存空位元組,
      如果 SDS 修改后 len 大于 1MB 那么 len = 1MB + len + 1byte,
    2. 惰性釋放
      如果縮短 SDS 的字串長度,redis并不是馬上減少 SDS 所占記憶體,只是增加 free 的長度,同時向外提供 API ,真正需要釋放的時候,才去重新縮小 SDS 所占的記憶體
  • 二進制安全,
    C 語言中的字串是以 ”\0“ 作為字串的結束標記,而 SDS 是使用 len 的長度來標記字串的結束,所以SDS 可以存盤字串之外的任意二進制流,因為有可能有的二進制流在流中就包含了”\0“造成字串提前結束,也就是說 SDS 不依賴 “\0” 作為結束的依據,

  • 兼容C語言
    SDS 按照慣例使用 ”\0“ 作為結尾的管理,部分普通C 語言的字串 API 也可以使用,

鏈表

C語言中并沒有鏈表這個資料結構所以 Redis 自己實作了一個,Redis 中的鏈表是:

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

總結一下性質:

  • 雙向鏈表,某個節點尋找上一個或者下一個節點時間復雜度 O(1),
  • list 記錄了 head 和 tail,尋找 head 和 tail 的時間復雜度為 O(1),
  • 獲取鏈表的長度 len 時間復雜度 O(1),

字典

字典資料結構極其類似 java 中的 Hashmap,

Redis的字典由三個基礎的資料結構組成,最底層的單位是哈希表節點,結構如下:

typedef struct dictEntry {
    
    // 鍵
    void *key;

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

    // 指向下個哈希表節點,形成鏈表
    struct dictEntry *next;

} dictEntry;

實際上哈希表節點就是一個單項串列的節點,保存了一下下一個節點的指標, key 就是節點的鍵,v是這個節點的值,這個 v 既可以是一個指標,也可以是一個 uint64_t或者 int64_t 整數,*next 指向下一個節點,

通過一個哈希表的陣列把各個節點鏈接起來:
typedef struct dictht {

    // 哈希表陣列
    dictEntry **table;

    // 哈希表大小
    unsigned long size;
    
    // 哈希表大小掩碼,用于計算索引值
    // 總是等于 size - 1
    unsigned long sizemask;

    // 該哈希表已有節點的數量
    unsigned long used;

} dictht;

dictht

通過圖示我們觀察:

dictht.png

實際上,如果對java 的基本資料結構了解的同學就會發現,這個資料結構和 java 中的 HashMap 是很類似的,就是陣列加鏈表的結構,

字典的資料結構:

typedef struct dict {

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

    // 私有資料
    void *privdata;

    // 哈希表
    dictht ht[2];

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

    // 目前正在運行的安全迭代器的數量
    int iterators; /* number of iterators currently running */

} 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;

字典的資料結構如下圖:

dict

這里我們可以看到一個dict 擁有兩個 dictht,一般來說只使用 ht[0],當擴容的時候發生了rehash的時候,ht[1]才會被使用,

當我們觀察或者研究一個hash結構的時候偶我們首先要考慮的這個 dict 如何插入一個資料?

我們梳理一下插入資料的邏輯,

  • 計算Key 的 hash 值,找到 hash 映射到 table 陣列的位置,

  • 如果資料已經有一個 key 存在了,那就意味著發生了 hash 碰撞,新加入的節點,就會作為鏈表的一個節點接到之前節點的 next 指標上,

  • 如果 key 發生了多次碰撞,造成鏈表的長度越來越長,會使得字典的查詢速度下降,為了維持正常的負載,Redis 會對 字典進行 rehash 操作,來增加 table 陣列的長度,所以我們要著重了解一下 Redis 的 rehash,步驟如下:

    1. 根據ht[0] 的資料和操作的型別(擴大或縮小),分配 ht[1] 的大小,
    2. 將 ht[0] 的資料 rehash 到 ht[1] 上,
    3. rehash 完成以后,將ht[1] 設定為 ht[0],生成一個新的ht[1]備用,
  • 漸進式的 rehash ,
    其實如果字典的 key 數量很大,達到千萬級以上,rehash 就會是一個相對較長的時間,所以為了字典能夠在 rehash 的時候能夠繼續提供服務,Redis 提供了一個漸進式的 rehash 實作,rehash的步驟如下:

    1. 分配 ht[1] 的空間,讓字典同時持有 ht[1] 和 ht[0],
    2. 在字典中維護一個 rehashidx,設定為 0 ,表示字典正在 rehash,
    3. 在rehash期間,每次對字典的操作除了進行指定的操作以外,都會根據 ht[0] 在 rehashidx 上對應的鍵值對 rehash 到 ht[1]上,
    4. 隨著操作進行, ht[0] 的資料就會全部 rehash 到 ht[1] ,設定ht[0] 的 rehashidx 為 -1,漸進的 rehash 結束,

這樣保證資料能夠平滑的進行 rehash,防止 rehash 時間過久阻塞執行緒,

  • 在進行 rehash 的程序中,如果進行了 delete 和 update 等操作,會在兩個哈希表上進行,如果是 find 的話優先在ht[0] 上進行,如果沒有找到,再去 ht[1] 中查找,如果是 insert 的話那就只會在 ht[1]中插入資料,這樣就會保證了 ht[1] 的資料只增不減,ht[0]的資料只減不增,

dict的資料結構定義

為了實作增量式重哈希(incremental rehashing),dict的資料結構里包含兩個哈希表,在重哈希期間,資料從第一個哈希表向第二個哈希表遷移,

dict的C代碼定義如下(出自Redis原始碼dict.h):

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

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;

/* 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 */
    int iterators; /* number of iterators currently running */
} dict;

為了能更清楚地展示dict的資料結構定義,我們用一張結構圖來表示它,如下,

Redis dict結構圖

結合上面的代碼和結構圖,可以很清楚地看出dict的結構,一個dict由如下若干項組成:

  • 一個指向dictType結構的指標(type),它通過自定義的方式使得dict的key和value能夠存盤任何型別的資料,
  • 一個私有資料指標(privdata),由呼叫者在創建dict的時候傳進來,
  • 兩個哈希表(ht[2]),只有在重哈希的程序中,ht[0]和ht[1]才都有效,而在平常情況下,只有ht[0]有效,ht[1]里面沒有任何資料,上圖表示的就是重哈希進行到中間某一步時的情況,
  • 當前重哈希索引(rehashidx),如果rehashidx = -1,表示當前沒有在重哈希程序中;否則,表示當前正在進行重哈希,且它的值記錄了當前重哈希進行到哪一步了,
  • 當前正在進行遍歷的iterator的個數,這不是我們現在討論的重點,暫時忽略,

dictType結構包含若干函式指標,用于dict的呼叫者對涉及key和value的各種操作進行自定義,這些操作包含:

  • hashFunction,對key進行哈希值計算的哈希演算法,
  • keyDup和valDup,分別定義key和value的拷貝函式,用于在需要的時候對key和value進行深拷貝,而不僅僅是傳遞物件指標,
  • keyCompare,定義兩個key的比較操作,在根據key進行查找時會用到,
  • keyDestructor和valDestructor,分別定義對key和value的解構式,

私有資料指標(privdata)就是在dictType的某些操作被呼叫時會傳回給呼叫者,

需要詳細察看的是dictht結構,它定義一個哈希表的結構,由如下若干項組成:

  • 一個dictEntry指標陣列(table),key的哈希值最終映射到這個陣列的某個位置上(對應一個bucket),如果多個key映射到同一個位置,就發生了沖突,那么就拉出一個dictEntry鏈表,
  • size:標識dictEntry指標陣列的長度,它總是2的指數,
  • sizemask:用于將哈希值映射到table的位置索引,它的值等于(size-1),比如7, 15, 31, 63,等等,也就是用二進制表示的各個bit全1的數字,每個key先經過hashFunction計算得到一個哈希值,然后計算(哈希值 & sizemask)得到在table上的位置,相當于計算取余(哈希值 % size),
  • used:記錄dict中現有的資料個數,它與size的比值就是裝載因子(load factor),這個比值越大,哈希值沖突概率越高,

dictEntry結構中包含k, v和指向鏈表下一項的next指標,k是void指標,這意味著它可以指向任何型別,v是個union,當它的值是uint64_t、int64_t或double型別時,就不再需要額外的存盤,這有利于減少記憶體碎片,當然,v也可以是void指標,以便能存盤任何型別的資料,

dict的創建(dictCreate)

dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));

    _dictInit(d,type,privDataPtr);
    return d;
}

int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    _dictReset(&d->ht[0]);
    _dictReset(&d->ht[1]);
    d->type = type;
    d->privdata = https://www.cnblogs.com/ITjieduwu/p/privDataPtr;
    d->rehashidx = -1;
    d->iterators = 0;
    return DICT_OK;
}

static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

dictCreate為dict的資料結構分配空間并為各個變數賦初值,其中兩個哈希表ht[0]和ht[1]起始都沒有分配空間,table指標都賦為NULL,這意味著要等第一個資料插入時才會真正分配空間,

dict的查找(dictFind)

#define dictIsRehashing(d) ((d)->rehashidx != -1)

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

    if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
    if (dictIsRehashing(d)) _dictRehashStep(d);
    h = dictHashKey(d, key);
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key))
                return he;
            he = he->next;
        }
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}

上述dictFind的原始碼,根據dict當前是否正在重哈希,依次做了這么幾件事:

  • 如果當前正在進行重哈希,那么將重哈希程序向前推進一步(即呼叫_dictRehashStep),實際上,除了查找,插入和洗掉也都會觸發這一動作,這就將重哈希程序分散到各個查找、插入和洗掉操作中去了,而不是集中在某一個操作中一次性做完,
  • 計算key的哈希值(呼叫dictHashKey,里面的實作會呼叫前面提到的hashFunction),
  • 先在第一個哈希表ht[0]上進行查找,在table陣列上定位到哈希值對應的位置(如前所述,通過哈希值與sizemask進行按位與),然后在對應的dictEntry鏈表上進行查找,查找的時候需要對key進行比較,這時候呼叫dictCompareKeys,它里面的實作會呼叫到前面提到的keyCompare,如果找到就回傳該項,否則,進行下一步,
  • 判斷當前是否在重哈希,如果沒有,那么在ht[0]上的查找結果就是最終結果(沒找到,回傳NULL),否則,在ht[1]上進行查找(程序與上一步相同),

下面我們有必要看一下增量式重哈希的_dictRehashStep的實作,

static void _dictRehashStep(dict *d) {
    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);
        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 */
        while(de) {
            unsigned int 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... */
    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;
}

dictRehash每次將重哈希至少向前推進n步(除非不到n步整個重哈希就結束了),每一步都將ht[0]上某一個bucket(即一個dictEntry鏈表)上的每一個dictEntry移動到ht[1]上,它在ht[1]上的新位置根據ht[1]的sizemask進行重新計算,rehashidx記錄了當前尚未遷移(有待遷移)的ht[0]的bucket位置,

如果dictRehash被呼叫的時候,rehashidx指向的bucket里一個dictEntry也沒有,那么它就沒有可遷移的資料,這時它嘗試在ht[0].table陣列中不斷向后遍歷,直到找到下一個存有資料的bucket位置,如果一直找不到,則最多走n*10步,本次重哈希暫告結束,

最后,如果ht[0]上的資料都遷移到ht[1]上了(即d->ht[0].used == 0),那么整個重哈希結束,ht[0]變成ht[1]的內容,而ht[1]重置為空,

根據以上對于重哈希程序的分析,我們容易看出,本文前面的dict結構圖中所展示的正是rehashidx=2時的情況,前面兩個bucket(ht[0].table[0]和ht[0].table[1])都已經遷移到ht[1]上去了,

dict的插入(dictAdd和dictReplace)

dictAdd插入新的一對key和value,如果key已經存在,則插入失敗,

dictReplace也是插入一對key和value,不過在key存在的時候,它會更新value,

int dictAdd(dict *d, void *key, void *val)
{
    dictEntry *entry = dictAddRaw(d,key);

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

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

    if (dictIsRehashing(d)) _dictRehashStep(d);

    /* Get the index of the new element, or -1 if
     * the element already exists. */
    if ((index = _dictKeyIndex(d, key)) == -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;
}

static int _dictKeyIndex(dict *d, const void *key)
{
    unsigned int h, idx, table;
    dictEntry *he;

    /* Expand the hash table if needed */
    if (_dictExpandIfNeeded(d) == DICT_ERR)
        return -1;
    /* Compute the key hash value */
    h = dictHashKey(d, key);
    for (table = 0; table <= 1; table++) {
        idx = h & 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))
                return -1;
            he = he->next;
        }
        if (!dictIsRehashing(d)) break;
    }
    return idx;
}

以上是dictAdd的關鍵實作代碼,我們主要需要注意以下幾點:

  • 它也會觸發推進一步重哈希(_dictRehashStep),
  • 如果正在重哈希中,它會把資料插入到ht[1];否則插入到ht[0],
  • 在對應的bucket中插入資料的時候,總是插入到dictEntry的頭部,因為新資料接下來被訪問的概率可能比較高,這樣再次查找它時就比較次數較少,
  • _dictKeyIndex在dict中尋找插入位置,如果不在重哈希程序中,它只查找ht[0];否則查找ht[0]和ht[1],
  • _dictKeyIndex可能觸發dict記憶體擴展(_dictExpandIfNeeded,它將哈希表長度擴展為原來兩倍,具體請參考dict.c中原始碼),

dictReplace在dictAdd基礎上實作,如下:

int dictReplace(dict *d, void *key, void *val)
{
    dictEntry *entry, auxentry;

    /* Try to add the element. If the key
     * does not exists dictAdd will suceed. */
    if (dictAdd(d, key, val) == DICT_OK)
        return 1;
    /* It already exists, get the entry */
    entry = dictFind(d, key);
    /* Set the new value and free the old one. Note that it is important
     * to do that in this order, as the value may just be exactly the same
     * as the previous one. In this context, think to reference counting,
     * you want to increment (set), and then decrement (free), and not the
     * reverse. */
    auxentry = *entry;
    dictSetVal(d, entry, val);
    dictFreeVal(d, &auxentry);
    return 0;
}

在key已經存在的情況下,dictReplace會同時呼叫dictAdd和dictFind,這其實相當于兩次查找程序,這里Redis的代碼不夠優化,

dict的洗掉(dictDelete)

dictDelete的原始碼這里忽略,具體請參考dict.c,需要稍加注意的是:

  • dictDelete也會觸發推進一步重哈希(_dictRehashStep)
  • 如果當前不在重哈希程序中,它只在ht[0]中查找要洗掉的key;否則ht[0]和ht[1]它都要查找,
  • 洗掉成功后會呼叫key和value的解構式(keyDestructor和valDestructor),

dict的實作相對來說比較簡單,本文就介紹到這,在下一篇中我們將會介紹Redis中動態字串的實作——sds,敬請期待,

sds的資料結構定義

我們知道,在C語言中,字串是以’\0’字符結尾(NULL結束符)的字符陣列來存盤的,通常表達為字符指標的形式(char *),它不允許位元組0出現在字串中間,因此,它不能用來存盤任意的二進制資料,

我們可以在sds.h中找到sds的型別定義:

typedef char *sds;
肯定有人感到困惑了,竟然sds就等同于char *?我們前面提到過,sds和傳統的C語言字串保持型別兼容,因此它們的型別定義是一樣的,都是char *,在有些情況下,需要傳入一個C語言字串的地方,也確實可以傳入一個sds,但是,sds和char *并不等同,sds是Binary Safe的,它可以存盤任意二進制資料,不能像C語言字串那樣以字符’\0’來標識字串的結束,因此它必然有個長度欄位,但這個長度欄位在哪里呢?實際上sds還包含一個header結構:

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一共有5種型別的header,之所以有5種,是為了能讓不同長度的字串可以使用不同大小的header,這樣,短字串就能使用較小的header,從而節省記憶體,

一個sds字串的完整結構,由在記憶體地址上前后相鄰的兩部分組成:

一個header,通常包含字串的長度(len)、最大容量(alloc)和flags,sdshdr5有所不同,
一個字符陣列,這個字符陣列的長度等于最大容量+1,真正有效的字串資料,其長度通常小于最大容量,在真正的字串資料之后,是空余未用的位元組(一般以位元組0填充),允許在不重新分配記憶體的前提下讓字串資料向后做有限的擴展,在真正的字串資料之后,還有一個NULL結束符,即ASCII碼為0的’\0’字符,這是為了和傳統C字串兼容,之所以字符陣列的長度比最大容量多1個位元組,就是為了在字串長度達到最大容量時仍然有1個位元組存放NULL結束符,
除了sdshdr5之外,其它4個header的結構都包含3個欄位:

len: 表示字串的真正長度(不包含NULL結束符在內),
alloc: 表示字串的最大容量(不包含最后多余的那個位元組),
flags: 總是占用一個位元組,其中的最低3個bit用來表示header的型別,header的型別共有5種,在sds.h中有常量定義,
#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

sds的資料結構,我們有必要非常仔細地去決議它,

Redis dict結構舉例

上圖是sds的一個內部結構的例子,圖中展示了兩個sds字串s1和s2的記憶體結構,一個使用sdshdr8型別的header,另一個使用sdshdr16型別的header,但它們都表達了同樣的一個長度為6的字串的值:”tielei”,下面我們結合代碼,來解釋每一部分的組成,

sds的字符指標(s1和s2)就是指向真正的資料(字符陣列)開始的位置,而header位于記憶體地址較低的方向,在sds.h中有一些跟決議header有關的宏定義:

#define SDS_TYPE_MASK 7
#define SDS_TYPE_BITS 3
#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
#define SDS_TYPE_5_LEN(f) ((f)>>SDS_TYPE_BITS)

其中SDS_HDR用來從sds字串獲得header起始位置的指標,比如SDS_HDR(8, s1)表示s1的header指標,SDS_HDR(16, s2)表示s2的header指標,

當然,使用SDS_HDR之前我們必須先知道到底是哪一種header,這樣我們才知道SDS_HDR第1個引數應該傳什么,由sds字符指標獲得header型別的方法是,先向低地址方向偏移1個位元組的位置,得到flags欄位,比如,s1[-1]和s2[-1]分別獲得了s1和s2的flags的值,然后取flags的最低3個bit得到header的型別,

由于s1[-1] == 0x01 == SDS_TYPE_8,因此s1的header型別是sdshdr8,
由于s2[-1] == 0x02 == SDS_TYPE_16,因此s2的header型別是sdshdr16,
有了header指標,就能很快定位到它的len和alloc欄位:

s1的header中,len的值為0x06,表示字串資料長度為6;alloc的值為0x80,表示字符陣列最大容量為128,
s2的header中,len的值為0x0006,表示字串資料長度為6;alloc的值為0x03E8,表示字符陣列最大容量為1000,(注意:圖中是按小端地址構成)
在各個header的型別定義中,還有幾個需要我們注意的地方:

在各個header的定義中使用了__attribute__ ((packed)),是為了讓編譯器以緊湊模式來分配記憶體,如果沒有這個屬性,編譯器可能會為struct的欄位做優化對齊,在其中填充空位元組,那樣的話,就不能保證header和sds的資料部分緊緊前后相鄰,也不能按照固定向低地址方向偏移1個位元組的方式來獲取flags欄位了,

在各個header的定義中最后有一個char buf[],我們注意到這是一個沒有指明長度的字符陣列,這是C語言中定義字符陣列的一種特殊寫法,稱為柔性陣列(flexible array member),只能定義在一個結構體的最后一個欄位上,

它在這里只是起到一個標記的作用,表示在flags欄位后面就是一個字符陣列,或者說,它指明了緊跟在flags欄位后面的這個字符陣列在結構體中的偏移位置,而程式在為header分配的記憶體的時候,它并不占用記憶體空間,

如果計算sizeof(struct sdshdr16)的值,那么結果是5個位元組,其中沒有buf欄位,
sdshdr5與其它幾個header結構不同,它不包含alloc欄位,而長度使用flags的高5位來存盤,

因此,它不能為字串分配空余空間,如果字串需要動態增長,那么它就必然要重新分配記憶體才行,所以說,這種型別的sds字串更適合存盤靜態的短字串(長度小于32),

至此,我們非常清楚地看到了:sds字串的header,其實隱藏在真正的字串資料的前面(低地址方向),這樣的一個定義,有如下幾個好處:

header和資料相鄰,而不用分成兩塊記憶體空間來單獨分配,這有利于減少記憶體碎片,提高存盤效率(memory efficiency),

雖然header有多個型別,但sds可以用統一的char *來表達,且它與傳統的C語言字串保持型別兼容,

如果一個sds里面存盤的是可列印字串,那么我們可以直接把它傳給C函式,比如使用strcmp比較字串大小,或者使用printf進行列印,
弄清了sds的資料結構,它的具體操作函式就比較好理解了,

sds的一些基礎函式
sdslen(const sds s): 獲取sds字串長度,
sdssetlen(sds s, size_t newlen): 設定sds字串長度,
sdsinclen(sds s, size_t inc): 增加sds字串長度,
sdsalloc(const sds s): 獲取sds字串容量,
sdssetalloc(sds s, size_t newlen): 設定sds字串容量,
sdsavail(const sds s): 獲取sds字串空余空間(即alloc - len),
sdsHdrSize(char type): 根據header型別得到header大小,
sdsReqType(size_t string_size):

根據字串資料長度計算所需要的header型別,
這里我們挑選sdslen和sdsReqType的代碼,察看一下,

static inline size_t sdslen(const sds s) {
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return SDS_TYPE_5_LEN(flags);
        case SDS_TYPE_8:
            return SDS_HDR(8,s)->len;
        case SDS_TYPE_16:
            return SDS_HDR(16,s)->len;
        case SDS_TYPE_32:
            return SDS_HDR(32,s)->len;
        case SDS_TYPE_64:
            return SDS_HDR(64,s)->len;
    }
    return 0;
}
 
static inline char sdsReqType(size_t string_size) {
    if (string_size < 1<<5)
        return SDS_TYPE_5;
    if (string_size < 1<<8)
        return SDS_TYPE_8;
    if (string_size < 1<<16)
        return SDS_TYPE_16;
    if (string_size < 1ll<<32)
        return SDS_TYPE_32;
    return SDS_TYPE_64;
}

跟前面的分析類似,sdslen先用s[-1]向低地址方向偏移1個位元組,得到flags;然后與SDS_TYPE_MASK進行按位與,得到header型別;然后根據不同的header型別,呼叫SDS_HDR得到header起始指標,進而獲得len欄位,

通過sdsReqType的代碼,很容易看到:

長度在0和2^5-1之間,選用SDS_TYPE_5型別的header,
長度在25和28-1之間,選用SDS_TYPE_8型別的header,
長度在28和216-1之間,選用SDS_TYPE_16型別的header,
長度在216和232-1之間,選用SDS_TYPE_32型別的header,
長度大于232的,選用SDS_TYPE_64型別的header,能表示的最大長度為264-1,
注:sdsReqType的實作代碼,直到3.2.0,它在長度邊界值上都一直存在問題,直到最近3.2 branch上的commit 6032340才修復,

sds的創建和銷毀

sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;
    sds s;
    char type = sdsReqType(initlen);
    /* Empty strings are usually created in order to append. Use type 8
     * since type 5 is not good at this. */
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    int hdrlen = sdsHdrSize(type);
    unsigned char *fp; /* flags pointer. */
 
    sh = s_malloc(hdrlen+initlen+1);
    if (!init)
        memset(sh, 0, hdrlen+initlen+1);
    if (sh == NULL) return NULL;
    s = (char*)sh+hdrlen;
    fp = ((unsigned char*)s)-1;
    switch(type) {
        case SDS_TYPE_5: {
            *fp = type | (initlen << SDS_TYPE_BITS);
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
    }
    if (initlen && init)
        memcpy(s, init, initlen);
    s[initlen] = '\0';
    return s;
}
 
sds sdsempty(void) {
    return sdsnewlen("",0);
}
 
sds sdsnew(const char *init) {
    size_t initlen = (init == NULL) ? 0 : strlen(init);
    return sdsnewlen(init, initlen);
}
 
void sdsfree(sds s) {
    if (s == NULL) return;
    s_free((char*)s-sdsHdrSize(s[-1]));
}

?
sdsnewlen創建一個長度為initlen的sds字串,并使用init指向的字符陣列(任意二進制資料)來初始化資料,如果init為NULL,那么使用全0來初始化資料,它的實作中,我們需要注意的是:

如果要創建一個長度為0的空字串,那么不使用SDS_TYPE_5型別的header,而是轉而使用SDS_TYPE_8型別的header,這是因為創建的空字串一般接下來的操作很可能是追加資料,但SDS_TYPE_5型別的sds字串不適合追加資料(會引發記憶體重新分配),
需要的記憶體空間一次性進行分配,其中包含三部分:header、資料、最后的多余位元組(hdrlen+initlen+1),
初始化的sds字串資料最后會追加一個NULL結束符(s[initlen] = ‘\0’),
關于sdsfree,需要注意的是:記憶體要整體釋放,所以要先計算出header起始指標,把它傳給s_free函式,這個指標也正是在sdsnewlen中呼叫s_malloc回傳的那個地址,

sds的連接(追加)操作

sds sdscatlen(sds s, const void *t, size_t len) {
    size_t curlen = sdslen(s);
 
    s = sdsMakeRoomFor(s,len);
    if (s == NULL) return NULL;
    memcpy(s+curlen, t, len);
    sdssetlen(s, curlen+len);
    s[curlen+len] = '\0';
    return s;
}
 
sds sdscat(sds s, const char *t) {
    return sdscatlen(s, t, strlen(t));
}
 
sds sdscatsds(sds s, const sds t) {
    return sdscatlen(s, t, sdslen(t));
}
 
sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
 
    /* 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;
 
    type = sdsReqType(newlen);
 
    /* Don't use type 5: the user is appending to the string and type 5 is
     * not able to remember empty space, so sdsMakeRoomFor() must be called
     * at every appending operation. */
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;
 
    hdrlen = sdsHdrSize(type);
    if (oldtype==type) {
        newsh = s_realloc(sh, hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else {
        /* Since the header size changes, need to move the string forward,
         * and can't use realloc */
        newsh = s_malloc(hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);
        s_free(sh);
        s = (char*)newsh+hdrlen;
        s[-1] = type;
        sdssetlen(s, len);
    }
    sdssetalloc(s, newlen);
    return s;
}

sdscatlen將t指向的長度為len的任意二進制資料追加到sds字串s的后面,本文開頭演示的string的append命令,內部就是呼叫sdscatlen來實作的,

在sdscatlen的實作中,先呼叫sdsMakeRoomFor來保證字串s有足夠的空間來追加長度為len的資料,sdsMakeRoomFor可能會分配新的記憶體,也可能不會,

sdsMakeRoomFor是sds實作中很重要的一個函式,關于它的實作代碼,我們需要注意的是:

如果原來字串中的空余空間夠用(avail >= addlen),那么它什么也不做,直接回傳,

如果需要分配空間,它會比實際請求的要多分配一些,以防備接下來繼續追加,它在字串已經比較長的情況下要至少多分配SDS_MAX_PREALLOC個位元組,這個常量在sds.h中定義為(1024*1024)=1MB,

按分配后的空間大小,可能需要更換header型別(原來header的alloc欄位太短,表達不了增加后的容量),

如果需要更換header,那么整個字串空間(包括header)都需要重新分配(s_malloc),并拷貝原來的資料到新的位置,

如果不需要更換header(原來的header夠用),那么呼叫一個比較特殊的s_realloc,試圖在原來的地址上重新分配空間,s_realloc的具體實作得看Redis編譯的時候選用了哪個allocator(在Linux上默認使用jemalloc),

但不管是哪個realloc的實作,它所表達的含義基本是相同的:它盡量在原來分配好的地址位置重新分配,如果原來的地址位置有足夠的空余空間完成重新分配,那么它回傳的新地址與傳入的舊地址相同;否則,它分配新的地址塊,并進行資料搬遷,參見http://man.cx/realloc,

從sdscatlen的函式介面,我們可以看到一種使用模式:呼叫它的時候,傳入一個舊的sds變數,然后它回傳一個新的sds變數,由于它的內部實作可能會造成地址變化,因此呼叫者在呼叫完之后,原來舊的變數就失效了,而都應該用新回傳的變數來替換,不僅僅是sdscatlen函式,sds中的其它函式(比如sdscpy、sdstrim、sdsjoin等),還有Redis中其它一些能自動擴展記憶體的資料結構(如ziplist),也都是同樣的使用模式,

淺談sds與string的關系

現在我們回過頭來看看本文開頭給出的string操作的例子,

append操作使用sds的sdscatlen來實作,前面已經提到,

setbit和getrange都是先根據key取到整個sds字串,然后再從字串選取或修改指定的部分,由于sds就是一個字符陣列,所以對它的某一部分進行操作似乎都比較簡單,

但是,string除了支持這些操作之外,當它存盤的值是個數字的時候,它還支持incr、decr等操作,那么,當string存盤數字值的時候,它的內部存盤還是sds嗎?

實際上,不是了,而且,這種情況下,setbit和getrange的實作也會有所不同,這些細節,我們放在下一篇介紹robj的時候再進行系統地討論,

什么是ziplist

Redis官方對于ziplist的定義是(出自ziplist.c的檔案頭部注釋):

The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values, where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time.

翻譯一下就是說:ziplist是一個經過特殊編碼的雙向鏈表,它的設計目標就是為了提高存盤效率,ziplist可以用于存盤字串或整數,其中整數是按真正的二進制表示進行編碼的,而不是編碼成字串序列,它能以O(1)的時間復雜度在表的兩端提供pushpop操作,

實際上,ziplist充分體現了Redis對于存盤效率的追求,一個普通的雙向鏈表,鏈表中每一項都占用獨立的一塊記憶體,各項之間用地址指標(或參考)連接起來,這種方式會帶來大量的記憶體碎片,而且地址指標也會占用額外的記憶體,而ziplist卻是將表中每一項存放在前后連續的地址空間內,一個ziplist整體占用一大塊記憶體,它是一個表(list),但其實不是一個鏈表(linked list),

另外,ziplist為了在細節上節省記憶體,對于值的存盤采用了變長的編碼方式,大概意思是說,對于大的整數,就多用一些位元組來存盤,而對于小的整數,就少用一些位元組來存盤,我們接下來很快就會討論到這些實作細節,

ziplist的資料結構定義

ziplist的資料結構組成是本文要討論的重點,實際上,ziplist還是稍微有點復雜的,它復雜的地方就在于它的資料結構定義,一旦理解了資料結構,它的一些操作也就比較容易理解了,

我們接下來先從總體上介紹一下ziplist的資料結構定義,然后舉一個實際的例子,通過例子來解釋ziplist的構成,如果你看懂了這一部分,本文的任務就算完成了一大半了,

從宏觀上看,ziplist的記憶體結構如下:

<zlbytes><zltail><zllen><entry>...<entry><zlend>

各個部分在記憶體上是前后相鄰的,它們分別的含義如下:

  • <zlbytes>: 32bit,表示ziplist占用的位元組總數(也包括<zlbytes>本身占用的4個位元組),
  • <zltail>: 32bit,表示ziplist表中最后一項(entry)在ziplist中的偏移位元組數,<zltail>的存在,使得我們可以很方便地找到最后一項(不用遍歷整個ziplist),從而可以在ziplist尾端快速地執行push或pop操作,
  • <zllen>: 16bit, 表示ziplist中資料項(entry)的個數,zllen欄位因為只有16bit,所以可以表達的最大值為216-1,這里需要特別注意的是,如果ziplist中資料項個數超過了16bit能表達的最大值,ziplist仍然可以來表示,那怎么表示呢?這里做了這樣的規定:如果`<zllen>`小于等于216-2(也就是不等于2^16-1),那么<zllen>就表示ziplist中資料項的個數;否則,也就是<zllen>等于16bit全為1的情況,那么<zllen>就不表示資料項個數了,這時候要想知道ziplist中資料項總數,那么必須對ziplist從頭到尾遍歷各個資料項,才能計數出來,
  • <entry>: 表示真正存放資料的資料項,長度不定,一個資料項(entry)也有它自己的內部結構,這個稍后再解釋,
  • <zlend>: ziplist最后1個位元組,是一個結束標記,值固定等于255,

上面的定義中還值得注意的一點是:<zlbytes><zltail><zllen>既然占據多個位元組,那么在存盤的時候就有大端(big endian)和小端(little endian)的區別,ziplist采取的是小端模式來存盤,這在下面我們介紹具體例子的時候還會再詳細解釋,

我們再來看一下每一個資料項<entry>的構成:

<prevrawlen><len><data>

我們看到在真正的資料(<data>)前面,還有兩個欄位:

  • <prevrawlen>: 表示前一個資料項占用的總位元組數,這個欄位的用處是為了讓ziplist能夠從后向前遍歷(從后一項的位置,只需向前偏移prevrawlen個位元組,就找到了前一項),這個欄位采用變長編碼,
  • <len>: 表示當前資料項的資料長度(即<data>部分的長度),也采用變長編碼,

那么<prevrawlen><len>是怎么進行變長編碼的呢?各位讀者打起精神了,我們終于講到了ziplist的定義中最繁瑣的地方了,

先說<prevrawlen>,它有兩種可能,或者是1個位元組,或者是5個位元組:

  1. 如果前一個資料項占用位元組數小于254,那么<prevrawlen>就只用一個位元組來表示,這個位元組的值就是前一個資料項的占用位元組數,
  2. 如果前一個資料項占用位元組數大于等于254,那么<prevrawlen>就用5個位元組來表示,其中第1個位元組的值是254(作為這種情況的一個標記),而后面4個位元組組成一個整型值,來真正存盤前一個資料項的占用位元組數,

有人會問了,為什么沒有255的情況呢?

這是因為:255已經定義為ziplist結束標記<zlend>的值了,在ziplist的很多操作的實作中,都會根據資料項的第1個位元組是不是255來判斷當前是不是到達ziplist的結尾了,因此一個正常的資料的第1個位元組(也就是<prevrawlen>的第1個位元組)是不能夠取255這個值的,否則就沖突了,

<len>欄位就更加復雜了,它根據第1個位元組的不同,總共分為9種情況(下面的表示法是按二進制表示):

  1. |00pppppp| - 1 byte,第1個位元組最高兩個bit是00,那么<len>欄位只有1個位元組,剩余的6個bit用來表示長度值,最高可以表示63 (2^6-1),
  2. |01pppppp|qqqqqqqq| - 2 bytes,第1個位元組最高兩個bit是01,那么<len>欄位占2個位元組,總共有14個bit用來表示長度值,最高可以表示16383 (2^14-1),
  3. |10__|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes,第1個位元組最高兩個bit是10,那么len欄位占5個位元組,總共使用32個bit來表示長度值(6個bit舍棄不用),最高可以表示2^32-1,需要注意的是:在前三種情況下,<data>都是按字串來存盤的;從下面第4種情況開始,<data>開始變為按整數來存盤了,
  4. |11000000| - 1 byte,<len>欄位占用1個位元組,值為0xC0,后面的資料<data>存盤為2個位元組的int16_t型別,
  5. |11010000| - 1 byte,<len>欄位占用1個位元組,值為0xD0,后面的資料<data>存盤為4個位元組的int32_t型別,
  6. |11100000| - 1 byte,<len>欄位占用1個位元組,值為0xE0,后面的資料<data>存盤為8個位元組的int64_t型別,
  7. |11110000| - 1 byte,<len>欄位占用1個位元組,值為0xF0,后面的資料<data>存盤為3個位元組長的整數,
  8. |11111110| - 1 byte,<len>欄位占用1個位元組,值為0xFE,后面的資料<data>存盤為1個位元組的整數,
  9. |1111xxxx| - - (xxxx的值在0001和1101之間),這是一種特殊情況,xxxx從1到13一共13個值,這時就用這13個值來表示真正的資料,注意,這里是表示真正的資料,而不是資料長度了,也就是說,在這種情況下,后面不再需要一個單獨的<data>欄位來表示真正的資料了,而是<len><data>合二為一了,另外,由于xxxx只能取0001和1101這13個值了(其它可能的值和其它情況沖突了,比如0000和1110分別同前面第7種第8種情況沖突,1111跟結束標記沖突),而小數值應該從0開始,因此這13個值分別表示0到12,即xxxx的值減去1才是它所要表示的那個整數資料的值,

好了,ziplist的資料結構定義,我們介紹完了,現在我們看一個具體的例子,

Redis Ziplist Sample

上圖是一份真實的ziplist資料,我們逐項解讀一下:

  • 這個ziplist一共包含33個位元組,位元組編號從byte[0]到byte[32],圖中每個位元組的值使用16進制表示,
  • 頭4個位元組(0x21000000)是按小端(little endian)模式存盤的<zlbytes>欄位,什么是小端呢?就是指資料的低位元組保存在記憶體的低地址中(參見維基百科詞條Endianness),因此,這里<zlbytes>的值應該決議成0x00000021,用十進制表示正好就是33,
  • 接下來4個位元組(byte[4..7])是<zltail>,用小端存盤模式來解釋,它的值是0x0000001D(值為29),表示最后一個資料項在byte[29]的位置(那個資料項為0x05FE14),
  • 再接下來2個位元組(byte[8..9]),值為0x0004,表示這個ziplist里一共存有4項資料,
  • 接下來6個位元組(byte[10..15])是第1個資料項,其中,prevrawlen=0,因為它前面沒有資料項;len=4,相當于前面定義的9種情況中的第1種,表示后面4個位元組按字串存盤資料,資料的值為”name”,
  • 接下來8個位元組(byte[16..23])是第2個資料項,與前面資料項存盤格式類似,存盤1個字串”tielei”,
  • 接下來5個位元組(byte[24..28])是第3個資料項,與前面資料項存盤格式類似,存盤1個字串”age”,
  • 接下來3個位元組(byte[29..31])是最后一個資料項,它的格式與前面的資料項存盤格式不太一樣,其中,第1個位元組prevrawlen=5,表示前一個資料項占用5個位元組;第2個位元組=FE,相當于前面定義的9種情況中的第8種,所以后面還有1個位元組用來表示真正的資料,并且以整數表示,它的值是20(0x14),
  • 最后1個位元組(byte[32])表示<zlend>,是固定的值255(0xFF),

總結一下,這個ziplist里存了4個資料項,分別為:

  • 字串: “name”
  • 字串: “tielei”
  • 字串: “age”
  • 整數: 20

(好吧,被你發現了~~tielei實際上當然不是20歲,他哪有那么年輕啊……)

實際上,這個ziplist是通過兩個hset命令創建出來的,這個我們后半部分會再提到,

好了,既然你已經閱讀到這里了,說明你還是很有耐心的(其實我寫到這里也已經累得不行了),可以先把本文收藏,休息一下,回頭再看后半部分,

接下來我要貼一些代碼了,

ziplist的介面

我們先不著急看實作,先來挑幾個ziplist的重要的介面,看看它們長什么樣子:

unsigned char *ziplistNew(void);
unsigned char *ziplistMerge(unsigned char **first, unsigned char **second);
unsigned char *ziplistPush(unsigned char *zl, unsigned char *s, unsigned int slen, int where);
unsigned char *ziplistIndex(unsigned char *zl, int index);
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p);
unsigned char *ziplistPrev(unsigned char *zl, unsigned char *p);
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen);
unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p);
unsigned char *ziplistFind(unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip);
unsigned int ziplistLen(unsigned char *zl);

我們從這些介面的名字就可以粗略猜出它們的功能,下面簡單解釋一下:

  • ziplist的資料型別,沒有用自定義的struct之類的來表達,而就是簡單的unsigned char *,這是因為ziplist本質上就是一塊連續記憶體,內部組成結構又是一個高度動態的設計(變長編碼),也沒法用一個固定的資料結構來表達,
  • ziplistNew: 創建一個空的ziplist(只包含<zlbytes><zltail><zllen><zlend>),
  • ziplistMerge: 將兩個ziplist合并成一個新的ziplist,
  • ziplistPush: 在ziplist的頭部或尾端插入一段資料(產生一個新的資料項),注意一下這個介面的回傳值,是一個新的ziplist,呼叫方必須用這里回傳的新的ziplist,替換之前傳進來的舊的ziplist變數,而經過這個函式處理之后,原來舊的ziplist變數就失效了,為什么一個簡單的插入操作會導致產生一個新的ziplist呢?這是因為ziplist是一塊連續空間,對它的追加操作,會引發記憶體的realloc,因此ziplist的記憶體位置可能會發生變化,實際上,我們在之前介紹sds的文章中提到過類似這種介面使用模式(參見sdscatlen函式的說明),
  • ziplistIndex: 回傳index引數指定的資料項的記憶體位置,index可以是負數,表示從尾端向前進行索引,
  • ziplistNext和ziplistPrev分別回傳一個ziplist中指定資料項p的后一項和前一項,
  • ziplistInsert: 在ziplist的任意資料項前面插入一個新的資料項,
  • ziplistDelete: 洗掉指定的資料項,
  • ziplistFind: 查找給定的資料(由vstr和vlen指定),注意它有一個skip引數,表示查找的時候每次比較之間要跳過幾個資料項,為什么會有這么一個引數呢?其實這個引數的主要用途是當用ziplist表示hash結構的時候,是按照一個field,一個value來依次存入ziplist的,也就是說,偶數索引的資料項存field,奇數索引的資料項存value,當按照field的值進行查找的時候,就需要把奇數項跳過去,
  • ziplistLen: 計算ziplist的長度(即包含資料項的個數),

ziplist的插入邏輯決議

ziplist的相關介面的具體實作,還是有些復雜的,限于篇幅的原因,我們這里只結合代碼來講解插入的邏輯,插入是很有代表性的操作,通過這部分來一窺ziplist內部的實作,其它部分的實作我們也就會很容易理解了,

ziplistPush和ziplistInsert都是插入,只是對于插入位置的限定不同,它們在內部實作都依賴一個名為__ziplistInsert的內部函式,其代碼如下(出自ziplist.c):

static 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 = https://www.cnblogs.com/ITjieduwu/p/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 zipEncodeLength 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 += zipPrevEncodeLength(NULL,prevlen);
    reqlen += zipEncodeLength(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. */
    nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;

    /* 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. */
        zipPrevEncodeLength(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 += zipPrevEncodeLength(p,prevlen);
    p += zipEncodeLength(p,encoding,slen);
    if (ZIP_IS_STR(encoding)) {
        memcpy(p,s,slen);
    } else {
        zipSaveInteger(p,value,encoding);
    }
    ZIPLIST_INCR_LENGTH(zl,1);
    return zl;
}

我們來簡單決議一下這段代碼:

  • 這個函數是在指定的位置p插入一段新的資料,待插入資料的地址指標是s,長度為slen,插入后形成一個新的資料項,占據原來p的配置,原來位于p位置的資料項以及后面的所有資料項,需要統一向后移動,給新插入的資料項留出空間,引數p指向的是ziplist中某一個資料項的起始位置,或者在向尾端插入的時候,它指向ziplist的結束標記<zlend>
  • 函式開始先計算出待插入位置前一個資料項的長度prevlen,這個長度要存入新插入的資料項的<prevrawlen>欄位,
  • 然后計算當前資料項占用的總位元組數reqlen,它包含三部分:<prevrawlen><len>和真正的資料,其中的資料部分會通過呼叫zipTryEncoding先來嘗試轉成整數,
  • 由于插入導致的ziplist對于記憶體的新增需求,除了待插入資料項占用的reqlen之外,還要考慮原來p位置的資料項(現在要排在待插入資料項之后)的<prevrawlen>欄位的變化,本來它保存的是前一項的總長度,現在變成了保存當前插入的資料項的總長度,這樣它的<prevrawlen>欄位本身需要的存盤空間也可能發生變化,這個變化可能是變大也可能是變小,這個變化了多少的值nextdiff,是呼叫zipPrevLenByteDiff計算出來的,如果變大了,nextdiff是正值,否則是負值,
  • 現在很容易算出來插入后新的ziplist需要多少位元組了,然后呼叫ziplistResize來重新調整大小,ziplistResize的實作里會呼叫allocator的zrealloc,它有可能會造成資料拷貝,
  • 現在額外的空間有了,接下來就是將原來p位置的資料項以及后面的所有資料都向后挪動,并為它設定新的<prevrawlen>欄位,此外,還可能需要調整ziplist的<zltail>欄位,
  • 最后,組裝新的待插入資料項,放在位置p,

hash與ziplist

hash是Redis中可以用來存盤一個物件結構的比較理想的資料型別,一個物件的各個屬性,正好對應一個hash結構的各個field,

我們在網上很容易找到這樣一些技術文章,它們會說存盤一個物件,使用hash比string要節省記憶體,實際上這么說是有前提的,具體取決于物件怎么來存盤,如果你把物件的多個屬性存盤到多個key上(各個屬性值存成string),當然占的記憶體要多,但如果你采用一些序列化方法,比如Protocol Buffers,或者Apache Thrift,先把物件序列化為位元組陣列,然后再存入到Redis的string中,那么跟hash相比,哪一種更省記憶體,就不一定了,

當然,hash比序列化后再存入string的方式,在支持的操作命令上,還是有優勢的:它既支持多個field同時存取(hmset/hmget),也支持按照某個特定的field單獨存取(hset/hget),

實際上,hash隨著資料的增大,其底層資料結構的實作是會發生變化的,當然存盤效率也就不同,在field比較少,各個value值也比較小的時候,hash采用ziplist來實作;而隨著field增多和value值增大,hash可能會變成dict來實作,當hash底層變成dict來實作的時候,它的存盤效率就沒法跟那些序列化方式相比了,

當我們為某個key第一次執行 hset key field value 命令的時候,Redis會創建一個hash結構,這個新創建的hash底層就是一個ziplist,

robj *createHashObject(void) {
    unsigned char *zl = ziplistNew();
    robj *o = createObject(OBJ_HASH, zl);
    o->encoding = OBJ_ENCODING_ZIPLIST;
    return o;
}

上面的createHashObject函式,出自object.c,它負責的任務就是創建一個新的hash結構,可以看出,它創建了一個type = OBJ_HASHencoding = OBJ_ENCODING_ZIPLIST的robj物件,

實際上,本文前面給出的那個ziplist實體,就是由如下兩個命令構建出來的,

hset user:100 name tielei
hset user:100 age 20

每執行一次hset命令,插入的field和value分別作為一個新的資料項插入到ziplist中(即每次hset產生兩個資料項),

當隨著資料的插入,hash底層的這個ziplist就可能會轉成dict,那么到底插入多少才會轉呢?

還記得本文開頭提到的兩個Redis配置嗎?

hash-max-ziplist-entries 512
hash-max-ziplist-value 64

這個配置的意思是說,在如下兩個條件之一滿足的時候,ziplist會轉成dict:

  • 當hash中的資料項(即field-value對)的數目超過512的時候,也就是ziplist資料項超過1024的時候(請參考t_hash.c中的hashTypeSet函式),
  • 當hash中插入的任意一個value的長度超過了64的時候(請參考t_hash.c中的hashTypeTryConversion函式),

Redis的hash之所以這樣設計,是因為當ziplist變得很大的時候,它有如下幾個缺點:

  • 每次插入或修改引發的realloc操作會有更大的概率造成記憶體拷貝,從而降低性能,
  • 一旦發生記憶體拷貝,記憶體拷貝的成本也相應增加,因為要拷貝更大的一塊資料,
  • 當ziplist資料項過多的時候,在它上面查找指定的資料項就會性能變得很低,因為ziplist上的查找需要進行遍歷,

總之,ziplist本來就設計為各個資料項挨在一起組成連續的記憶體空間,這種結構并不擅長做修改操作,一旦資料發生改動,就會引發記憶體realloc,可能導致記憶體拷貝,

quicklist概述

Redis對外暴露的上層list資料型別,經常被用作佇列使用,比如它支持的如下一些操作:

  • lpush: 在左側(即串列頭部)插入資料,
  • rpop: 在右側(即串列尾部)洗掉資料,
  • rpush: 在右側(即串列尾部)插入資料,
  • lpop: 在左側(即串列頭部)洗掉資料,

這些操作都是O(1)時間復雜度的,

當然,list也支持在任意中間位置的存取操作,比如lindexlinsert,但它們都需要對list進行遍歷,所以時間復雜度較高,為O(N),

概況起來,list具有這樣的一些特點:它是一個能維持資料項先后順序的串列(各個資料項的先后順序由插入位置決定),便于在表的兩端追加和洗掉資料,而對于中間位置的存取具有O(N)的時間復雜度,這不正是一個雙向鏈表所具有的特點嗎?

list的內部實作quicklist正是一個雙向鏈表,在quicklist.c的檔案頭部注釋中,是這樣描述quicklist的:

A doubly linked list of ziplists

它確實是一個雙向鏈表,而且是一個ziplist的雙向鏈表,

這是什么意思呢?

我們知道,雙向鏈表是由多個節點(Node)組成的,這個描述的意思是:quicklist的每個節點都是一個ziplist,ziplist我們已經在上一篇介紹過,

ziplist本身也是一個能維持資料項先后順序的串列(按插入位置),而且是一個記憶體緊縮的串列(各個資料項在記憶體上前后相鄰),比如,一個包含3個節點的quicklist,如果每個節點的ziplist又包含4個資料項,那么對外表現上,這個list就總共包含12個資料項,

quicklist的結構為什么這樣設計呢?總結起來,大概又是一個空間和時間的折中:

  • 雙向鏈表便于在表的兩端進行push和pop操作,但是它的記憶體開銷比較大,首先,它在每個節點上除了要保存資料之外,還要額外保存兩個指標;其次,雙向鏈表的各個節點是單獨的記憶體塊,地址不連續,節點多了容易產生記憶體碎片,
  • ziplist由于是一整塊連續記憶體,所以存盤效率很高,但是,它不利于修改操作,每次資料變動都會引發一次記憶體的realloc,特別是當ziplist長度很長的時候,一次realloc可能會導致大批量的資料拷貝,進一步降低性能,

于是,結合了雙向鏈表和ziplist的優點,quicklist就應運而生了,

不過,這也帶來了一個新問題:到底一個quicklist節點包含多長的ziplist合適呢?比如,同樣是存盤12個資料項,既可以是一個quicklist包含3個節點,而每個節點的ziplist又包含4個資料項,也可以是一個quicklist包含6個節點,而每個節點的ziplist又包含2個資料項,

這又是一個需要找平衡點的難題,我們只從存盤效率上分析一下:

  • 每個quicklist節點上的ziplist越短,則記憶體碎片越多,記憶體碎片多了,有可能在記憶體中產生很多無法被利用的小碎片,從而降低存盤效率,這種情況的極端是每個quicklist節點上的ziplist只包含一個資料項,這就蛻化成一個普通的雙向鏈表了,
  • 每個quicklist節點上的ziplist越長,則為ziplist分配大塊連續記憶體空間的難度就越大,有可能出現記憶體里有很多小塊的空閑空間(它們加起來很多),但卻找不到一塊足夠大的空閑空間分配給ziplist的情況,這同樣會降低存盤效率,這種情況的極端是整個quicklist只有一個節點,所有的資料項都分配在這僅有的一個節點的ziplist里面,這其實蛻化成一個ziplist了,

可見,一個quicklist節點上的ziplist要保持一個合理的長度,那到底多長合理呢?這可能取決于具體應用場景,實際上,Redis提供了一個配置引數list-max-ziplist-size,就是為了讓使用者可以來根據自己的情況進行調整,

list-max-ziplist-size -2

我們來詳細解釋一下這個引數的含義,它可以取正值,也可以取負值,

當取正值的時候,表示按照資料項個數來限定每個quicklist節點上的ziplist長度,比如,當這個引數配置成5的時候,表示每個quicklist節點的ziplist最多包含5個資料項,

當取負值的時候,表示按照占用位元組數來限定每個quicklist節點上的ziplist長度,這時,它只能取-1到-5這五個值,每個值含義如下:

  • -5: 每個quicklist節點上的ziplist大小不能超過64 Kb,(注:1kb => 1024 bytes)
  • -4: 每個quicklist節點上的ziplist大小不能超過32 Kb,
  • -3: 每個quicklist節點上的ziplist大小不能超過16 Kb,
  • -2: 每個quicklist節點上的ziplist大小不能超過8 Kb,(-2是Redis給出的默認值)
  • -1: 每個quicklist節點上的ziplist大小不能超過4 Kb,

另外,list的設計目標是能夠用來存盤很長的資料串列的,比如,Redis官網給出的這個教程:Writing a simple Twitter clone with PHP and Redis,就是使用list來存盤類似Twitter的timeline資料,

當串列很長的時候,最容易被訪問的很可能是兩端的資料,中間的資料被訪問的頻率比較低(訪問起來性能也很低),如果應用場景符合這個特點,那么list還提供了一個選項,能夠把中間的資料節點進行壓縮,從而進一步節省記憶體空間,Redis的配置引數list-compress-depth就是用來完成這個設定的,

list-compress-depth 0

這個引數表示一個quicklist兩端不被壓縮的節點個數,注:這里的節點個數是指quicklist雙向鏈表的節點個數,而不是指ziplist里面的資料項個數,實際上,一個quicklist節點上的ziplist,如果被壓縮,就是整體被壓縮的,

引數list-compress-depth的取值含義如下:

  • 0: 是個特殊值,表示都不壓縮,這是Redis的默認值,
  • 1: 表示quicklist兩端各有1個節點不壓縮,中間的節點壓縮,
  • 2: 表示quicklist兩端各有2個節點不壓縮,中間的節點壓縮,
  • 3: 表示quicklist兩端各有3個節點不壓縮,中間的節點壓縮,
  • 依此類推…

由于0是個特殊值,很容易看出quicklist的頭節點和尾節點總是不被壓縮的,以便于在表的兩端進行快速存取,

Redis對于quicklist內部節點的壓縮演算法,采用的LZF——一種無損壓縮演算法,

quicklist的資料結構定義

quicklist相關的資料結構定義可以在quicklist.h中找到:

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;

typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned int 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;

quicklistNode結構代表quicklist的一個節點,其中各個欄位的含義如下:

  • prev: 指向鏈表前一個節點的指標,
  • next: 指向鏈表后一個節點的指標,
  • zl: 資料指標,如果當前節點的資料沒有壓縮,那么它指向一個ziplist結構;否則,它指向一個quicklistLZF結構,
  • sz: 表示zl指向的ziplist的總大小(包括zlbyteszltailzllenzlend和各個資料項),需要注意的是:如果ziplist被壓縮了,那么這個sz的值仍然是壓縮前的ziplist大小,
  • count: 表示ziplist里面包含的資料項個數,這個欄位只有16bit,稍后我們會一起計算一下這16bit是否夠用,
  • encoding: 表示ziplist是否壓縮了(以及用了哪個壓縮演算法),目前只有兩種取值:2表示被壓縮了(而且用的是LZF壓縮演算法),1表示沒有壓縮,
  • container: 是一個預留欄位,本來設計是用來表明一個quicklist節點下面是直接存資料,還是使用ziplist存資料,或者用其它的結構來存資料(用作一個資料容器,所以叫container),但是,在目前的實作中,這個值是一個固定的值2,表示使用ziplist作為資料容器,
  • recompress: 當我們使用類似lindex這樣的命令查看了某一項本來壓縮的資料時,需要把資料暫時解壓,這時就設定recompress=1做一個標記,等有機會再把資料重新壓縮,
  • attempted_compress: 這個值只對Redis的自動化測驗程式有用,我們不用管它,
  • extra: 其它擴展欄位,目前Redis的實作里也沒用上,

quicklistLZF結構表示一個被壓縮過的ziplist,其中:

  • sz: 表示壓縮后的ziplist大小,
  • compressed: 是個柔性陣列(flexible array member),存放壓縮后的ziplist位元組陣列,

真正表示quicklist的資料結構是同名的quicklist這個struct:

  • head: 指向頭節點(左側第一個節點)的指標,
  • tail: 指向尾節點(右側第一個節點)的指標,
  • count: 所有ziplist資料項的個數總和,
  • len: quicklist節點的個數,
  • fill: 16bit,ziplist大小設定,存放list-max-ziplist-size引數的值,
  • compress: 16bit,節點壓縮深度設定,存放list-compress-depth引數的值,

Redis quicklist 結構圖

上圖是一個quicklist的結構圖舉例,圖中例子對應的ziplist大小配置和節點壓縮深度配置,如下:

list-max-ziplist-size 3
list-compress-depth 2

這個例子中我們需要注意的幾點是:

  • 兩端各有2個橙黃色的節點,是沒有被壓縮的,它們的資料指標zl指向真正的ziplist,中間的其它節點是被壓縮過的,它們的資料指標zl指向被壓縮后的ziplist結構,即一個quicklistLZF結構,
  • 左側頭節點上的ziplist里有2項資料,右側尾節點上的ziplist里有1項資料,中間其它節點上的ziplist里都有3項資料(包括壓縮的節點內部),這表示在表的兩端執行過多次pushpop操作后的一個狀態,

現在我們來大概計算一下quicklistNode結構中的count欄位這16bit是否夠用,

我們已經知道,ziplist大小受到list-max-ziplist-size引數的限制,按照正值和負值有兩種情況:

  • 當這個引數取正值的時候,就是恰好表示一個quicklistNode結構中zl所指向的ziplist所包含的資料項的最大值,list-max-ziplist-size引數是由quicklist結構的fill欄位來存盤的,而fill欄位是16bit,所以它所能表達的值能夠用16bit來表示,
  • 當這個引數取負值的時候,能夠表示的ziplist最大長度是64 Kb,而ziplist中每一個資料項,最少需要2個位元組來表示:1個位元組的prevrawlen,1個位元組的datalen欄位和data合二為一;詳見上一篇),所以,ziplist中資料項的個數不會超過32 K,用16bit來表達足夠了,

實際上,在目前的quicklist的實作中,ziplist的大小還會受到另外的限制,根本不會達到這里所分析的最大值,

下面進入代碼分析階段,

quicklist的創建

當我們使用lpushrpush命令第一次向一個不存在的list里面插入資料的時候,Redis會首先呼叫quicklistCreate介面創建一個空的quicklist,

quicklist *quicklistCreate(void) {
    struct quicklist *quicklist;

    quicklist = zmalloc(sizeof(*quicklist));
    quicklist->head = quicklist->tail = NULL;
    quicklist->len = 0;
    quicklist->count = 0;
    quicklist->compress = 0;
    quicklist->fill = -2;
    return quicklist;
}

在很多介紹資料結構的書上,實作雙向鏈表的時候經常會多增加一個空余的頭節點,主要是為了插入和洗掉操作的方便,從上面quicklistCreate的代碼可以看出,quicklist是一個不包含空余頭節點的雙向鏈表(headtail都初始化為NULL),

quicklist的push操作

quicklist的push操作是呼叫quicklistPush來實作的,

void quicklistPush(quicklist *quicklist, void *value, const size_t sz,
                   int where) {
    if (where == QUICKLIST_HEAD) {
        quicklistPushHead(quicklist, value, sz);
    } else if (where == QUICKLIST_TAIL) {
        quicklistPushTail(quicklist, value, sz);
    }
}

/* Add new entry to head node of quicklist.
 *
 * Returns 0 if used existing head.
 * Returns 1 if new head created. */
int quicklistPushHead(quicklist *quicklist, void *value, size_t sz) {
    quicklistNode *orig_head = quicklist->head;
    if (likely(
            _quicklistNodeAllowInsert(quicklist->head, quicklist->fill, sz))) {
        quicklist->head->zl =
            ziplistPush(quicklist->head->zl, value, sz, ZIPLIST_HEAD);
        quicklistNodeUpdateSz(quicklist->head);
    } else {
        quicklistNode *node = quicklistCreateNode();
        node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);

        quicklistNodeUpdateSz(node);
        _quicklistInsertNodeBefore(quicklist, quicklist->head, node);
    }
    quicklist->count++;
    quicklist->head->count++;
    return (orig_head != quicklist->head);
}

/* Add new entry to tail node of quicklist.
 *
 * Returns 0 if used existing tail.
 * Returns 1 if new tail created. */
int quicklistPushTail(quicklist *quicklist, void *value, size_t sz) {
    quicklistNode *orig_tail = quicklist->tail;
    if (likely(
            _quicklistNodeAllowInsert(quicklist->tail, quicklist->fill, sz))) {
        quicklist->tail->zl =
            ziplistPush(quicklist->tail->zl, value, sz, ZIPLIST_TAIL);
        quicklistNodeUpdateSz(quicklist->tail);
    } else {
        quicklistNode *node = quicklistCreateNode();
        node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_TAIL);

        quicklistNodeUpdateSz(node);
        _quicklistInsertNodeAfter(quicklist, quicklist->tail, node);
    }
    quicklist->count++;
    quicklist->tail->count++;
    return (orig_tail != quicklist->tail);
}

不管是在頭部還是尾部插入資料,都包含兩種情況:

  • 如果頭節點(或尾節點)上ziplist大小沒有超過限制(即_quicklistNodeAllowInsert回傳1),那么新資料被直接插入到ziplist中(呼叫ziplistPush),
  • 如果頭節點(或尾節點)上ziplist太大了,那么新創建一個quicklistNode節點(對應地也會新創建一個ziplist),然后把這個新創建的節點插入到quicklist雙向鏈表中(呼叫_quicklistInsertNodeAfter),

_quicklistInsertNodeAfter的實作中,還會根據list-compress-depth的配置將里面的節點進行壓縮,它的實作比較繁瑣,我們這里就不展開討論了,

quicklist的其它操作

quicklist的操作較多,且實作細節都比較繁雜,這里就不一一分析原始碼了,我們簡單介紹一些比較重要的操作,

quicklist的pop操作是呼叫quicklistPopCustom來實作的,quicklistPopCustom的實作程序基本上跟quicklistPush相反,先從頭部或尾部節點的ziplist中把對應的資料項洗掉,如果在洗掉后ziplist為空了,那么對應的頭部或尾部節點也要洗掉,洗掉后還可能涉及到里面節點的解壓縮問題,

quicklist不僅實作了從頭部或尾部插入,也實作了從任意指定的位置插入,quicklistInsertAfterquicklistInsertBefore就是分別在指定位置后面和前面插入資料項,這種在任意指定位置插入資料的操作,情況比較復雜,有眾多的邏輯分支,

  • 當插入位置所在的ziplist大小沒有超過限制時,直接插入到ziplist中就好了;
  • 當插入位置所在的ziplist大小超過了限制,但插入的位置位于ziplist兩端,并且相鄰的quicklist鏈表節點的ziplist大小沒有超過限制,那么就轉而插入到相鄰的那個quicklist鏈表節點的ziplist中;
  • 當插入位置所在的ziplist大小超過了限制,但插入的位置位于ziplist兩端,并且相鄰的quicklist鏈表節點的ziplist大小也超過限制,這時需要新創建一個quicklist鏈表節點插入,
  • 對于插入位置所在的ziplist大小超過了限制的其它情況(主要對應于在ziplist中間插入資料的情況),則需要把當前ziplist分裂為兩個節點,然后再其中一個節點上插入資料,

quicklistSetOptions用于設定ziplist大小配置引數(list-max-ziplist-size)和節點壓縮深度配置引數(list-compress-depth),代碼比較簡單,就是將相應的值分別設定給quicklist結構的fill欄位和compress欄位,

skiplist資料結構簡介

skiplist本質上也是一種查找結構,用于解決演算法中的查找問題(Searching),即根據給定的key,快速查到它所在的位置(或者對應的value),

我們在《Redis內部資料結構詳解》系列的第一篇中介紹dict的時候,曾經討論過:一般查找問題的解法分為兩個大類:一個是基于各種平衡樹,一個是基于哈希表,但skiplist卻比較特殊,它沒法歸屬到這兩大類里面,

這種資料結構是由William Pugh發明的,最早出現于他在1990年發表的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》,對細節感興趣的同學可以下載論文原文來閱讀,

skiplist,顧名思義,首先它是一個list,實際上,它是在有序鏈表的基礎上發展起來的,

我們先來看一個有序鏈表,如下圖(最左側的灰色節點表示一個空的頭結點):

有序鏈表結構圖

在這樣一個鏈表中,如果我們要查找某個資料,那么需要從頭開始逐個進行比較,直到找到包含資料的那個節點,或者找到第一個比給定資料大的節點為止(沒找到),也就是說,時間復雜度為O(n),同樣,當我們要插入新資料的時候,也要經歷同樣的查找程序,從而確定插入位置,

假如我們每相鄰兩個節點增加一個指標,讓指標指向下下個節點,如下圖:

每兩個節點增加一個跳躍指標的有序鏈表

這樣所有新增加的指標連成了一個新的鏈表,但它包含的節點個數只有原來的一半(上圖中是7, 19, 26),現在當我們想查找資料的時候,可以先沿著這個新鏈表進行查找,當碰到比待查資料大的節點時,再回到原來的鏈表中進行查找,比如,我們想查找23,查找的路徑是沿著下圖中標紅的指標所指向的方向進行的:

一個搜索路徑的例子

  • 23首先和7比較,再和19比較,比它們都大,繼續向后比較,
  • 但23和26比較的時候,比26要小,因此回到下面的鏈表(原鏈表),與22比較,
  • 23比22要大,沿下面的指標繼續向后和26比較,23比26小,說明待查資料23在原鏈表中不存在,而且它的插入位置應該在22和26之間,

在這個查找程序中,由于新增加的指標,我們不再需要與鏈表中每個節點逐個進行比較了,需要比較的節點數大概只有原來的一半,

利用同樣的方式,我們可以在上層新產生的鏈表上,繼續為每相鄰的兩個節點增加一個指標,從而產生第三層鏈表,如下圖:

兩層跳躍指標

在這個新的三層鏈表結構上,如果我們還是查找23,那么沿著最上層鏈表首先要比較的是19,發現23比19大,接下來我們就知道只需要到19的后面去繼續查找,從而一下子跳過了19前面的所有節點,可以想象,當鏈表足夠長的時候,這種多層鏈表的查找方式能讓我們跳過很多下層節點,大大加快查找的速度,

skiplist正是受這種多層鏈表的想法的啟發而設計出來的,實際上,按照上面生成鏈表的方式,上面每一層鏈表的節點個數,是下面一層的節點個數的一半,這樣查找程序就非常類似于一個二分查找,使得查找的時間復雜度可以降低到O(log n),但是,這種方法在插入資料的時候有很大的問題,新插入一個節點之后,就會打亂上下相鄰兩層鏈表上節點個數嚴格的2:1的對應關系,如果要維持這種對應關系,就必須把新插入的節點后面的所有節點(也包括新插入的節點)重新進行調整,這會讓時間復雜度重新蛻化成O(n),洗掉資料也有同樣的問題,

skiplist為了避免這一問題,它不要求上下相鄰兩層鏈表之間的節點個數有嚴格的對應關系,而是為每個節點隨機出一個層數(level),比如,一個節點隨機出的層數是3,那么就把它鏈入到第1層到第3層這三層鏈表中,為了表達清楚,下圖展示了如何通過一步步的插入操作從而形成一個skiplist的程序:

skiplist插入形成程序

從上面skiplist的創建和插入程序可以看出,每一個節點的層數(level)是隨機出來的,而且新插入一個節點不會影響其它節點的層數,因此,插入操作只需要修改插入節點前后的指標,而不需要對很多節點都進行調整,這就降低了插入操作的復雜度,實際上,這是skiplist的一個很重要的特性,這讓它在插入性能上明顯優于平衡樹的方案,這在后面我們還會提到,

根據上圖中的skiplist結構,我們很容易理解這種資料結構的名字的由來,skiplist,翻譯成中文,可以翻譯成“跳表”或“跳躍表”,指的就是除了最下面第1層鏈表之外,它會產生若干層稀疏的鏈表,這些鏈表里面的指標故意跳過了一些節點(而且越高層的鏈表跳過的節點越多),這就使得我們在查找資料的時候能夠先在高層的鏈表中進行查找,然后逐層降低,最終降到第1層鏈表來精確地確定資料位置,在這個程序中,我們跳過了一些節點,從而也就加快了查找速度,

剛剛創建的這個skiplist總共包含4層鏈表,現在假設我們在它里面依然查找23,下圖給出了查找路徑:

skiplist上的查找路徑展示

需要注意的是,前面演示的各個節點的插入程序,實際上在插入之前也要先經歷一個類似的查找程序,在確定插入位置后,再完成插入操作,

至此,skiplist的查找和插入操作,我們已經很清楚了,而洗掉操作與插入操作類似,我們也很容易想象出來,這些操作我們也應該能很容易地用代碼實作出來,

當然,實際應用中的skiplist每個節點應該包含key和value兩部分,前面的描述中我們沒有具體區分key和value,但實際上串列中是按照key進行排序的,查找程序也是根據key在比較,

但是,如果你是第一次接觸skiplist,那么一定會產生一個疑問:節點插入時隨機出一個層數,僅僅依靠這樣一個簡單的亂數操作而構建出來的多層鏈表結構,能保證它有一個良好的查找性能嗎?為了回答這個疑問,我們需要分析skiplist的統計性能,

在分析之前,我們還需要著重指出的是,執行插入操作時計算亂數的程序,是一個很關鍵的程序,它對skiplist的統計特性有著很重要的影響,這并不是一個普通的服從均勻分布的亂數,它的計算程序如下:

  • 首先,每個節點肯定都有第1層指標(每個節點都在第1層鏈表里),
  • 如果一個節點有第i層(i>=1)指標(即節點已經在第1層到第i層鏈表中),那么它有第(i+1)層指標的概率為p,
  • 節點最大的層數不允許超過一個最大值,記為MaxLevel,

這個計算隨機層數的偽碼如下所示:

randomLevel()
    level := 1
    // random()回傳一個[0...1)的亂數
    while random() < p and level < MaxLevel do
        level := level + 1
    return level

randomLevel()的偽碼中包含兩個引數,一個是p,一個是MaxLevel,在Redis的skiplist實作中,這兩個引數的取值為:

p = 1/4
MaxLevel = 32

skiplist的演算法性能分析

在這一部分,我們來簡單分析一下skiplist的時間復雜度和空間復雜度,以便對于skiplist的性能有一個直觀的了解,如果你不是特別偏執于演算法的性能分析,那么可以暫時跳過這一小節的內容,

我們先來計算一下每個節點所包含的平均指標數目(概率期望),節點包含的指標數目,相當于這個演算法在空間上的額外開銷(overhead),可以用來度量空間復雜度,

根據前面randomLevel()的偽碼,我們很容易看出,產生越高的節點層數,概率越低,定量的分析如下:

  • 節點層數至少為1,而大于1的節點層數,滿足一個概率分布,
  • 節點層數恰好等于1的概率為1-p,
  • 節點層數大于等于2的概率為p,而節點層數恰好等于2的概率為p(1-p),
  • 節點層數大于等于3的概率為p2,而節點層數恰好等于3的概率為p2(1-p),
  • 節點層數大于等于4的概率為p3,而節點層數恰好等于4的概率為p3(1-p),
  • ……

因此,一個節點的平均層數(也即包含的平均指標數目),計算如下:

skiplist平均層數計算

現在很容易計算出:

  • 當p=1/2時,每個節點所包含的平均指標數目為2;
  • 當p=1/4時,每個節點所包含的平均指標數目為1.33,這也是Redis里的skiplist實作在空間上的開銷,

接下來,為了分析時間復雜度,我們計算一下skiplist的平均查找長度,查找長度指的是查找路徑上跨越的跳數,而查找程序中的比較次數就等于查找長度加1,以前面圖中標出的查找23的查找路徑為例,從左上角的頭結點開始,一直到結點22,查找長度為6,

為了計算查找長度,這里我們需要利用一點小技巧,我們注意到,每個節點插入的時候,它的層數是由隨機函式randomLevel()計算出來的,而且隨機的計算不依賴于其它節點,每次插入程序都是完全獨立的,所以,從統計上來說,一個skiplist結構的形成與節點的插入順序無關,

這樣的話,為了計算查找長度,我們可以將查找程序倒過來看,從右下方第1層上最后到達的那個節點開始,沿著查找路徑向左向上回溯,類似于爬樓梯的程序,我們假設當回溯到某個節點的時候,它才被插入,這雖然相當于改變了節點的插入順序,但從統計上不影響整個skiplist的形成結構,

現在假設我們從一個層數為i的節點x出發,需要向左向上攀爬k層,這時我們有兩種可能:

  • 如果節點x有第(i+1)層指標,那么我們需要向上走,這種情況概率為p,
  • 如果節點x沒有第(i+1)層指標,那么我們需要向左走,這種情況概率為(1-p),

這兩種情形如下圖所示:

skiplist沿查找路徑回溯

用C(k)表示向上攀爬k個層級所需要走過的平均查找路徑長度(概率期望),那么:

C(0)=0
C(k)=(1-p)×(上圖中情況b的查找長度) + p×(上圖中情況c的查找長度)

代入,得到一個差分方程并化簡:

C(k)=(1-p)(C(k)+1) + p(C(k-1)+1)
C(k)=1/p+C(k-1)
C(k)=k/p

這個結果的意思是,我們每爬升1個層級,需要在查找路徑上走1/p步,而我們總共需要攀爬的層級數等于整個skiplist的總層數-1,

那么接下來我們需要分析一下當skiplist中有n個節點的時候,它的總層數的概率均值是多少,這個問題直觀上比較好理解,根據節點的層數隨機演算法,容易得出:

  • 第1層鏈表固定有n個節點;
  • 第2層鏈表平均有n*p個節點;
  • 第3層鏈表平均有n*p2個節點;

所以,從第1層到最高層,各層鏈表的平均節點數是一個指數遞減的等比數列,容易推算出,總層數的均值為log1/pn,而最高層的平均節點數為1/p,

綜上,粗略來計算的話,平均查找長度約等于:

  • C(log1/pn-1)=(log1/pn-1)/p

即,平均時間復雜度為O(log n),

當然,這里的時間復雜度分析還是比較粗略的,比如,沿著查找路徑向左向上回溯的時候,可能先到達左側頭結點,然后沿頭結點一路向上;還可能先到達最高層的節點,然后沿著最高層鏈表一路向左,但這些細節不影響平均時間復雜度的最后結果,另外,這里給出的時間復雜度只是一個概率平均值,但實際上計算一個精細的概率分布也是有可能的,詳情還請參見William Pugh的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》,

skiplist與平衡樹、哈希表的比較

  • skiplist和各種平衡樹(如AVL、紅黑樹等)的元素是有序排列的,而哈希表不是有序的,因此,在哈希表上只能做單個key的查找,不適宜做范圍查找,所謂范圍查找,指的是查找那些大小在指定的兩個值之間的所有節點,
  • 在做范圍查找的時候,平衡樹比skiplist操作要復雜,在平衡樹上,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點,如果不對平衡樹進行一定的改造,這里的中序遍歷并不容易實作,而在skiplist上進行范圍查找就非常簡單,只需要在找到小值之后,對第1層鏈表進行若干步的遍歷就可以實作,
  • 平衡樹的插入和洗掉操作可能引發子樹的調整,邏輯復雜,而skiplist的插入和洗掉只需要修改相鄰節點的指標,操作簡單又快速,
  • 從記憶體占用上來說,skiplist比平衡樹更靈活一些,一般來說,平衡樹每個節點包含2個指標(分別指向左右子樹),而skiplist每個節點包含的指標數目平均為1/(1-p),具體取決于引數p的大小,如果像Redis里的實作一樣,取p=1/4,那么平均每個節點包含1.33個指標,比平衡樹更有優勢,
  • 查找單個key,skiplist和平衡樹的時間復雜度都為O(log n),大體相當;而哈希表在保持較低的哈希值沖突概率的前提下,查找時間復雜度接近O(1),性能更高一些,所以我們平常使用的各種Map或dictionary結構,大都是基于哈希表實作的,
  • 從演算法實作難度上來比較,skiplist比平衡樹要簡單得多,

Redis中的skiplist實作

在這一部分,我們討論Redis中的skiplist實作,

在Redis中,skiplist被用于實作暴露給外部的一個資料結構:sorted set,準確地說,sorted set底層不僅僅使用了skiplist,還使用了ziplist和dict,這幾個資料結構的關系,我們下一章再討論,現在,我們先花點時間把sorted set的關鍵命令看一下,這些命令對于Redis里skiplist的實作,有重要的影響,

sorted set的命令舉例

sorted set是一個有序的資料集合,對于像類似排行榜這樣的應用場景特別適合,

現在我們來看一個例子,用sorted set來存盤代數課(algebra)的成績表,原始資料如下:

  • Alice 87.5
  • Bob 89.0
  • Charles 65.5
  • David 78.0
  • Emily 93.5
  • Fred 87.5

這份資料給出了每位同學的名字和分數,下面我們將這份資料存盤到sorted set里面去:

sorted set命令舉例

對于上面的這些命令,我們需要的注意的地方包括:

  • 前面的6個zadd命令,將6位同學的名字和分數(score)都輸入到一個key值為algebra的sorted set里面了,注意Alice和Fred的分數相同,都是87.5分,
  • zrevrank命令查詢Alice的排名(命令中的rev表示按照倒序排列,也就是從大到小),回傳3,排在Alice前面的分別是Emily、Bob、Fred,而排名(rank)從0開始計數,所以Alice的排名是3,注意,其實Alice和Fred的分數相同,這種情況下sorted set會把分數相同的元素,按照字典順序來排列,按照倒序,Fred排在了Alice的前面,
  • zscore命令查詢了Charles對應的分數,
  • zrevrange命令查詢了從大到小排名為0~3的4位同學,
  • zrevrangebyscore命令查詢了分數在80.0和90.0之間的所有同學,并按分數從大到小排列,

總結一下,sorted set中的每個元素主要表現出3個屬性:

  • 資料本身(在前面的例子中我們把名字存成了資料),
  • 每個資料對應一個分數(score),
  • 根據分數大小和資料本身的字典排序,每個資料會產生一個排名(rank),可以按正序或倒序,

Redis中skiplist實作的特殊性

我們簡單分析一下前面出現的幾個查詢命令:

  • zrevrank由資料查詢它對應的排名,這在前面介紹的skiplist中并不支持,
  • zscore由資料查詢它對應的分數,這也不是skiplist所支持的,
  • zrevrange根據一個排名范圍,查詢排名在這個范圍內的資料,這在前面介紹的skiplist中也不支持,
  • zrevrangebyscore根據分數區間查詢資料集合,是一個skiplist所支持的典型的范圍查找(score相當于key),

實際上,Redis中sorted set的實作是這樣的:

  • 當資料較少時,sorted set是由一個ziplist來實作的,
  • 當資料多的時候,sorted set是由一個dict + 一個skiplist來實作的,簡單來講,dict用來查詢資料到分數的對應關系,而skiplist用來根據分數查詢資料(可能是范圍查找),

這里sorted set的構成我們在下一章還會再詳細地討論,現在我們集中精力來看一下sorted set與skiplist的關系,:

  • zscore的查詢,不是由skiplist來提供的,而是由那個dict來提供的,
  • 為了支持排名(rank),Redis里對skiplist做了擴展,使得根據排名能夠快速查到資料,或者根據分數查到資料之后,也同時很容易獲得排名,而且,根據排名的查找,時間復雜度也為O(log n),
  • zrevrange的查詢,是根據排名查資料,由擴展后的skiplist來提供,
  • zrevrank是先在dict中由資料查到分數,再拿分數到skiplist中去查找,查到后也同時獲得了排名,

前述的查詢程序,也暗示了各個操作的時間復雜度:

  • zscore只用查詢一個dict,所以時間復雜度為O(1)
  • zrevrank, zrevrange, zrevrangebyscore由于要查詢skiplist,所以zrevrank的時間復雜度為O(log n),而zrevrange, zrevrangebyscore的時間復雜度為O(log(n)+M),其中M是當前查詢回傳的元素個數,

總結起來,Redis中的skiplist跟前面介紹的經典的skiplist相比,有如下不同:

  • 分數(score)允許重復,即skiplist的key允許重復,這在最開始介紹的經典skiplist中是不允許的,
  • 在比較時,不僅比較分數(相當于skiplist的key),還比較資料本身,在Redis的skiplist實作中,資料本身的內容唯一標識這份資料,而不是由key來唯一標識,另外,當多個元素分數相同的時候,還需要根據資料內容來進字典排序,
  • 第1層鏈表不是一個單向鏈表,而是一個雙向鏈表,這是為了方便以倒序方式獲取一個范圍內的元素,
  • 在skiplist中可以很方便地計算出每個元素的排名(rank),

skiplist的資料結構定義

#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25

typedef struct zskiplistNode {
    robj *obj;
    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;

這段代碼出自server.h,我們來簡要分析一下:

  • 開頭定義了兩個常量,ZSKIPLIST_MAXLEVEL和ZSKIPLIST_P,分別對應我們前面講到的skiplist的兩個引數:一個是MaxLevel,一個是p,
  • zskiplistNode定義了skiplist的節點結構,
    • obj欄位存放的是節點資料,它的型別是一個string robj,本來一個string robj可能存放的不是sds,而是long型,但zadd命令在將資料插入到skiplist里面之前先進行了解碼,所以這里的obj欄位里存盤的一定是一個sds,有關robj的詳情可以參見系列文章的第三篇:《Redis內部資料結構詳解(3)——robj》,這樣做的目的應該是為了方便在查找的時候對資料進行字典序的比較,而且,skiplist里的資料部分是數字的可能性也比較小,
    • score欄位是資料對應的分數,
    • backward欄位是指向鏈表前一個節點的指標(前向指標),節點只有1個前向指標,所以只有第1層鏈表是一個雙向鏈表,
    • level[]存放指向各層鏈表后一個節點的指標(后向指標),每層對應1個后向指標,用forward欄位表示,另外,每個后向指標還對應了一個span值,它表示當前的指標跨越了多少個節點,span用于計算元素排名(rank),這正是前面我們提到的Redis對于skiplist所做的一個擴展,需要注意的是,level[]是一個柔性陣列(flexible array member),因此它占用的記憶體不在zskiplistNode結構里面,而需要插入節點的時候單獨為它分配,也正因為如此,skiplist的每個節點所包含的指標數目才是不固定的,我們前面分析過的結論——skiplist每個節點包含的指標數目平均為1/(1-p)——才能有意義,
  • zskiplist定義了真正的skiplist結構,它包含:
    • 頭指標header和尾指標tail,
    • 鏈表長度length,即鏈表包含的節點總數,注意,新創建的skiplist包含一個空的頭指標,這個頭指標不包含在length計數中,
    • level表示skiplist的總層數,即所有節點層數的最大值,

下圖以前面插入的代數課成績表為例,展示了Redis中一個skiplist的可能結構:

Redis skiplist結構舉例

注意:圖中前向指標上面括號中的數字,表示對應的span的值,即當前指標跨越了多少個節點,這個計數不包括指標的起點節點,但包括指標的終點節點,

假設我們在這個skiplist中查找score=89.0的元素(即Bob的成績資料),在查找路徑中,我們會跨域圖中標紅的指標,這些指標上面的span值累加起來,就得到了Bob的排名(2+2+1)-1=4(減1是因為rank值以0起始),需要注意這里算的是從小到大的排名,而如果要算從大到小的排名,只需要用skiplist長度減去查找路徑上的span累加值,即6-(2+2+1)=1,

可見,在查找skiplist的程序中,通過累加span值的方式,我們就能很容易算出排名,相反,如果指定排名來查找資料(類似zrange和zrevrange那樣),也可以不斷累加span并時刻保持累加值不超過指定的排名,通過這種方式就能得到一條O(log n)的查找路徑,

Redis中的sorted set

我們前面提到過,Redis中的sorted set,是在skiplist, dict和ziplist基礎上構建起來的:

  • 當資料較少時,sorted set是由一個ziplist來實作的,
  • 當資料多的時候,sorted set是由一個叫zset的資料結構來實作的,這個zset包含一個dict + 一個skiplist,dict用來查詢資料到分數(score)的對應關系,而skiplist用來根據分數查詢資料(可能是范圍查找),

在這里我們先來討論一下前一種情況——基于ziplist實作的sorted set,在本系列前面關于ziplist的文章里,我們介紹過,ziplist就是由很多資料項組成的一大塊連續記憶體,由于sorted set的每一項元素都由資料和score組成,因此,當使用zadd命令插入一個(資料, score)對的時候,底層在相應的ziplist上就插入兩個資料項:資料在前,score在后,

ziplist的主要優點是節省記憶體,但它上面的查找操作只能按順序查找(可以正序也可以倒序),因此,sorted set的各個查詢操作,就是在ziplist上從前向后(或從后向前)一步步查找,每一步前進兩個資料項,跨域一個(資料, score)對,

隨著資料的插入,sorted set底層的這個ziplist就可能會轉成zset的實作(轉換程序詳見t_zset.c的zsetConvert),那么到底插入多少才會轉呢?

還記得本文開頭提到的兩個Redis配置嗎?

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

這個配置的意思是說,在如下兩個條件之一滿足的時候,ziplist會轉成zset(具體的觸發條件參見t_zset.c中的zaddGenericCommand相關代碼):

  • 當sorted set中的元素個數,即(資料, score)對的數目超過128的時候,也就是ziplist資料項超過256的時候,
  • 當sorted set中插入的任意一個資料的長度超過了64的時候,

最后,zset結構的代碼定義如下:

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

Redis為什么用skiplist而不用平衡樹?

在前面我們對于skiplist和平衡樹、哈希表的比較中,其實已經不難看出Redis里使用skiplist而不用平衡樹的原因了,現在我們看看,對于這個問題,Redis的作者 @antirez 是怎么說的:

There are a few reasons:

  1. They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.

  2. A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.

  3. They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.

這段話原文出處:

https://news.ycombinator.com/item?id=1171423

這里從記憶體占用、對范圍查找的支持和實作難易程度這三方面總結的原因,我們在前面其實也都涉及到了,


轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/458201.html

標籤:Java

上一篇:Java-GUI編程之ImageIO的使用

下一篇:Java 從零開始實作一個畫圖板、以及影像處理功能,代碼可復現

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 【C++】Microsoft C++、C 和匯編程式檔案

    ......

    uj5u.com 2020-09-10 00:57:23 more
  • 例外宣告

    相比于斷言適用于排除邏輯上不可能存在的狀態,例外通常是用于邏輯上可能發生的錯誤。 例外宣告 Item 1:當函式不可能拋出例外或不能接受拋出例外時,使用noexcept 理由 如果不打算拋出例外的話,程式就會認為無法處理這種錯誤,并且應當盡早終止,如此可以有效地阻止例外的傳播與擴散。 示例 //不可 ......

    uj5u.com 2020-09-10 00:57:27 more
  • Codeforces 1400E Clear the Multiset(貪心 + 分治)

    鏈接:https://codeforces.com/problemset/problem/1400/E 來源:Codeforces 思路:給你一個陣列,現在你可以進行兩種操作,操作1:將一段沒有 0 的區間進行減一的操作,操作2:將 i 位置上的元素歸零。最終問:將這個陣列的全部元素歸零后操作的最少 ......

    uj5u.com 2020-09-10 00:57:30 more
  • UVA11610 【Reverse Prime】

    本人看到此題沒有翻譯,就附帶了一個自己的翻譯版本 思考 這一題,它的第一個要求是找出所有 $7$ 位反向質數及其質因數的個數。 我們應該需要質數篩篩選1~$10^{7}$的所有數,這里就不慢慢介紹了。但是,重讀題,我們突然發現反向質數都是 $7$ 位,而將它反過來后的數字卻是 $6$ 位數,這就說明 ......

    uj5u.com 2020-09-10 00:57:36 more
  • 統計區間素數數量

    1 #pragma GCC optimize(2) 2 #include <bits/stdc++.h> 3 using namespace std; 4 bool isprime[1000000010]; 5 vector<int> prime; 6 inline int getlist(int ......

    uj5u.com 2020-09-10 00:57:47 more
  • C/C++編程筆記:C++中的 const 變數詳解,教你正確認識const用法

    1、C中的const 1、區域const變數存放在堆疊區中,會分配記憶體(也就是說可以通過地址間接修改變數的值)。測驗代碼如下: 運行結果: 2、全域const變數存放在只讀資料段(不能通過地址修改,會發生寫入錯誤), 默認為外部聯編,可以給其他源檔案使用(需要用extern關鍵字修飾) 運行結果: ......

    uj5u.com 2020-09-10 00:58:04 more
  • 【C++犯錯記錄】VS2019 MFC添加資源不懂如何修改資源宏ID

    1. 首先在資源視圖中,添加資源 2. 點擊新添加的資源,復制自動生成的ID 3. 在解決方案資源管理器中找到Resource.h檔案,編輯,使用整個專案搜索和替換的方式快速替換 宏宣告 4. Ctrl+Shift+F 全域搜索,點擊查找全部,然后逐個替換 5. 為什么使用搜索替換而不使用屬性視窗直 ......

    uj5u.com 2020-09-10 00:59:11 more
  • 【C++犯錯記錄】VS2019 MFC不懂的批量添加資源

    1. 打開資源頭檔案Resource.h,在其中預先定義好宏 ID(不清楚其實ID值應該設定多少,可以先新建一個相同的資源項,再在這個資源的ID值的基礎上遞增即可) 2. 在資源視圖中選中專案資源,按F7編輯資源檔案,按 ID 型別 相對路徑的形式添加 資源。(別忘了先把檔案拷貝到專案中的res檔案 ......

    uj5u.com 2020-09-10 01:00:19 more
  • C/C++編程筆記:關于C++的參考型別,專供新手入門使用

    今天要講的是C++中我最喜歡的一個用法——參考,也叫別名。 參考就是給一個變數名取一個變數名,方便我們間接地使用這個變數。我們可以給一個變數創建N個參考,這N + 1個變數共享了同一塊記憶體區域。(參考型別的變數會占用記憶體空間,占用的記憶體空間的大小和指標型別的大小是相同的。雖然參考是一個物件的別名,但 ......

    uj5u.com 2020-09-10 01:00:22 more
  • 【C/C++編程筆記】從頭開始學習C ++:初學者完整指南

    眾所周知,C ++的學習曲線陡峭,但是花時間學習這種語言將為您的職業帶來奇跡,并使您與其他開發人員區分開。您會更輕松地學習新語言,形成真正的解決問題的技能,并在編程的基礎上打下堅實的基礎。 C ++將幫助您養成良好的編程習慣(即清晰一致的編碼風格,在撰寫代碼時注釋代碼,并限制類內部的可見性),并且由 ......

    uj5u.com 2020-09-10 01:00:41 more
最新发布
  • Rust中的智能指標:Box<T> Rc<T> Arc<T> Cell<T> RefCell<T> Weak

    Rust中的智能指標是什么 智能指標(smart pointers)是一類資料結構,是擁有資料所有權和額外功能的指標。是指標的進一步發展 指標(pointer)是一個包含記憶體地址的變數的通用概念。這個地址參考,或 ” 指向”(points at)一些其 他資料 。參考以 & 符號為標志并借用了他們所 ......

    uj5u.com 2023-04-20 07:24:10 more
  • Java的值傳遞和參考傳遞

    值傳遞不會改變本身,參考傳遞(如果傳遞的值需要實體化到堆里)如果發生修改了會改變本身。 1.基本資料型別都是值傳遞 package com.example.basic; public class Test { public static void main(String[] args) { int ......

    uj5u.com 2023-04-20 07:24:04 more
  • [2]SpinalHDL教程——Scala簡單入門

    第一個 Scala 程式 shell里面輸入 $ scala scala> 1 + 1 res0: Int = 2 scala> println("Hello World!") Hello World! 檔案形式 object HelloWorld { /* 這是我的第一個 Scala 程式 * 以 ......

    uj5u.com 2023-04-20 07:23:58 more
  • 理解函式指標和回呼函式

    理解 函式指標 指向函式的指標。比如: 理解函式指標的偽代碼 void (*p)(int type, char *data); // 定義一個函式指標p void func(int type, char *data); // 宣告一個函式func p = func; // 將指標p指向函式func ......

    uj5u.com 2023-04-20 07:23:52 more
  • Django筆記二十五之資料庫函式之日期函式

    本文首發于公眾號:Hunter后端 原文鏈接:Django筆記二十五之資料庫函式之日期函式 日期函式主要介紹兩個大類,Extract() 和 Trunc() Extract() 函式作用是提取日期,比如我們可以提取一個日期欄位的年份,月份,日等資料 Trunc() 的作用則是截取,比如 2022-0 ......

    uj5u.com 2023-04-20 07:23:45 more
  • 一天吃透JVM面試八股文

    什么是JVM? JVM,全稱Java Virtual Machine(Java虛擬機),是通過在實際的計算機上仿真模擬各種計算機功能來實作的。由一套位元組碼指令集、一組暫存器、一個堆疊、一個垃圾回收堆和一個存盤方法域等組成。JVM屏蔽了與作業系統平臺相關的資訊,使得Java程式只需要生成在Java虛擬機 ......

    uj5u.com 2023-04-20 07:23:31 more
  • 使用Java接入小程式訂閱訊息!

    更新完微信服務號的模板訊息之后,我又趕緊把微信小程式的訂閱訊息給實作了!之前我一直以為微信小程式也是要企業才能申請,沒想到小程式個人就能申請。 訊息推送平臺🔥推送下發【郵件】【短信】【微信服務號】【微信小程式】【企業微信】【釘釘】等訊息型別。 https://gitee.com/zhongfuch ......

    uj5u.com 2023-04-20 07:22:59 more
  • java -- 緩沖流、轉換流、序列化流

    緩沖流 緩沖流, 也叫高效流, 按照資料型別分類: 位元組緩沖流:BufferedInputStream,BufferedOutputStream 字符緩沖流:BufferedReader,BufferedWriter 緩沖流的基本原理,是在創建流物件時,會創建一個內置的默認大小的緩沖區陣列,通過緩沖 ......

    uj5u.com 2023-04-20 07:22:49 more
  • Java-SpringBoot-Range請求頭設定實作視頻分段傳輸

    老實說,人太懶了,現在基本都不喜歡寫筆記了,但是網上有關Range請求頭的文章都太水了 下面是抄的一段StackOverflow的代碼...自己大修改過的,寫的注釋挺全的,應該直接看得懂,就不解釋了 寫的不好...只是希望能給視頻網站開發的新手一點點幫助吧. 業務場景:視頻分段傳輸、視頻多段傳輸(理 ......

    uj5u.com 2023-04-20 07:22:42 more
  • Windows 10開發教程_編程入門自學教程_菜鳥教程-免費教程分享

    教程簡介 Windows 10開發入門教程 - 從簡單的步驟了解Windows 10開發,從基本到高級概念,包括簡介,UWP,第一個應用程式,商店,XAML控制元件,資料系結,XAML性能,自適應設計,自適應UI,自適應代碼,檔案管理,SQLite資料庫,應用程式到應用程式通信,應用程式本地化,應用程式 ......

    uj5u.com 2023-04-20 07:22:35 more