Redis 源碼簡潔剖析 05 – ziplist 壓縮列表

ziplist 是什麼

壓縮列表,記憶體緊湊的數據結構,佔用一塊連續的記憶體空間。一個 ziplist 可以包含多個節點(entry), 每個節點可以保存一個長度受限的字元數組(不以 \0 結尾的 char 數組)或者整數, 包括:

  • 字元數組

    • 長度小於等於 63 (2^6-1)位元組的字元數組
    • 長度小於等於 16383 (12^14-1) 位元組的字元數組
    • 長度小於等於 4294967295 (2^32-1)位元組的字元數組
  • 整數

    • 4 位長,介於 012 之間的無符號整數
    • 1 位元組長,有符號整數
    • 3 位元組長,有符號整數
    • int16_t 類型整數
    • int32_t 類型整數
    • int64_t 類型整數

Redis 哪些數據結構使用了 ziplist?

  • 哈希鍵
  • 列表鍵
  • 有序集合鍵

ziplist 特點

優點

  • 節省記憶體

缺點

  • 不能保存過多的元素,否則訪問性能會下降
  • 不能保存過大的元素,否則容易導致記憶體重新分配,甚至引起連鎖更新

ziplist 數據結構

啥都不說了,都在注釋里。

// ziplist 中的元素,是 string 或者 integer
typedef struct {
    // 如果元素是 string,slen 就表示長度
    unsigned char *sval;
    unsigned int slen;
    // 如果是 integer,sval 是 NULL,lval 就是 integer 的值
    long long lval;
} ziplistEntry;

為了方便地取出 ziplist 的各個域以及一些指針地址, ziplist 模組定義了以下宏:

// 取出 zlbytes 的值
#define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl)))

// 取出 zltail 的值
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t))))

// 取出 zllen 的值
#define ZIPLIST_LENGTH(zl)      (*((uint16_t*)((zl)+sizeof(uint32_t)*2)))

// 返回 ziplist header 部分的長度,總是固定的 10 位元組
#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t))

// 返回 ziplist end 部分的長度,總是固定的 1 位元組
#define ZIPLIST_END_SIZE        (sizeof(uint8_t))

// 返回到達 ziplist 第一個節點(表頭)的地址
#define ZIPLIST_ENTRY_HEAD(zl)  ((zl)+ZIPLIST_HEADER_SIZE)

// 返回到達 ziplist 最後一個節點(表尾)的地址
#define ZIPLIST_ENTRY_TAIL(zl)  ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl)))

// 返回 ziplist 的末端,也即是 zlend 之前的地址
#define ZIPLIST_ENTRY_END(zl)   ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)

ziplist 節點

typedef struct zlentry {
    // 前一個節點的長度,通過這個值,可以進行指針計算,從而跳轉到上一個節點
    unsigned int prevrawlen;
    unsigned int prevrawlensize;
    // entry 的編碼方式
    // 1. entry 是 string,可能是 1 2 5 個位元組的 header
    // 2. entry 是 integer,固定為 1 位元組
    unsigned int lensize;
    // 實際 entry 的位元組數
    // 1. entry 是 string,則表示 string 的長度
    // 2. entry 是 integer,則根據數值範圍,可能是 1, 2, 3, 4, 8
    unsigned int len;
    // prevrawlensize + lensize
    unsigned int headersize;
    // ZIP_STR_* 或者 ZIP_INT_*
    unsigned char encoding;
    unsigned char *p;            /* Pointer to the very start of the entry, that
                                    is, this points to prev-entry-len field. */
} zlentry;

pre_entry_length

記錄前一個節點的長度。通過這個值,可以進行指針計算,從而跳轉到上一個節點

area        |<---- previous entry --->|<--------------- current entry ---------------->|

size          5 bytes                   1 byte             ?          ?        ?
            +-------------------------+-----------------------------+--------+---------+
component   | ...                     | pre_entry_length | encoding | length | content |
            |                         |                  |          |        |         |
value       |                         | 0000 0101        |    ?     |   ?    |    ?    |
            +-------------------------+-----------------------------+--------+---------+
            ^                         ^
address     |                         |
            p = e - 5                 e

以上圖為例,從當前節點的指針 e,減去 pre_entry_length 的值(0000 0101 的十進位值,5),就可以得到指向前一個節點的地址 p。

encoding 和 length

encodinglength 兩部分一起決定了 content 部分所保存的數據的類型(以及長度)。

其中, encoding 域的長度為兩個 bit , 它的值可以是 00011011

  • 000110 表示 content 部分保存著字元數組。
  • 11 表示 content 部分保存著整數。

000110 開頭的字元數組的編碼方式如下:

表格中的下劃線 _ 表示留空,而變數 b 、 x 等則代表實際的二進位數據。為了方便閱讀,多個位元組之間用空格隔開。

11 開頭的整數編碼如下:

content

content 部分保存著節點的內容,類型和長度由 encodinglength 決定。

以下是一個保存著字元數組 hello world 的節點的例子:

area      |<---------------------- entry ----------------------->|

size        ?                  2 bit      6 bit    11 byte
          +------------------+----------+--------+---------------+
component | pre_entry_length | encoding | length | content       |
          |                  |          |        |               |
value     | ?                |    00    | 001011 | hello world   |
          +------------------+----------+--------+---------------+

encoding 域的值 00 表示節點保存著一個長度小於等於 63 位元組的字元數組, length 域給出了這個字元數組的準確長度 —— 11 位元組(的二進位 001011), content 則保存著字元數組值 hello world 本身(為了方便表示, content 部分使用字元而不是二進位表示)。

以下是另一個節點,它保存著整數 10086

area      |<---------------------- entry ----------------------->|

size        ?                  2 bit      6 bit    2 bytes
          +------------------+----------+--------+---------------+
component | pre_entry_length | encoding | length | content       |
          |                  |          |        |               |
value     | ?                |    11    | 000000 | 10086         |
          +------------------+----------+--------+---------------+

encoding 域的值 11 表示節點保存的是一個整數; 而 length 域的值 000000 表示這個節點的值的類型為 int16_t ; 最後, content 保存著整數值 10086 本身(為了方便表示, content 部分用十進位而不是二進位表示)。

ziplist 基本操作

創建新 ziplist

創建一個新的 ziplist,時間複雜度是 O(1)。

/* Create a new empty ziplist. */
unsigned char *ziplistNew(void) {
    // header 和 end 需要的位元組數
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
    // 分配 bytes 長度的記憶體
    unsigned char *zl = zmalloc(bytes);
    // 賦值 zlbytes
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    // 賦值 zltail
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    // 賦值 zllen
    ZIPLIST_LENGTH(zl) = 0;
    // 賦值 zlend
    zl[bytes-1] = ZIP_END;
    return zl;
}
area        |<---- ziplist header ---->|<-- end -->|

size          4 bytes   4 bytes 2 bytes  1 byte
            +---------+--------+-------+-----------+
component   | zlbytes | zltail | zllen | zlend     |
            |         |        |       |           |
value       |  1011   |  1010  |   0   | 1111 1111 |
            +---------+--------+-------+-----------+
                                       ^
                                       |
                               ZIPLIST_ENTRY_HEAD
                                       &
address                        ZIPLIST_ENTRY_TAIL
                                       &
                               ZIPLIST_ENTRY_END

空白 ziplist 的表頭、表尾和末端處於同一地址。

創建了 ziplist 之後, 就可以往裡面添加新節點了, 根據新節點添加位置的不同, 這個工作可以分為兩類來進行:

  1. 將節點添加到 ziplist 末端:在這種情況下,新節點的後面沒有任何節點。
  2. 將節點添加到某個/某些節點的前面:在這種情況下,新節點的後面有至少一個節點。

以下兩個小節分別討論這兩種情況。

將節點添加到末端

將新節點添加到 ziplist 的末端需要執行以下三個步驟:

  1. 記錄到達 ziplist 末端所需的偏移量(因為之後的記憶體重分配可能會改變 ziplist 的地址,因此記錄偏移量而不是保存指針)
  2. 根據新節點要保存的值,計算出編碼這個值所需的空間大小,以及編碼它前一個節點的長度所需的空間大小,然後對 ziplist 進行記憶體重分配。
  3. 設置新節點的各項屬性: pre_entry_lengthencodinglengthcontent
  4. 更新 ziplist 的各項屬性,比如記錄空間佔用的 zlbytes ,到達表尾節點的偏移量 zltail ,以及記錄節點數量的 zllen

舉個例子,假設現在要將一個新節點添加到只含有一個節點的 ziplist 上,程式首先要執行步驟 1 ,定位 ziplist 的末端:

area        |<---- ziplist header ---->|<--- entries -->|<-- end -->|

size          4 bytes  4 bytes  2 bytes  5 bytes          1 bytes
            +---------+--------+-------+----------------+-----------+
component   | zlbytes | zltail | zllen | entry 1        | zlend     |
            |         |        |       |                |           |
value       |  10000  |  1010  |   1   | ?              | 1111 1111 |
            +---------+--------+-------+----------------+-----------+
                                       ^                ^
                                       |                |
address                         ZIPLIST_ENTRY_HEAD   ZIPLIST_ENTRY_END
                                       &
                                ZIPLIST_ENTRY_TAIL

然後執行步驟 2 ,程式需要計算新節點所需的空間:

假設我們要添加到節點裡的值為字元數組 hello world , 那麼保存這個值共需要 12 位元組的空間:

  • 11 位元組用於保存字元數組本身;
  • 另外 1 位元組中的 2 bit 用於保存類型編碼 00 , 而其餘 6 bit 則保存字元數組長度 11 的二進位 001011

另外,節點還需要 1 位元組, 用於保存前一個節點的長度 5 (二進位 101 )。

合算起來,為了添加新節點, ziplist 總共需要多分配 13 位元組空間。 以下是分配完成之後, ziplist 的樣子:

area        |<---- ziplist header ---->|<------------ entries ------------>|<-- end -->|

size          4 bytes  4 bytes  2 bytes  5 bytes          13 bytes           1 bytes
            +---------+--------+-------+----------------+------------------+-----------+
component   | zlbytes | zltail | zllen | entry 1        | entry 2          | zlend     |
            |         |        |       |                |                  |           |
value       |  10000  |  1010  |   1   | ?              | pre_entry_length | 1111 1111 |
            |         |        |       |                | ?                |           |
            |         |        |       |                |                  |           |
            |         |        |       |                | encoding         |           |
            |         |        |       |                | ?                |           |
            |         |        |       |                |                  |           |
            |         |        |       |                | length           |           |
            |         |        |       |                | ?                |           |
            |         |        |       |                |                  |           |
            |         |        |       |                | content          |           |
            |         |        |       |                | ?                |           |
            |         |        |       |                |                  |           |
            +---------+--------+-------+----------------+------------------+-----------+
                                       ^                ^
                                       |                |
address                       ZIPLIST_ENTRY_HEAD   ZIPLIST_ENTRY_END
                                       &
                              ZIPLIST_ENTRY_TAIL

步驟三,更新新節點的各項屬性(為了方便表示, content 的內容使用字元而不是二進位來表示):

area        |<---- ziplist header ---->|<------------ entries ------------>|<-- end -->|

size          4 bytes  4 bytes  2 bytes  5 bytes          13 bytes           1 bytes
            +---------+--------+-------+----------------+------------------+-----------+
component   | zlbytes | zltail | zllen | entry 1        | entry 2          | zlend     |
            |         |        |       |                |                  |           |
value       |  10000  |  1010  |   1   | ?              | pre_entry_length | 1111 1111 |
            |         |        |       |                | 101              |           |
            |         |        |       |                |                  |           |
            |         |        |       |                | encoding         |           |
            |         |        |       |                | 00               |           |
            |         |        |       |                |                  |           |
            |         |        |       |                | length           |           |
            |         |        |       |                | 001011           |           |
            |         |        |       |                |                  |           |
            |         |        |       |                | content          |           |
            |         |        |       |                | hello world      |           |
            |         |        |       |                |                  |           |
            +---------+--------+-------+----------------+------------------+-----------+
                                       ^                ^
                                       |                |
address                       ZIPLIST_ENTRY_HEAD   ZIPLIST_ENTRY_END
                                       &
                              ZIPLIST_ENTRY_TAIL

最後一步,更新 ziplist 的 zlbytes 、 zltail 和 zllen 屬性:

area        |<---- ziplist header ---->|<------------ entries ------------>|<-- end -->|

size          4 bytes  4 bytes  2 bytes  5 bytes          13 bytes           1 bytes
            +---------+--------+-------+----------------+------------------+-----------+
component   | zlbytes | zltail | zllen | entry 1        | entry 2          | zlend     |
            |         |        |       |                |                  |           |
value       |  11101  |  1111  |  10   | ?              | pre_entry_length | 1111 1111 |
            |         |        |       |                | 101              |           |
            |         |        |       |                |                  |           |
            |         |        |       |                | encoding         |           |
            |         |        |       |                | 00               |           |
            |         |        |       |                |                  |           |
            |         |        |       |                | length           |           |
            |         |        |       |                | 001011           |           |
            |         |        |       |                |                  |           |
            |         |        |       |                | content          |           |
            |         |        |       |                | hello world      |           |
            |         |        |       |                |                  |           |
            +---------+--------+-------+----------------+------------------+-----------+
                                       ^                ^                  ^
                                       |                |                  |
address                                |          ZIPLIST_ENTRY_TAIL   ZIPLIST_ENTRY_END
                                       |
                               ZIPLIST_ENTRY_HEAD

將節點添加到某個/某些節點的前面

比起將新節點添加到 ziplist 的末端, 將一個新節點添加到某個/某些節點的前面要複雜得多, 因為這種操作除了將新節點添加到 ziplist 以外, 還可能引起後續一系列節點的改變。

舉個例子,假設我們要將一個新節點 new 添加到節點 prevnext 之間:

   add new entry here
           |
           V
+----------+----------+----------+----------+----------+
|          |          |          |          |          |
|   prev   |   next   | next + 1 | next + 2 |   ...    |
|          |          |          |          |          |
+----------+----------+----------+----------+----------+

程式首先為新節點擴大 ziplist 的空間:

+----------+----------+----------+----------+----------+----------+
|          |          |          |          |          |          |
|   prev   |   ???    |   next   | next + 1 | next + 2 |   ...    |
|          |          |          |          |          |          |
+----------+----------+----------+----------+----------+----------+

           |<-------->|
              expand
              space

然後設置 new 節點的各項值 —— 到目前為止,一切都和前面介紹的添加操作一樣:

             set value,
             property,
             length,
             etc.
                |
                v
+----------+----------+----------+----------+----------+----------+
|          |          |          |          |          |          |
|   prev   |   new    |   next   | next + 1 | next + 2 |   ...    |
|          |          |          |          |          |          |
+----------+----------+----------+----------+----------+----------+

現在,新的 new 節點取代原來的 prev 節點, 成為了 next 節點的新前驅節點, 不過, 因為這時 next 節點的 pre_entry_length 域編碼的仍然是 prev 節點的長度, 所以程式需要將 new 節點的長度編碼進 next 節點的 pre_entry_length 域里, 這裡會出現三種可能:

  1. nextpre_entry_length 域的長度正好能夠編碼 new 的長度(都是 1 位元組或者都是 5 位元組)
  2. nextpre_entry_length 只有 1 位元組長,但編碼 new 的長度需要 5 位元組
  3. nextpre_entry_length 有 5 位元組長,但編碼 new 的長度只需要 1 位元組

對於情況 1 和 3 , 程式直接更新 nextpre_entry_length 域。

如果是第二種情況, 那麼程式必須對 ziplist 進行記憶體重分配, 從而擴展 next 的空間。 然而,因為 next 的空間長度改變了, 所以程式又必須檢查 next 的後繼節點 —— next+1 , 看它的 pre_entry_length 能否編碼 next 的新長度, 如果不能的話,程式又需要繼續對 next+1 進行擴容。

這就是說, 在某個/某些節點的前面添加新節點之後, 程式必須沿著路徑挨個檢查後續的節點,是否滿足新長度的編碼要求, 直到遇到一個能滿足要求的節點(如果有一個能滿足,則這個節點之後的其他節點也滿足), 或者到達 ziplist 的末端 zlend 為止。

刪除節點

刪除節點和添加操作的步驟類似。

  1. 定位目標節點,並計算節點的空間長度 target-size
   target start here
           |
           V
+----------+----------+----------+----------+----------+----------+
|          |          |          |          |          |          |
|   prev   |  target  |   next   | next + 1 | next + 2 |   ...    |
|          |          |          |          |          |          |
+----------+----------+----------+----------+----------+----------+

           |<-------->|
            target-size
  1. 進行記憶體移位,覆蓋 target 原本的數據,然後通過記憶體重分配,收縮多餘空間:
   target start here
           |
           V
+----------+----------+----------+----------+----------+
|          |          |          |          |          |
|   prev   |   next   | next + 1 | next + 2 |   ...    |
|          |          |          |          |          |
+----------+----------+----------+----------+----------+

           | <------------------------------------------ memmove
  1. 檢查 nextnext+1 等後續節點能否滿足新前驅節點的編碼。和添加操作一樣,刪除操作也可能會引起連鎖更新。

參考鏈接

Redis 源碼簡潔剖析系列

最簡潔的 Redis 源碼剖析系列文章

Java 編程思想-最全思維導圖-GitHub 下載鏈接,需要的小夥伴可以自取~

原創不易,希望大家轉載時請先聯繫我,並標註原文鏈接。