redis續集

来源:https://www.cnblogs.com/ITjieduwu/archive/2022/04/14/16138600.html
-Advertisement-
Play Games

SDS (Simple Dynamic String)是 Redis 最基礎的數據結構。直譯過來就是”簡單的動態字元串“。Redis 自己實現了一個動態的字元串,而不是直接使用了 C 語言中的字元串。 sds 的數據結構: struct sdshdr { // buf 中已占用空間的長度 int l ...


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 = 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 = 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)。

實際上,ha

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • io流主要應用在各種腳本的開發列如,一個目錄爬行要去使用字典文件,還可以用來進行文件加密。後面可以深入研究一下序列化漏洞 ...
  • 在直播、語聊房、K 歌房場景中,為增加趣味性和互動性,玩家可以通過變聲來搞怪,通過混響烘托氣氛,通過立體聲使聲音更具立體感。ZegoExpress SDK 提供了多種預設的變聲、混響、混響回聲、立體聲效果,開發者可以靈活設置自己想要的聲音,在通話或直播過程中動態調整變聲、混響、混響回聲、虛擬立體聲,... ...
  • 一、profile的多文檔配置方式 1、profile文件方式:提供多個配置文件,每個代表一種環境 如: 1.application-dev.properties/yml 開發環境 2.application-test.properties/yml 測試環境 3.application-pro.pr ...
  • Java自增 本文分為以下部分: 慄子 慄子解釋 來點複雜的 位元組碼解讀 總結 慄子 java存在一種神奇的操作符,,自增1,但是經常分不清楚 **i** 和**++i** 兩者的區別,雖然最後結果可能都是 i+1,但是在不同場景使用有不同效果。先上一段代碼。 public class Increa ...
  • 今天為大家介紹使用 mitmproxy 這個抓包工具如何監控手機上網,並且通過抓包,把我們想要的數據下載下來。 啟動 mitmproxy 首先我們通過執行命令 mitmweb 啟動mitmproxy,讓它處理監聽狀態,服務會監聽本機 8080 埠,啟動後如下: Python學習交流Q群:90671 ...
  • 前言 今天這個案例,就是控制自己的攝像頭拍照,並且把拍下來的照片,通過郵件發到自己的郵箱里。想完成今天的這個案例,只要記住一個重點:你需要一個攝像頭 思路 通過opencv調用攝像頭拍照保存圖像本地 用email庫構造郵件內容,保存的圖像以附件形式插入郵件內容 用smtplib庫發送郵件到指定郵箱 ...
  • 老闆最近越來越過分了,快下班了發給我幾百個表格讓我把內容合併到一個表格內去。 還好我會Python,分分鐘就搞定了,這要是換個不會Python的,不得加班到第二天天亮去了~ 這麼好用的技能,必須分享給大家,話不多說,咱們直接開始! 準備工作 咱們需要先準備表格數據,會爬蟲的兄弟可以自己爬一點,不會的 ...
  • #Java 從零開始實現一個畫圖板、以及圖像處理功能,代碼可復現 這是一個學習分享博客,帶你從零開始實現一個畫圖板、圖像處理的小項目,為了降低閱讀難度,本博客將畫圖板的一步步迭代優化過程展示給讀者,篇幅較長,Java初學者可放心食用。(文末有源代碼) ##本博客實現的功能(根據本文講解的順序) 直線 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...