Redis的ZSet底層資料結構,ZSet型別全面解析

BJRA發表於2024-11-03

文章目錄

一、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值升序排列

注意事項

  1. 有序集合中的元素是唯一的,但分數(score)可以重複。
  2. 插入、刪除、查詢的時間複雜度都是 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底層資料結構,你真的懂了嗎?

相關文章