Redis底層詳解(五) 壓縮列表

一、壓縮列表概述

       壓縮列表是一種編碼過的“鏈表”旨在實現高效的內存管理。它可以存儲整數和字符串,整數以小端序存儲,字符串則以字節數組存儲。壓縮列表的內存存儲結構如下圖所示:

       其中zlbytes、zltail、zllen 是 壓縮列表頭( ziplist header ),entry1 到 entryN 是列表的結點部分,zlen 是結尾標記。

二、壓縮列表頭(ziplist header)

       zlbytes :4個字節,指定了壓縮列表總共需要多少個字節(包含它本身的這四個字節)。
       zltail : 4個字節,指定了列表首地址到尾結點 (entryN) 的偏移量,這樣彈出尾結點就不需要遍歷所有結點了。
       zllen:2個字節,指定了壓縮列表中結點的數量。當有大於等於 2^16-1 個結點時,這個值爲 2^16-1,並且需要遍歷整個結點列表才能算出總共有多少個結點。

        zl 是壓縮列表的首地址(頭指針),默認是 unsigned char 類型的。整個壓縮列表的佔用字節總數(即 zlbytes 字段)就是以小端序存儲在這個首地址上的,由於 zlbytes 字段佔用 4 個字節,所以在獲取值的時候需要先轉換成 uint32_t,也就是上面的宏定義中 ZIPLIST_BYTES 的含義(先進行指針強轉再用 * 解指針)。同理,ZIPLIST_TAIL_OFFSET 和 ZIPLIST_LENGTH 這兩個宏分別是獲取 zltail (4字節)和 zlen (2字節)字段的值。ZIPLIST_HEADER_SIZE 爲上面三者佔用字節總數。
        ZIPLIST_END_SIZE 爲 zlend 佔用的字節數,即 1字節。
        ZIPLIST_ENTRY_HEAD 和 ZIPLIST_ENTRY_TAIL 分別代表壓縮列表的 首結點 和 尾結點 的首地址。intrev32ifbe 是前一篇文章提到的字節序轉換。
        ZIPLIST_ENTRY_END 表示壓縮列表結尾標記 zlend 的地址。zlend 用一個字節標誌的壓縮列表的結尾,值爲 0xff (即十進制中的 255),所有的結點的第一個字節不會是 0xff。

三、壓縮列表結點(entry)

       1、zlentry結構體

       這是壓縮列表結點的結構體,但是實際存儲的時候並不是這樣的,只是在進行計算的時候需要把結點從內存編碼中轉換出來才方便寫邏輯。於是,有了這麼一個結構體。
       a、每個結點需要記錄前一個結點佔用的字節數 prerawlen,以及存儲 prerawlen 需要用到的字節數 prevrawlensize;
       b、每個結點可以是字符串或者數組,當它是字符串時,len爲字符串長度;當它是整數時,len爲存儲整數需要的字節數;lensize 爲存儲 len 需要的字節數;
       c、headersize = prevrawlensize + lensize;
       d、encoding 表示結點的存儲方式;
       e、p 爲該結點在內存中的首地址;

       2、entry 內存存儲
       實際上,一個結點的信息存儲如下:

        prevlen 代表前一個結點的長度(集成了 prerawlen 和 prevrawlensize);
        encoding 代表當前結點的內存編碼方式;
        entry-data 則是實際的結點內存數據。

        3、prevlen
        prevlen 表示前一個結點的長度,在內存中的存儲實現如下:

        a、如果長度小於 254 (定義在 ZIP_BIGLEN 中),那麼它用一個1個字節(8位無符號整數)來存儲長度;

        b、如果長度大於等於254,則需要分配5個字節,第一個字節設置成 254,後面四個字節代表實際長度,後四個字節的存儲方式採用小端序;

       有 encode 就有 decode,decode 就是將內存中的值反序列化到臨時變量中方便邏輯使用,它是 encode 的逆運算。宏 ZIP_DECODE_PREVLEN 就是做這個 decode 的,具體實現如下:

        ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) 會通過指針 ptr 指向位置的第一個字節判斷是 1 字節 的長度 還是 5 字節的長度。然後再去內存中取得對應的長度值存到 prevlen 中。

        4、encoding

        encoding 字段可能佔用1個字節,也可能佔用多個字節,完全取決於第一個字節的編碼。當 encoding 字段的第一個字節的前兩個比特位爲 00、01、10 時,代表這個結點是個字符串。爲11時,代表這個結點是個整數。

       首先討論字符串的情況,相關宏定義如下:

        ZIP_STR_MASK 的二進制表示爲 11000000,所以 ZIP_IS_STR 的位運算的含義,就是根據 enc 這個字節的前兩位來判斷這個結點是否是字符串。
        a、ZIP_STR_06B 總共佔用 1 個字節。表示長度小於等於 2^6-1 的字符串。存儲方式如下圖所示,|xxxxxx|存儲實際長度;

        b、ZIP_STR_14B 總共佔用 2 個字節。表示長度小於等於 2^14-1 的字符串,存儲方式如下圖所示,剩下的 14 個比特位按照大端序存儲;

        c、ZIP_STR_32B 總共佔用 5 個字節。表示長度小於等於 2^32-1 的字符串,第一個字節的低 6 位設置成0,後 32 個比特位按照大端序存儲。

        再來討論整數的情況,相關宏定義如下:

        a、ZIP_INT_16B 第1個字節爲 |11000000|,總共佔用 3 個字節。後 2 字節表示 16位 整數;
        b、ZIP_INT_32B 第1個字節爲 |11010000|,總共佔用 5 個字節。後 4 字節表示 32位 整數;
        c、ZIP_INT_64B 第1個字節爲 |11100000|,總共佔用 9 個字節。後 8 字節表示 64位 整數;
        d、ZIP_INT_24B 第1個字節爲 |11110000|,總共佔用 4 個字節。後 3 字節表示 24位 整數;
        e、ZIP_INT_8B   第1個字節爲 |11111110|,  總共佔用 2 個字節。後 1 字節表示 8 位 整數;

        f、|1111xxxx| 用來表示 0 到 12 的 4 位整數,|xxxx| 的取值爲 |0001| 到 |1101| (其中 |0000| 、|1110|、 |1111| 因爲已經有編碼佔用,所以不能用)。舉個例子,  |0001|  代表的是 0, |0002| 代表 1, 以此類推。
        所有整數存儲採用小端序。

        5、entry-data

        實際的結點數據以小端序存儲在 entry-data 中。

四、連鎖更新

        考慮這樣一種情況, 有多個連續的、長度介於 250 字節到 253 字節之間的結點 e1 到 eN 。由於長度範圍在 [250, 253],所以每個結點的 prevlen 字段只需要一個字節。其中 e1 的 prevlen 字段等於 0。如圖所示:

        這時,我們在列表頭插入一個新結點 new ,這個新結點的長度大於等於 254 。如圖所示:

e1 的 prevlen 字段就表示 new 結點的長度(大於等於254),需要從 1字節 變爲 5字節。換言之,e1 結點的長度增加了4。 e1 結點原本的長度範圍在 [250,253],現在變成了 [254, 257],那麼 e2 結點的 prevlen 字段也從原來的 1 字節變成了 5 字節。就這樣以此類推, 一直到 eN,所有結點的 prevlen 字段都從 1 字節 增長爲 5 字節。這就是連鎖更新。
        連鎖更新最壞情況下會觸發 n 次空間重分配 (realloc) 和內存移動 (memmove),每次空間重分配的複雜度是O(n),所以連鎖更新的最壞時間複雜度是O(n^2)。但是由於要有恰好多個長度 [250, 253] 的結點纔會觸發連鎖更新,連續三五個在這個範圍內的結點是不會影響性能的。

五、列表實例

0, 12, 13, 127, 128, 32767, 32768, 8388607, 8388608, 2147483647, 2147483648, "Hello World"

         上圖所示的列表,在 redis 中以 ziplist 的存儲方式存儲後,得到下圖的內存佈局:

         每一行顯示 16 個字節,這個 ziplist 總共佔用 74 個字節。
         第一個字段是 zlbytes ,小端序存儲 4a,十進制的值恰好爲 74 ,即總共佔用的字節數;
         第二個字段是 zltail,小端序存儲 3c,十進制的值爲 60,即壓縮列表首地址到 "Hello World" 這個結點的首地址的偏移量;
         第三個字段是 zllen ,小端序存儲 0c,十進制的值爲 12,即代表總共 12 個結點;
         接下來就是每個結點了,結點的第1個字節代表它前驅結點的長度,結點元素 0 和 12 採用的是 |1111xxxx| 編碼;13 和 127 採用 |11111110| 的 8 位整數編碼;128 和 32767 採用的是 |11000000| 的 16 位整數編碼; "Hello World" 這個字符串被看作是一個結點整體,它的兩個額外字節 0a 和 0b 分別代表 前面結點 2147483648 的長度 和 本身編碼 |00xxxxxx| (b 是 十進制中的 11,代表 "Hello World" 字符串的長度)。

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