Redis內部資料結構詳解(5)——quicklist

張鐵蕾發表於2016-07-22

本文是《Redis內部資料結構詳解》系列的第五篇。在本文中,我們介紹一個Redis內部資料結構——quicklist。Redis對外暴露的list資料型別,它底層實現所依賴的內部資料結構就是quicklist。

我們在討論中還會涉及到兩個Redis配置(在redis.conf中的ADVANCED CONFIG部分):

list-max-ziplist-size -2
list-compress-depth 0複製程式碼

我們在討論中會詳細解釋這兩個配置的含義。

注:本文討論的quicklist實現基於Redis原始碼的3.2分支。

quicklist概述

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

  • lpush: 在左側(即列表頭部)插入資料。
  • rpop: 在右側(即列表尾部)刪除資料。
  • rpush: 在右側(即列表尾部)插入資料。
  • lpop: 在左側(即列表頭部)刪除資料。

這些操作都是O(1)時間複雜度的。

當然,list也支援在任意中間位置的存取操作,比如lindexlinsert,但它們都需要對list進行遍歷,所以時間複雜度較高。

概況起來,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的總大小(包括zlbytes, zltail, zllen, zlend和各個資料項)。需要注意的是:如果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引數的值。

上圖是一個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和它所支撐的Redis資料型別sorted set,敬請期待。

相關文章