redis的zset內部實現

Redis對象

講解的很不錯,重點分析了zset的skiplist的實現以及爲什麼快的原因

Redis對象由redisObject結構體表示。


 

1

2

3

4

5

6

7


 

typedef struct redisObject {

unsigned type:4; // 對象的類型,包括 /* Object types */

unsigned encoding:4; // 底部爲了節省空間,一種type的數據,可以採用不同的存儲方式

unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */

int refcount; // 引用計數

void *ptr;

} robj;

  • Redis中的每個鍵值對的鍵和值都是一個redisObject。
  • 共有五種類型的對象:字符串(String)、列表(List)、哈希(Hash)、集合(Set)、有序集合(SortedSet),源碼server.h如下定義:

 

1

2

3

4

5

6


 

/* The actual Redis Object */

#define OBJ_STRING 0

#define OBJ_LIST 1

#define OBJ_SET 2

#define OBJ_ZSET 3

#define OBJ_HASH 4

  • 每種類型的對象至少都有兩種或以上的編碼方式;可以在不同的使用場景上優化對象的使用場景。用TYPE命令可查看某個鍵值對的類型

對象編碼

Redis目前使用的編碼方式:


 

1

2

3

4

5

6

7

8

9

10

11

12


 

/* Objects encoding. Some kind of objects like Strings and Hashes can be

* internally represented in multiple ways. The 'encoding' field of the object

* is set to one of this fields for this object.

*/

#define OBJ_ENCODING_RAW /* Raw representation */ 簡單動態字符串

#define OBJ_ENCODING_INT /* Encoded as integer */ 整數

#define OBJ_ENCODING_HT /* Encoded as hash table */ 字典

#define OBJ_ENCODING_ZIPLIST /* Encoded as ziplist */ 壓縮列表

#define OBJ_ENCODING_INTSET /* Encoded as intset */ 整數集合

#define OBJ_ENCODING_SKIPLIST /* Encoded as skiplist */ 跳躍表

#define OBJ_ENCODING_EMBSTR /* Embedded sds string encoding */ embstr編碼的簡單動態字符串

#define OBJ_ENCODING_QUICKLIST /* Encoded as linked list of ziplists */

本質上,Redis就是基於這些數據結構而構造出一個對象存儲系統。redisObject結構體有個ptr指針,指向對象的底層實現數據結構,encoding屬性記錄對象所使用的編碼,即該對象使用什麼數據結構作爲底層實現。

zset介紹

有序集合對象的編碼可以是ziplist或者skiplist。同時滿足以下條件時使用ziplist編碼:

  • 元素數量小於128個
  • 所有member的長度都小於64字節

以上兩個條件的上限值可通過zset-max-ziplist-entries和zset-max-ziplist-value來修改。

ziplist編碼的有序集合使用緊挨在一起的壓縮列表節點來保存,第一個節點保存member,第二個保存score。ziplist內的集合元素按score從小到大排序,score較小的排在表頭位置。

skiplist編碼的有序集合底層是一個命名爲zset的結構體,而一個zset結構同時包含一個字典和一個跳躍表。跳躍表按score從小到大保存所有集合元素。而字典則保存着從member到score的映射,這樣就可以用O(1)的複雜度來查找member對應的score值。雖然同時使用兩種結構,但它們會通過指針來共享相同元素的member和score,因此不會浪費額外的內存。

zset操作命令


 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17


 

zadd(key, score, member):向名稱爲key的zset中添加元素member,score用於排序。如果該元素已經存在,則根據score更新該元素的順序。

zrem(key, member) :刪除名稱爲key的zset中的元素member

zincrby(key, increment, member) :如果在名稱爲key的zset中已經存在元素member,則該元素的score增加increment;否則向集合中添加該元素,其score的值爲increment

zrank(key, member) :返回名稱爲key的zset(元素已按score從小到大排序)中member元素的rank(即index,從0開始),若沒有member元素,返回“nil”

zrevrank(key, member) :返回名稱爲key的zset(元素已按score從大到小排序)中member元素的rank(即index,從0開始),若沒有member元素,返回“nil”

zrange(key, start, end):返回名稱爲key的zset(元素已按score從小到大排序)中的index從start到end的所有元素

zrevrange(key, start, end):返回名稱爲key的zset(元素已按score從大到小排序)中的index從start到end的所有元素

zrangebyscore(key, min, max):返回名稱爲key的zset中score >= min且score <= max的所有元素 zcard(key):返回名稱爲key的zset的基數

zscore(key, element):返回名稱爲key的zset中元素element的score zremrangebyrank(key, min, max):刪除名稱爲key的zset中rank >= min且rank <= max的所有元素 zremrangebyscore(key, min, max) :刪除名稱爲key的zset中score >= min且score <= max的所有元素

skiplist介紹

跳錶(skip List)是一種隨機化的數據結構,基於並聯的鏈表,實現簡單,插入、刪除、查找的複雜度均爲O(logN)。簡單說來跳錶也是鏈表的一種,只不過它在鏈表的基礎上增加了跳躍功能,正是這個跳躍的功能,使得在查找元素時,跳錶能夠提供O(logN)的時間複雜度。

先來看一個有序鏈表,如下圖(最左側的灰色節點表示一個空的頭結點):

有序鏈表結構圖

在這樣一個鏈表中,如果我們要查找某個數據,那麼需要從頭開始逐個進行比較,直到找到包含數據的那個節點,或者找到第一個比給定數據大的節點爲止(沒找到)。也就是說,時間複雜度爲O(n)。同樣,當我們要插入新數據的時候,也要經歷同樣的查找過程,從而確定插入位置。

假如我們每相鄰兩個節點增加一個指針,讓指針指向下下個節點,如下圖:

每兩個節點增加一個跳躍指針的有序鏈表

這樣所有新增加的指針連成了一個新的鏈表,但它包含的節點個數只有原來的一半(上圖中是7, 19, 26)。現在當我們想查找數據的時候,可以先沿着這個新鏈表進行查找。當碰到比待查數據大的節點時,再回到原來的鏈表中進行查找。比如,我們想查找23,查找的路徑是沿着下圖中標紅的指針所指向的方向進行的:

一個搜索路徑的例子

  • 23首先和7比較,再和19比較,比它們都大,繼續向後比較。
  • 但23和26比較的時候,比26要小,因此回到下面的鏈表(原鏈表),與22比較。
  • 23比22要大,沿下面的指針繼續向後和26比較。23比26小,說明待查數據23在原鏈表中不存在,而且它的插入位置應該在22和26之間。

在這個查找過程中,由於新增加的指針,我們不再需要與鏈表中每個節點逐個進行比較了。需要比較的節點數大概只有原來的一半。

利用同樣的方式,我們可以在上層新產生的鏈表上,繼續爲每相鄰的兩個節點增加一個指針,從而產生第三層鏈表。如下圖:

兩層跳躍指針

在這個新的三層鏈表結構上,如果我們還是查找23,那麼沿着最上層鏈表首先要比較的是19,發現23比19大,接下來我們就知道只需要到19的後面去繼續查找,從而一下子跳過了19前面的所有節點。可以想象,當鏈表足夠長的時候,這種多層鏈表的查找方式能讓我們跳過很多下層節點,大大加快查找的速度。

skiplist正是受這種多層鏈表的想法的啓發而設計出來的。實際上,按照上面生成鏈表的方式,上面每一層鏈表的節點個數,是下面一層的節點個數的一半,這樣查找過程就非常類似於一個二分查找,使得查找的時間複雜度可以降低到O(log n)。但是,這種方法在插入數據的時候有很大的問題。新插入一個節點之後,就會打亂上下相鄰兩層鏈表上節點個數嚴格的2:1的對應關係。如果要維持這種對應關係,就必須把新插入的節點後面的所有節點(也包括新插入的節點)重新進行調整,這會讓時間複雜度重新蛻化成O(n)。刪除數據也有同樣的問題。

skiplist爲了避免這一問題,它不要求上下相鄰兩層鏈表之間的節點個數有嚴格的對應關係,而是爲每個節點隨機出一個層數(level)。比如,一個節點隨機出的層數是3,那麼就把它鏈入到第1層到第3層這三層鏈表中。爲了表達清楚,下圖展示瞭如何通過一步步的插入操作從而形成一個skiplist的過程:

skiplist插入形成過程

從上面skiplist的創建和插入過程可以看出,每一個節點的層數(level)是隨機出來的,而且新插入一個節點不會影響其它節點的層數。因此,插入操作只需要修改插入節點前後的指針,而不需要對很多節點都進行調整。這就降低了插入操作的複雜度。實際上,這是skiplist的一個很重要的特性,這讓它在插入性能上明顯優於平衡樹的方案。這在後面我們還會提到。

skiplist,指的就是除了最下面第1層鏈表之外,它會產生若干層稀疏的鏈表,這些鏈表裏面的指針故意跳過了一些節點(而且越高層的鏈表跳過的節點越多)。這就使得我們在查找數據的時候能夠先在高層的鏈表中進行查找,然後逐層降低,最終降到第1層鏈表來精確地確定數據位置。在這個過程中,我們跳過了一些節點,從而也就加快了查找速度。

剛剛創建的這個skiplist總共包含4層鏈表,現在假設我們在它裏面依然查找23,下圖給出了查找路徑:

skiplist上的查找路徑展示

需要注意的是,前面演示的各個節點的插入過程,實際上在插入之前也要先經歷一個類似的查找過程,在確定插入位置後,再完成插入操作。

實際應用中的skiplist每個節點應該包含key和value兩部分。前面的描述中我們沒有具體區分key和value,但實際上列表中是按照key(score)進行排序的,查找過程也是根據key在比較。

執行插入操作時計算隨機數的過程,是一個很關鍵的過程,它對skiplist的統計特性有着很重要的影響。這並不是一個普通的服從均勻分佈的隨機數,它的計算過程如下:

  • 首先,每個節點肯定都有第1層指針(每個節點都在第1層鏈表裏)。
  • 如果一個節點有第i層(i>=1)指針(即節點已經在第1層到第i層鏈表中),那麼它有第(i+1)層指針的概率爲p。
  • 節點最大的層數不允許超過一個最大值,記爲MaxLevel。

這個計算隨機層數的僞碼如下所示:


 

1

2

3

4

5

6


 

randomLevel()

level := 1

// random()返回一個[0...1)的隨機數

while random() < p and level < MaxLevel do

level := level + 1

return level

randomLevel()的僞碼中包含兩個參數,一個是p,一個是MaxLevel。在Redis的skiplist實現中,這兩個參數的取值爲:


 

1

2


 

p = 1/4

MaxLevel = 32

skiplist與平衡樹、哈希表的比較

  • skiplist和各種平衡樹(如AVL、紅黑樹等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做單個key的查找,不適宜做範圍查找。所謂範圍查找,指的是查找那些大小在指定的兩個值之間的所有節點。
  • 在做範圍查找的時候,平衡樹比skiplist操作要複雜。在平衡樹上,我們找到指定範圍的小值之後,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這裏的中序遍歷並不容易實現。而在skiplist上進行範圍查找就非常簡單,只需要在找到小值之後,對第1層鏈表進行若干步的遍歷就可以實現。
  • 平衡樹的插入和刪除操作可能引發子樹的調整,邏輯複雜,而skiplist的插入和刪除只需要修改相鄰節點的指針,操作簡單又快速。
  • 從內存佔用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節點包含2個指針(分別指向左右子樹),而skiplist每個節點包含的指針數目平均爲1/(1-p),具體取決於參數p的大小。如果像Redis裏的實現一樣,取p=1/4,那麼平均每個節點包含1.33個指針,比平衡樹更有優勢。
  • 查找單個key,skiplist和平衡樹的時間複雜度都爲O(log n),大體相當;而哈希表在保持較低的哈希值衝突概率的前提下,查找時間複雜度接近O(1),性能更高一些。所以我們平常使用的各種Map或dictionary結構,大都是基於哈希表實現的。
  • 從算法實現難度上來比較,skiplist比平衡樹要簡單得多。

Redis中的skiplist實現

skiplist的數據結構定義:


 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18


 

#define ZSKIPLIST_MAXLEVEL 32

#define ZSKIPLIST_P 0.25

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;

簡單分析一下幾個查詢命令:

  • zrevrank由數據查詢它對應的排名,這在前面介紹的skiplist中並不支持。
  • zscore由數據查詢它對應的分數,這也不是skiplist所支持的。
  • zrevrange根據一個排名範圍,查詢排名在這個範圍內的數據。這在前面介紹的skiplist中也不支持。
  • zrevrangebyscore根據分數區間查詢數據集合,是一個skiplist所支持的典型的範圍查找(score相當於key,數據相當於value)。

實際上,Redis中sorted set的實現是這樣的:

  • 當數據較少時,sorted set是由一個ziplist來實現的。
  • 當數據多的時候,sorted set是由一個dict + 一個skiplist來實現的。簡單來講,dict用來查詢數據到分數的對應關係,而skiplist用來根據分數查詢數據(可能是範圍查找)。

看一下sorted set與skiplist的關係,:

  • zscore的查詢,不是由skiplist來提供的,而是由那個dict來提供的。
  • 爲了支持排名(rank),Redis裏對skiplist做了擴展,使得根據排名能夠快速查到數據,或者根據分數查到數據之後,也同時很容易獲得排名。而且,根據排名的查找,時間複雜度也爲O(log n)。
  • zrevrange的查詢,是根據排名查數據,由擴展後的skiplist來提供。
  • zrevrank是先在dict中由數據查到分數,再拿分數到skiplist中去查找,查到後也同時獲得了排名。

總結起來,Redis中的skiplist跟前面介紹的經典的skiplist相比,有如下不同:

  • 分數(score)允許重複,即skiplist的key允許重複。這在最開始介紹的經典skiplist中是不允許的。
  • 在比較時,不僅比較分數(相當於skiplist的key),還比較數據本身。在Redis的skiplist實現中,數據本身的內容唯一標識這份數據,而不是由key來唯一標識。另外,當多個元素分數相同的時候,還需要根據數據內容來進字典排序。
  • 第1層鏈表不是一個單向鏈表,而是一個雙向鏈表。這是爲了方便以倒序方式獲取一個範圍內的元素。
  • 在skiplist中可以很方便地計算出每個元素的排名(rank)。

Redis爲什麼用skiplist而不用平衡樹?


 

1

2

3

4

5

6

7


 

There are a few reasons:

1) They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.

2) A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.

3) They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.

這裏從內存佔用、對範圍查找的支持和實現難易程度這三方面總結的原因。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章