Redis內部數據結構詳解(4)——ziplist



本篇導讀本文是《Redis內部數據結構詳解》系列的第四篇,介紹ziplist。ziplist的操做相對來講比較複雜,建議本文分兩次閱讀:先一口氣讀完ziplist的數據結構的介紹,這一部分基本不包含代碼,應該能夠在10分鐘內讀完;而後建議你休息片刻,並將本文收藏。而後在時間充裕的時候再閱讀後半部分。祝閱讀愉快!redis


本文中,咱們首先介紹一個新的Redis內部數據結構——ziplist,而後在文章後半部分咱們會討論一下在robj, dict和ziplist的基礎上,Redis對外暴露的hash結構是怎樣構建起來的。
數組


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

hash-max-ziplist-entries 512
hash-max-ziplist-value 64

本文的後半部分會對這兩個配置作詳細的解釋。app

什麼是ziplist

Redis官方對於ziplist的定義是(出自ziplist.c的文件頭部註釋):curl

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.ide

翻譯一下就是說:ziplist是一個通過特殊編碼的雙向鏈表,它的設計目標就是爲了提升存儲效率。ziplist能夠用於存儲字符串或整數,其中整數是按真正的二進制表示進行編碼的,而不是編碼成字符串序列。它能以O(1)的時間複雜度在表的兩端提供pushpop操做。函數

實際上,ziplist充分體現了Redis對於存儲效率的追求。一個普通的雙向鏈表,鏈表中每一項都佔用獨立的一塊內存,各項之間用地址指針(或引用)鏈接起來。這種方式會帶來大量的內存碎片,並且地址指針也會佔用額外的內存。而ziplist倒是將表中每一項存放在先後連續的地址空間內,一個ziplist總體佔用一大塊內存。它是一個表(list),但其實不是一個鏈表(linked list)。性能

另外,ziplist爲了在細節上節省內存,對於值的存儲採用了變長的編碼方式,大概意思是說,對於大的整數,就多用一些字節來存儲,而對於小的整數,就少用一些字節來存儲。咱們接下來很快就會討論到這些實現細節。this

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,因此能夠表達的最大值爲2^16-1。這裏須要特別注意的是,若是ziplist中數據項個數超過了16bit能表達的最大值,ziplist仍然能夠來表示。那怎麼表示呢?這裏作了這樣的規定:若是<zllen>小於等於2^16-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的數據結構定義,咱們介紹了完了,如今咱們看一個具體的例子。


圖片


上圖是一份真實的ziplist數據。咱們逐項解讀一下:

  • 這個ziplist一共包含33個字節。字節編號從byte[0]到byte[32]。圖中每一個字節的值使用16進製表示。

  • 頭4個字節(0x21000000)是按小端(little endian)模式存儲的<zlbytes>字段。什麼是小端呢?就是指數據的低字節保存在內存的低地址中(參見維基百科詞條Endianness{:target="_blank"})。所以,這裏<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; 
    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)。

實際上,hash隨着數據的增大,其底層數據結構的實現是會發生變化的,固然存儲效率也就不一樣。在field比較少,各個value值也比較小的時候,hash採用ziplist來實現;而隨着field增多和value值增大,hash可能會變成dict來實現。當hash底層變成dict來實現的時候,它的存儲效率就無法跟那些序列化方式相比了。

當咱們爲某個key第一次執行 hset key field value 命令的時候,Redis會建立一個hash結構,這個新建立的hash底層就是一個ziplist。

robj *createHashObject(void) {
    unsigned char *zl = ziplistNew();
    robj *o = createObject(OBJ_HASH, zl);
    o->encoding = OBJ_ENCODING_ZIPLIST;
    return o;
}

上面的createHashObject函數,出自object.c,它負責的任務就是建立一個新的hash結構。能夠看出,它建立了一個type = OBJ_HASHencoding = OBJ_ENCODING_ZIPLIST的robj對象。

實際上,本文前面給出的那個ziplist實例,就是由以下兩個命令構建出來的。

hset user:100 name tielei
hset user:100 age 20

每執行一次hset命令,插入的field和value分別做爲一個新的數據項插入到ziplist中(即每次hset產生兩個數據項)。

當隨着數據的插入,hash底層的這個ziplist就可能會轉成dict。那麼到底插入多少纔會轉呢?

還記得本文開頭提到的兩個Redis配置嗎?

hash-max-ziplist-entries 512
hash-max-ziplist-value 64

這個配置的意思是說,在以下兩個條件之一知足的時候,ziplist會轉成dict:

  • 當hash中的數據項(即field-value對)的數目超過512的時候,也就是ziplist數據項超過1024的時候(請參考t_hash.c中的hashTypeSet函數)。

  • 當hash中插入的任意一個value的長度超過了64的時候(請參考t_hash.c中的hashTypeTryConversion函數)。

Redis的hash之因此這樣設計,是由於當ziplist變得很大的時候,它有以下幾個缺點:

  • 每次插入或修改引起的realloc操做會有更大的機率形成內存拷貝,從而下降性能。

  • 一旦發生內存拷貝,內存拷貝的成本也相應增長,由於要拷貝更大的一塊數據。

  • 當ziplist數據項過多的時候,在它上面查找指定的數據項就會性能變得很低,由於ziplist上的查找須要進行遍歷。

總之,ziplist原本就設計爲各個數據項挨在一塊兒組成連續的內存空間,這種結構並不擅長作修改操做。一旦數據發生改動,就會引起內存realloc,可能致使內存拷貝。

相關文章
相關標籤/搜索