犧牲速度來節省記憶體,Redis是覺得自己太快了嗎

雙子孤狼發表於2021-01-15

前言

本文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>

其中 zlbyteszltailzllenziplisthead 部分,entryziplistentries 部分,每一個 entry 代表一個資料,最後 zlend 表示 ziplistend 部分,如下圖所示:

ziplist 中每個屬性代表的含義如下表格所示:

屬性 型別 長 度 說明
zlbytes uint32_t 4位元組 記錄壓縮列表佔用記憶體位元組數(包括本身所佔用的 4 個位元組)。
zltail uint32_t 4位元組 記錄壓縮列表尾節點距離壓縮列表的起始地址有多少個位元組(通過這個值可以計算出尾節點的地址)
zllen uint16_t 2位元組 記錄壓縮列表中包含的節點數量,當列表值超過可以儲存的最大值(65535)時,此值固定儲存 65535(即 216 次方 減 1),因此此時需要遍歷整個壓縮列表才能計算出真實節點數。
entry 節點 - 壓縮列表中的各個節點,長度由儲存的實際資料決定。
zlend uint8_t 1位元組 特殊字元 0xFF(即十進位制 255),用來標記壓縮列表的末端(其他正常的節點沒有被標記為 255 的,因為 255 用來標識末尾,後面可以看到,正常節點都是標記為 254)。

entry 儲存結構

ziplistheadend 存的都是長度和標記,而 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,此時 prevlen5 個位元組來表示,其中第 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,而儲存位元組陣列時,則可能是 000110 三種中的一種。

  • 當儲存整數時,第 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,但是 000011111110 已經被表格中前面表示的資料型別佔用了,所以實際上的範圍是 0001-1101,此時能儲存資料 1-13,再減去 1 之後範圍就是 0-12。至於為什麼要減去 1 是從使用習慣來說 0 是一個非常常用的資料,所以才會選擇統一減去 1 來儲存一個 0-12 的區間而不是直接儲存 1-13 的區間。

  • 當儲存位元組陣列時,第 1 個位元組的前 2 位為 0001 或者 10,其他位則用來記錄位元組陣列的長度:
編碼 長度 entry儲存的資料
00pppppp 1位元組 長度小於等於 63 位元組(6 位)的位元組陣列
01pppppp qqqqqqqq 2位元組 長度小於等於 16383 位元組(14 位)的位元組陣列
10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt 5位元組 長度小於等於 232 次方減 132 位)的位元組陣列,其中第 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 個節點,節點中儲存的是整數 25

[0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
      |             |          |       |       |     |
   zlbytes        zltail     zllen    "2"     "5"   end
  1. 第一組 4 個位元組為 zlbytes 部分,0f 轉成二進位制就是 1111 也就是 15,代表整個 ziplist 長度是 15 個位元組。
  2. 第二組 4 個位元組 zltail 部分,0c 轉成二進位制就是 1100 也就是 12,這裡記錄的是壓縮列表尾節點距離起始地址有多少個位元組,也就是就是說 [02 f6] 這個尾節點距離起始位置有 12 個位元組。
  3. 第三組 2 個位元組就是記錄了當前 ziplistentry 的數量,02 轉成二進位制就是 10,也就是說當前 ziplist2 個節點。
  4. 第四組 2 個位元組 [00 f3] 就是第一個 entry00 表示 0,因為這是第 1 個節點,所以前一個節點長度為 0f3 轉成二進位制就是 11110011,剛好對應了表格中的編碼 1111xxxx,所以後面四位就是儲存了一個 0-12位的整數。0011 轉成十進位制就是 3,減去 1 得到 2,所以第一個 entry 儲存的資料就是 2
  5. 第五組 2 個位元組 [02 f6] 就是第二個 entry02 即為 2,表示前一個節點的長度為 2,注意,因為這裡算出來的結果是小於 254,所以就代表了這裡只用到了 1 個位元組來儲存上一個節點的長度(如果等於 254,這說明接下來 4 個位元組才儲存的是長度),所以後面的 f6 就是當前節點的資料,轉換成二進位制為 11110110,對應了表格中的編碼 1111xxxx,同樣的後四位 0110 儲存的是真實資料,計算之後得出是5。
  6. 最後一組1個位元組[ff]轉成二進位制就是 11111111,代表這是整個 ziplist 的結尾。

假如這時候又新增了一個 Hello World 字串到列表中,那麼就會新增一個 entry,如下所示:

[02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64]
  1. 第一組的 1 個位元組 02 轉成十進位制就是 2 ,表示前一個節點(即上面示例中的 [02 f6])長度是 2
  2. 第 二組的2 個位元組 0b 轉成二進位制為 00001011,以 00 開頭,符合編碼 00pppppp,而除掉最開始的兩位 00,計算之後得到十進位制 11,這就說明後面位元組陣列的長度是 11
  3. 第三組剛好是 11 個位元組,對應了上面的長度,所以這裡就是真正儲存了 Hello World 的位元組陣列。

ziplist 連鎖更新問題

上面提到 entry 中的 prevlen 屬性可能是 1 個位元組也可能是 5 個位元組,那麼我們來設想這麼一種場景:假設一個 ziplist 中,連續多個 entry 的長度都是一個接近但是又不到 254 的值(介於 250~253 之間),那麼這時候 ziplist 中每個節點都只用了 1 個位元組來儲存上一個節點的長度,假如這時候新增了一個新節點,如 entry1 ,其長度大於 254 個位元組,此時 entry1 的下一個節點 entry2prelen 屬性就必須要由 1 個位元組變為 5 個位元組,也就是需要執行空間重分配,而此時 entry2 因為增加了 4 個位元組,導致長度又大於 254 個位元組了,那麼它的下一個節點 entry3prelen 屬性也會被改變為 5 個位元組。依此類推,這種產生連續多次空間重分配的現象就稱之為連鎖更新。同樣的,不僅僅是新增節點,執行刪除節點操作同樣可能會發生連鎖更新現象。

雖然 ziplist 可能會出現這種連鎖更新的場景,但是一般如果只是發生在少數幾個節點之間,那麼並不會嚴重影響效能,而且這種場景發生的概率也比較低,所以實際使用時不用過於擔心。

總結

本文主要講解了 Redis 當中的 ziplist(壓縮列表),一種用時間換取空間的資料結構,在介紹壓縮列表儲存結構的同時通過一個儲存示例來分析了 ziplist 是如何儲存資料的,最後介紹了 ziplist 中可能發生的連鎖更新問題。

相關文章