Redis核心原理與實踐--列表實現原理之ziplist

binecy發表於2021-09-16

列表型別可以儲存一組按插入順序排序的字串,它非常靈活,支援在兩端插入、彈出資料,可以充當棧和佇列的角色。

> LPUSH fruit apple
(integer) 1
> RPUSH fruit banana
(integer) 2
> RPOP fruit
"banana"
> LPOP fruit
"apple"

本文探討Redis中列表型別的實現。

ziplist

使用陣列和連結串列結構都可以實現列表型別。Redis中使用的是連結串列結構。下面是一種常見的連結串列實現方式adlist.h:

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

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;

Redis內部使用該連結串列儲存執行資料,如主服務下所有的從伺服器資訊。
但Redis並不使用該連結串列儲存使用者列表資料,因為它對記憶體管理不夠友好:
(1)連結串列中每一個節點都佔用獨立的一塊記憶體,導致記憶體碎片過多。
(2)連結串列節點中前後節點指標佔用過多的額外記憶體。
讀者可以思考一下,用什麼結構可以比較好地解決上面的兩個問題?沒錯,陣列。ziplist是一種類似陣列的緊湊型連結串列格式。它會申請一整塊記憶體,在這個記憶體上存放該連結串列所有資料,這就是ziplist的設計思想。

定義

ziplist總體佈局如下:

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
  • zlbytes:uint32_t,記錄整個ziplist佔用的位元組數,包括zlbytes佔用的4位元組。
  • zltail:uint32_t,記錄從ziplist起始位置到最後一個節點的偏移量,用於支援連結串列從尾部彈出或反向(從尾到頭)遍歷連結串列。
  • zllen:uint16_t,記錄節點數量,如果存在超過216-2個節點,則這個值設定為216-1,這時需要遍歷整個ziplist獲取真正的節點數量。
  • zlend:uint8_t,一個特殊的標誌節點,等於255,標誌ziplist結尾。其他節點資料不會以255開頭。

entry就是ziplist中儲存的節點。entry的格式如下:

<prevlen> <encoding> <entry-data>
  • entry-data:該節點元素,即節點儲存的資料。
  • prevlen:記錄前驅節點長度,單位為位元組,該屬性長度為1位元組或5位元組。
    ① 如果前驅節點長度小於254,則使用1位元組儲存前驅節點長度。
    ② 否則,使用5位元組,並且第一個位元組固定為254,剩下4個位元組儲存前驅節點長度。
  • encoding:代表當前節點元素的編碼格式,包含編碼型別和節點長度。一個ziplist中,不同節點元素的編碼格式可以不同。編碼格式規範如下:
    ① 00pppppp(pppppp代表encoding的低6位,下同):字串編碼,長度小於或等於63(26-1),長度存放在encoding的低6位中。
    ② 01pppppp:字串編碼, 長度小於或等於16383(214-1),長度存放在encoding的後6位和encoding後1位元組中。
    ③ 10000000:字串編碼,長度大於16383(214-1),長度存放在encoding後4位元組中。
    ④ 11000000:數值編碼, 型別為int16_t,佔用2位元組。
    ⑤ 11010000:數值編碼,型別為int32_t,佔用4位元組。
    ⑥ 11100000:數值編碼,型別為int64_t,佔用8位元組。
    ⑦ 11110000:數值編碼,使用3位元組儲存一個整數。
    ⑧ 11111110:數值編碼,使用1位元組儲存一個整數。
    ⑨ 1111xxxx:使用encoding低4位儲存一個整數,儲存數值範圍為0~12。該編碼下encoding低4位的可用範圍為0001~1101,encoding低4位減1為實際儲存的值。
    ⑩ 11111111:255,ziplist結束節點。
    注意第②、③種編碼格式,除了encoding屬性,還需要額外的空間儲存節點元素長度。第⑨種格式也比較特殊,節點元素直接存放在encoding屬性上。該編碼是針對小數字的優化。這時entry-data為空。

位元組序

encoding屬性使用多個位元組儲存節點元素長度,這種多位元組資料儲存在計算機記憶體中或者進行網路傳輸時的位元組順序稱為位元組序,位元組序有兩種型別:大端位元組序和小端位元組序。

  • 大端位元組序:低位元組資料儲存在記憶體高地址位置,高位元組資料儲存在記憶體低地址位置。
  • 小端位元組序:低位元組資料儲存在記憶體低地址位置,高位元組資料儲存在記憶體高地址位置。

數值0X44332211的大端位元組序和小端位元組序儲存方式如圖2-1所示。
圖2-1

CPU處理指令通常是按照記憶體地址增長方向執行的。使用小端位元組序,CPU可以先讀取並處理低位位元組,執行計算的借位、進位操作時效率更高。大端位元組序則更符合人們的讀寫習慣。
ziplist採取的是小端位元組序。
下面是Redis提供的一個簡單例子:

  • [0f 00 00 00]:zlbytes為15,代表整個ziplist佔用15位元組,注意該數值以小端位元組序儲存。
  • [0c 00 00 00]:zltail為12,代表從ziplist起始位置到最後一個節點([02 f6])的偏移量。
  • [02 00]:zllen為2,代表ziplist中有2個節點。
  • [00 f3]:00代表前一個節點長度,f3使用了encoding第⑨種編碼格式,儲存資料為encoding低4位減1,即2。
  • [02 f6]:02代表前一個節點長度為2位元組,f5編碼格式同上,儲存資料為5。
  • [ff]:結束標誌節點。
    ziplist是Redis中比較複雜的資料結構,希望讀者結合上述屬性說明和例子,理解ziplist中資料的存放格式。

操作分析

提示:本節以下程式碼如無特殊說明,均在ziplist.h、ziplist.c中。

ziplistFind函式負責在ziplist中查詢元素:

unsigned char *ziplistFind(unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip) {
    int skipcnt = 0;
    unsigned char vencoding = 0;
    long long vll = 0;

    while (p[0] != ZIP_END) {
        unsigned int prevlensize, encoding, lensize, len;
        unsigned char *q;
        // [1]
        ZIP_DECODE_PREVLENSIZE(p, prevlensize);
        // [2]
        ZIP_DECODE_LENGTH(p + prevlensize, encoding, lensize, len);
        q = p + prevlensize + lensize;

        if (skipcnt == 0) {
            // [3]
            if (ZIP_IS_STR(encoding)) {
                if (len == vlen && memcmp(q, vstr, vlen) == 0) {
                    return p;
                }
            } else {
                // [4]
                if (vencoding == 0) {
                    if (!zipTryEncoding(vstr, vlen, &vll, &vencoding)) {
                        vencoding = UCHAR_MAX;
                    }
                    assert(vencoding);
                }

                // [5]
                if (vencoding != UCHAR_MAX) {
                    long long ll = zipLoadInteger(q, encoding);
                    if (ll == vll) {
                        return p;
                    }
                }
            }

            // [6]
            skipcnt = skip;
        } else {
            skipcnt--;
        }

        // [7]
        p = q + len;
    }

    return NULL;
}

引數說明:

  • p:指定從ziplist哪個節點開始查詢。
  • vstr、vlen:待查詢元素的內容和長度。
  • skip:間隔多少個節點才執行一次元素對比操作。

【1】計算當前節點prevlen屬性長度是1位元組還是5位元組,結果存放在prevlensize變數中。
【2】計算當前節點相關屬性,結果存放在如下變數中:
encoding:節點編碼格式。
lensize:額外存放節點元素長度的位元組數,第②、③種格式的encoding編碼需要額外的空間存放節點元素長度。
len:節點元素的長度。
【3】如果當前節點元素是字串編碼,則對比String的內容,若相等則返回。
【4】當前節點元素是數值編碼,並且還沒有對待查詢內容vstr進行編碼,則對它進行編碼操作(編碼操作只執行一次),編碼後的數值儲存在vll變數中。
【5】如果上一步編碼成功(待查詢內容也是數值),則對比編碼後的結果,否則不需要對比編碼結果。zipLoadInteger函式從節點元素中提取節點儲存的數值,與上一步得到的vll變數進行對比。
【6】skipcnt不為0,直接跳過節點並將skipcnt減1,直到skipcnt為0才對比資料。
【7】p指向p + prevlensize + lensize + len(資料長度),得到下一個節點的起始位置。

提示:由於原始碼中部分函式太長,為了版面整潔,本書將其劃分為多個程式碼段,並使用“// more”標誌該函式後續還有其他程式碼段,請讀者留意該標誌。

下面看一下如何在ziplist中插入節點:

unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
    ...
    // [1]
    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);
        }
    }

    // [2]
    if (zipTryEncoding(s,slen,&value,&encoding)) {
        reqlen = zipIntSize(encoding);
    } else {
        reqlen = slen;
    }

    // [3]
    reqlen += zipStorePrevEntryLength(NULL,prevlen);
    reqlen += zipStoreEntryEncoding(NULL,encoding,slen);

    // [4]
    int forcelarge = 0;
    nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
    if (nextdiff == -4 && reqlen < 4) {
        nextdiff = 0;
        forcelarge = 1;
    }

    // more
}

引數說明:

  • zl:待插入ziplist。
  • p:指向插入位置的後驅節點。
  • s、slen:待插入元素的內容和長度。

【1】計算前驅節點長度並存放到prevlen變數中。
如果p沒有指向ZIP_END,則可以直接取p節點的prevlen屬性,否則需要通過ziplist.zltail找到前驅節點,再獲取前驅節點的長度。
【2】對待插入元素的內容進行編碼,並將內容的長度存放在reqlen變數中。
zipTryEncoding函式嘗試將元素內容編碼為數值,如果元素內容能編碼為數值,則該函式返回1,這時value指向編碼後的值,encoding儲存對應編碼格式,否則返回0。
【3】zipStorePrevEntryLength函式計算prevlen屬性的長度(1位元組或5位元組)。
zipStoreEntryEncoding函式計算額外存放節點元素長度所需位元組數(encoding編碼中第②、③種格式)。reqlen變數值新增這兩個函式的返回值後成為插入節點長度。
【4】zipPrevLenByteDiff函式計算後驅節點prevlen屬性長度需調整多少個位元組,結果存放在nextdiff變數中。

假如p指向節點為e2,而插入前e2的前驅節點為e1,e2的prevlen儲存e1的長度。
插入後e2的前驅節點為插入節點,這時e2的prevlen應該儲存插入節點長度,所以e2的prevlen需要修改。圖2-2展示了一個簡單示例。
圖2-2

從圖2-2可以看到,後驅節點e2的prevlen屬性長度從1變成了5,則nextdiff變數為4。
如果插入節點長度小於4,並且原後驅節點e2的prevlen屬性長度為5,則這時設定forcelarge為1,代表強制保持後驅節點e2的prevlen屬性長度不變。讀者可以思考一下,為什麼要這樣設計?
繼續分析__ziplistInsert函式:

unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
    ...
    // [5]
    offset = p-zl;
    zl = ziplistResize(zl,curlen+reqlen+nextdiff);
    p = zl+offset;

    if (p[0] != ZIP_END) {
        // [6]
        memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);

        // [7]
        if (forcelarge)
            zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
        else
            zipStorePrevEntryLength(p+reqlen,reqlen);

        // [8]
        ZIPLIST_TAIL_OFFSET(zl) =
            intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
        // [9]
        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 {
        // [10]
        ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
    }

    // [11]
    if (nextdiff != 0) {
        offset = p-zl;
        zl = __ziplistCascadeUpdate(zl,p+reqlen);
        p = zl+offset;
    }

    // [12]
    p += zipStorePrevEntryLength(p,prevlen);
    p += zipStoreEntryEncoding(p,encoding,slen);
    if (ZIP_IS_STR(encoding)) {
        memcpy(p,s,slen);
    } else {
        zipSaveInteger(p,value,encoding);
    }
    // [13]
    ZIPLIST_INCR_LENGTH(zl,1);
    return zl;
}

【5】重新為ziplist分配記憶體,主要是為插入節點申請空間。新ziplist的記憶體大小為curlen+reqlen+nextdiff(curlen變數為插入前ziplist長度)。將p重新賦值為zl+offset(offset變數為插入節點的偏移量),是因為ziplistResize函式可能會為ziplist申請新的記憶體地址。
下面針對存在後驅節點的場景進行處理。
【6】將插入位置後面所有的節點後移,為插入節點騰出空間。移動空間的起始地址為p-nextdiff,減去nextdiff是因為後驅節點的prevlen屬性需要調整nextdiff長度。移動空間的長度為curlen-offset-1+nextdiff,減1是因為最後的結束標誌節點已經在ziplistResize函式中設定了。
memmove是C語言提供的記憶體移動函式。
【7】修改後驅節點的prevlen屬性。
【8】更新ziplist.zltail,將其加上reqlen的值。
【9】如果存在多個後驅節點,則ziplist.zltail還要加上nextdiff的值。
如果只有一個後驅節點,則不需要加上nextdiff,因為這時後驅節點大小變化了nextdiff,但後驅節點只移動了reqlen。

提示:zipEntry函式會將給定節點的所有資訊賦值到zlentry結構體中。zlentry結構體用於在計算過程中存放節點資訊,實際儲存資料格式並不使用該結構體。讀者不要被tail這個變數名誤導,它只是指向插入節點的後驅節點,並不一定指向尾節點。

【10】這裡針對不存在後驅節點的場景進行處理,只需更新最後一個節點偏移量ziplist.zltail。
【11】級聯更新。
【12】寫入插入資料。
【13】更新ziplist節點數量ziplist.zllen。

解釋一下以下程式碼:

ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);

intrev32ifbe函式完成以下工作:如果主機使用的小端位元組序,則不做處理。如果主機使用的大端位元組序,則反轉資料位元組序(資料第1位與第4位、第2位與第3位交換),這樣會將大端位元組序資料轉化為小端位元組序,或者將小端位元組序資料轉化為大端位元組序。
在上面的程式碼中,如果主機CPU使用的是小端位元組序,則intrev32ifbe函式不做任何處理。
如果主機CPU使用的是大端位元組序,則從記憶體取出資料後,先呼叫intrev32ifbe函式將資料轉化為大端位元組序後再計算。計算完成後,呼叫intrev32ifbe函式將資料轉化為小端位元組序後再存入記憶體。

級聯更新

例2-1:
考慮一種極端場景,在ziplist的e2節點前插入一個新的節點ne,元素資料長度為254,如圖2-3所示。
圖2-3

插入節點如圖2-4所示。
圖2-4

插入節點後e2的prevlen屬性長度需要更新為5位元組。
注意e3的prevlen,插入前e2的長度為253,所以e3的prevlen屬性長度為1位元組,插入新節點後,e2的長度為257,那麼e3的prevlen屬性長度也要更新了,這就是級聯更新。在極端情況下,e3後續的節點也要繼續更新prevlen屬性。
我們看一下級聯更新的實現:

unsigned char *__ziplistCascadeUpdate(unsigned char *zl, unsigned char *p) {
    size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), rawlen, rawlensize;
    size_t offset, noffset, extra;
    unsigned char *np;
    zlentry cur, next;
    // [1]
    while (p[0] != ZIP_END) {
        // [2]
        zipEntry(p, &cur);
        rawlen = cur.headersize + cur.len;
        rawlensize = zipStorePrevEntryLength(NULL,rawlen);
              
        if (p[rawlen] == ZIP_END) break;
        // [3]
        zipEntry(p+rawlen, &next);
        
        if (next.prevrawlen == rawlen) break;
        // [4]
        if (next.prevrawlensize < rawlensize) {
            // [5]
            offset = p-zl;
            extra = rawlensize-next.prevrawlensize;
            zl = ziplistResize(zl,curlen+extra);
            p = zl+offset;

            // [6]
            np = p+rawlen;
            noffset = np-zl;

            if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) {
                ZIPLIST_TAIL_OFFSET(zl) =
                    intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
            }

            // [7]
            memmove(np+rawlensize,
                np+next.prevrawlensize,
                curlen-noffset-next.prevrawlensize-1);
            zipStorePrevEntryLength(np,rawlen);

            // [8]
            p += rawlen;
            curlen += extra;
        } else {
            // [9]
            if (next.prevrawlensize > rawlensize) {
                zipStorePrevEntryLengthLarge(p+rawlen,rawlen);
            } else {
                // [10]
                zipStorePrevEntryLength(p+rawlen,rawlen);
            }
            // [11]
            break;
        }
    }
    return zl;
}

引數說明:

  • p:p指向插入節點的後驅節點,為了描述方便,下面將p指向的節點稱為當前節點。

【1】如果遇到ZIP_END,則退出迴圈。
【2】如果下一個節點是ZIP_END,則退出。
rawlen變數為當前節點長度,rawlensize變數為當前節點長度佔用的位元組數。
p[rawlen]即p的後驅節點的第一個位元組。
【3】計算後驅節點資訊。如果後驅節點的prevlen等於當前節點的長度,則退出。
【4】假設儲存當前節點長度需要使用actprevlen(1或者5)個位元組,這裡需要處理3種情況。情況1:後驅節點的prevlen屬性長度小於actprevlen,這時需要擴容,如例2-1中的場景。
【5】重新為ziplist分配記憶體。
【6】如果後驅節點非ZIP_END,則需要修改ziplist.zltail屬性。
【7】將當前節點後面所有的節點後移,騰出空間用來修改後驅節點的prevlen。
【8】將p指標指向後驅節點,繼續處理後面節點的prevlen。
【9】情況2:後驅節點的prevlen屬性長度大於actprevlen,這時需要縮容。為了不讓級聯更新繼續下去,這時強制後驅節點的prevlen保持不變。
【10】情況3:後驅節點的prevlen屬性長度等於actprevlen,只要修改後驅節點prevlen值,不需要調整ziplist的大小。
【11】情況2和情況3中級聯更新不需要繼續,退出。
回到上面__ziplistInsert函式中為什麼要設定forcelarge為1的問題,這樣是為了避免插入小節點時,導致級聯更新現象的出現,所以強制保持後驅節點的prevlen屬性長度不變。

從上面的分析我們可以看到,級聯更新下的效能是非常糟糕的,而且程式碼複雜度也高,那麼怎麼解決這個問題呢?我們先看一下為什麼需要使用prevlen這個屬性?這是因為反向遍歷時,每向前跨過一個節點,都必須知道前面這個節點的長度。
既然這樣,我們把每個節點長度都儲存一份到節點的最後位置,反向遍歷時,直接從前一個節點的最後位置獲取前一個節點的長度不就可以了嗎?而且這樣每個節點都是獨立的,插入或刪除節點都不會有級聯更新的現象。基於這種設計,Redis作者設計另一種結構listpack。設計listpack的目的是取代ziplist,但是ziplist使用範圍比較廣,替換起來比較複雜,所以目前只應用在新增加的Stream結構中。等到我們分析Stream時再討論listpack的設計。由此可見,優秀的設計並不是一蹴而就的。
ziplist提供常用函式如表2-1所示。

函式 作用
ziplistNew 建立一個空的ziplist
ziplistPush 在ziplist頭部或尾部新增元素
ziplistInsert 插入元素到ziplist指定位置
ziplistFind 查詢給定的元素
ziplistDelete 刪除給定節點

即使使用新的listpack格式,每插入一個新節點,也還可能需要進行兩次記憶體拷貝。
(1)為整個連結串列分配新記憶體空間,主要是為新節點建立空間。
(2)將插入節點所有後驅節點後移,為插入節點騰出空間。
如果連結串列很長,則每次插入或刪除節點時都需要進行大量的記憶體拷貝,這個效能是無法接受的,那麼如何解決這個問題呢?這時就要用到quicklist了。
限於篇幅,關於quicklist內容,我們在下一文章分析。

本文內容摘自作者新書《Redis核心原理與實踐》,這本書深入地分析了Redis常用特性的內部機制與實現方式,大部分內容源自對Redis原始碼的分析,並從中總結出設計思路、實現原理。通過閱讀本書,讀者可以快速、輕鬆地瞭解Redis的內部執行機制。
經過該書編輯同意,我會繼續在個人技術公眾號(binecy)釋出書中部分章節內容,作為書的預覽內容,歡迎大家查閱,謝謝。

京東連結
豆瓣連結

相關文章