1. 程式人生 > >Redis 設計與實現 5:壓縮列表 ziplist

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

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