LSM-Tree - LevelDb Skiplist跳錶

lazytimes發表於2022-05-26

LSM-Tree - LevelDb Skiplist跳錶

跳錶介紹

跳錶(SkipList)是由William Pugh提出的。他在論文《Skip lists: a probabilistic alternative to balanced trees》中詳細地介紹了有關跳錶結構、插入刪除操作的細節。

文件:Skiplist跳錶原始論文 - pugh-skiplists-cacm1990.pdf
連結:http://note.youdao.com/notesh...

背景

線上性的資料資料結構中我們經常可以想到陣列和連結串列,陣列是插入慢查詢快,而連結串列是插入快,查詢要稍微慢一些,而跳錶主要是針對連結串列查詢速度進行優化的一種資料結構,多層級的跳錶實際上是對底層連結串列的索引,非常典型的空間換時間,把連結串列的查詢時間儘量控制在O(logN)。

實現

由於使用了類似索引點資料維護方式,所以新增和刪除需要同時維護跳錶結構,跳錶利用概率平衡的方式簡化新增和刪除操作,和樹操作利用左旋和右旋等操作維持資料平衡不同,跳錶利用了類似猜硬幣的方式抉擇出在哪一層插入或者刪除節點和更新索引。

從下面的abcde圖中,我們可以看一下跳錶的演進:

首先a是一個典型的連結串列結構,對於查詢來說需要O(n)的時間,連結串列長度越長查詢越慢。

b在a的基礎上,每次隔2個節點加一個額外的指標,通過這樣的操作,每次查詢時間就減少了【n/2】+次數。

c、d、e繼續按照這樣的思路繼續加額外指標,最終只留下從頭到尾的一層指標結束。

但是可以看到如果按照統一的思路每一層這樣加節點對於維護整個節點的效率十分低,我們將擁有額外指標的節點看作一個K層節點,按照圖中整理可以看到對於1層的節點佔了50%,2層為25%,3層為12.5%......如果插入新節點能按照這樣的規律進行插入刪除,那麼效率提升就不會出現很大的效能影響。

維護輔助指標會帶來更大的複雜度,索引在每一層的節點中都會指向當前層所在的下一個節點,也就是說每一層都是一個連結串列。

時間複雜度如何計算的?

推導公式如下:

n/2^k => n / 2^k = 2 => h = log2n -1 => O(logn)

k代表節點層數,h表示最高階

原始的連結串列有n個元素,則一級索引有n/2 個元素、二級索引有 n/4 個元素、k級索引就有 n/(2^k)個元素。最高階索引一般有2個元素(頭指向尾),最高階索引指向2 = n/(2^h),即 h =(log2)n-1,最高階的索引h為索引層高度+原資料高度,最終跳錶高度為 h = (log2) n。

經過索引的優化之後,整個查詢的時間複雜度可以近似看作O(logn) ,和 二分查詢的效率類似。

空間複雜度如何計算?

隨著層數的增加,建立索引的空間開銷是越來越小的,一層索引為 n/2,二層索引為 n/4,三層為 n/8 .....最後 n/2 + n/4 + n/8 +.... + 2(最高層一般為2個節點)最後因為分母相加可以認為是近似 O(n) 的空間複雜度。

查詢

下面是跳錶的增刪改查的處理流程,由於刪除和插入都依賴查詢,我們先從查詢開始介紹:

查詢的操作方式可以看下面的繪圖,比如如果需要查詢存在於中間的節點17,則會根據線條順序查詢,這裡簡述查詢順序:

  1. 從索引的最高層進行查詢,直接找到下一個節點。
  2. 如果當前內容大於節點內容,則直接找下一個節點比較。
  3. 如果當前節點等於查詢節點則直接返回。
  4. 如果當前節點大於節點,並且下一個節點大於當前節點,並且層高不為0,則繼續往層高更低的一個層級節點查詢同時回到更低層級前一個節點,如果層高為0,則返回當前節點,當前節點的key要大於查詢的key。

查詢比較好理解,就是用索引快速跨越多個連結串列節點減少搜尋次數,然後層數下探找到相關的節點,注意擁有索引的節點,通常在上層節點會有指向下層的指標。

插入

插入操作比較關鍵,因為這裡涉及到非常影響跳錶效能的索引節點選舉動作。按照之前的查詢操作步驟,插入操作需要每次記錄每一層的前任節點

關鍵點:插入的關鍵點在於選舉那個節點增加層高來維持二分查詢的效率,在找到位置之後,通常使用隨機拋硬幣的方式隨機為節點增加層高,層級越高被選中的概率通常會指數倍的降低,之後根據三個引數:種子(用於實現概率隨機)以及當前節點的層數和概率值P或者其他的隨機值演算法進行計算,但是為了防止單節點層高過高,通常會限制最終層高防止單一節點的層高超過上限。

根據墨菲定律,無論單一節點層高過高可能性再低,都需要做限制。

這裡挑了LevelDB跳錶資料結構的一段程式碼進行介紹:

func (p *DB) randHeight() (h int) {
    // 限制跳錶擴充套件的最高層級
    const branching = 4
    h = 1
    for h < tMaxHeight && p.rnd.Int()%branching == 0 {
        h++
    }
    return
}

插入節點之後,會根據記錄的前任節點在每一層的位置按照跳錶規則建立新節點的索引。

跳錶插入新節點本身的更新十分非常簡單,只需要把當前節點下一個節點指向插入節點的下一個節點的下一個節點,插入節點的下一個節點指向當前節點 即可。

插入操作時間複雜度

如果是單連結串列,那麼一次遍歷就可以完成,結果永遠都是O(1),對於跳錶的插入,最壞的情況也就是需要在所有層都更新索引,這種情況是O(logN)

刪除

刪除也是依賴查詢完成的,根據查詢找到待刪除節點之後在每一層按照查詢的規則把當前節點刪除即可。

刪除的時間複雜度快慢取決於於查詢的層數,假設需要刪除N個元素,而每一層其實都是一個單向的連結串列,單連結串列的查詢是O(1),同時是因為跳錶最優是近似二分查詢的效率,索引層數為logn,刪除的層數也是logN,N取決於層級。

最終刪除元素的總時間包含:

查詢元素的時間 + _刪除 logn個元素的時間 = O(logn) + O(logn) = 2O(logn),忽略常數部分最終結果為 O(logn)。

使用場景

其實多數和Key-Value有關的LST-Tree 資料結構都有類似的跳錶實現,因為連結串列在業務方面使用可能比較少,但是在資料結構和資料庫設計上面卻是至關重要的地位:

  • HBase
  • Redis
  • LevelDB

小結

  • 跳錶讓連結串列也能夠完成二分查詢的操作
  • 元素的插入會根據拋硬幣和權重分配隨機選舉Level
  • 最底層永遠是原始連結串列,而上層則是索引資料
  • 索引節點通常會多一個指標指向下層節點,但是不是所有的程式設計都是按照這種方式,有其他的處理方式間接實現此功能(具體可以看redis 的 zset原始碼)
  • 跳錶查詢、插入、刪除的時間複雜度為O(log n),與平衡二叉樹接近

LevelDb跳錶實現

在之前討論合併壓縮檔案使用了歸併排序的方式進行鍵合併,而內部的資料庫除了歸併排序之外還使用了比較關鍵的[[LSM-Tree - LevelDb Skiplist跳錶]]來進行有序鍵值管理。

跳錶在Redis和Kafka中都有實現,這裡的Skiplist其實也是類似的,可以看作C++版本的跳錶案例。

這部分就不看作者的文件了,我們直接原始碼開幹。

基礎結構

首先我們需要清楚LevelDB的跳錶包含了什麼東西?在程式碼的一開始定義了 Node節點用來表示連結串列節點,以及 Iterator迭代器的內容進行迭代,內部定義了std::atomic<Node*> next_[1] 長度等於節點高度的陣列。

next_[0]是最底層的節點(用於跳錶跨層獲取資料),核心是作者自認為寫的一般的Random 隨機器(通過位操作生成隨機的一個位號碼)。

LevelDB的整個實現比較簡潔規範,在設計上定義了很多函式來簡化複雜程式碼的增加,建議看不懂就多看幾遍跳錶的理論。

重要方法

levelDB插入操作 #levelDB查詢操作

在瞭解過[[LSM-Tree - LevelDb Skiplist跳錶]]之後,我們發現對於跳錶這種資料結構來說,核心部分在於查詢和插入兩個部分,當然查詢是理解插入點前提,但是對於插入拋硬幣選舉的實現有必要深究一下。

查詢操作

查詢操作比較好理解,和跳錶的資料結構規定差不多,和[[LSM-Tree - LevelDb Skiplist跳錶]]的實現類似:

可以發現和跳錶原始的實現方式如出一轍,這裡相當於復讀理論的內容:

  1. 從索引的最高層進行查詢,直接找到下一個節點。
  2. 如果當前內容大於節點內容,則直接找下一個節點比較。
  3. 如果當前節點等於查詢節點則直接返回。
  4. 如果當前節點大於節點,並且下一個節點大於當前節點,並且層高不為0,則繼續往層高更低的一個層級節點查詢同時回到更低層級前一個節點,如果層高為0,則返回當前節點,當前節點的key要大於查詢的key。
// 返回層級最前的節點,該節點位於鍵的位置或之後。如果沒有這樣的節點,返回nullptr。如果prev不是空的,則在[0...max_height_1]中的每一級,將prev[level]的指標填充到前一個 節點的指標來填充[0...max_height_1]中的每一級的 "level"。
template <typename Key, class Comparator>

typename SkipList<Key, Comparator>::Node*

SkipList<Key, Comparator>::FindGreaterOrEqual(const Key& key,

Node** prev) const {

    Node* x = head_;

    // 防止無限for迴圈
    int level = GetMaxHeight() - 1;
    
    while (true) {
    
        Node* next = x->Next(level);
        

        if (KeyIsAfterNode(key, next)) {
            
            // 如果當前節點在層級之後,則查詢下一個連結串列節點
            
            x = next;
        
        } else {
            
        
            if (prev != nullptr) prev[level] = x;
            
            if (level == 0) {
            
                return next;
        
            } else {
            
                // 層級下沉
                
                level--;
            
            }
    
        }
    
    }

}

插入操作

插入操作的程式碼如下,注意跳錶需要在插入之前對於節點進行加鎖的操作。

template <typename Key, class Comparator>

void SkipList<Key, Comparator>::Insert(const Key& key) {

    // 因為前置節點最多有kMaxHeight層,所以直接使用kMaxHeight 簡單粗暴
    Node* prev[kMaxHeight];

    // 返回層級最前的節點,該節點位於鍵的位置或之後。如果沒有這樣的節點,返回nullptr。如果prev不是空的,則在[0...max_height_1]中的每一級,將prev[level]的指標填充到前一個 節點的指標來填充[0...max_height_1]中的每一級的 "level"。
    Node* x = FindGreaterOrEqual(key, prev);

    // 不允許進行重複插入操作(同步加鎖)
    assert(x == nullptr || !Equal(key, x->key));
    
    // **新增層級選舉**,使用隨機函式和最高層級限制,按照類似拋硬幣的規則選擇是否新增層級。
    // 隨機獲取一個 level 值
    int height = RandomHeight();
    
    // 當前隨機level是否大於 當前點跳錶層數
    if (height > GetMaxHeight()) {

        // 頭指標下探到最低層
    
        for (int i = GetMaxHeight(); i < height; i++) {
            prev[i] = head_;
        }
        
        /*
        這部分建議多讀讀原註釋。
        
        機器翻譯:在沒有任何同步的情況下突變max_height_是可以的。與併發讀取器之間沒有任何同步。一個併發的讀者在觀察到的新值的併發讀者將看到max_height_的舊值。的新水平指標(nullptr),或者在下面的迴圈中設定一個新的值。下面的迴圈中設定的新值。在前一種情況下,讀者將立即下降到下一個級別,因為nullptr會在所有的鍵之後。在後一種情況下,讀取器將使用新的節點

        理解:意思是說這一步不需要併發加鎖,這是因為併發讀讀取到更新的跳錶層數,哪怕現在這個節點沒有插入,也會返回nullptr,在leveldb的比較器當中的nullpt會在最前面,預設看作比所有的key都要大,所以會往下繼續找,這樣就可以保證寫入和讀取都是符合預期的。
        */
        max_height_.store(height, std::memory_order_relaxed);
    
    }
    
    // 新增跳錶節點
    x = NewNode(key, height);
    
    for (int i = 0; i < height; i++) {
    // NoBarrier_SetNext()就夠了,因為當我們在prev[i]中釋出一個指標 "x "時,我們會新增一個障礙。我們在prev[i]中釋出一個指向 "x "的指標。
    
        // 更新指標引用
        // 為了保證併發讀的準確性,需要先設定節點指標然後再設定原始表的prev 指標
        x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
        // 內部會強制進行同步
        prev[i]->SetNext(i, x);
    
    }

}

跳錶實現的難點在於層數的確定,而LevelDB的難點在於插入節點如何保證併發寫入的時候能夠正確的併發讀

RandomHeight() 新增層級選舉

在LevelDb中層級選舉的核心的程式碼是:height < kMaxHeight && rnd_.OneIn(kBranching),內部在控制跳錶層數最多不超過kMaxHeight層的情況下,對於4取餘的操作實現構造 P = 3/4 的幾何分佈,最終判斷是否新增層數。

原始情況下跳錶增加1層為 1/2,2層為1/4,3層為1/8,4層為1/16。LevelDB的11層最高層限制key的數量,但是11層的節點概率通常會非常非常小。
最終LevelDB選擇的結果是3/4 的節點為 1 層節點,3/16 的節點為 2 層節點,3/64 的節點為 3 層節點,依此類推。

層級選舉的特點:

  1. 插入新節點的指標數通過獨立計算一個概率值決定,使全域性節點的指標數滿足幾何分佈即可。
  2. 插入時不需要做額外的節點調整,只需要先找到其需要放的位置,然後修改他和前驅的指向即可。
template <typename Key, class Comparator>

int SkipList<Key, Comparator>::RandomHeight() {

    // 在kBranching中以1的概率增加高度
    
    static const unsigned int kBranching = 4;
    
    int height = 1;
    // rnd_.OneIn(kBranching):"1/n "的時間會返回真沒其他情況會返回假
    // 相當於層數會按照4 的倍數減小, 4層是3層的4分之一,簡單理解為 每次加一層概率就要乘一個 1/4。
    while (height < kMaxHeight && rnd_.OneIn(kBranching)) {
    
        height++;
    
    }
    
    assert(height > 0);
    
    assert(height <= kMaxHeight);
    
    return height;

}

從上面的程式碼可以看到概率P使用了1/4計算方式,使用1/4的好處是讓層數更為分散,典型的時間換空間的操作,雖然會犧牲一部分空間,但是獲得更高的效能,在此情況下,可以最多支援 n = (1/p)^kMaxHeight 個節點的情況

對於LevelDB這種寫快過讀的業務,效率是最優考慮。

12層高的節點最多可以儲存多少資料?那麼可以直接使用4^12 計算約等於 16M。當然12層的概率微乎其微。

刪除操作

LevelDB跳錶是沒有刪除這個概念的,相對應的更新也是針對next指標的變動。

  1. 除非跳錶被銷燬,跳錶節點只會增加而不會被刪除,因為跳錶根本不對外提供刪除介面
  2. 被插入到跳錶中的節點,除了 next 指標其他域都是不可變的,並且只有插入操作會改變跳錶。(以此來替代更新)

遍歷操作

之前的[[LSM-Tree - LevelDb 原始碼解析]] 分析解釋過整個跳錶的遍歷通過Iterator完成,內部使用了歸併排序對於key進行排序,同時null ptr作為特殊值永遠排在最前面。

LevelDB自帶的迭代器實現較為豐富,除開迭代器經典的remove()next()haseNext()之外,還有SeekSeekToFirstSeekToLast、以及Prev向前遍歷的操作

// Advances to the next position.

// REQUIRES: Valid()

void Next();

// Advances to the previous position.

// REQUIRES: Valid()

void Prev();

// Advance to the first entry with a key >= target

void Seek(const Key& target);

// Position at the first entry in list.

// Final state of iterator is Valid() iff list is not empty.

void SeekToFirst();

// Position at the last entry in list.

// Final state of iterator is Valid() iff list is not empty.

void SeekToLast();

這裡需要特意強調的是向前遍歷這個操作並不是通過增加prev指標反向迭代的,而是從head開始查詢,也是時間換空間。

最後有兩個比較頻繁的使用操作FindLastFindLessThan,註釋寫的簡單明瞭,就不多介紹了。

// Return the latest node with a key < key.

// Return head_ if there is no such node.

Node* FindLessThan(const Key& key) const;

  
// Return the last node in the list.

// Return head_ if list is empty.

Node* FindLast() const;

總結

LevelDB的跳錶設計難點主要體現在併發讀寫的維持以及節點的層級選舉上面,這一部分是和原始的跳錶差別比較大的地方,而其他地方基本可以看作原始跳錶的理論設計的,所以把 LevelDB 作為跳錶的模板程式碼學習也是十分推薦的。

參考資料

Skip List--跳錶(全網最詳細的跳錶文章沒有之一) - 簡書 (jianshu.com)

跳錶資料結構的實現,JAVA版本的連結串列可以看下面的程式碼:
algo/SkipList.java at master · wangzheng0822/algo · GitHub

相關文章