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。