文章目錄
一、ZSet有序集合型別
- 1.1 簡介
- 1.2 應用場景
- 1.3 底層結構
- 1.4 ZSet常用命令
二、ZSet底層結構詳解
-
2.1 資料結構
-
2.2 壓縮列表ZipList
-
2.3 跳錶詳解
- 2.3.1 跳錶是什麼(what)
- 2.3.2 跳錶怎麼做的(how)
- 2.3.3 為什麼需要跳錶(WHY)/跳錶高效的動態插入和刪除
- 2.3.4 ZSet中的跳錶
-
2.4 什麼時候採用壓縮列表、什麼時候採用跳錶
三、Redis跳錶與MySQL B+樹
- 3.1 對比
- 3.2 MySQL為什麼用B+樹,而不是跳錶
- 3.3 ZSet為什麼用跳錶,而不是B+樹/紅黑樹/二叉樹
四、Hash、B+樹、跳錶的比較
一、ZSet有序集合型別
1.1 簡介
詳細介紹:Redis五種資料型別、String、List、Set、Hash、ZSet
ZSet(有序集合)即SortedSet,是一個自動根據元素score排序的有序集合。它是一個可排序的set集合,在 Set 的基礎上增加了一個權重引數 score,使得集合中的元素能夠按 score 進行有序排列。在 Redis 中,有序集合的最大成員數是 2^32 - 1。ZSet具備下列特性:
- 可排序。根據score值排序,如果多個元素score相同 則會按照字典進行排序
- 元素不重複,member必須唯一。注意:集合成員是唯一的,但評分可以重複
- 查詢速度快,也可以根據member查詢分數
在 Zset 中,集合元素的新增、刪除和查詢的時間複雜度都是 O(logn),這得益於 Redis 使用跳錶SkipList來實現 Zset。
因為ZSet的可排序特性,經常被用來實現排行榜這樣的功能。
1.2 應用場景
- 排行榜應用:有序集合使得我們能夠方便地實現排行榜,比如網站的文章排行、學生成績排行等。
- 帶權重的訊息佇列:可以透過 score 來控制訊息的優先順序。
- 時間線:使用 Zset 來實現時間線功能。例如將釋出的訊息作為元素、訊息的釋出時間作為分數,然後用 Zset 來儲存和排序所有的訊息。你可以很容易地獲取到最新的訊息,或者獲取到任何時間段內的訊息。
- 延時佇列:你可以將需要延時處理的任務作為元素,任務的執行時間作為分數,然後使用 Zset 來儲存和排序所有的任務。你可以定期掃描 Zset,處理已經到達執行時間的任務。
以上只是 ZSet 的一些常見應用場景,實際上Zset 的應用非常廣泛,只要是需要排序和排名功能的場景,都可以考慮使用 ZSet。
1.3 底層結構
ZSet與Java中的TreeSet有些類似,但底層資料結構卻差別很大。ZSet中的每一個元素都帶有一個score屬性,可以基於score屬性對元素排序。底層實現有兩種方式:當元素較少或總體元素佔用空間較少時,使用壓縮列表ZipList來實現;當不符合使用壓縮列表的條件時,使用跳錶SkipList+ 字典hashtable來實現。注意,集合成員是唯一的,但是評分可以重複。
-
Redis ZSet 的底層實現為跳躍列表和雜湊表兩種,跳躍列表保證了元素的排序和快速的插入效能,雜湊表則提供了快速查詢的能力。
-
當元素數量不多時,HT和SkipList的優勢不明顯,而且更耗記憶體。因此zset還會採用ZipList結構來節省記憶體,不過需要同時滿足兩個條件:
- 元素數量小於zset_max_ziplist_entries,預設值128
- 每個元素都小於zset_max_ziplist_value位元組,預設值64
補充:ziplist本身沒有排序功能,而且沒有鍵值對的概念,因此需要有zset透過編碼實現:
- ZipList是連續記憶體,因此score和element是緊挨在一起的兩個entry,element在前,score在後
- score越小越接近隊首,score越大越接近隊尾,按照score值升序排列
注意事項:
- 有序集合中的元素是唯一的,但分數(score)可以重複。
- 插入、刪除、查詢的時間複雜度都是 O(log(N))。對於獲取排名(排行榜)的操作,Redis 有序集合是非常高效的。
1.4 ZSet常用命令
ZSet的常見命令有:
- ZADD key [NX|XX] [CH] [INCR] score member [score member ...] :新增一個或多個元素到zset ,如果已經存在則更新其score值
- ZREM key member [member ...] :刪除zset中的一個指定元素
- ZSCORE key member : 獲取zset中的指定元素的score值
- ZRANK key member:獲取指定元素在zset 中的排名(從0開始)
- ZCARD key:獲取zset中的元素個數
- ZCOUNT key min max:統計score值在給定範圍內的所有元素的個數
- ZINCRBY key increment member:讓zset中的指定元素自增,步長為指定的increment值
- ZRANGE key start stop [WITHSCORES]:按返回有序集合中的,下標在
之間的元素(有 WITHSCORES 會顯示評分) - zrevrange key start stop [WITHSCORES] :
- ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]:返回score值介於
到 之間(含兩端)的成員,limit offset count即是偏移數目(score從小到大) - zrevrangebyscore key max min [WITHSCORES] [LIMIT offset count] :根據score值從大到小排列
- ZDIFF、ZINTER、ZUNION:求差集、交集、並集
注意:所有的排名預設都是升序,如果要降序則在命令的Z後面新增REV即可,例如:
-
升序獲取sorted set 中的指定元素的排名:ZRANK key member
-
降序獲取sorted set 中的指定元素的排名:ZREVRANK key memeber
127.0.0.1:6379> zadd zset1 1 first 2 second 3 third 4 four #往zset添中加一個或多個元素
(integer) 4
127.0.0.1:6379> zrange zset1 0 -1 #返回有序集合中、下標在<start> <end>之間的元素(有 WITHSCORES 會顯示評分)。0 -1 表示所有元素
1) "first"
2) "second"
3) "third"
4) "four"
127.0.0.1:6379> zrevrange zset1 0 -1
1) "four"
2) "third"
3) "second"
4) "first"
127.0.0.1:6379> zscore zset1 third #獲取zset中的third元素的score值
"3"
127.0.0.1:6379> zrank zset1 third #返回third在集合中的排名,從0開始
(integer) 2
127.0.0.1:6379> zrevrank zset1 third
(integer) 1
127.0.0.1:6379> zrangebyscore zset1 -inf +inf #給zset集合中的元素從小到大排序,-inf:負無窮,+inf:正無窮。返回score值介於-inf到+inf之間(含兩端)的成員(score從小到大)
1) "first"
2) "second"
3) "third"
4) "four"
127.0.0.1:6379> zrangebyscore zset1 -inf +inf withscores #從小到大排序並輸出鍵值
1) "first"
2) "1"
3) "second"
4) "2"
5) "third"
6) "3"
7) "four"
8) "4"
127.0.0.1:6379> zrangebyscore zset1 -inf 1 withscores #指定負無窮到1的範圍
1) "first"
2) "1"
127.0.0.1:6379> zrem zset1 four #移除zset集合中指定的元素
(integer) 1
127.0.0.1:6379> zcard zset1 #檢視zset集合中元素個數
(integer) 3
127.0.0.1:6379> zrangebyscore zset1 1 2 withscores #根據score值從小到大排列
1) "first"
2) "1"
3) "second"
4) "2"
127.0.0.1:6379> zrevrangebyscore zset1 2 1 withscores #根據score值從大到小排列
1) "second"
2) "2"
3) "first"
4) "1"
127.0.0.1:6379> zcount zset1 1 2 #統計score值在1到2之間的元素個數
(integer) 2
二、ZSet底層結構詳解
2.1 資料結構
有序集合Zset底層實現會根據實際情況選擇使用壓縮列表(ziplist)或者跳躍表(skiplist):當元素較少或總體元素佔用空間較少時,使用壓縮列表ZipList來實現;當不符合使用壓縮列表的條件時,使用跳錶SkipList+ 字典hashtable來實現。
-
Redis ZSet 的底層實現為跳躍列表和雜湊表兩種,跳躍列表保證了元素的排序和快速的插入效能,雜湊表則提供了快速查詢的能力。
-
當元素數量不多時,HT和SkipList的優勢不明顯,而且更耗記憶體。因此zset還會採用ZipList結構來節省記憶體,不過需要同時滿足兩個條件:
- 元素數量小於zset_max_ziplist_entries,預設值128
- 每個元素都小於zset_max_ziplist_value位元組,預設值64
具體細節可參考本文1.3小節
2.2 壓縮列表ZipList
ZipList是一種特殊的“雙端連結串列”(並非連結串列),由一系列特殊編碼的連續記憶體塊組成,像記憶體連續的陣列。可以在任意一端進行壓入/彈出操作,並且該操作的時間複雜度為O(1)。
壓縮列表 底層資料結構:本質是一個陣列,增加了列表長度、尾部偏移量、列表元素個數、以及列表結束標識,有利於快速尋找列表的首尾節點;但對於其他正常的元素,如元素2、元素3,只能一個個遍歷,效率仍沒有很高效。
屬性 | 型別 | 長度 | 說明 |
---|---|---|---|
zlbytes | uint32_t | 4位元組 | 一個 4 位元組的整數,表示整個壓縮列表佔用的位元組數量,包括 <zlbytes> 自身的大小 |
zltail | uint32_t | 4位元組 | 一個 4 位元組的整數,記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少位元組,透過這個偏移量,可以確定表尾節點的地址 |
zllen | uint16_t | 2位元組 | 一個 2 位元組的整數,表示壓縮列表中的節點數量。最大值為UINT16_MAX(65534),如果超過這個數,此處會記錄為65535,但節點的真實數量需要遍歷整個壓縮列表才能計算出 |
entry | 列表節點 | 不定 | 壓縮列表中的元素,每個元素都由一個或多個位元組組成,節點的長度由節點儲存的內容決定。每個元素的第一個位元組(又稱為"entry header")用於表示這個元素的長度以及編碼方式 |
zlend | uint8_t | 1位元組 | 一個位元組,特殊值0xFF(十進位制255),表示壓縮列表的結束 |
注意:
-
如果查詢定位首個元素或最後1個元素,可以透過表頭 “zlbytes”、“zltail_offset” 元素快速獲取,複雜度是 O(1)。但是查詢其他元素時,就沒有這麼高效了,只能逐個查詢下去,比如 entryN 的複雜度就是 O(N)
-
ZipList雖然節省記憶體,但申請記憶體必須是連續空間,如果記憶體佔用較多,申請效率較低。
2.3 跳錶詳解
學習一個新知識,從三方面分析:WHAT、WHY、HOW
2.3.1 跳錶是什麼(what)
**SkipList(跳錶)**首先是連結串列,在有序連結串列的基礎上,增加了多級索引,透過多級索引位置的轉跳,實現了快速查詢元素。但與傳統連結串列相比有幾點差異:
- 元素按照升序排列儲存
- 節點可能包含多個指標,指標跨度不同
在 Redis 原始碼中,跳躍表的結構定義如下:
typedef struct zskiplistNode {
robj *obj;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
- zskiplistNode 結構體表示跳躍表中的一個節點,包含元素物件(obj)、分數(score)、指向前一個節點的指標(backward)和一個包含多個層的陣列(level)。每一層都包含一個指向下一個節點的指標(forward)和一個表示當前節點到下一個節點的跨度(span)。
- zskiplist 結構體表示一個跳躍表,包含頭節點(header)、尾節點(tail)、跳躍表中的節點數量(length)和當前跳躍表的最大層數(level)。
跳錶查詢、插入和刪除操作的時間複雜度都是 O(logN)。
SkipList的特點:
- 跳躍表是一個雙向連結串列,每個節點都包含score和ele值
- 節點按照score值排序,score值一樣則按照ele字典排序
- 每個節點都可以包含多層指標,層數是1到32之間的隨機數
- 不同層指標到下一個節點的跨度不同,層級越高,跨度越大
- 增刪改查效率與紅黑樹基本一致,實現卻更簡單
對於一個單連結串列來說,即使連結串列中的資料是有序的,如果我們想要查詢某個資料,也必須從頭到尾的遍歷連結串列,很顯然這種查詢效率是十分低效的,時間複雜度為O(n)。
普通連結串列想查詢元素27,只能從連結串列頭部一個個往後遍歷,需要遍歷6次 才能找到元素27
2.3.2 跳錶怎麼做的(how)
跳錶怎麼做的(how):建立多級索引
如建立一級索引
如果覺得慢,可以建立二級索引
當資料量特別大的時候,跳錶的時間複雜度為 O(logN)。其本身利用的思想,有點類似於二分法。
2.3.3 為什麼需要跳錶(WHY)/跳錶高效的動態插入和刪除
因為普通連結串列查詢一個元素 時間複雜度O(n);而跳錶查詢的時間複雜度為O(logn),查詢速度更快。不僅如此,刪除、插入等操作的時間複雜度也是O(logn)
- 跳錶這個動態資料結構,不僅支援查詢操作,還支援動態的插入、刪除操作,而且插入、刪除操作的時間複雜度也是 O(logn)。
- 對於單純的單連結串列,需要遍歷每個結點來找到插入的位置。但是對於跳錶來說,因為其查詢某個結點的時間複雜度是 O(logn),所以這裡查詢某個資料應該插入的位置,時間複雜度也是 O(logn)。
2.3.4 ZSet中的跳錶
SkipList作為ZSet的儲存結構,整體儲存結構如下圖,核心點主要是包括一個dict物件和一個skiplist物件。dict儲存key、value,key為元素,value為分值;skiplist儲存的有序的元素列表,每個元素包括元素和分值。
上圖中 zskiplist 結構包含以下屬性:
- header:指向跳錶的表頭節點
- tail:指向跳錶的表尾節點
- level:記錄目前跳錶內,層數最大的那個節點層數(表頭節點的層數不計算在內)
- length:記錄跳錶的長度,也就是跳錶目前包含節點的數量(表頭節點不計算在內)
位於 zskiplist 結構右側是四個 zskiplistNode 結構,該結構包含以下屬性:
- 層(level):節點中用 L1、L2、L3 等字樣標記節點的各個層,L1 代表第一層,L2 代表第二層,以此類推。每個層都帶有兩個屬性:前進指標和跨度。前進指標用於訪問位於表尾方向的其它節點,而跨度則記錄了前進指標所指向節點和當前節點的距離。
- 後退(backward)指標:節點中用 BW 字樣標識節點的後退指標,它指向位於當前節點的前一個節點。後退指標在程式從表尾向表頭遍歷時使用。
- 分值(score):各個節點中的 1.0、2.0 和 3.0 是節點所儲存的分值。在跳躍表中,節點按各自所儲存的分值從小到大排列。
- 成員物件(obj):各個節點中的 o1、o2 和 o3 是節點所儲存的成員物件。
2.4 什麼時候採用壓縮列表、什麼時候採用跳錶
什麼時候採用壓縮列表、什麼時候採用跳錶呢
- 有序集合儲存的元素數量小於128個
- 有序集合儲存的所有元素的長度小於64位元組
上述 1且2的時候,採用壓縮列表;否則採用跳錶
三、Redis跳錶與MySQL B+樹
3.1 對比
Redis跳錶:
B+Tree:
MySQL B+樹:
相比於標準的B+樹,InnoDB使用的B+樹有如下特點:
- B+ 樹的葉子節點之間是用「雙向連結串列」進行連線,既能向右遍歷、也能向左遍歷
- B+ 樹點節點內容是資料頁,資料頁裡存放了使用者的記錄以及各種資訊,每個資料頁預設大小是 16 KB
區別:
MySQL 的 B+ 樹、Redis 的跳錶都是用於儲存有序資料的資料結構,但它們有一些關鍵的區別,使得它們在不同的場景和用途中各有優勢。
-
結構差異:B+ 樹是一種多路搜尋樹,每個節點可以有多個子節點,而跳錶是一種基於連結串列的資料結構,每個節點只有一個下一個節點,但可以有多個快速通道指向後面的節點。
-
空間利用率:B+ 樹的磁碟讀寫操作是以頁(通常是 4KB)為單位的,每個節點儲存多個鍵值對,可以更好地利用磁碟空間,減少 I/O 操作。而跳錶的空間利用率相對較低。
-
插入和刪除操作:跳錶的插入和刪除操作相對簡單,時間複雜度為 O(logN),並且不需要像 B+ 樹那樣進行復雜的節點分裂和合並操作。
-
範圍查詢:B+ 樹的所有葉子節點形成了一個有序連結串列,因此非常適合進行範圍查詢。而跳錶雖然也可以進行範圍查詢,但效率相對較低。
因此,B+ 樹和跳錶不能簡單地相互替換。在需要大量進行磁碟 I/O 操作和範圍查詢的場景(如資料庫索引)中,B+ 樹可能是更好的選擇;而在主要進行記憶體操作,且需要頻繁進行插入和刪除操作的場景(如 Redis)中,跳錶可能更有優勢。
3.2 MySQL為什麼用B+樹,而不是跳錶
MySQL是持久化資料庫、即儲存到磁碟上,因此查詢時要求更少磁碟 IO,且 Mysql 是讀多寫少的場景較多,顯然 B+ 樹更加適合Mysql。
Redis是直接操作記憶體的、並不需要磁碟io;而MySQL需要去讀取磁碟io,所以MySQL使用b+樹的方式去減少磁碟io。B+樹原理是 葉子節點儲存資料、非葉子節點儲存索引,每次讀取磁碟頁時就會讀取一整個節點,每個葉子節點還要指向前後節點的指標,其目的是最大限度地降低磁碟io
資料在記憶體中讀取 耗費時間是磁碟IO讀取的百萬分之一,而Redis是記憶體中讀取資料、不涉及IO,因此使用了跳錶,跳錶模型是更快更簡單的方式
3.3 ZSet為什麼用跳錶,而不是B+樹/紅黑樹/二叉樹
1)ZSet為什麼不用B+樹,而用跳錶
-
時間複雜度優勢:跳錶是一種基於連結串列的資料結構,可以在O(log n)的時間內進行插入、刪除和查詢操作。而B樹需要維護平衡,操作的時間複雜度較高,通常為O(log n)或者更高。在絕大多數情況下,跳錶的效能要優於B樹。
-
簡單高效:跳錶的實現相對簡單,並且容易理解和除錯。相比之下,B樹的實現相對複雜一些,需要處理更多的情況,例如節點的分裂和合並等操作。
-
空間利用率高:在關鍵字比較少的情況下,跳錶的空間利用率要優於B樹。B樹通常需要每個節點儲存多個關鍵字和指標,而跳錶只需要每個節點儲存一個關鍵字和一個指標。
-
併發效能好:跳錶的插入和刪除操作比B樹更加簡單,因此在併發環境下更容易實現高效能。在多執行緒讀寫的情況下,跳錶能夠提供較好的併發效能。
總的來說,Redis選擇跳錶作為有序集合資料結構的底層實現,是基於跳錶本身的優點:時間複雜度優勢、簡單高效、空間利用率高和併發效能好。這使得Redis在處理有序集合的操作時能夠獲得較好的效能和併發能力。Redis是記憶體資料庫、不存在IO的瓶頸,而B+樹純粹是為了MySQL這種IO資料庫準備的。B+樹的每個節點的數量都是一個MySQL分割槽頁的大小。
2)ZSet為什麼不用紅黑樹、二叉樹
紅黑樹、二叉樹查詢一個元素的時間複雜度也是O(logn)
- ZSet有個核心操作,範圍查詢:跳錶效率比紅黑樹高,跳錶可以做到 logn 時間複雜度內,快速查詢,找到區間起點、依次往後遍歷即可,但紅黑樹範圍查詢的效率沒有跳錶高(每一層加了指標)
- 跳錶實現比紅黑樹及平衡二叉樹簡單、易懂:可以有效控制跳錶的索引層級來控制記憶體的消耗,
四、Hash、B+樹、跳錶的比較
資料結構 | 實現原理 | key查詢方式 | 查詢效率 | 儲存大小 | 插入、刪除效率 |
---|---|---|---|---|---|
Hash | 雜湊表 | 支援單key | 接近O(1) | 小,除了資料沒有額外的儲存 | O(1) |
B+樹 | 平衡二叉樹擴充套件而來 | 單key,範圍,分頁 | O(logn) | 除了資料,還多了左右指標,以及葉子節點指標 | O(logn),需要調整樹的結構,演算法比較複雜 |
跳錶 | 有序連結串列擴充套件而來 | 單key,分頁 | O(logn) | 除了資料,還多了指標,但是每個節點的指標小於<2,所以比B+樹佔用空間小 | O(logn),只用處理連結串列,演算法比較簡單 |
參考
Redis常見面試題:ZSet底層資料結構,SDS、壓縮列表ZipList、跳錶SkipList
Redis資料結構:Zset型別全面解析
redis的zset底層資料結構,你真的懂了嗎?