壓縮列表是 ZSET、HASH和 LIST 型別的其中一種編碼的底層實現,是由一系列特殊編碼的連續記憶體塊組成的順序型資料結構,其目的是節省記憶體。
ziplist 的結構
外層結構
下圖展示了壓縮列表的組成:
各個欄位的含義如下:
zlbytes
:是一個無符號 4 位元組整數,儲存著 ziplist 使用的記憶體數量。
通過zlbytes
,程式可以直接對 ziplist 的記憶體大小進行調整,無須為了計算 ziplist 的記憶體大小而遍歷整個列表。zltail
:壓縮列表 最後一個 entry 距離起始地址的偏移量,佔 4 個位元組。
這個偏移量使得對錶尾的pop
操作可以在無須遍歷整個列表的情況下進行。zllen
:壓縮列表的節點entry
數目,佔 2 個位元組。
當壓縮列表的元素數目超過2^16 - 2
的時候,zllen
會設定為2^16-1
,當程式查詢到值為2^16-1
,就需要遍歷整個壓縮列表才能獲取到元素數目。所以zllen
並不能替代zltail
。entryX
:壓縮列表儲存資料的節點,可以為位元組陣列或者整數。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
屬性主要的作用是反向遍歷。通過 ziplist
的 zltail
,我們可以得到最後一個節點的位置,接著可以獲取到前一個節點的長度 len,指標向前移動 len,就是指向倒數第二個節點的位置了。以此類推,可以一直往前遍歷。
encoding
encoding
記錄了節點的 data
屬性所儲存資料的型別和長度。型別主要有兩種:字串和整數。
型別 1. 字串
如果 encoding
以 00
、01
或者 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 的長度。
下圖為字串三種長度結構的示意圖(來源):
型別 2. 整數
如果 encoding
以 11
開頭,就表示資料型別是整數。
#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 種編碼,說起來麻煩,看圖吧(來源)。
看了上圖的最後一個型別,可能有小夥伴就有疑問:為啥沒有 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)\) !
雖然說,連鎖更新的時間複雜度高,但是它造成大的效能影響的概率很低,原因如下:
- 壓縮列表中需要需要有連續多個長度剛好為 250 ~ 253 的節點,才有可能發生連鎖更新。實際上,這種情況並不多見。
- 即使有連續多個長度剛好為 250 ~ 253 的節點,連續的個數也不多,不會對效能造成很大影響
因此,壓縮列表插入操作,平均複雜度還是 \(O(n)\).
總結:
- 壓縮列表是一種為節約記憶體而開發的順序型資料結構,是 ZSET、HASH 和 LIST 的底層實現之一。
- 壓縮列表有 3 種字串型別編碼、6 種整數型別編碼
- 壓縮列表的增刪,可能會引發連鎖更新操作,但這種操作出現的機率並不高。
本文的分析沒有特殊說明都是基於 Redis 6.0 版本原始碼
redis 6.0 原始碼:https://github.com/redis/redis/tree/6.0