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


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

前言

hello,你們好,又見面啦😊。curl

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

今天來講下zset的底層實現壓縮表(在數據庫量小的時候用),若是有對zset不明白的,看上面的傳送門哈。
源碼分析

壓縮列表的概念提出

傳統的數組

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

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

以下圖,一共五個元素,每一個元素的長度都是不同的,這個時候選擇最大值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數據類型的底層實現壓縮表,先從壓縮表是什麼,剖析了其主要組成部分,進而經過多幅過程圖解釋了壓縮表是如何層級更新的,最後結合源碼對壓縮表進行描述,如建立過程,升級過程,中間穿插例子和過程圖。

若是以爲寫得還行,麻煩給個贊👍,您的承認纔是我寫做的動力!

若是以爲有說的不對的地方,歡迎評論指出。

好了,拜拜咯。

感謝你們❤

1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。

2.關注公衆號「學習Java的小姐姐」便可加我好友,我拉你進「Java技術交流羣」,你們一塊兒共同交流和進步。

相關文章
相關標籤/搜索