前言
本文GitHub已收錄:https://zhouwenxing.github.io/
正常情況下我們選擇使用 Redis
就是為了提升查詢速度,然而讓人意外的是,Redis
當中卻有一種比較有意思的資料結構,這種資料結構通過犧牲部分讀寫速度來達到節省記憶體的目的,這就是 ziplist
(壓縮列表),Redis
為什麼要這麼做呢?難道真的是覺得自己的速度太快了,犧牲一點速度也不影響嗎?
什麼是壓縮列表
ziplist
是為了節省記憶體而設計出來的一種資料結構。ziplist
是由一系列特殊編碼組成的連續記憶體塊的順序型資料結構,一個 ziplist
可以包含任意多個 entry
,而每一個 entry
又可以儲存一個位元組陣列或者一個整數值。
ziplist
作為一種列表,其和普通的雙端列表,如 linkedlist
的最大區別就是 ziplist
並不儲存前後節點的指標,而 linkedlist
一般每個節點都會維護一個指向前置節點和一個指向後置節點的指標。那麼 ziplist
不維護前後節點的指標,它又是如何尋找前後節點的呢?
ziplist
雖然不維護前後節點的指標,但是它卻維護了上一個節點的長度和當前節點的長度,然後每次通過長度來計算出前後節點的位置。既然涉及到了計算,那麼相對於直接儲存指標的方式肯定有效能上的損耗,這就是一種典型的用時間來換取空間的做法。因為每次讀取前後節點都需要經過計算才能得到前後節點的位置,所以會消耗更多的時間,而在 Redis
中,一個指標是佔了 8
個位元組,但是大部分情況下,如果直接儲存長度是達不到 8
個位元組的,所以採用儲存長度的設計方式在大部分場景下是可以節省記憶體空間的。
ziplist 的儲存結構
ziplist
的組成結構為:
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
其中 zlbytes
,zltail
,zllen
為 ziplist
的 head
部分,entry
為 ziplist
的 entries
部分,每一個 entry
代表一個資料,最後 zlend
表示 ziplist
的 end
部分,如下圖所示:
ziplist
中每個屬性代表的含義如下表格所示:
屬性 | 型別 | 長 度 | 說明 |
---|---|---|---|
zlbytes | uint32_t | 4位元組 | 記錄壓縮列表佔用記憶體位元組數(包括本身所佔用的 4 個位元組)。 |
zltail | uint32_t | 4位元組 | 記錄壓縮列表尾節點距離壓縮列表的起始地址有多少個位元組(通過這個值可以計算出尾節點的地址) |
zllen | uint16_t | 2位元組 | 記錄壓縮列表中包含的節點數量,當列表值超過可以儲存的最大值(65535 )時,此值固定儲存 65535 (即 2 的 16 次方 減 1 ),因此此時需要遍歷整個壓縮列表才能計算出真實節點數。 |
entry | 節點 | - | 壓縮列表中的各個節點,長度由儲存的實際資料決定。 |
zlend | uint8_t | 1位元組 | 特殊字元 0xFF (即十進位制 255 ),用來標記壓縮列表的末端(其他正常的節點沒有被標記為 255 的,因為 255 用來標識末尾,後面可以看到,正常節點都是標記為 254 )。 |
entry 儲存結構
ziplist
的 head
和 end
存的都是長度和標記,而 entry
儲存的是具體元素,這又是經過特殊的設計的一種儲存格式,每個 entry
都以包含兩段資訊的後設資料作為字首,每一個 entry
的組成結構為:
<prevlen> <encoding> <entry-data>
prevlen
prevlen
屬性儲存了前一個 entry
的長度,通過此屬效能夠從後到前遍歷列表。 prevlen
屬性的長度可能是 1
位元組也可能是 5
位元組:
- 當連結串列的前一個
entry
佔用位元組數小於254
,此時prevlen
只用1
個位元組進行表示。
<prevlen from 0 to 253> <encoding> <entry>
- 當連結串列的前一個
entry
佔用位元組數大於等於254
,此時prevlen
用5
個位元組來表示,其中第1
個位元組的值固定是254
(相當於是一個標記,代表後面跟了一個更大的值),後面4
個位元組才是真正儲存前一個entry
的佔用位元組數。
0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry>
注意:1
個位元組完全你能儲存 255
的大小,之所以只取到 254
是因為 zlend
就是固定的 255
,所以 255
這個數要用來判斷是否是 ziplist
的結尾。
encoding
encoding
屬性儲存了當前 entry
所儲存資料的型別以及長度。encoding
長度為 1
位元組,2
位元組或者 5
位元組長。前面我們提到,每一個 entry
中可以儲存位元組陣列和整數,而 encoding
屬性的第 1
個位元組就是用來確定當前 entry
儲存的是整數還是位元組陣列。當儲存整數時,第 1
個位元組的前兩位總是 11
,而儲存位元組陣列時,則可能是 00
、01
和 10
三種中的一種。
- 當儲存整數時,第
1
個位元組的前2
位固定為11
,其他位則用來記錄整數值的型別或者整數值(下表所示的編碼中前兩位均為11
):
編碼 | 長度 | entry儲存的資料 |
---|---|---|
11000000 | 1位元組 | int16_t型別整數 |
11010000 | 1位元組 | int32_t型別整數 |
11100000 | 1位元組 | int64_t型別整數 |
11110000 | 1位元組 | 24位有符號整數 |
11111110 | 1位元組 | 8位有符號整數 |
1111xxxx | 1位元組 | xxxx 代表區間 0001-1101 ,儲存了一個介於 0-12 之間的整數,此時 entry-data 屬性被省略 |
注意:xxxx
四位編碼範圍是 0000-1111
,但是 0000
,1111
和 1110
已經被表格中前面表示的資料型別佔用了,所以實際上的範圍是 0001-1101
,此時能儲存資料 1-13
,再減去 1
之後範圍就是 0-12
。至於為什麼要減去 1
是從使用習慣來說 0
是一個非常常用的資料,所以才會選擇統一減去 1
來儲存一個 0-12
的區間而不是直接儲存 1-13
的區間。
- 當儲存位元組陣列時,第
1
個位元組的前2
位為00
、01
或者10
,其他位則用來記錄位元組陣列的長度:
編碼 | 長度 | entry儲存的資料 |
---|---|---|
00pppppp | 1位元組 | 長度小於等於 63 位元組(6 位)的位元組陣列 |
01pppppp qqqqqqqq | 2位元組 | 長度小於等於 16383 位元組(14 位)的位元組陣列 |
10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt | 5位元組 | 長度小於等於 2 的 32 次方減 1 (32 位)的位元組陣列,其中第 1 個位元組的後 6 位設定為 0 ,暫時沒有用到,後面的 32 位(4 個位元組)儲存了資料 |
entry-data
entry-data
儲存的是具體資料。當儲存小整數(0-12
)時,因為 encoding
就是資料本身,此時 entry-data
部分會被省略,省略了 entry-data
部分之後的 ziplist
中的 entry
結構如下:
<prevlen> <encoding>
壓縮列表中 entry
的資料結構定義如下(原始碼 ziplist.c
檔案內),當然實際儲存並沒有直接使用到這個結構定義,這個結構只是用來接收資料,所以大家瞭解一下就可以了:
typedef struct zlentry {
unsigned int prevrawlensize;//儲存prevrawlen所佔用的位元組數
unsigned int prevrawlen;//儲存上一個連結串列節點需要的位元組數
unsigned int lensize;//儲存len所佔用的位元組數
unsigned int len;//儲存連結串列當前節點的位元組數
unsigned int headersize;//當前連結串列節點的頭部大小(prevrawlensize + lensize)即非資料域的大小
unsigned char encoding;//編碼方式
unsigned char *p;//指向當前節點的起始位置(因為列表內的資料也是一個字串物件)
} zlentry;
ziplist 資料示例
上面講解了大半天,可能大家都覺得枯燥無味了,也可能會覺得雲裡霧裡,這個沒有關係,這些只要心裡有個概念,用到的時候再查詢對應資料就可以了,並不需要全部記住,接下來讓我們一起通過兩個例子來體會一下 ziplist
到底是如何來組織儲存資料的。
下面就是一個壓縮列表的儲存示例,這個壓縮列表裡面儲存了 2
個節點,節點中儲存的是整數 2
和 5
:
[0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
| | | | | |
zlbytes zltail zllen "2" "5" end
- 第一組
4
個位元組為zlbytes
部分,0f
轉成二進位制就是1111
也就是15
,代表整個ziplist
長度是15
個位元組。 - 第二組
4
個位元組zltail
部分,0c
轉成二進位制就是1100
也就是12
,這裡記錄的是壓縮列表尾節點距離起始地址有多少個位元組,也就是就是說[02 f6]
這個尾節點距離起始位置有12
個位元組。 - 第三組
2
個位元組就是記錄了當前ziplist
中entry
的數量,02
轉成二進位制就是10
,也就是說當前ziplist
有2
個節點。 - 第四組
2
個位元組[00 f3]
就是第一個entry
,00
表示0
,因為這是第1
個節點,所以前一個節點長度為0
,f3
轉成二進位制就是11110011
,剛好對應了表格中的編碼1111xxxx
,所以後面四位就是儲存了一個0-12
位的整數。0011
轉成十進位制就是3
,減去1
得到2
,所以第一個entry
儲存的資料就是2
。 - 第五組
2
個位元組[02 f6]
就是第二個entry
,02
即為2
,表示前一個節點的長度為2
,注意,因為這裡算出來的結果是小於254
,所以就代表了這裡只用到了1
個位元組來儲存上一個節點的長度(如果等於254
,這說明接下來4
個位元組才儲存的是長度),所以後面的f6
就是當前節點的資料,轉換成二進位制為11110110
,對應了表格中的編碼1111xxxx
,同樣的後四位0110
儲存的是真實資料,計算之後得出是5。 - 最後一組1個位元組[ff]轉成二進位制就是
11111111
,代表這是整個ziplist
的結尾。
假如這時候又新增了一個 Hello World
字串到列表中,那麼就會新增一個 entry
,如下所示:
[02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64]
- 第一組的
1
個位元組02
轉成十進位制就是2
,表示前一個節點(即上面示例中的[02 f6]
)長度是2
。 - 第 二組的
2
個位元組0b
轉成二進位制為00001011
,以00
開頭,符合編碼00pppppp
,而除掉最開始的兩位00
,計算之後得到十進位制11
,這就說明後面位元組陣列的長度是11
。 - 第三組剛好是
11
個位元組,對應了上面的長度,所以這裡就是真正儲存了Hello World
的位元組陣列。
ziplist 連鎖更新問題
上面提到 entry
中的 prevlen
屬性可能是 1
個位元組也可能是 5
個位元組,那麼我們來設想這麼一種場景:假設一個 ziplist
中,連續多個 entry
的長度都是一個接近但是又不到 254
的值(介於 250~253
之間),那麼這時候 ziplist
中每個節點都只用了 1
個位元組來儲存上一個節點的長度,假如這時候新增了一個新節點,如 entry1
,其長度大於 254
個位元組,此時 entry1
的下一個節點 entry2
的 prelen
屬性就必須要由 1
個位元組變為 5
個位元組,也就是需要執行空間重分配,而此時 entry2
因為增加了 4
個位元組,導致長度又大於 254
個位元組了,那麼它的下一個節點 entry3
的 prelen
屬性也會被改變為 5
個位元組。依此類推,這種產生連續多次空間重分配的現象就稱之為連鎖更新。同樣的,不僅僅是新增節點,執行刪除節點操作同樣可能會發生連鎖更新現象。
雖然 ziplist
可能會出現這種連鎖更新的場景,但是一般如果只是發生在少數幾個節點之間,那麼並不會嚴重影響效能,而且這種場景發生的概率也比較低,所以實際使用時不用過於擔心。
總結
本文主要講解了 Redis
當中的 ziplist
(壓縮列表),一種用時間換取空間的資料結構,在介紹壓縮列表儲存結構的同時通過一個儲存示例來分析了 ziplist
是如何儲存資料的,最後介紹了 ziplist
中可能發生的連鎖更新問題。