Redis 設計與實現 5:壓縮列表

小新是也發表於2020-12-27

壓縮列表是 ZSET、HASH和 LIST 型別的其中一種編碼的底層實現,是由一系列特殊編碼的連續記憶體塊組成的順序型資料結構,其目的是節省記憶體。

ziplist 的結構

外層結構

下圖展示了壓縮列表的組成:
ziplist 的結構

各個欄位的含義如下:

  1. zlbytes:是一個無符號 4 位元組整數,儲存著 ziplist 使用的記憶體數量。
    通過 zlbytes,程式可以直接對 ziplist 的記憶體大小進行調整,無須為了計算 ziplist 的記憶體大小而遍歷整個列表。
  2. zltail:壓縮列表 最後一個 entry 距離起始地址的偏移量,佔 4 個位元組。
    這個偏移量使得對錶尾的 pop 操作可以在無須遍歷整個列表的情況下進行。
  3. zllen:壓縮列表的節點 entry 數目,佔 2 個位元組。
    當壓縮列表的元素數目超過 2^16 - 2 的時候,zllen 會設定為2^16-1,當程式查詢到值為2^16-1,就需要遍歷整個壓縮列表才能獲取到元素數目。所以 zllen 並不能替代 zltail
  4. entryX:壓縮列表儲存資料的節點,可以為位元組陣列或者整數。
  5. zlend:壓縮列表的結尾,佔一個位元組,恆為 0xFF

實現的程式碼 ziplist.c 中,ziplist 定義成了巨集屬性。

// 相當於 zlbytes,ziplist 使用的記憶體位元組數
#define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl)))
// 相當於 zltail,最後一個 entry 距離 ziplist 起始位置的偏移量
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t))))
// 相當於 zllen,entry 的數量
#define ZIPLIST_LENGTH(zl)      (*((uint16_t*)((zl)+sizeof(uint32_t)*2)))
// zlbytes + zltail + zllen 的長度,也就是 4 + 4 + 2 = 10
#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t))
// zlend 的長度,1 位元組
#define ZIPLIST_END_SIZE        (sizeof(uint8_t))
// 指向第一個 entry 起始位置的指標
#define ZIPLIST_ENTRY_HEAD(zl)  ((zl)+ZIPLIST_HEADER_SIZE)
// 指向最後一個 entry 起始位置的指標
#define ZIPLIST_ENTRY_TAIL(zl)  ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl)))
// 相當於 zlend,指向 ziplist 最後一個位元組
#define ZIPLIST_ENTRY_END(zl)   ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)

以下是重建新的空 ziplist 的程式碼實現,在 ziplist.c 中:

unsigned char *ziplistNew(void) {
	// ziplist 頭加上結尾標誌位元組數,就是 ziplist 使用記憶體的位元組數了
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
    unsigned char *zl = zmalloc(bytes);
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    // 因為沒有 entry 列表,所以尾部偏移量是 ZIPLIST_HEADER_SIZE
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    // entry 節點數量是 0
    ZIPLIST_LENGTH(zl) = 0;
    // 設定尾標識。
    // #define ZIP_END 255 
    zl[bytes-1] = ZIP_END;
    return zl;
}

entry 節點的結構

佈局

節點的結構一般是:<prevlen> <encoding> <entry-data>

  • prevlen:前一個 entry 的大小,用於反向遍歷。
  • encoding:編碼,由於 ziplist 就是用來節省空間的,所以 ziplist 有多種編碼,用來表示不同長度的字串或整數。
  • data:用於儲存 entry 真實的資料;

prevlen

節點的 prevlen 屬性以位元組為單位,記錄了壓縮列表中前一個節點的長度。編碼長度可以是 1 位元組或者 5 位元組。

  • 當前面節點長度小於 254 的時候,長度為 1 個位元組。
  • 當前面節點長度大於 254 的時候,1 個位元組不夠存了。前面第一個位元組就設定為 254,後面 4 個位元組才是真正的前面節點的長度。

下圖展示了 1 位元組 和 5 位元組 prevlen 的示意圖(來源)
不同長度的 prevlen 示意圖

prevlen 屬性主要的作用是反向遍歷。通過 ziplistzltail,我們可以得到最後一個節點的位置,接著可以獲取到前一個節點的長度 len,指標向前移動 len,就是指向倒數第二個節點的位置了。以此類推,可以一直往前遍歷。

encoding

encoding 記錄了節點的 data 屬性所儲存資料的型別和長度。型別主要有兩種:字串和整數。

型別 1. 字串

如果 encoding0001 或者 10 開頭,就表示資料型別是字串

#define ZIP_STR_06B (0 << 6)
#define ZIP_STR_14B (1 << 6)
#define ZIP_STR_32B (2 << 6)

字串有三種編碼:

  • 長度 < 2^6 時,以 00 開頭,後 6 位表示 data 的長度,。
  • 2^6 <= 長度 < 2^14 時,以 01 開頭,後續 6 位 + 下一個位元組的 8 位 = 14 位表示 data 的長度。
  • 2^14 <= 長度 < 2^32 位元組時,以 10 開頭,後續 6 位不用,從下一位元組起連續 32 位表示 data 的長度。

下圖為字串三種長度結構的示意圖(來源):
ziplist 字串編碼示意圖

型別 2. 整數

如果 encoding11 開頭,就表示資料型別是整數

#define ZIP_INT_16B (0xc0 | 0<<4)
#define ZIP_INT_32B (0xc0 | 1<<4)
#define ZIP_INT_64B (0xc0 | 2<<4)
#define ZIP_INT_24B (0xc0 | 3<<4)
#define ZIP_INT_8B 0xfe

#define ZIP_INT_IMM_MIN 0xf1    /* 11110001 */
#define ZIP_INT_IMM_MAX 0xfd    /* 11111101 */

整數一共有 6 種編碼,說起來麻煩,看圖吧(來源)。
ziplist 整數編碼示意圖
看了上圖的最後一個型別,可能有小夥伴就有疑問:為啥沒有 11111111
答:因為 11111111 表示 zlend (十進位制的 255,十六進位制的 oxff)

data

data 表示真實存的資料,可以是字串或者整數,從編碼可以得知型別和長度。知道長度,就知道 data 的起始位置了。

比較特殊的是,整數 1 ~ 13 (0001 ~ 1101),因為比較短,剛好可以塞在 encoding 欄位裡面,所以就沒有 data

連鎖更新

通過上面的分析,我們知道:

  • 前個節點的長度小於 254 的時候,用 1 個位元組儲存 prevlen
  • 前個位元組的長度大於等於 254 的時候,用 5 個位元組儲存 prevlen

現在我們來考慮一種情況:假設一個壓縮列表中,有多個長度 250 ~ 253 的節點,假設是 entry1 ~ entryN。
因為都是小於 254,所以都是用 1 個位元組儲存 prevlen
如果此時,在壓縮列表最前面,插入一個 254 長度的節點,此時它的長度需要 5 個位元組
也就是說 entry1.prevlen 會從 1 個位元組變為 5 個位元組,因為 prevlen 變長,entry1 的長度超過 254 了。
這下就糟糕了,entry2.prevlen 也會因為 entry1 而變長,entry2 長度也會超過 254 了。
然後接著 entry3 也會連鎖更新。。。直到節點不超過 254, 噩夢終止。。。

這種由於一個節點的增刪,後續節點變長而導致的連續重新分配記憶體的現象,就是連鎖更新。最壞情況下,會導致整個壓縮列表的所有節點都重新分配記憶體。

每次分配空間的最壞時間複雜度是 \(O(n)\),所以連鎖更新的最壞時間複雜度高達 \(O(n^2)\) !

雖然說,連鎖更新的時間複雜度高,但是它造成大的效能影響的概率很低,原因如下:

  1. 壓縮列表中需要需要有連續多個長度剛好為 250 ~ 253 的節點,才有可能發生連鎖更新。實際上,這種情況並不多見。
  2. 即使有連續多個長度剛好為 250 ~ 253 的節點,連續的個數也不多,不會對效能造成很大影響

因此,壓縮列表插入操作,平均複雜度還是 \(O(n)\).

總結:

  • 壓縮列表是一種為節約記憶體而開發的順序型資料結構,是 ZSET、HASH 和 LIST 的底層實現之一。
  • 壓縮列表有 3 種字串型別編碼、6 種整數型別編碼
  • 壓縮列表的增刪,可能會引發連鎖更新操作,但這種操作出現的機率並不高。

本文的分析沒有特殊說明都是基於 Redis 6.0 版本原始碼
redis 6.0 原始碼:https://github.com/redis/redis/tree/6.0

相關文章