Redis 設計與實現 5:壓縮列表 ziplist
阿新 • • 發佈:2020-12-28
壓縮列表是 ZSET、HASH和 LIST 型別的其中一種編碼的底層實現,是由一系列特殊編碼的連續記憶體塊組成的順序型資料結構,其目的是節省記憶體。
# ziplist 的結構
## 外層結構
下圖展示了壓縮列表的組成:
![ziplist 的結構](https://img-blog.csdnimg.cn/20201227004951909.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2lkZXNpcmVjY3g=,size_16,color_FFFFFF,t_70)
各個欄位的含義如下:
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`。
5. `entryX`:壓縮列表儲存資料的節點,可以為位元組陣列或者整數。
6. `zlend`:壓縮列表的結尾,佔一個位元組,恆為 `0xFF`。
實現的程式碼 `ziplist.c` 中,`ziplist` 定義成了巨集屬性。
```c
// 相當於 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` 中:
```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`:前一個 `entry` 的大小,用於反向遍歷。
- `encoding`:編碼,由於 `ziplist` 就是用來節省空間的,所以 `ziplist` 有多種編碼,用來表示不同長度的字串或整數。
- `data`:用於儲存 `entry` 真實的資料;
### prevlen
節點的 `prevlen` 屬性以位元組為單位,記錄了壓縮列表中**前一個節點的長度**。編碼長度可以是 1 位元組或者 5 位元組。
- 當前面節點長度小於 254 的時候,長度為 1 個位元組。
- 當前面節點長度大於 254 的時候,1 個位元組不夠存了。前面第一個位元組就設定為 254,後面 4 個位元組才是真正的前面節點的長度。
下圖展示了 1 位元組 和 5 位元組 prevlen 的示意圖([來源](https://segmentfault.com/a/1190000016901154))
![不同長度的 prevlen 示意圖](https://img-blog.csdnimg.cn/20201227213614286.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2lkZXNpcmVjY3g=,size_16,color_FFFFFF,t_70)
`prevlen` 屬性主要的作用是**反向遍歷**。通過 `ziplist` 的 `zltail`,我們可以得到最後一個節點的位置,接著可以獲取到前一個節點的長度 len,指標向前移動 len,就是指向倒數第二個節點的位置了。以此類推,可以一直往前遍歷。
### encoding
`encoding` 記錄了節點的 `data` 屬性所儲存資料的型別和長度。型別主要有兩種:字串和整數。
#### 型別 1. 字串
如果 `encoding` 以 `00`、`01` 或者 `10` 開頭,就表示資料型別是**字串**。
```c
#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 的長度。
下圖為字串三種長度結構的示意圖([來源](https://segmentfault.com/a/1190000016901154)):
![ziplist 字串編碼示意圖](https://img-blog.csdnimg.cn/20201227215730198.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2lkZXNpcmVjY3g=,size_16,color_FFFFFF,t_70)
#### 型別 2. 整數
如果 `encoding` 以 `11` 開頭,就表示資料型別是**整數**。
```c
#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** 種編碼,說起來麻煩,看圖吧([來源](https://segmentfault.com/a/1190000016901154))。
![ziplist 整數編碼示意圖](https://img-blog.csdnimg.cn/2020122722121924.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2lkZXNpcmVjY3g=,size_16,color_FFFFFF,t_70)
看了上圖的最後一個型別,可能有小夥伴就有疑問:為啥沒有 `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/