深入理解跳躍連結串列在Redis中的應用
0.前言
前面寫了一篇關於跳錶基本原理和特性的文章,本次繼續介紹跳錶的概率平衡和工程實現,跳錶在Redis、LevelDB、ES中都有應用,本文以Redis為工程藍本,分析跳錶在Redis中的工程實現。
通過本文你將瞭解到以下內容:
- Redis基本的資料型別和底層資料結構
- Redis的有序集合的實現方法
- Redis的跳錶實現細節
1.Redis的資料結構
Redis對外共有約五種型別的物件:
- 字串(String)
- 列表(List)
- 雜湊(Hash)
- 集合(Set)
- 有序集合(SortedSet)
redis原始碼檔案src/server.h中對於5種結構的定義:
1 /* The actual Redis Object */ 2 #define OBJ_STRING 0 /* String object. */ 3 #define OBJ_LIST 1 /* List object. */ 4 #define OBJ_SET 2 /* Set object. */ 5 #define OBJ_ZSET 3 /* Sorted set object. */ 6 #define OBJ_HASH 4 /* Hash object. */
Redis物件由redisObject結構體表示,從src/server.h可以看到該結構的定義如下:
1 typedef struct redisObject { 2 unsigned type:4; 3 unsigned encoding:4; 4 unsigned lru:LRU_BITS; 5 int refcount; 6 void *ptr; 7 } robj;
redisObject明確了物件型別、物件編碼方式、過期設定、引用計數、記憶體指標等,從而完整表示一個key-value鍵值對。
由於Redis是基於記憶體的,Antirez在實現這5種資料型別時在底層建立了多種資料結構,在物件底層選擇採用哪種結構來實現,需要根據物件大小以及單個元素大小來進行確定,從而提高空間使用率和效率。
如圖展示了Redis對外使用的資料型別和底層的資料結構:
有序集合物件的編碼可以是ziplist或者skiplist,在元素小於128並且元素長度小於64Byte時才會選擇壓縮列表實現,一般使用skiplist跳錶實現。
2.Redis的ZSet
ZSet結構同時包含一個字典和一個跳躍表,跳躍表按score從小到大儲存所有集合元素。
字典儲存著從member到score的對映。這兩種結構通過指標共享相同元素的member和score,不會浪費額外記憶體。
1 typedef struct zset { 2 dict *dict; 3 zskiplist *zsl; 4 } zset;
ZSet中的字典和跳錶佈局:
注:圖片源自網路
3.ZSet中跳錶的實現細節
- 隨機層數的實現原理
跳錶是一個概率型的資料結構,元素的插入層數是隨機指定的。Willam Pugh在論文中描述了它的計算過程如下:
- 指定節點最大層數 MaxLevel,指定概率 p, 預設層數 lvl 為1
- 生成一個0~1的隨機數r,若r<p,且lvl<MaxLevel ,則lvl ++
- 重複第 2 步,直至生成的r >p 為止,此時的 lvl 就是要插入的層數。
論文中生成隨機層數的偽碼:
論文中關於隨機層數的偽碼
在Redis中對跳錶的實現基本上也是遵循這個思想的,只不過有微小差異,看下Redis關於跳錶層數的隨機原始碼src/z_set.c:
1 /* Returns a random level for the new skiplist node we are going to create. 2 * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL 3 * (both inclusive), with a powerlaw-alike distribution where higher 4 * levels are less likely to be returned. */ 5 int zslRandomLevel(void) { 6 int level = 1; 7 while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) 8 level += 1; 9 return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; 10 }
其中兩個巨集的定義在redis.h中:
1 #define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */ 2 #define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
可以看到while中的:
1 (random()&0xFFFF) < (ZSKIPLIST_P*0xFFFF)
第一眼看到這個公式,因為涉及位運算有些詫異,需要研究一下Antirez為什麼使用位運算來這麼寫?
最開始的猜測是random()返回的是浮點數[0-1],於是乎線上找了個浮點數轉二進位制的工具,輸入0.25看了下結果:
可以看到0.25的32bit轉換16進位制結果為0x3e800000,如果與0xFFFF做與運算結果是0,好像也符合預期,再試一個0.5:
可以看到0.5的32bit轉換16進位制結果為0x3f000000,如果與0xFFFF做與運算結果還是0,不符合預期。
我印象中C語言的math庫好像並沒有直接random函式,所以就去Redis原始碼中找找看,於是下載了3.2版本程式碼,也並沒有找到random()的實現,不過找到了其他幾個地方的應用:
- random()在dict.c中的使用:
- random()在cluster.c中的使用:
看到這裡的取模運算,後知後覺地發現原以為random()是個[0-1]的浮點數,但是現在看來是uint32才對,這樣Antirez的式子就好理解了。
由於ZSKIPLIST_P=0.25,所以相當於0xFFFF右移2位變為0x3FFF,假設random()比較均勻,
在進行0xFFFF與運算之後高16位清零之後,低16位取值就落在0x0000-0xFFFF之間,這樣while為真的概率只有1/4,更一般地說為真的概率為1/ZSKIPLIST_P。
對於隨機層數的實現並不統一,重要的是隨機數的生成,在LevelDB中對跳錶層數的生成程式碼是這樣的:
1 template <typename Key, typename Value> 2 int SkipList<Key, Value>::randomLevel() { 3 4 static const unsigned int kBranching = 4; 5 int height = 1; 6 while (height < kMaxLevel && ((::Next(rnd_) % kBranching) == 0)) { 7 height++; 8 } 9 assert(height > 0); 10 assert(height <= kMaxLevel); 11 return height; 12 } 13 14 uint32_t Next( uint32_t& seed) { 15 seed = seed & 0x7fffffffu; 16 17 if (seed == 0 || seed == 2147483647L) { 18 seed = 1; 19 } 20 static const uint32_t M = 2147483647L; 21 static const uint64_t A = 16807; 22 uint64_t product = seed * A; 23 seed = static_cast<uint32_t>((product >> 31) + (product & M)); 24 if (seed > M) { 25 seed -= M; 26 } 27 return seed; 28 }
可以看到leveldb使用隨機數與kBranching取模,如果值為0就增加一層,這樣雖然沒有使用浮點數,但是也實現了概率平衡。
- 跳錶結點的平均層數
我們很容易看出,產生越高的節點層數出現概率越低,無論如何層數總是滿足冪次定律越大的數出現的概率越小。
冪次定律:如果某件事的發生頻率和它的某個屬性成冪關係,那麼這個頻率就可以稱之為符合冪次定律。冪次定律的表現是少數幾個事件的發生頻率佔了整個發生頻率的大部分, 而其餘的大多數事件只佔整個發生頻率的一個小部分。
冪次定律應用到跳錶的隨機層數來說就是大部分的節點層數都是黃色部分,只有少數是綠色部分,並且概率很低。
定量的分析如下:
- 節點層數至少為1,大於1的節點層數滿足一個概率分佈。
- 節點層數恰好等於1的概率為p^0(1-p)。
- 節點層數恰好等於2的概率為p^1(1-p)。
- 節點層數恰好等於3的概率為p^2(1-p)。
- 節點層數恰好等於4的概率為p^3(1-p)。
- 依次遞推節點層數恰好等於K的概率為p^(k-1)(1-p)
因此如果我們要求節點的平均層數,那麼也就轉換成了求概率分佈的期望問題了,靈魂畫手大白再次上線:
表中P為概率,V為對應取值,給出了所有取值和概率的可能,因此就可以求這個概率分佈的期望了。
方括號裡面的式子其實就是高一年級學的等比數列,常用技巧錯位相減求和,從中可以看到結點層數的期望值與1-p成反比。
對於Redis而言,當p=0.25時結點層數的期望是1.33。
小結:在Redis原始碼中有詳盡的關於插入和刪除調整跳錶的過程,本文就不再展開了,程式碼並不算難懂,都是純C寫的沒有那麼多炫技的特效,放心大膽讀起來。
4.參考資料
- http://note.huangz.me/algorithm/arithmetic/power-law.html
- https://juejin.im/post/5cb885a8f265da03973aa8a1
- https://epaperpress.com/sortsearch/download/skiplist.pdf
- https://www.h-schmidt.net/FloatConverter/IEEE754.html
- http://www.ruanyifeng.com/blog/2010/06/ieee_floating-point_representation.html
- https://cyningsun.github.io/06-18-2018/skiplist.html