LSM-Tree - LevelDb 原始碼解析

lazytimes 發表於 2022-05-19

LSM-Tree - LevelDb 原始碼解析

引言

在上一篇文章[[LSM-Tree - LevelDb瞭解和實現]]中介紹了LevelDb相關的資料結構和核心元件,LevelDB的核心讀寫部分,以及為什麼在這個資料庫中寫入的速度要比讀取的速度快上好幾倍。

LevelDB的原始碼還是比較好懂的,好懂到我只學過學JAVA只有定點基礎C語言入門知識的人也能看懂,另一方面作者在關鍵的地方都給了註釋,甚至告訴你為什麼要這麼設計<s>(寫的很好很棒讓人落淚為什麼自己沒這樣的同事)</s>。

如果還是看不懂,作者也寫了很多資料結構介紹的md文件(在doc目錄中)告訴你核心元件的作用。

總之,不要懼怕這個資料庫,無論是作為優秀程式碼和設計模式還是各種主流資料結構演算法應用都非常值得學習和參考。

Tip:這一節程式碼內容非常多,所以不建議在手機或者移動裝置閱讀,更適合在PC上觀看。

原始碼執行

LevelDB的編譯是比較簡單的,可以從官網直接克隆程式碼。

地址:https://github.com/google/leveldb

具體操作步驟如下(也可以參考倉庫中的README):

git clone --recurse-submodules https://github.com/google/leveldb.git
mkdir -p build && cd build
cmake -DCMAKE_BUILD_TYPE=Release .. && cmake --build .

完成整個編譯動作之後,我們可以新增一個動態庫,一個靜態庫和test目錄,接著就可以編寫單元測試了,同時官方的原始碼中有很多的單元測試可以提供自己編寫的測試程式進行除錯使用,當然這裡跳過這些內容,直接從原始碼開始。

底層儲存儲存結構

關聯:[[SSTable]]

在LevelDB中SSTable是整個資料庫最重要的結構,所有的SSTable檔案本身的內容是不可修改的,雖然通常資料在記憶體中操作,但是資料不可能無限儲存,當資料到達一定量之後就需要持久化到磁碟中,而壓縮合並的處理就十分考驗系統效能了,為此LevelDb使用分層的結構進行儲存,下面我們從外部的使用結構開始來了解內部的設計。

整個外部的黑盒就是資料庫本身了,以事務性資料庫為例,通常的操作無非就是ACID四種,但是放到LSM-Tree的資料結構有點不一樣,因為更新和刪除其實都會通過“新增”與“合併”的方式完成新資料對舊資料的覆蓋。

扯遠了,我們從簡單的概念開始,首先是整個DB的原始碼,DB原始碼可以通過以下路徑訪問:

https://github.com/google/lev...

首先我們需要了解DB儲存結構,可以看到儲存引擎的對外提供的介面十分簡單:

class LEVELDB_EXPORT DB {

public:

    // 設定資料庫的key-value結構,如果沒有返回OK則視為操作失敗,
    // 備註:考慮預設開啟sync=true操作,`Put` 方法在內部最終會呼叫 `Write` 方法,只是在上層為呼叫者提供了兩個不同的選擇。
    virtual Status Put(const WriteOptions& options, const Slice& key,
    
    const Slice& value) = 0;
    
    // 成功返回OK,如果異常則不返回OK,如果什麼都返回,說明被刪除的Key不存在,
    virtual Status Delete(const WriteOptions& options, const Slice& key) = 0;
    
    // 寫入操作
    virtual Status Write(const WriteOptions& options, WriteBatch* updates) = 0;
    
    // 根據key獲取資料
    virtual Status Get(const ReadOptions& options, const Slice& key,
    
    std::string* value) = 0;

} 

GetPut 是 LevelDB 為上層提供的用於讀寫的介面,注意這個介面的UpdateDelele操作 實際上是通過Put完成的,實現方式是內部做了型別判斷,十分有意思,這裡可以先留意一下。

write部分

下面先從寫入操作開始,看看資料是如何進入到LevelDb,以及內部是如何管理的。

Write的內部邏輯算是比較複雜的,所以這裡畫了下基本流程圖:

LSM-Tree - LevelDb 原始碼解析

我們從DB的Write()介面方法切入,簡化程式碼之後大致的流程如下:

    //  為寫入構建足夠的空間,此時可以不需要加鎖。
    Status status = MakeRoomForWrite(updates == nullptr);
    //  通過 `AddRecord` 方法向日志中追加一條寫操作的記錄;
    status = log_->AddRecord(WriteBatchInternal::Contents(write_batch));
    //  如果日誌記錄成功,則將資料進行寫入
    if (status.ok()) {
        status = WriteBatchInternal::InsertInto(write_batch, mem_);
    }

整個執行流程如下:

  • 首先呼叫 MakeRoomForWrite 方法為即將進行的寫入提供足夠的空間。

    • 如果當前空間不足需要凍結當前的memtable,此時發生 Minor Compaction 並建立一個新的 MemTable 物件。
    • 如果滿足觸發Major Compaction需要對資料進行壓縮並且對於SSTable進行合併。
  • 通過AddRecord方法向日志中追加一條寫操作記錄。
  • 最終呼叫memtable往記憶體結構中新增key/value,完成最終寫入操作。

將寫入操作的原始碼邏輯簡化之後最終如下:


Status DBImpl::Write(const WriteOptions& options, WriteBatch* my_batch) {
  Writer w(&mutex_);
  w.batch = my_batch;

  MakeRoomForWrite(my_batch == NULL);
    
  uint64_t last_sequence = versions_->LastSequence();
  Writer* last_writer = &w;
  WriteBatch* updates = BuildBatchGroup(&last_writer);
  WriteBatchInternal::SetSequence(updates, last_sequence + 1);
    // 記錄最終的操作記錄點
  last_sequence += WriteBatchInternal::Count(updates);
    // 日誌編寫
  log_->AddRecord(WriteBatchInternal::Contents(updates));
    // 將資料寫入memtable
  WriteBatchInternal::InsertInto(updates, mem_);

  versions_->SetLastSequence(last_sequence);
  return Status::OK();
}

上面程式碼有較多的方法封裝,這裡我們一個個來看。

MaybeScheduleCompaction()壓縮合並(如果覺得這裡突兀可以請參閱上文的流程圖)在原始碼中系統會定時檢查是否可以進行壓縮合並,if/else用於多執行緒併發寫入的時候進行合併寫入的操作,當發現有不同執行緒在操作就會等待結果或者等到拿到鎖之後接管合併寫入的操作。

如果對於下面的程式碼有疑問可以閱讀[[LSM-Tree - LevelDb瞭解和實現]]中關於“合併寫入”的部分,為了節省時間,可以在網頁中直接輸入關鍵字“合併寫入”快速定位,這裡假設讀者已經瞭解基本的工作流程,就不再贅述了。

LevelDb合併寫入操作

void DBImpl::MaybeScheduleCompaction() {

mutex_.AssertHeld();

if (background_compaction_scheduled_) {

// Already scheduled
    // 正在壓縮

} else if (shutting_down_.load(std::memory_order_acquire)) {

// DB is being deleted; no more background compactions
    
    // DB正在被刪除;不再有後臺壓縮

} else if (!bg_error_.ok()) {

// Already got an error; no more changes
    // 已經發生異常,不能做更多改動。

} else if (imm_ == nullptr && manual_compaction_ == nullptr &&

    !versions_->NeedsCompaction()) {

    // 不需要合併則不工作

    } else {
    // 設定當前正常進行壓縮合並

    background_compaction_scheduled_ = true;
    // 開始壓縮合並

    env_->Schedule(&DBImpl::BGWork, this);

    }

}

不可變memtable

在write的函式內部有這樣一串程式碼,此時會暫停解鎖等待寫入,這個寫入又是幹嘛的?

Status status = MakeRoomForWrite(updates == nullptr);

進入方法內部會發現通過一個while迴圈判斷當前的 memtable狀態,一旦發現memtable寫入已經寫滿整個mem,則需要停止寫入並且將當前的memtable轉為imutiablememtable,並且建立新的mem切換寫入,此時還會同時根據一些條件判斷是否可以進行壓縮 mem

這裡額外解釋原始碼中GUARDED_BY含義:

GUARDED_BY是資料成員的屬性,該屬性宣告資料成員受給定功能保護。對資料的讀操作需要共享訪問,而寫操作則需要互斥訪問。

該 GUARDED_BY屬性宣告執行緒必須先鎖定listener_list_mutex才能對其進行讀寫listener_list,從而確保增量和減量操作是原子的。

總結:其實就是一個典型的互斥共享鎖,至於實現不是本文的重點。

mem可以看作是當前的系統備忘錄或者說臨時的記賬板,和大多數的日誌或者關係型資料庫類似,都是先寫入日誌在進行後續的所有“事務”操作,也就是日誌優先於記錄操作 原則,根據日誌寫入操作加鎖來完成併發操作的正常執行。

MakeRoomForWrite 方法中比較關鍵的部分都加了註釋,很多操作作者都有介紹意圖,程式碼邏輯都比較簡單,多看幾遍基本瞭解大致思路即可。(C++語法看不懂不必過多糾結,明白他要做什麼就行,主要是我也看不懂,哈哈)

while (true) {

    if (!bg_error_.ok()) {
    
        // Yield previous error
        
        s = bg_error_;
        
        break;
    
    } else if (allow_delay && versions_->NumLevelFiles(0) >=
    
    config::kL0_SlowdownWritesTrigger) {
        // 我們正接近於達到對L0檔案數量的硬性限制。L0檔案的數量。當我們遇到硬性限制時,與其將單個寫操作延遲數而是在我們達到硬限制時,開始將每個mem單獨寫1ms以減少延遲變化。另外。這個延遲將一些CPU移交給壓縮執行緒,因為 如果它與寫入者共享同一個核心的話。
        
        mutex_.Unlock();
        
        env_->SleepForMicroseconds(1000);
        // 不要將一個單一的寫入延遲超過一次
        allow_delay = false; 
        mutex_.Lock();
    
    } else if (!force &&
    
    (mem_->ApproximateMemoryUsage() <= options_.write_buffer_size)) {
        
        // 在當前的mem中還有空間
        break;
    
    } else if (imm_ != nullptr) {
        
        // 我們已經填滿了當前的memtable,但之前的的mem還在寫入,所以需要等待
        
        background_work_finished_signal_.Wait();
    
    } else if (versions_->NumLevelFiles(0) >= 
                config::kL0_StopWritesTrigger) {
    
        
        background_work_finished_signal_.Wait();
    
    } else {
    
        // A試圖切換到一個新的memtable並觸發對舊memtable的壓縮
        assert(versions_->PrevLogNumber() == 0);
        // 新建檔案號
        uint64_t new_log_number = versions_->NewFileNumber(); //return next_file_number_++;
        
        WritableFile* lfile = nullptr;
        // 新建可寫入檔案, 內部通過一個map構建一個檔案:檔案狀態的簡易檔案系統
        // typedef std::map<std::string, FileState*> FileSystem;
        s = env_->NewWritableFile(LogFileName(dbname_, new_log_number), &lfile);
        
        if (!s.ok()) {
            // 避免死迴圈重複新增檔案號
            versions_->ReuseFileNumber(new_log_number);
            break;
        
        }
    
        delete log_;
        
        delete logfile_;
        
        logfile_ = lfile;
        
        logfile_number_ = new_log_number;
        // 寫入日誌
        log_ = new log::Writer(lfile);
        // **重點:imm_ 就是immutable 他將引用指向當前已經寫滿的mem,其實和mem物件沒什麼區別,就是加了一個互斥共享鎖而已(寫互斥,讀共享)**
        imm_ = mem_;
        
        has_imm_.store(true, std::memory_order_release);
        // 新建新的memtable
        mem_ = new MemTable(internal_comparator_);
        // 引用至新塊
        mem_->Ref();
        
        force = false; // Do not force another compaction if have room
        // 嘗試對於已滿mem壓縮合並 ,此處承接上文
        MaybeScheduleCompaction();
    
    }

}

下面用一個簡單的示意圖瞭解上面的大致流程:

LSM-Tree - LevelDb 原始碼解析

注意這對於[[SSTable]]的原始理論的實現結構顯然是有一定出入,當然這是很正常的理論和實踐的差別。

在通常情況下memtable可以通過短暫的延遲讀寫請求等待壓縮完成,但是一旦發現mem佔用的記憶體過大,此時就需要給當前的mem加鎖變為_imu狀態,然後建立一個新的 MemTable 例項並且把新進來的請求轉到新的mem中,這樣就可以繼續接受外界的寫操作,不再需要等待 Minor Compaction 的結束了。

再次注意此處會通過函式 MaybeScheduleCompaction 是否進行壓縮合並的操作判斷。

這種無等待的設計思路來自於:[[Dynamic-sized NonBlocking Hash table]],可以自己下下論文來看看,當然也可以等我後面的文章。

log部分

寫入的大致操作流程瞭解之後,下面來看看LevelDb的日誌管理也就是AddRecord()函式的操作:

LSM-Tree - LevelDb 原始碼解析

注意日誌的核心部分並不在AddRecord()內部,因為內部只有一些簡單的字串拼接操作,這裡將核心放到了RecordType的部分,可以看到這裡通過當前日誌字元長度判斷不同的型別,RecordType標識當前記錄在塊裡面的位置:

    //....
    enum RecordType {

        // Zero is reserved for preallocated files
        
        kZeroType = 0,
        
          
        
        kFullType = 1,
        
          
        
        // For fragments
        
        kFirstType = 2,
        
        kMiddleType = 3,
        
        kLastType = 4
    
    };
    //....
    
    
    RecordType type;
    
    const bool end = (left == fragment_length);
    
    if (begin && end) {
    
        type = kFullType;
    
    } else if (begin) {
    
        type = kFirstType;
    
    } else if (end) {
    
        type = kLastType;
    
    } else {
    
        type = kMiddleType;
    
    }
First:是使用者記錄第一個片段的型別,
Last:是使用者記錄的最後一個片段的型別。
Middle:是一個使用者記錄的所有內部片段的型別。

如果看不懂原始碼,可以根據作者的md文件介紹也可以大致瞭解日誌檔案結構:

 record :=
      checksum: uint32     // crc32c of type and data[] ; little-endian
      length: uint16       // little-endian
      type: uint8          // One of FULL, FIRST, MIDDLE, LAST
      data: uint8[length]

我們可以根據描述簡單畫一個圖:

LSM-Tree - LevelDb 原始碼解析

RecordType內部的定義可以看到日誌固定為32KB大小,在日誌檔案中將分為多部分,但是一個日誌只包含在一個單一的檔案塊。

RecordType 儲存的內容如下:

  • 前面4個位元組用於CRC校驗
  • 接著兩個位元組是塊資料長度
  • 接著是一個位元組的型別標識(標識當前日誌記錄在塊中位置)
  • 最後是資料payload部分

32kb大小選擇是考慮到日誌記錄行的磁碟對齊和日誌讀寫,針對日誌寫的速度也非常快,寫入的日誌先寫入記憶體的檔案表,然後通過fdatasync(...)方法將緩衝區fflush到磁碟中並且持久化,最後通過日誌完成故障恢復的操作。

需要注意如果日誌記錄較大可能存在於多個block塊中。

一個記錄永遠不會在一個塊的最後六個位元組內開始,理由是一個記錄前面需要一些其他部分佔用空間(也就是記錄行的校驗和資料長度標識資訊等)。

為了防止單個日誌塊被拆分到多個檔案以及壓縮考慮,這種“浪費”是可以被接受。

如果讀者非要清楚最後幾個位元組儲存的是什麼,想滿足自己的好奇心,可以看下面的程式碼:

dest_->Append(Slice("\x00\x00\x00\x00\x00\x00", leftover));

日誌寫流程圖

日誌寫的流程比較簡單,主要分歧點是當前塊剩餘空間是否夠寫入一個header,並且最後6個位元組將會填充空格進行補齊。

在日誌寫入的過程中通過一個while(ture)不斷判斷buffer大小,如果大小超過32KB-最後6個位元組,則需要停止寫入並且把開始寫入到現在位置為一個資料塊。

下面是日誌寫流程圖:

日誌寫流程圖

下面是日誌讀流程圖:

日誌讀流程圖

既然日誌大小為32kb,那麼日誌的讀寫單位也應該是32kb,接著便是掃描資料塊,在掃描chunk的時候如果發現CRC校驗不通過則返回錯誤資訊,如果資料破損則丟棄當前chunk。

翻了一下程式碼,簡單來說就是讀取通過while(true)迴圈read,直到讀取到型別為Lastchunk,日誌記錄讀取完成。

memtable比較有意思的特點是無論插入還是刪除都是通過“新增”的方式實現的(你沒有看錯),內部通過Mainfest維護狀態,同時根據版本號和序列號維護一條記錄是新增還是刪除並且保證讀取到的內容是最新值,具體介紹同樣在上一節[[LSM-Tree - LevelDb瞭解和實現]]中。

注意寫入日誌之後記錄是不能查詢的(因為中間有可能存在斷電故障導致真實記錄沒有寫入),日誌僅作為故障恢復,只有資料寫入到mem之後才被訪問到

關於mem新增和刪除的程式碼如下:

namespace {

class MemTableInserter : public WriteBatch::Handler {

public:
    
    SequenceNumber sequence_;
    
    MemTable* mem_;
    
    void Put(const Slice& key, const Slice& value) override {
    
        mem_->Add(sequence_, kTypeValue, key, value);
        
        sequence_++;
    
    }
    
    void Delete(const Slice& key) override {
    
        mem_->Add(sequence_, kTypeDeletion, key, Slice());
        
        sequence_++;
    
    }
    
    };

} // namespace

Add()函式的內部通過一個[[LSM-Tree - LevelDb Skiplist跳錶]]完成資料的插入,在資料的node中包含了記錄鍵值,為了保證讀取的資料永遠是最新的,記錄需要在skiplist內部進行排序,節點排序使用的是比較常見的比較器Compare,如果使用者想要自定義排序(例如處理不同的字元編碼等)可以編寫自己的比較器實現。

對於一條記錄的結構我們也可以從 Add() 函式中看到作者的註釋:

// Format of an entry is concatenation of:
  //  key_size     : varint32 of internal_key.size()
  //  key bytes    : char[internal_key.size()]
  //  tag          : uint64((sequence << 8) | type)
  //  value_size   : varint32 of value.size()
  //  value bytes  : char[value.size()]
[[VarInt32編碼]]:在這裡雖然是變長整型型別但是實際使用4個位元組表示。
uint64((sequence << 8) | type:位運算之後實際為7個位元組的sequence長度
注意在tag和value_size中間有一個ValueType標記來標記記錄是新增還是刪除。

VarInt32 (vary int 32),即:長度可變的 32 為整型型別。一般來說,int 型別的長度固定為 32 位元組。但 VarInt32 型別的資料長度是不固定的,VarInt32 中每個位元組的最高位有特殊的含義。如果最高位為 1 代表下一個位元組也是該數字的一部分。

因此,表示一個整型數字最少用 1 個位元組,最多用 5 個位元組表示。如果某個系統中大部分數字需要 >= 4 位元組才能表示,那其實並不適合用 VarInt32 來編碼。

根據get()程式碼內部通過valueType進行區分,valueType佔用一個位元組的空間進行判斷新增還是刪除記錄,預設比較器判斷新增或者刪除記錄邏輯如下:

if (comparator_.comparator.user_comparator()->Compare(

    Slice(key_ptr, key_length - 8), key.user_key()) == 0) {
    
    // Correct user key
    
    const uint64_t tag = DecodeFixed64(key_ptr + key_length - 8);
    
    switch (static_cast<ValueType>(tag & 0xff)) {
    
    case kTypeValue: {
    
        Slice v = GetLengthPrefixedSlice(key_ptr + key_length);
        
        value->assign(v.data(), v.size());
        
        return true;
    
    }
    
    case kTypeDeletion:
    
        *s = Status::NotFound(Slice());
        
        return true;
    
    }

}

根據程式碼定義和上面的描述畫出下面的結構圖:

LSM-Tree - LevelDb 原始碼解析

Compare鍵排序

LevelDb的memtable通過跳錶維護了鍵,內部預設情況下通過InternalKeyComparator對於鍵進行比較,下面是比較內部邏輯:

比較器通過 user_keysequence_number 進行排序,同時按照user_key進行升序排序,序列號通過插入的時間遞增,以此來保證無論是增加還是刪除都是獲取到最新的資訊。

/*
 一個用於內部鍵的比較器,它使用一個指定的比較器用於使用者鍵部分比較,並通過遞減序列號來打破平衡。
*/
int InternalKeyComparator::Compare(const Slice& akey, const Slice& bkey) const {

    // Order by:
    
    // 增加使用者金鑰(根據使用者提供的比較器)。
    
    // 遞減序列號
    
    // 遞減型別(儘管序列號應該足以消除歧義)。
    
    int r = user_comparator_->Compare(ExtractUserKey(akey), ExtractUserKey(bkey));
    
    if (r == 0) {
    
        const uint64_t anum = DecodeFixed64(akey.data() + akey.size() - 8);
        
        const uint64_t bnum = DecodeFixed64(bkey.data() + bkey.size() - 8);
        
        if (anum > bnum) {
        
            r = -1;
        
        } else if (anum < bnum) {
        
            r = +1;
        
        }
    
    }
    
    return r;

}

需要注意被比較key可能包含完全不同的內容,這裡讀者肯定會有疑問對於key獲取值進行提取資訊是否會有影響,然而從get的邏輯來看它可以通過鍵長度,和序列號等資訊進行獲取Key,並且獲取是header的頭部資訊,所以key是任何型別都是沒有影響的。

記錄查詢

現在我們再回過頭來看一下memtable是如何讀取的,從memtableimumemble的關係可以看出有點類似快取,當memtable寫滿之後轉為imumem並且等待同步至磁碟。

key讀取和查詢的順序如下:

  • 在memtable中獲取指定Key,如果資料符合條件則結束查詢。
  • 在Imumemtable中查詢指定Key,如果資料符合條件則結束查詢。
  • 按低層至高層的順序在level i層的sstable檔案中查詢指定的key,若搜尋到符合條件的資料項就會結束查詢,否則返回Not Found錯誤,表示資料庫中不存在指定的資料。

記錄按照層級關係進行搜尋,首先是從當前記憶體中正在寫入memtable搜尋,接著是imumemtable,再接著是存在於磁碟不同層級的SSTable,SSTable通過*.ldb的形式進行標記,可以快速找到。

最終我們可以把LevelDb的查詢看作下面的形式:

LSM-Tree - LevelDb 原始碼解析

小結

這一部分我們瞭解了LevelDB原始碼部分等基礎結構DB,介紹了LevelDB的基礎對外介面,LevelDB和map的介面看起來十分類似,這一部分重點講述了讀寫操作等原始碼,以及內部合併壓縮的一些細節。

另外記錄查詢等動作和之前介紹LevelDB等讀寫流程大致類似,當然程式碼簡化了很多的內容,讀者可以根據自己感興趣的內容研究。

SSTable操作

前面我們提到了記錄的增刪改查底層查詢,和日誌的讀寫細節,下面則針對谷歌發明的特殊資料結構SSTable進行介紹。

SSTable如何工作?

SSTable在初始的論文中可以總結出下面的特點:

  • 寫入的時候不寫入磁碟而是先寫入記憶體表的資料結構。
  • 當資料結構記憶體佔用超過一定的閾值就可以直接寫入到磁碟檔案由於已經是排好序的狀態,所以可以直接對舊結構覆蓋,寫入效率比較高。並且寫入和資料結構改動可以同時進行。
  • 讀寫順序按照 記憶體 - 磁碟 - 上一次寫入檔案 - 未找到。
  • 後臺定時執行緒定時合併和壓縮排序分段,將廢棄值給覆蓋或者丟棄。

[[SSTable]] 最早出現在谷歌2006年的論文當中,LevelDB的SSTable設計也有部分特性體現這個資料結構,當然並不是完全一致的,LevelDB利用SSTable在磁碟中維護多層級的資料節點。

可以認為了解SSTable結構就相當於瞭解了LevelDb的核心資料結構設計。

多層級SSTable

我們重點看看多層級的SSTable部分,levelDB在磁碟中掃描SSTable的時候LevelDB並不會跳過層級,這裡肯定會有疑問每個層級都掃一遍的效率問題,針對這個問題作者在db中設計了下面的資料結構:

struct FileMetaData {

    FileMetaData() : refs(0), allowed_seeks(1 << 30), file_size(0) {}
    int refs;
    
    int allowed_seeks; // 允許壓縮搜尋
    
    uint64_t number;
    
    uint64_t file_size; // 檔案大小
    
    InternalKey smallest; // 表提供的最小內部金鑰
    
    InternalKey largest; // 表提供最大內部金鑰

};

在上面的結構體宣告中定義了壓縮SSTable檔案的全部資訊,包括最大值和最小值,執行查詢次數,檔案引用次數和檔案號,SSTable會按照固定的形式儲存到同一個目錄下面,所以可以通過檔案號進行快速搜尋。

查詢和記錄key順序類似,都是按照從小到大的順序進行讀取的,以Level0為例,裡面通常包含4個固定的SSTable,並且內部通常存在key交叉,所以會按照從SSTable1-4的順序進行讀取,而更高層次的層級則通過查詢上面結構體的最大值和最小值的資訊(smallest和largest)。

具體的檔案搜尋細節可以通過TableCache::FindTable查詢 ,由於篇幅有限這裡就不貼程式碼了,簡要邏輯是配合快取和RandomAccessFile對於檔案進行讀寫,然後把讀到的檔案資訊寫入到記憶體中方便下次獲取。

如果瞭解Mysql Btree設計會發現檔案搜尋有些類似頁目錄的查詢。不同的是Btree頁目錄通過頁目錄等稀疏搜尋。

SSTable合併

我們再來看看SSTable是如何合併的,之前提到過SSTable通過MaybeScheduleCompaction嘗試合併,需要注意這個合併壓縮和Bigtable的形式類似,都是根據不同的條件判斷是否進行合併,一旦可以合併便執行BackgroundCompaction操作。

合併分為兩種情況,一種是Minor Compaction,另一種是將Memtable資料寫滿轉為不可變物件(實際就是加鎖),執行CompactMemtable進行壓縮。

合併操作簡化版原始碼如下:

void DBImpl::CompactMemTable() {

    VersionEdit edit;
    Version* base = versions_->current();
    WriteLevel0Table(imm_, &edit, base);
    versions_->LogAndApply(&edit, &mutex_);
    RemoveObsoleteFiles();

}

CompactMemTable方法會先構建當前的修改版本號,然後呼叫WriteLevel0Table()方法嘗試把當前的Imumtable寫入到Level0的層級。
如果發現Level0的層級SSTable過多,則進一步進行Major Compaction,同時根據BackgroudCompcation()選擇合適的壓縮層級和壓縮方式。

下面是writeLevel0的簡化程式碼:

簡化程式碼的最後幾行程式碼會獲取檔案資訊的最大值和最小值以此判斷是否在當前SSTable搜尋還是跳轉到下一個。

資料如果是寫入Level0我們可以看作是Major Compaction

Status DBImpl::WriteLevel0Table(MemTable* mem, VersionEdit* edit,

Version* base) {
    
    // SSTable檔案資訊
    FileMetaData meta;
    
    meta.number = versions_->NewFileNumber();
    
    pending_outputs_.insert(meta.number);

    Iterator* iter = mem->NewIterator();
    // 構建SSTable檔案
    BuildTable(dbname_, env_, options_, table_cache_, iter, &meta);

    pending_outputs_.erase(meta.number);

    // 注意,如果 file_size 為零,則該檔案已被刪除,並且不應被新增到清單中。
    // 獲取檔案資訊的最大值和最小值
    const Slice min_user_key = meta.smallest.user_key();
    
    const Slice max_user_key = meta.largest.user_key();
    // level層級掃描
    base->PickLevelForMemTableOutput(min_user_key, max_user_key);
    // 寫入檔案
    edit->AddFile(level, meta.number, meta.file_size, meta.smallest,
    
    return Status::ok();
    

結合上下兩段原始碼可以發現檔案管理最終是通過VersionEdit來完成的,如果寫入成功了則返回當前的SSTable的FileMetaData,在VersionEdit內部通過logAndApply的方式記錄檔案內部的變化,也就是前文介紹的日誌管理功能了,完成之後通過RemoveObsoleteFiles()方法進行資料的清理操作。

如果Level0寫滿了此時就需要進行Major Compaction,這個壓縮會比前面的要複雜一些因為涉及低層級到高層級的壓縮。

這裡需要再回看BackgroundCompaction的程式碼,具體程式碼如下:

void DBImpl::BackgroundCompaction() {  
    // 如果存在不可變imumem,進行壓縮合並
    CompactMemTable();
  
    versions_->PickCompaction();

    VersionSet::LevelSummaryStorage tmp;

    CompactionState* compact = new CompactionState(c);
    
    DoCompactionWork(compact);

    CleanupCompaction(compact);

    c->ReleaseInputs();

    RemoveObsoleteFiles();

}

首先根據VersionSet 查詢需要壓縮的資訊,並且打包加入到 Compaction 物件,這個物件根據查詢次數和大小限制來選擇需要壓縮的兩個層級,因為level0中包含很多重疊鍵,則會在更高層級找到有重疊的鍵的SSTable,再通過FileMetaData找到需要壓縮的檔案,另外查詢頻繁的SSTable將會“升級”到更高層級進行壓縮儲存,並且更新檔案資訊方便下一次查詢。

合併的觸發條件

每個 SSTable 在建立之後的 allowed_seeks 都為 100 次,當 allowed_seeks < 0 時就會觸發該檔案的與更高層級和合並,因為頻繁查詢的資料通常會降低系統效能。

這樣的設計理由是在高層級搜尋鍵說明在上一層肯定是相同的鍵查詢,同時也是為了減少每次都覆蓋掃描多層級掃描尋找資料。最終這種設計方式核心是以更新FileMetaData 來減少下一次查詢的效能開銷。

另外這種處理可以簡單理解為我們在作業系統中進行深層次資料夾搜尋的時候,如果頻繁查詢某個深層次的資料很麻煩,解決此問題的第一種方式是建立一個“快捷方式”的資料夾,另一種是直接做標籤直接指向這個目錄,其實兩者都是差不多的,所以壓縮設計也是同理。

LevelDB 中的 DoCompactionWork 方法會對所有傳入的 SSTable 中的鍵值使用歸併排序進行合併,最後會在更高層級中生成一個新的 SSTable。

歸併排序主要是對於key進行歸併,使得迭代的時候key就是有序的可以直接合併到指定的高高層級。關鍵程式碼存在於下面的程式碼
Iterator* input = versions_->MakeInputIterator(compact->compaction);

歸併排序

DoCompactionWork 歸併排序 的原始碼如下:

Status DBImpl::DoCompactionWork(CompactionState* compact) {
    int64_t imm_micros = 0; // Micros spent doing imm_ compactions
    

    if (snapshots_.empty()) {
        // 快照為空,找到直接採用記錄資訊的最後序列號
    
        compact->smallest_snapshot = versions_->LastSequence();
    
    } else {
        // 快照存在,則拋棄之前所有的序列
        compact->smallest_snapshot = snapshots_.oldest()->sequence_number();
    
    }
    
      
    // 對於待壓縮資料進行,內部生成一個MergingIterator,當構建迭代器之後鍵內部就是有序的狀態了,也就是前面說的歸併排序的部分
    Iterator* input = versions_->MakeInputIterator(compact->compaction);

    input->SeekToFirst();
    
    Status status;
    
    ParsedInternalKey ikey;
    
    std::string current_user_key;
    //當前記錄user key
    bool has_current_user_key = false;
    
    SequenceNumber last_sequence_for_key = kMaxSequenceNumber;
    
    while (input->Valid() && !shutting_down_.load(std::memory_order_acquire)) {
    
    // 優先考慮imumemtable的壓縮工作
    
    if (has_imm_.load(std::memory_order_relaxed)) {
    
        const uint64_t imm_start = env_->NowMicros();
        
        imm_micros += (env_->NowMicros() - imm_start);
    
    }
    
    
    Slice key = input->key();
    
    if (compact->compaction->ShouldStopBefore(key) &&
        
        compact->builder != nullptr) {
        
        status = FinishCompactionOutputFile(compact, input);
    
    }
    
      
    
    // 處理鍵/值,新增到狀態等。
    
    bool drop = false;
    
    if (!ParseInternalKey(key, &ikey)) {
    
        // 刪除和隱藏唄刪除key
        
        current_user_key.clear();
        
        has_current_user_key = false;
        // 更新序列號
        last_sequence_for_key = kMaxSequenceNumber;
    
    } else {
    
        if (!has_current_user_key ||
        
            user_comparator()->Compare(ikey.user_key, Slice(current_user_key)) !=
        
        0) {
        
        // 使用者key第一次出現
        
        current_user_key.assign(ikey.user_key.data(), ikey.user_key.size());
        
        has_current_user_key = true;
        
        last_sequence_for_key = kMaxSequenceNumber;
        
    }
        
      
    
    if (last_sequence_for_key <= compact->smallest_snapshot) {
    
        // 壓縮以後舊key邊界的被新的覆蓋
        
        drop = true; // (A)
    
    } else if (ikey.type == kTypeDeletion &&
    
        ikey.sequence <= compact->smallest_snapshot &&
    
        compact->compaction->IsBaseLevelForKey(ikey.user_key)) {
    
        // 對於這個使用者金鑰:
    
        // (1) 高層沒有資料
        
        // (2) 較低層的資料會有較大的序列號
        
        // (3) 層中的資料在此處被壓縮並具有
        
        // 較小的序列號將在下一個被丟棄
        
        // 這個迴圈的幾次迭代(根據上面的規則(A))。
        
        // 因此,此刪除標記已過時,可以刪除。
    
        drop = true;
    
    }
    
      
    
    last_sequence_for_key = ikey.sequence;

}

}

歸併排序並且處理完鍵值資訊完成跨層級壓縮,之後便是是一些收尾工作,收尾工作需要對於壓縮之後的資訊統計。

    CompactionStats stats;

    stats.micros = env_->NowMicros() - start_micros - imm_micros;
    //選擇兩個層級的SSTable
    for (int which = 0; which < 2; which++) {
    
        for (int i = 0; i < compact->compaction->num_input_files(which); i++) {
        
            stats.bytes_read += compact->compaction->input(which, i)->file_size;
        
        }
    
    }
    for (size_t i = 0; i < compact->outputs.size(); i++) {
    
        stats.bytes_written += compact->outputs[i].file_size;
    
    }
    // 壓縮到更高的層級
    stats_[compact->compaction->level() + 1].Add(stats);
    // 註冊壓縮結果
    InstallCompactionResults(compact);
    // 壓縮資訊儲存
    VersionSet::LevelSummaryStorage tmp;
    Log(options_.info_log, "compacted to: %s", versions_->LevelSummary(&tmp));
    
    return status;

最後層級壓縮的預設層級為7個層級,在原始碼中有如下定義:

static const int kNumLevels = 7;

小結

這裡我們小結一下合併壓縮的兩個操作:Minor CompactionMajor Compaction

Minor Compaction:這個GC主要是Level0層級的一些壓縮操作,由於Level0層級被較為頻繁使用,類似一級快取,鍵值不會強制要求進行排序,所以重疊的鍵會比較多,整個壓縮的過程比較好理解,關鍵部分是skiplist(跳錶)中構建一個新的SSTable並且插入到指定層級。

注:Minor Compaction進行的時候會暫停Major Compaction操作。

Minor Compaction:這個比Minor Compaction複雜不少,不僅包含跨層級壓縮,還包括鍵範圍確定和迭代器歸併排序和最終的統計資訊操作,其中最最關鍵的部分是歸併排序壓縮列表,之後將舊檔案和新檔案合併生產新的VersionSet資訊,另外這裡除開全域性的壓縮排度和管理操作之外。

另外Minor Compaction完成之後還會再嘗試一次Minor Compaction,因為Minor Compaction可能帶來更多的重複鍵,所以再進行一次壓縮可以進一步提高查詢效率。

Major Compaction:這個操作需要暫停整個LevelDB的讀寫,因為此時需要對於整個LevelDb的多層級進行跨層級合併,跨層級壓縮要複雜很多,具體的細節會在後面介紹。

這裡可以認為是作者在測試的過程發現一種情況並且做的優化。

儲存狀態 - VersionSet

從這個物件名稱來看直接理解為“版本集合”,在內部通過一個Version的結構體對於鍵值資訊進行“版本控制”,毫無疑問這是由於多執行緒壓縮所帶來的特性,所以最終是一個雙向連結串列+歷史版本的形式串聯,但是永遠只有一個版本是當前版本。
VersionSet最為頻繁也是比較關鍵的一個操作函式LogAndApply,下面是簡化之後的VersionSet::LogAndApply程式碼:

這裡可以對照關係型資料庫Mysql的Mvcc中的undo log類比進行理解
Status VersionSet::LogAndApply(VersionEdit* edit, port::Mutex* mu) {
    // 更新版本連結串列資訊
    if (!edit->has_prev_log_number_) {
    
        edit->SetPrevLogNumber(prev_log_number_);
    
    }

    edit->SetNextFile(next_file_number_);
    
    edit->SetLastSequence(last_sequence_);

    Version* v = new Version(this);
    // 構建當前的版本version,委託給建造器進行構建
    Builder builder(this, current_);
    
    builder.Apply(edit);
    
    builder.SaveTo(v);

    // 關鍵方法:內部通過打分機制確定檔案所在的層級,值得注意的是level0的層級確定在原始碼中有較多描述
    Finalize(v);

// 如有必要,通過建立包含當前版本快照的臨時檔案來初始化新的描述符日誌檔案。

    std::string new_manifest_file;
    //  沒有理由在這裡解鎖*mu,因為我們只在第一次呼叫LogAndApply時(開啟資料庫時)碰到這個路徑。
    new_manifest_file = DescriptorFileName(dbname_, manifest_file_number_);
    // 寫入mainfest檔案
    env_->NewWritableFile(new_manifest_file, &descriptor_file_);

    // 寫入版本資訊快照
    WriteSnapshot(descriptor_log_);

    // 把記錄寫到 MANIFEST中

    descriptor_log_->AddRecord(record);

    //如果建立了新的檔案,則將當前版本指向這個檔案

    SetCurrentFile(env_, dbname_, manifest_file_number_);

    // 註冊新版本
    
    AppendVersion(v);
    
    return Status::OK();

}

關鍵部分註釋已給出,這裡的Mainfest細節在之前沒有提到過,在作者提供的impl.md是這樣介紹mainfest的:

MANIFEST 檔案列出了組成每個級別的排序表集、相應的鍵範圍和其他重要的後設資料。 每當重新開啟資料庫時,都會建立一個新的 MANIFEST 檔案(檔名中嵌入了一個新編號)。 MANIFEST 檔案被格式化為日誌,並且對服務狀態所做的更改(隨著檔案的新增或刪除)被附加到此日誌中。

從個人的角度來看,這個檔案有點類似BigTable中的後設資料Meta

SSTable檔案格式

理解這部分不需要急著看原始碼,在倉庫中的table_format.md的檔案中同樣有相關描述,這裡就直接照搬官方文件翻譯了:

leveldb 檔案格式
<beginning_of_file>
[資料塊 1]
[資料塊 2]
...
[資料塊N]
[元塊 1]
...
[元塊K]
[元索引塊]
[索引塊]
[頁尾](固定大小;從 file_size - sizeof(Footer) 開始)
<end_of_file>

我們可以根據描述畫一個對應的結構圖:

LSM-Tree - LevelDb 原始碼解析

上面的結構圖從上至下的介紹如下:

  • 資料塊:按照LSM-Tree的資料儲存規範,按照key/value的順序形式進行排序,資料塊根據block.builder.cc的內部邏輯進行格式化,並且可以選擇是否壓縮儲存。
  • 後設資料塊:後設資料塊和資料塊類似也使用block.builder.cc進行格式化,同時可選是否壓縮,後設資料塊後續擴充套件更多的型別(主要用作資料型別記錄)
  • “元索引”塊:為每個其他後設資料塊索引,鍵為元塊的名稱,值為指向該元塊的 BlockHandle。
  • “索引塊”:包含資料塊的索引,鍵是對應字串>=資料塊的最後一個鍵,並且在連續的資料塊的第一個鍵之前,值是 資料塊的 BlockHandle。
  • 檔案的最後是一個固定長度的頁尾,其中包含元索引和索引塊的 BlockHandle 以及一個幻數
幻數又被稱為魔數,比如JAVA的位元組碼第一個位元組8位是CAFEBABE,數值和位元組大小沒什麼意義,更多是作者的興趣。

注意Footer頁尾固定48個位元組的大小,我們能在其中拿到 元索引塊索引塊的位置,然後通過這兩個索引尋找其他值對應的位置。

更詳細的內容可以繼續參考table_format.md介紹,這裡就不再贅述了。

TableBuilder

SSTable介面定義於一個TableBuilder構建器當中,TableBuilder 提供了用於構建 Table 的介面,關於此介面的定義如下:

TableBuilder提供了用於建立表的介面 (一個從鍵到值的不可變和排序的對映)。

多個執行緒可以在一個TableBuilder上呼叫const方法而不需要外部同步。但如果任何一個執行緒可能呼叫一個非常量方法,所有訪問同一個TableBuilder的執行緒必須使用外部同步。

// TableBuilder 提供了用於構建 Table 的介面
//(從鍵到值的不可變且排序的對映)。
//
// 多個執行緒可以在 TableBuilder 上呼叫 const 方法,而無需
// 外部同步,但如果任何執行緒可能呼叫
// 非常量方法,所有訪問同一個 TableBuilder 的執行緒都必須使用
// 外部同步。
class LEVELDB_EXPORT TableBuilder {

public:

    TableBuilder(const Options& options, WritableFile* file);
    
    TableBuilder(const TableBuilder&) = delete;
    
    TableBuilder& operator=(const TableBuilder&) = delete;
    /*
    改變該構建器所使用的選項。注意:只有部分的
    選項欄位可以在構建後改變。如果一個欄位是
    不允許動態變化,並且其在結構中的值
    中的值與傳遞給本方法的結構中的值不同。
    結構中的值不同,該方法將返回一個錯誤
    而不改變任何欄位。
    */
    Status ChangeOptions(const Options& options);
    
    void Add(const Slice& key, const Slice& value);
    
    void Flush();
    
    Status status() const;
    
    Status Finish();
    /*
    表示應該放棄這個建設者的內容。停止
    在此函式返回後停止使用傳遞給建構函式的檔案。
    如果呼叫者不打算呼叫Finish(),它必須在銷燬此構建器之前呼叫Abandon()
    之前呼叫Abandon()。
    需要。Finish()、Abandon()未被呼叫。
    */
    void Abandon();
    
    uint64_t NumEntries() const;
    
    uint64_t FileSize() const;
    
    private:
    
        bool ok() const { return status().ok(); }
        
        void WriteBlock(BlockBuilder* block, BlockHandle* handle);
        
        void WriteRawBlock(const Slice& data, CompressionType, BlockHandle* handle);
        
        struct Rep;
        
        Rep* rep_;
    
    };
} 

小結

SSTable 相關的設計在整個LevelDB中有著重要的地位和作用,我們介紹了SSTable的多層級合併和壓縮的細節,以及兩種不同的壓縮形式,第一種是針對Level0的簡單壓縮,簡單壓縮只需要把存在於記憶體中的SSTable也就是將Imumemtable壓縮到磁碟中儲存,特別注意的是這個動作在第一次完成之後通常還會再執行一次,目的是為了防止合併之後產生的。

另一種是針對頻繁Key查詢進行的多層級壓縮,多層級壓縮要比簡單壓縮複雜許多,但是多層級壓縮是提高整個LevelDB寫入效能和查詢效能到關鍵。

最後,從LevelDB中也可以看到很多經典資料結構和演算法的實現,比鍵管理利用了跳錶+歸併排序的方式提高管理效率,排序的內容不僅利於查詢,在儲存的時候也有利於資料的順序掃描。

Skiplist跳錶

跳錶不僅在LevelDb中使用,還在許多其他的中介軟體中存在實現,這一部分內容將會放到下一篇文章單獨介紹。

壓縮檔案使用了歸併排序的方式進行鍵合併,而內部的資料庫除了歸併排序之外還使用了比較關鍵的[[LSM-Tree - LevelDb Skiplist跳錶]]來進行有序鍵值管理,在瞭解LevelDB跳錶的細節之前,需要先了解跳錶這個資料結構的基本概念。

[[LevelDb跳錶實現]]

布隆過濾器

Bloom Filter是一種空間效率很高的隨機資料結構,它利用位陣列很簡潔地表示一個集合,並能判斷一個元素是否屬於這個集合。Bloom Filter的這種高效是有一定代價的:在判斷一個元素是否屬於某個集合時,有可能會把不屬於這個集合的元素誤認為屬於這個集合(false positive)。因此,Bloom Filter不適合那些“零錯誤”的應用場合。而在能容忍低錯誤率的應用場合下,Bloom Filter通過極少的錯誤換取了儲存空間的極大節省。

leveldb中利用布隆過濾器判斷指定的key值是否存在於sstable中,若過濾器表示不存在,則該key一定不存在,由此加快了查詢的效率。

布隆過濾器在不同的開源元件中用的也比較多,所以這裡同樣放到了一篇單獨文章講解。

[[LSM-Tree - LevelDb布隆過濾器]]

寫在最後

LevelDB的設計還是很有意思的,關鍵是大部分的程式碼都有解釋和介紹。

原始碼內容很多,但是仔細分析的話不難分析,感謝看到最後。

參考資料

相關閱讀