Redis - 數據結構詳解(下)
上期,我們詳細介紹了 Redis 的 3 種底層數據結構。下面我們介紹其餘的數據結構,來看看它們是如何實現的。
壓縮列表
壓縮列表是 Redis 爲了節約內存而開發的,是由一系列特殊編碼的連續內存塊組成的順序性數據結構,我們可以從源碼的註釋中看到官方對它的定義。
The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values, where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time. However, because every operation requires a reallocation of the memory used by the ziplist, the actual complexity is related to the amount of memory used by the ziplist.
也就是說,ziplist 是一種特殊編碼的雙向鏈表,目標是爲了提高內存的存儲效率。它的每個節點可用於存儲字符串或整數值,其中整數是按真正的整數進行編碼,而不是一系列字符。它允許在列表的任意一端以 O(1) 的時間複雜度提供 push 和 pop 操作。但是,它的每個操作都需要進行內存重分配,實際的複雜性與 ziplist 使用的內存大小有關,也就是和它的存儲的節點個數有關。
實際上,ziplist 充分體現了 Redis 對於存儲效率的追求。一個普通的雙向鏈表,鏈表中每一項都佔用獨立的一塊內存,各項之間用指針連接起來,這種方式會帶來大量的內存碎片,而且地址指針也會佔用額外的內存。而 ziplist 卻是將表中每一項存放在前後連續的地址空間內,一個 ziplist 整體佔用一大塊內存。從這種方式理解,其實它是一個表(list),並不是一個鏈表(linked list)。
另外,ziplist 爲了在細節上節省內存,對於值的存儲採用了變長的編碼方式,也就是說,對於大的整數,就多用一些字節來存儲,而對於小的整數,就少用一些字節來存儲。我們接下來就會討論到這些實現細節。
實現
從源碼註釋中可以看到 ziplist 的組成結構:
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
它並沒有像其他數據結構一樣,用自定義的 struct 之類的來表達,而就是簡單的 unsigned char *,可以從它的創建 API 中就可以看出。
// 創建一個新的壓縮列表
unsigned char *ziplistNew(void) {
unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
unsigned char *zl = zmalloc(bytes);
ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
ZIPLIST_LENGTH(zl) = 0;
zl[bytes-1] = ZIP_END;
return zl;
}
其中,各個部分的含義如下:
ziplist 中的每個節點都包含兩個片段的元數據作爲前綴。第一個元數據記錄了前一個節點的長度,方便從後到前遍歷列表。第二個元數據記錄了節點存儲數據的類型和長度。所以一個完整的節點是這樣存儲的:
<prevlen> <encoding> <entry-data>
有時, 屬性本身即存儲了節點自身的類型,也記錄了節點的值,比如一些小整數。在這種情況下,可以表示爲:
<prevlen> <encoding>
連鎖更新
上面介紹中,entry 屬性中保存的 prevlen 有 1 字節和 5 字節,所以就會出現一種情況:當壓縮列表存儲的節點 e1 到 eN 的長度都介於 250 字節到 253 字節之間,如果在 e1 之前插入了一個長度大於等於 254 字節的新節點,由於 prevlen 的改變,會導致連鎖更新,時間複雜度爲 O(n^2)。雖然連鎖更新的複雜度較高,但它真正造成性能問題的概率是很低的,原因如下:
-
壓縮列表要恰好有多個連續的、長度介於 250 字節到 253 字節之間的節點,連鎖更新纔會被觸發,但在實際中,這種情況很少見。
-
即使出現,但只要被更新的節點數量不多,就不會對性能造成任何影響。
使用場景
ziplist 是 哈希對象的底層實現之一,當一個哈希對象包含少量鍵值對,並且每個鍵值對要麼就是小整數值,要麼就是長度比較短的字符串時,Redis 就會使用 ziplist 來作爲哈希對象的底層實現。在 Redis 3.2 版本之前,ziplist 也是列表對象的底層實現之一,但 3.2 版本之後被 quicklist 取代。
跳躍表
跳躍表是一種有序數據結構,通過在每個節點中維持多個指向其他節點的指針,來達到快速訪問節點的目的。相比平衡樹,其實現簡單,而且在大部分情況下的效率能和平衡樹相媲美。
實現
// 跳躍表節點實現
typedef struct zskiplistNode {
// 成員對象(不可與其他節點重複)
sds ele;
// 節點分值(可重複,所有節點按此屬性從小到大排序)
double score;
// 後退指針
struct zskiplistNode *backward;
// 層
struct zskiplistLevel {
// 前進指針
struct zskiplistNode *forward;
// 跨度
unsigned long span;
} level[];
} zskiplistNode;
// 跳躍表實現
typedef struct zskiplist {
// 表頭指針,表尾指針
struct zskiplistNode *header, *tail;
// 跳躍表中節點的數量(不含表頭節點)
unsigned long length;
// 跳躍表中層數最大的節點(不含表頭節點)
int level;
} zskiplist;
多個跳躍表節點組成了跳躍表,程序可以以 O(1) 的時間複雜度獲取表頭、表尾指針以及節點的數量;跳躍表中以 score 屬性的大小進行排序,score 相同,則以成員 ele 屬性的字典序排列;新增節點的層數根據冪次定律取得一個介於 1 和 32 之間的值。
跳躍表的實現中,存在着一個不填充數據,但滿層的頭結點,頭結點存在的原因如下:
-
zskiplist 的頭指針 head 不會隨數據的變動而變動。
-
頭結點的層數達到了最大值 32,在第一次查找時,可以和 zskiplist 中 level 屬性定位查找節點,沒有頭結點的話,只能從第一個填充了數據的節點開始,但它的層數可能不是分層最高的,會拖慢查詢的效率。
使用場景
Redis 使用跳躍表作爲有序集合鍵的底層實現之一,如果一個有序集合包含的元素數量比較多,又或者有序集合中元素的成員是比較長的字符串時,Redis 就會使用跳躍表來作爲有序集合鍵的底層實現。這裏就有個問題,爲什麼 Redis 不使用平衡樹呢?原因有以下幾點:
-
從算法實現難度比較,skiplist 比平衡樹要簡單得多,更加直觀,便於調試。
-
從區間查找效率比較,平衡樹比 skiplist 操作要複雜。在平衡樹上,我們找到指定範圍的小值之後,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這裏的中序遍歷並不容易實現。而在 skiplist 上進行範圍查找就非常簡單,只需要在找到小值之後,對第 1 層鏈表進行若干步的遍歷就可以實現。
-
從插入和刪除效率比較,平衡樹的插入和刪除操作可能引發子樹的調整,需要左旋或者右旋保證平衡,邏輯複雜,而 skiplist 的插入和刪除只需要修改相鄰節點的指針,操作簡單又快速。
-
從內存佔用上比較,skiplist 比平衡樹更靈活一些。一般來說,平衡樹每個節點包含 2 個指針(分別指向左右子樹),而 skiplist 每個節點包含的指針數目平均爲 1/(1-p),具體取決於參數 p 的大小。如果像 Redis 裏的實現一樣,取 p = 1/4,那麼平均每個節點包含 1.33 個指針,比平衡樹更有優勢。
整數集合
整數集合是 Redis 用來保存整數值的集合抽象數據結構,集合中不會出現重複的元素,並且是按值的大小從小到大有序地排列。
實現
// 整數集合實現
typedef struct intset {
// 編碼方式
uint32_t encoding;
// 集合包含的元素數量
uint32_t length;
// 保存元素的數組
int8_t contents[];
} intset;
其中,
- encoding 屬性的值有 3 個
-
INTSET_ENC_INT16:代表元素爲 16 位的整數
-
INTSET_ENC_INT32:代表元素爲 32 位的整數
-
INTSET_ENC_INT64:代表元素爲 64 位的整數
- contents 屬性的類型聲明爲 int8_t,但它不保存任何 int8_t 類型的值,而是取決於 encoding 屬性。
升級
當整數集合中元素的類型都是 int16_t 類型的值時,如果你需要添加一個 int32_t 類型的整數,會發生什麼呢?這時就會執行升級操作,來保證大類型可以被放入數組。升級可表述爲以下幾步:
-
按照升級後的類型和元素數量,分配內存空間。
-
將元素的類型升級爲新元素的類型,在保證有序性不變的前提下,放在新的位置上。
-
將新元素添加到數組中(因爲新元素的長度肯定最大,所以總是在頭或尾兩個位置上)
升級的好處,就是可以儘可能的節約內存,提升靈活性。
那麼,提到升級,大家肯定會想到是不是有降級,但整數集合不支持降級,原因就是,既然已經有大的元素插入,那麼以後大概率也會有,降級後,再插入大類型的整數,還會進行升級操作,所以降級操作價值不大,而且降級涉及內存重分配,也不是一種廉價的操作。
使用場景
整數集合是集合對象的底層實現之一,當一個集合只包含整數值元素,並且元素數量不多時,Redis 就會使用整數集合作爲集合對象的底層實現。
快表
快表是 Redis 在 3.2 版本中提供的一種數據結構,是一個基於 ziplist 的雙向鏈表,從源碼註釋中對快表的定義就可以看出這一點。
A doubly linked list of ziplists
quicklist 是 ziplist 和 linkedlist 的混合體,它將 linkedList 按段切分,每一段使用 ziplist 來緊湊存儲,多個 ziplist 之間使用雙向指針鏈接起來。quicklist 爲什麼要這樣設計呢,其實就是在空間和時間上進行的平衡:
-
附加空間:雙向鏈表每個節點除了保存數據,還要保存兩個指針,佔去 16 個字節 (64bit 系統的指針是 8 個字節)。
-
內存碎片:雙向鏈表的各個節點是單獨的內存快,地址不連續,節點過多容易造成內存碎片,影響內存管理效率。
-
ziplist 是一整塊連續內存,所以存儲效率很高。但它不利於進行修改操作,每次數據變動都會引發一次內存的 realloc。特別是當 ziplist 很長的時候,一次 realloc 可能會導致大批量的數據拷貝,進一步降低性能。
因此 Redis 從 3.2 版本開始對列表數據結構進行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。但這也帶來了一個新問題:quicklist 的一個節點中的 ziplist 包換多少數據項合適呢?這又是一個需要找到平衡點的難題。我們從存儲效率上分析一下:
-
每個 quicklist 節點上的 ziplist 越短,則內存碎片越多,有可能在內存中產生很多無法被利用的小碎片,從而降低存儲效率。這種情況的極端是每個 quicklist 節點上的 ziplist 只包含一個數據項,這就蛻化成一個普通的雙向鏈表。
-
每個 quicklist 節點上的 ziplist 越長,則爲 ziplist 分配大塊連續內存空間的難度就越大。有可能出現內存裏有很多小塊的空閒空間(它們加起來很多),但卻找不到一塊足夠大的空閒空間分配給 ziplist 的情況。這同樣會降低存儲效率。這種情況的極端是整個 quicklist 只有一個節點,所有的數據項都分配在這僅有的一個節點的 ziplist 裏面,這其實就蛻化成一個 ziplist。
所以,Redis 提供了一個配置參數 list-max-ziplist-size ,讓使用者可以根據自己的應用場景進行調整。這個配置可以取正值,也可以取負值。下面我們來解釋下不同值代表的含義:
-
當配置爲正值時,表示每個 quicklist 節點上的 ziplist 的最大數據項個數。例如,list-max-ziplist-size = 5 時,表示 ziplist 最多包含 5 個數據項。
-
當配置爲負值時,表示按照佔用字節數來限定每個 quicklist 節點上的 ziplist 長度。但這時,它只能取 -1 到 -5 這五個值,每個值含義如下:
另外,當列表很長的時候,最容易被訪問的很可能是兩端的數據,中間的數據被訪問的頻率比較低(訪問起來性能也很低)。如果應用場景符合這個特點,那麼 quicklist 還提供了一個選項,能夠把中間的數據節點進行壓縮,從而進一步節省內存空間。Redis 的配置參數 list-compress-depth 就是用來完成這個設置的。這個參數表示一個 quicklist 兩端不被壓縮的節點個數。注:這裏的節點個數是指 quicklist 雙向鏈表的節點個數,而不是指 ziplist 裏面的數據項個數。實際上,一個 quicklist 節點上的 ziplist,如果被壓縮,就是整體被壓縮的。參數 list-compress-depth 的取值含義如下:
-
0: 是個特殊值,表示都不壓縮。這是 Redis 的默認值。
-
1: 表示 quicklist 兩端各有 1 個節點不壓縮,中間的節點壓縮。
-
2: 表示 quicklist 兩端各有 2 個節點不壓縮,中間的節點壓縮。
-
3: 表示 quicklist 兩端各有 3 個節點不壓縮,中間的節點壓縮。
-
依此類推...
由於 0 是個特殊值,很容易看出 quicklist 的頭節點和尾節點總是不被壓縮的,以便於在表的兩端進行快速存取。Redis 對於 quicklist 內部節點的壓縮算法,採用的 LZF (一種無損壓縮算法)。
實現
// 快表節點
typedef struct quicklistNode {
// 上一個節點
struct quicklistNode *prev;
// 下一個節點
struct quicklistNode *next;
// 數據指針,如果未壓縮,指向一個ziplist,壓縮後指向quicklistLZF
unsigned char *zl;
// 指向的壓縮列表的大小,如果被壓縮,仍是未壓縮前的大小
unsigned int sz; /* ziplist size in bytes */
// ziplist中包含的數據項的個數
unsigned int count : 16; /* count of items in ziplist */
// 是否被壓縮,1:未壓縮 2:壓縮(LZF)
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
// 數據容器,表示底層用什麼數據結構存儲數據,目前只有ziplist一個容器
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
// 如果是一個被壓縮的數據,因使用類似lindex這樣的命令要暫時解壓,需要標記一下,等後面重新壓縮
unsigned int recompress : 1; /* was this node previous compressed? */
// Redis自動化測試中使用的字段
unsigned int attempted_compress : 1; /* node can't compress; too small */
// 擴展字段,供未來擴展使用
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
// 被壓縮的ziplist結構
typedef struct quicklistLZF {
/// 壓縮後的ziplist大小
unsigned int sz; /* LZF size in bytes*/
// 是個柔性數組,存放被壓縮後ziplist的字節數組
char compressed[];
} quicklistLZF;
// 快表結構
typedef struct quicklist {
// 表頭節點
quicklistNode *head;
// 表尾節點
quicklistNode *tail;
// 所有ziplist數據項的總和
unsigned long count; /* total count of all entries in all ziplists */
// quicklist節點的數量
unsigned long len; /* number of quicklistNodes */
// 單個節點的填充因子,存放list-max-ziplist-size參數的值
int fill : QL_FILL_BITS; /* fill factor for individual nodes */
// 節點壓縮深度,存放list-compress-depth參數的值
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
上圖中是一個含有 3 個節點的 quicklist,其中每個節點有 4 個數據項,兩邊的節點是未壓縮的,中間的節點是壓縮的。
使用場景
Redis 使用快表作爲列表對象的底層實現,不過在 3.2 版本之前,列表對象的底層實現是鏈表或者壓縮列表,使用快表替換鏈表和壓縮列表的實現原因,上面已經講解,這裏就不再贅述了。
zipmap
zipmap 看名字,感覺和 ziplist 很像,zip 有壓縮的意思,map 說明是一個有關於鍵值對的數據結構,可以猜測是對 map 實現上的一種內存優化,下面我們就來驗證一下。
String -> String Map data structure optimized for size.
從上述源碼註釋中得知,zipmap 其實就是一個 String,是一個壓縮了的 map 結構,分配了一段連續的內存空間,用來保存連續的 key-value 集合。它的特點是維護 map 所需要的額外信息很少,對於一個鍵值對最少只需要額外的三個字節來描述。不過這樣做的後果就是,對 map 操作時需要進行遍歷,時間複雜度爲 O(n),但由於鍵值對的個數不會很多,所以並不會有性能問題。
實現
zipmap 的數據結構特別簡單,簡單到新創建後的 zipmap 只有兩個字節,其中,首字節保存長度,尾字節保存結尾標誌 ZIPMAP_END,其中 ZIPMAP_END = 255,可以從 zipmap 的創建 API 就可以看出來。
/* Create a new empty zipmap. */
unsigned char *zipmapNew(void) {
unsigned char *zm = zmalloc(2);
zm[0] = 0; /* Length */
zm[1] = ZIPMAP_END;
return zm;
}
那麼 zipmap 是怎樣保存鍵值對的呢,源碼註釋中有一個例子,如果要保存 "foo" => "bar","hello" => "world",那麼會是這樣:
<zmlen><len>"foo"<len><free>"bar"<len>"hello"<len><free>"world"
其中
使用場景
Redis 中的哈希鍵,當元素個數比較少時,會使用到 zipmap,當達到給定的個數時,會切換到字典。因爲字典保存一個鍵值對需要的額外信息比較大 sizeof(struct dictEntry) = 24,而 zipmap 最少只需要三個字節,最多 11 個字節。在查詢效率上,當數據量特別小的時候,順序查詢花費的時間成本雖然是 O(n),但是 n 小,所以是可以接受的,這樣做可以節約內存。不過,這是在 Redis 2.6 版本之前纔有,在 2.6 版本及之後的版本中已經被 ziplist 替代,也就是說,當鍵值對比較少時,會採用 ziplist 去實現哈希類型的對象。
流
stream 是 Redis 5.0 版本新增加的數據結構,它底層數據的存儲依賴於 一棵 radix tree,主要用於消息隊列的實現,stream 可以說是 Redis 底層數據結構中最複雜的一個。
實現
typedef struct streamID {
// unix時間戳
uint64_t ms; /* Unix time in milliseconds. */
// 序號
uint64_t seq; /* Sequence number. */
} streamID;
typedef struct stream {
// 指向radix tree的指針
rax *rax; /* The radix tree holding the stream. */
// stream 中保存的元素的數量,以消息ID爲統計維度
uint64_t length; /* Number of elements inside this stream. */
// stream中最後一個消息ID
streamID last_id; /* Zero if there are yet no items. */
// 保存監聽該stream的消費者信息
rax *cgroups; /* Consumer groups dictionary: name -> streamCG */
} stream;
從上述實現中可以看出, streamID,也就是消息 ID,是由時間戳 + 序號兩部分組成,各佔 64 位,一共 128 位,此 ID 可由 Redis 自動生成,或者自定義,爲了保證消息的有序性,Redis 自動生成的 ID 是單調遞增的。由於消息 ID 中包含了時間戳,爲了避免 Redis 服務器時間錯誤,比如發生了時鐘向後跳躍,stream 中都維護了一個 last_id,來記錄最後一個消息 ID,若與 last_id 比較發現時間戳退後,則採用時間戳延用 last_id,遞增序號的方式生成消息 ID(這也是序號使用 64 位表示的原因,保證有足夠多的序號使用),從而保證消息 ID 的單調遞增。
然後,我們再來看 rax 這個屬性表示什麼,從源碼中的註釋中可以得到答案,即 rax 是一棵 radix tree 的實現。
Rax -- A radix tree implementation.
typedef struct raxNode {
uint32_t iskey:1;
uint32_t isnull:1;
uint32_t iscompr:1;
uint32_t size:29;
unsigned char data[];
} raxNode;
typedef struct rax {
// radix tree頭結點指針
raxNode *head;
// radix tree中存儲元素的總數
uint64_t numele;
// radix tree中節點總數
uint64_t numnodes;
} rax;
下面我們來詳細介紹一下 raxNode 的屬性:
下面我們來展開介紹一下:
- 如果節點沒有被壓縮,那麼會有 size 個子節點,每個子節點佔 1 個字節,並且有 size 個子節點指針,指向每個子節點。
[header iscompr=0][abc][a-ptr][b-ptr][c-ptr](value-ptr?)
- 如果節點是被壓縮的,那麼該節點只有 1 個子節點,佔 size 個字節,也僅有 1 個子節點指針。
[header iscompr=1][xyz][z-ptr](value-ptr?)
當我們使用 xadd key id field value [field value ...] 向其中添加消息時,還會涉及另外一個數據結構 listpack,它是什麼,也可以從源碼中得到答案:
Listpack -- A lists of strings serialization format
其實也是一個字符串,和 zipmap 很相似,從創建一個新的 listpack 的 API 就可以看出。
unsigned char *lpNew(void) {
unsigned char *lp = lp_malloc(LP_HDR_SIZE+1);
if (lp == NULL) return NULL;
lpSetTotalBytes(lp,LP_HDR_SIZE+1);
lpSetNumElements(lp,0);
lp[LP_HDR_SIZE] = LP_EOF;
return lp;
}
添加元素時,會先找到 radix tree 中的最後一個節點,校驗這個節點的 data 是否超過配置規定的大小(從佔用空間和元素總數上),沒有超過,則使用這個空間;若超過,則會新創建一個 listpack 結構,一個新創建的 listpack 中 field 的組織方式如下:
+-------+---------+------------+---------+--/--+---------+---------+-+
| count | deleted | num-fields | field_1 | field_2 | ... | field_N |0|
+-------+---------+------------+---------+--/--+---------+---------+-+
對於 value 的處理有兩種方式,通常是以第一種方式組織,但當添加的 field 和原有的 field 相同時,就採用第二種方式。
// 方式1
+-----+--------+----------+-------+-------+-/-+-------+-------+--------+
|flags|entry-id|num-fields|field-1|value-1|...|field-N|value-N|lp-count|
+-----+--------+----------+-------+-------+-/-+-------+-------+--------+
// 方式2
+-----+--------+-------+-/-+-------+--------+
|flags|entry-id|value-1|...|value-N|lp-count|
+-----+--------+-------+-/-+-------+--------+
由於 stream 的實現比較複雜,涉及的內容比較多,後面會單獨來講 stream,這裏僅做以上概述。
使用場景
Redis 使用 stream 作爲流對象的底層實現,功能就是我們熟知的消息隊列,雖然 Redis 本身就有一個發佈訂閱(pub/sub)可以實現消息隊列的功能,但消息不支持持久化,很容易造成消息丟失。另外 rax 還被用在了 Redis Cluster 中用於記錄 slot 與 key 的對應關係。
總結
從上述對 Redis 底層數據結構的介紹,我們可以看出,Redis 針對底層數據結構的設計是非常精細的,針對每個字節,甚至每一位都進行了考量,可以表現爲以下幾點:
-
存儲效率。Redis 是基於內存來存儲數據的,所以如何節省內存資源就是 Redis 數據結構努力的一個方向,上述數據結構中,基本上都能看到爲了減少內存碎片,以及壓縮存儲空間等做出的設計。
-
響應時間。對於 Redis 主模塊單線程設計,如果對一個數據結構操作過慢,那將是災難一樣的事情,所以也能看到 Redis 對數據結構操作的時間複雜度的優化。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/TLuass4H0RIowuij6nU23w