壓縮列表是 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