犧牲速度來節省記憶體,Redis是覺得自己太快了嗎
- 前言
- 什么是壓縮串列
- ziplist 的存盤結構
- entry 存盤結構
- prevlen
- encoding
- entry-data
- ziplist 資料示例
- ziplist 連鎖更新問題
- 總結
前言
本文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 中可能發生的連鎖更新問題,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/249832.html
標籤:其他
下一篇:linux
