本文GitHub已收錄:https://zhouwenxing.github.io/
正常狀況下咱們選擇使用 Redis
就是爲了提高查詢速度,然而讓人意外的是,Redis
當中卻有一種比較有意思的數據結構,這種數據結構經過犧牲部分讀寫速度來達到節省內存的目的,這就是 ziplist
(壓縮列表),Redis
爲何要這麼作呢?難道真的是以爲本身的速度太快了,犧牲一點速度也不影響嗎?java
ziplist
是爲了節省內存而設計出來的一種數據結構。ziplist
是由一系列特殊編碼組成的連續內存塊的順序型數據結構,一個 ziplist
能夠包含任意多個 entry
,而每個 entry
又能夠保存一個字節數組或者一個整數值。git
ziplist
做爲一種列表,其和普通的雙端列表,如 linkedlist
的最大區別就是 ziplist
並不存儲先後節點的指針,而 linkedlist
通常每一個節點都會維護一個指向前置節點和一個指向後置節點的指針。那麼 ziplist
不維護先後節點的指針,它又是如何尋找先後節點的呢?github
ziplist
雖然不維護先後節點的指針,可是它卻維護了上一個節點的長度和當前節點的長度,而後每次經過長度來計算出先後節點的位置。既然涉及到了計算,那麼相對於直接存儲指針的方式確定有性能上的損耗,這就是一種典型的用時間來換取空間的作法。由於每次讀取先後節點都須要通過計算才能獲得先後節點的位置,因此會消耗更多的時間,而在 Redis
中,一個指針是佔了 8
個字節,可是大部分狀況下,若是直接存儲長度是達不到 8
個字節的,因此採用存儲長度的設計方式在大部分場景下是能夠節省內存空間的。數組
ziplist
的組成結構爲:數據結構
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
其中 zlbytes
,zltail
,zllen
爲 ziplist
的 head
部分,entry
爲 ziplist
的 entries
部分,每個 entry
表明一個數據,最後 zlend
表示 ziplist
的 end
部分,以下圖所示:性能
ziplist
中每一個屬性表明的含義以下表格所示:ui
屬性 | 類型 | 長 度 | 說明 |
---|---|---|---|
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 )。 |
ziplist
的 head
和 end
存的都是長度和標記,而 entry
存儲的是具體元素,這又是通過特殊的設計的一種存儲格式,每一個 entry
都以包含兩段信息的元數據做爲前綴,每個 entry
的組成結構爲:編碼
<prevlen> <encoding> <entry-data>
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
屬性存儲了當前 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
存儲的是具體數據。當存儲小整數(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
究竟是如何來組織存儲數據的。
下面就是一個壓縮列表的存儲示例,這個壓縮列表裏面存儲了 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。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
的字節數組。上面提到 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
中可能發生的連鎖更新問題。