內存節省到極致!!!Redis中的壓縮表,值得了解...

redis源碼分析系列文章

[Redis源碼系列]在Liunx安裝和常見API 

爲什麼要從Redis源碼分析 

String底層實現——動態字符串SDS 

雙向鏈表都不懂,還說懂Redis?

面試官:說說Redis的Hash底層 我:......(來自閱文的面試題)

Redis的跳躍表確定不瞭解下

多圖解釋Redis的整數集合intset升級過程

前言

hello,大家好,又見面啦😊。

前面幾周我們一起看了Redis底層數據結構,如動態字符串SDS雙向鏈表Adlist字典Dict跳躍表整數集合intset,如果有對Redis常見的類型或底層數據結構不明白的請看上面傳送門。

今天來說下zset的底層實現壓縮表(在數據庫量小的時候用),如果有對zset不明白的,看上面的傳送門哈。

壓縮列表的概念提出

傳統的數組

同之前的底層數據一樣,壓縮列表也是由Redis設計的一種數據存儲結構。

他有點類似於數組,都是通過一片連續的內存空間來存儲數據。但是其和數組也有點區別,數組存儲不同長度的字符時,會選擇最大的字符長度作爲每個節點的內存大小。

如下圖,一共五個元素,每個元素的長度都是不一樣的,這個時候選擇最大值5作爲每個元素的內存大小,如果選擇小於5的,那麼第一個元素hello,第二個元素world就不能完整存儲,數據會丟失。

存在的問題

上面已經提到了需要用最大長度的字符串大小作爲整個數組所有元素的內存大小,如果只有一個元素的長度超大,但是其他的元素長度都比較小,那麼我們所有元素的內存都用超大的數字就會導致內存的浪費。

那麼我們應該如何改進呢?

引出壓縮列表

Redis引入了壓縮列表的概念,即多大的元素使用多大的內存,一切從實際出發,拒絕浪費。

如下圖,根據每個節點的實際存儲的內容決定內存的大小,即第一個節點佔用5個字節,第二個節點佔用5個字節,第三個節點佔用1個字節,第四個節點佔用4個字節,第五個節點佔用3個字節。

還有一個問題,我們在遍歷的時候不知道每個元素的大小,無法準確計算出下一個節點的具體位置。實際存儲不會出現上圖的橫線,我們並不知道什麼時候當前節點結束,什麼時候到了下一個節點。所以在redis中添加length屬性,用來記錄前一個節點的長度。

如下圖,如果需要從頭開始遍歷,取某個節點後面的數字,比如取“hello”的起始地址,但是不知道其結束地址在哪裏,我們取後面數字5,即可知道"hello"佔用了5個字節,即可順利找到下一節點“world”的起始位置。

 

壓縮列表圖解分析

整個壓縮列表圖解如下,大家可以大概看下,具體的後面部分會詳細說明。

表頭

表頭包括四個部分,分別是內存字節數zlbytes,尾節點距離起始地址的字節數zltail_offset,節點數量zllength,標誌結束的記號zlend。

 

  • zlbytes:記錄整個壓縮列表佔用的內存字節數。
  • zltail_offset:記錄壓縮列表尾節點距離壓縮列表的起始地址的字節數(目的是爲了直接定位到尾節點,方便反向查詢)
  • zllength:記錄了壓縮列表的節點數量。即在上圖中節點數量爲2。
  • zlend:保存一個常數255(0xFF),標記壓縮列表的末端。

數據節點

數據節點包括三個部分,分別是前一個節點的長度prev_entry_len,當前數據類型和編碼格式encoding,具體數據指針value。

  • prev_entry_len:記錄前驅節點的長度。
  • encoding:記錄當前數據類型和編碼格式
  • value:存放具體的數據。

壓縮列表的具體實現

壓縮列表的構成

Redis並沒有像之前的字符串SDS,字典,跳躍表等結構一樣,封裝一個結構體來保存壓縮列表的信息。而是通過定義一系列宏來對數據進行操作。

也就是說壓縮列表是一堆字節碼,咱也看不懂,Redis通過字節之間的定位和計算來獲取數據的。

//返回整個壓縮列表的總字節
#define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl)))

//返回壓縮列表的tail_offset變量,方便獲取最後一個節點的位置
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t))))

//返回壓縮列表的節點數量
#define ZIPLIST_LENGTH(zl)      (*((uint16_t*)((zl)+sizeof(uint32_t)*2)))

//返回壓縮列表的表頭的字節數
//(內存字節數zlbytes,最後一個節點地址ztail_offset,節點總數量zllength)
#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t))

//返回壓縮列表最後結尾的字節數
#define ZIPLIST_END_SIZE        (sizeof(uint8_t))

//返回壓縮列表首節點地址
#define ZIPLIST_ENTRY_HEAD(zl)  ((zl)+ZIPLIST_HEADER_SIZE)

//返回壓縮列表尾節點地址
#define ZIPLIST_ENTRY_TAIL(zl)  ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl)))

//返回壓縮列表最後結尾的地址
#define ZIPLIST_ENTRY_END(zl)   ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)

壓縮列表節點的構成

我們看下面的代碼,重點看註釋,Note that this is not how the data is actually encoded,這句話說明這並不是數據的實際存儲格式。

是不是有點逗,定義了卻沒使用。

因爲,這個結構存儲實在是太浪費空間了。這個結構32位機佔用了25(int類型5個,每個int佔4個字節,char類型1個,每個char佔用1個字節,char*類型1個,每個char*佔用4個字節,所以總共5*4+1*1+1*4=25)個字節,在64位機佔用了29(int類型5個,每個int佔4個字節,char類型1個,每個char佔用1個字節,char*類型1個,每個char*佔用8個字節,所以總共5*4+1*1+1*8=29個字節)。這不符合壓縮列表的設計目的。

/* We use this function to receive information about a ziplist entry.
 * Note that this is not how the data is actually encoded, is just what we
 * get filled by a function in order to operate more easily. */
typedef struct zlentry {
    unsigned int prevrawlensize; //記錄prevrawlen需要的字節數
    unsigned int prevrawlen;    //記錄上個節點的長度
    unsigned int lensize;        //記錄len需要的字節數
    unsigned int len;           //記錄節點長度
    unsigned int headersize;   //prevrawlensize+lensize 
    unsigned char encoding;   //編碼格式
    unsigned char *p;       //具體的數據指針
} zlentry;

所以Redis對上述結構進行了改進了,抽象合併了三個參數。

prev_entry_len:prevrawlensize和prevrawlen的總和。

如果前一個節點長度小於254字節,那麼prev_entry_len使用一個字節表示。

如果前一個節點長度大於等於254字節,那麼prev_entry_len使用五個字節表示。第一個字節爲常數oxff,後面四位爲真正的前一個節點的長度。

encoding:lensize和len的總和。Redis通過設置了一組宏定義,使其能夠具有lensize和len兩種功能。(具體即不展開了)

value:具體的數據。

壓縮列表的優點

節約內存。

壓縮列表的缺點

因爲壓縮表是緊湊存儲的,沒有多餘的空間。這就意味着插入一個新的元素就需要調用函數擴展內存。過程中可能需要重新分配新的內存空間,並將之前的內容一次性拷貝到新的地址。

如果數據量太多,重新分配內存和拷貝數據會有很大的消耗。所以壓縮表不適合存儲大型字符串,並且數據元素不能太多。

壓縮列表的連鎖更新過程圖解(重點)

前面提到每個節點entry都會有一個prevlen字段存儲前一個節點entry的長度,如果內容小於254,prevlen用一個字節存儲,如果大於254,就用五個字節存儲。這意味着如果某個entry經過操作從253字節變成了254字節,那麼他的下一個節點entry的pervlen字段就要更新,從1個字節擴展到5個字節;如果這個entry的長度本來也是253字節,那麼後面entry的prevlen字段還得繼續更新。

如果每個節點entry都存儲的253個字節的內容,那麼第一個entry修改後會導致後續所有的entry的級聯更新,這是一個比較損耗資源的操作。

所以,發生級聯更新的前提是有連續的250-253字節長度的節點。

步驟一

比如一開始的壓縮表呈現下圖所示(XXXX表示字符串),現在想要把第二個數據的改大點,哪個時候就會發生級聯更新了。

步驟二

我們想要分配四個長度的大小給第三個數據的prevlen,因爲第二個元素的prevlen字段是表示他前一個元素的大小。

步驟三

調整完發現第三個元素的長度增加了,所以第四個元素的prevlen字段也需要修改。

步驟四

調整完發現第四個元素的長度增加了,所以把第五個元素的prevlen字段也需要修改。

壓縮列表的源碼分析

創建空的壓縮表ziplistNew

主要的步驟是分配內存空間,初始化屬性,設置結束標記爲常量,最後返回壓縮表。

unsigned char *ziplistNew(void) {
    //表頭加末端大小
    unsigned int bytes = ZIPLIST_HEADER_SIZE+1;

    //爲上面兩部分(表頭和末端)分配空間
    unsigned char *zl = zmalloc(bytes);

    //初始化表屬性
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    ZIPLIST_LENGTH(zl) = 0;

    //設置模塊,賦值爲常量
    zl[bytes-1] = ZIP_END;

    return zl;
}

 

級聯更新(重點)

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

    //while循環,當到最後一個節點的時候結束循環
    while (p[0] != ZIP_END) {
        //將節點數據保存在cur中
        zipEntry(p, &cur);
        //取前節點長度編碼所佔字節數,和當前節點長度編碼所佔字節數,在加上當前節點的value長度
        //rawlen = prev_entry_len + encoding + value
        rawlen = cur.headersize + cur.len;
        rawlensize = zipStorePrevEntryLength(NULL,rawlen);

        //如果沒有下一個節點則跳出循環
        if (p[rawlen] == ZIP_END) break;
        //取出後面一個節點放在next中
        zipEntry(p+rawlen, &next);

        //當next的prevrawlen,即保存的上一個節點等於rawlen,說明不需要調整,現在的長度合適
        if (next.prevrawlen == rawlen) break;

        //如果next對前一個節點長度的編碼所需的字節數next.prevrawlensize小於上一個節點長度進行編碼所需要的長度
        //因此要對next節點的header部分進行擴展,以便能夠表示前一個節點的長度
        if (next.prevrawlensize < rawlensize) {
            //記錄當前指針的偏移量
            offset = p-zl;
            ///需要擴展的字節數
            extra = rawlensize-next.prevrawlensize;
            //調整壓縮列表的空間大小            
            zl = ziplistResize(zl,curlen+extra);
            //還原p指向的位置
            p = zl+offset;

           //next節點的新地址
            np = p+rawlen;
            //記錄next節點的偏移量
            noffset = np-zl;

          //更新壓縮列表的表頭tail_offset成員,如果next節點是尾節點就不用更新
            if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) {
                ZIPLIST_TAIL_OFFSET(zl) =
                    intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
            }

           //移動next節點到新地址,爲前驅節點cur騰出空間
            memmove(np+rawlensize,
                np+next.prevrawlensize,
                curlen-noffset-next.prevrawlensize-1);
            //將next節點的header以rawlen長度進行重新編碼,更新prevrawlensize和prevrawlen
            zipStorePrevEntryLength(np,rawlen);

            //更新p指針,移動到next節點,處理next的next節點
            p += rawlen;
            //更新壓縮列表的總字節數
            curlen += extra;
        } else {
            // 如果需要的內存反而更少了則強制保留現有內存不進行縮小
            // 僅浪費一點內存卻省去了大量移動複製操作而且後續增大時也無需再擴展
            if (next.prevrawlensize > rawlensize) {
                zipStorePrevEntryLengthLarge(p+rawlen,rawlen);
            } else {
             
                zipStorePrevEntryLength(p+rawlen,rawlen);
            }

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

結語

該篇主要講了Redis的zset數據類型的底層實現壓縮表,先從壓縮表是什麼,剖析了其主要組成部分,進而通過多幅過程圖解釋了壓縮表是如何層級更新的,最後結合源碼對壓縮表進行描述,如創建過程,升級過程,中間穿插例子和過程圖。

如果覺得寫得還行,麻煩給個贊👍,您的認可纔是我寫作的動力!

如果覺得有說的不對的地方,歡迎評論指出。

好了,拜拜咯。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章