Redis 記憶體壓縮原理

發表於2020-08-02

Redis 無疑是一個大量消耗記憶體的資料庫,因此 Redis 引入了一些設計巧妙的資料結構進行記憶體壓縮來減輕負擔。ziplist、quicklist 以及 intset 是其中最常用最重要的壓縮儲存結構。

瞭解編碼型別

Redis對外提供了 string, list, hash, set, zset等資料型別,每種資料型別可能存在多種不同的底層實現,這些底層資料結構被稱為編碼(encoding)。

以 list 型別為例,其經典的實現方式為雙向連結串列(linkedlist)。雙向連結串列的每個節點擁有一個前向指標一個後向指標,在64位系統下每個節點佔用了 2 * 64bit = 16 Byte 的額外空間。因此當 list 中元素較少時會使用 ziplist 作為底層資料結構。

object encoding <key> 命令可以檢視某個 key 的編碼型別:

127.0.0.1:6379> set a 1
OK
127.0.0.1:6379> object encoding a
"int"
127.0.0.1:6379> rpush l 1
(integer) 1
127.0.0.1:6379> object encoding l
"ziplist"

先總結一下各種資料結構可以使用的編碼型別,下文再對這些壓縮型別進行詳細說明:

  • string
    • raw: 動態字串(SDS)
    • embstr: 優化記憶體分配的字串編碼
    • int: 整數
  • list
    • linkedlist
    • ziplist
    • quicklist
  • set
    • hashtable
    • intset
  • hash
    • ziplist
    • hashtable
  • zset(sortedset)
    • ziplist
    • skiplist

本文接下來將詳細說明各種壓縮編碼的原理以及編碼決定規則。

ziplist

ziplist 是一段連續記憶體,類似於陣列結構。當元素比較少時使用陣列結構不僅節省記憶體,而且遍歷操作的開銷也不大。因此 list, hash, zset 在元素較少時都採用 ziplist 儲存。

ziplist 的原始碼可以在: redis/ziplist.c 中找到。

ziplist 儲存為一段裸二進位制資料(unsigned char *), 可以看到原始碼中大量使用巨集進行定義,雖然節省了大量記憶體但是程式碼可讀性較低。

ziplist 的結構:

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
  • zlbytes: uint32 型, 儲存整個ziplist當前被分配的空間,包含自身佔用的4個位元組。
  • zltail: uint32 型, 儲存ziplist中最後一個entry相對頭部的偏移量, 用於直接訪問尾端元素避免遍歷。
  • zllen: uint16 型, 記錄 ziplist 中元素的個數
  • entry: 實際儲存元素的單元
  • zlend: 魔法數字 255 標記 ziplist 的結尾, 沒有 entry 以 0xff 開頭不會出現誤判的問題

entry 是實際儲存資料的單元, 可以儲存 int 或 string 型別資料。在儲存 string 型別資料時 entry 的結構為:

  • prevlen: 表示前一個 entry 的長度,用於從後向前遍歷。
  • encoding: 儲存當前 entry 的資料型別和長度
  • entry-data: 實際的資料部分

當儲存 int 型別的資料時, 資料(entry-data)會被合併到 encoding 內部,此時沒有 entry-data 欄位。

當前一個元素長度小於254(255用於zlend)時,prevlen長度為1個位元組,值為前一個entry的長度;如果長度大於等於254,prevlen 用5個位元組表示,第一位元組設定為254,後面4個位元組儲存一個小端的無符號整型,表示前一個entry的長度。

encoding 用來表示 entry 的資料型別和長度。encoding 的全部定義可以在 ziplist.c 中找到。

下面列出幾種 encoding 的示例,encoding 中的字母表示一個bit:

  • 00pppppp: encoding 的長度為一個位元組,後6位表示字串的長度。因為長度最多6位,因此字串的長度不超過63
  • 01pppppp qqqqqqqq: encoding 的長度為兩個位元組, 後14位儲存字串的長度,因此字串的長度不超過16383
  • 11000000: encoding為3個位元組,後2個位元組表示一個int16
  • 1110000: encoding為4個位元組,後3個位元組表示一個有符號整型
  • 11111111: zlend

前面提到每個 entry 都會有一個 prevlen 欄位儲存前一個 entry 的長度。如果內容小於 254 位元組,prevlen 用 1 位元組儲存,否則就是 5 位元組。這意味著如果某個 entry 經過了修改操作從 253 位元組變成了 254 位元組,那麼它的下一個 entry 的 prevlen 欄位就要更新,從 1 個位元組擴充套件到 5 個位元組;如果這個 entry 的長度本來也是 253 位元組,那麼後面 entry 的 prevlen 欄位還得繼續更新。這種現象被稱為 ziplist 的級聯更新,新增、修改、刪除元素的操作都有可能導致級聯更新。

ziplist 不會預留擴充套件空間,每次插入一個新的元素就需要呼叫 realloc 擴充套件記憶體, 並可能需要將原有內容拷貝到新地址。

綜上,ziplist 是一個使用連續記憶體儲存資料,類似於陣列的資料結構。可以 O(1) 的時間複雜度訪問首尾元素。因為 entry 長度不確定,可以向前或向後順序訪問,不能隨機訪問。因為級聯更新的現象的存在,新增、修改、刪除元素操作的複雜度在 O(n) 到 O(n^2) 之間。

在滿足下列條件時, list, hash 和 sortedset 三種結構會採用 ziplist 編碼:

  • list: value 位元組數 <= list-max-ziplist-value 且 元素數 <= list-max-ziplist-entries
  • hash: value 位元組數 <= hash-max-ziplist-value 且 元素數 <= hash-max-ziplist-entries
  • zset: value 位元組數 <= zset-max-ziplist-value 且 元素數 <= zset-max-ziplist-entries

ziplist 儲存 list 時每個元素會作為一個 entry; 儲存 hash 時 key 和 value 會作為相鄰的兩個 entry; 儲存 zset 時 member 和 score 會作為相鄰的兩個entry。

當不滿足上述條件時,ziplist 會升級為 linkedlist, hashtable 或 skiplist 編碼。在任何情況下大記憶體的編碼都不會降級為 ziplist。

quicklist

Redis 3.2 版本引入了 quicklist 作為 list 的底層實現,不再使用 linkedlist 和 ziplist 實現。quicklist 是 ziplist 組成的雙向連結串列,它的每個節點都是一個 ziplist。

quicklist 是結合了 linkedlist 和 ziplist 優點的產物:

  • linkedlist 便於進行增刪改操作但是記憶體佔用較大
  • ziplist 記憶體佔用較少,但是因為每次修改都可能觸發 realloc 和 memcopy, 並且可能導致級聯更新。因此修改操作的效率較低,在 ziplist 較長時這個問題更加突出。

於是每個節點上 ziplist 的大小變成了一個需要折中的難題:

  • ziplist 越小,quicklist 越接近於 linkedlist。此時儲存效率下降,但是修改操作的效率較高。
  • ziplist 越大,quicklist 越接近於 ziplist。此時儲存效率上升,但是修改操作的效率降低。

redis 根據 list-max-ziplist-size 配置項來決定節點上 ziplist 的長度。

list-max-ziplist-size 為正值的時候,表示按照資料項個數來限定每個 quicklist 節點上的 ziplist 長度。比如,當這個引數配置成5的時候,表示每個 quicklist 節點的ziplist 最多包含5個資料項。

當為負值的時候,表示按照佔用位元組數來限定每個節點上的 ziplist 長度。這時,它只能取 -1 到 -5 這五個值:

  • -5: 每個節點上的 ziplist 大小不能超過64 KB
  • -4: 每個節點上的 ziplist 大小不能超過 32 KB。
  • -3: 每個節點上的 ziplist 大小不能超過16 Kb。
  • -2: 每個節點上的 ziplist 大小不能超過8 Kb。這是 redis 的預設設定。
  • -1: 每個節點上的 ziplist 大小不能超過4 Kb。

壓縮中間節點

對於一個很長的列表而言,最常使用的是其兩端的資料,中間資料被訪問的概率較低。因此,quicklist 允許將中間的節點使用 LZF 演算法進行壓縮以節省記憶體。

list-compress-depth 表示quicklist兩端不被壓縮的節點個數:

  • 0: 表示都不壓縮。這是Redis的預設值。
  • 1: 表示quicklist兩端各有1個節點不壓縮,中間的節點壓縮。
  • 2: 表示quicklist兩端各有2個節點不壓縮,中間的節點壓縮。
  • 以此類推...

intset

當集合中的元素均為整數且元素數少於 set-max-intset-entries 時,redis 採用 inset 編碼儲存集合。當插入非整數元素或元素數超過閾值後,intset 會升級為 hashtable 編碼進行儲存。

intset 的原始碼可以在: redis/intset.c 中找到。

intset 是整數元素組成的有序陣列, 可以支援 O(logn) 級別的查詢。

intset 的記憶體結構與 ziplist 類似是一段的記憶體。它由三個部分組成:

  • encoding: 表示intset中的每個資料元素用幾個位元組來儲存。它有三種可能的取值:
    • INTSET_ENC_INT16表示每個元素用2個位元組儲存
    • INTSET_ENC_INT32表示每個元素用4個位元組儲存
    • INTSET_ENC_INT64表示每個元素用8個位元組儲存。
  • length: 表示intset中的元素個數。encoding和length兩個欄位構成了intset的頭部(header)。
  • contents: 表示實際儲存的內容。它是一個C語言的柔性陣列(flexible array member)

需要注意的是,每次新增元素 intset 都會檢查是否需要將 INTSET_ENCODING 升級為更長的整數。與每個 entry 擁有獨立 encoding 的 ziplist 不同,inset 中所有成員使用統一的 encoding。

相關文章