LevelDB學習筆記 (2): 整體概覽與讀寫實現細節

周小倫發表於2021-07-04

1. leveldb整體介紹

首先leveldb的資料是儲存在磁碟上的。採用LSM-Tree實現,LSM-Tree把對於磁碟的隨機寫操作轉換成了順序寫操作。這是得益於此leveldb的寫操作非常快,為了做點這一點LSM-Tree的思路是將索引樹結構拆成一大一小兩棵樹,較小的一顆常駐記憶體,較大的一個持久化到磁碟。而隨著記憶體中的樹逐漸增大就會發生樹的合併和分裂,大概結構如下圖所示。後面還會詳細分析

下圖是整個leveldb的結構概述圖,首先我們會把資料寫入memtable(位於記憶體中),當memtable滿了之後。就會變成immutable memtable。也就是所謂的冷卻狀態,這個時候的memtable無法再被寫入資料。在immutable memtable中的資料會準備寫入SST(磁碟)中

2. leveldb的主要構成

1、Log檔案(位於磁碟)

在我們把資料寫Memtable前會先寫Log檔案,Log通過append的方式順序寫入。Log的存在使得機器當機導致的記憶體資料丟失得以恢復。

2、 Memtable(位於記憶體)

Leveldb主要的記憶體資料結構,採用跳錶進行實現。新的資料會首先寫入這裡。

3、Immutable Memtable(位於記憶體)

當Memtable內的資料設定的容量上限後,Memtable會變為Immutable為之後向SST檔案的歸併做準備。Immutable Mumtable不再接受使用者寫入,同時生成新的Memtable、log檔案供新資料寫入。

4、SST檔案(位於磁碟)

磁碟資料儲存檔案。SSTable(Sorted String Table)就是由記憶體中的資料不斷匯出並進行Compaction操作後形成的,而且SSTable的所有檔案是一種層級結構,第一層為Level 0,第二層為Level 1,依次類推,層級逐漸增高,這也是為何稱之為LevelDb的原因。除此之外,Compact動作會將多個SSTable合併成少量的幾個SSTable,以剔除無效資料,保證資料訪問效率並降低磁碟佔用。

SSTABLE是由多個segement組成,這樣可以減少碎片的產生。整體的結構如下圖引用自

S4nBu.png

5、Manifest檔案(位於磁碟)

Manifest檔案中記錄SST檔案在不同Level的分佈,單個SST檔案的最大最小key,以及其他一些LevelDB需要的元資訊。

6、Current檔案(位於磁碟)

從上面的介紹可以看出,LevelDB啟動時的首要任務就是找到當前的Manifest,而Manifest可能有多個。Current檔案簡單的記錄了當前Manifest的檔名,從而讓這個過程變得非常簡單。

上述的整體結構就可以利用下圖來描述

LevelDB 結構

3. leveldb讀寫實現速看

1. 寫操作的實現

首先我們通過之前寫過的簡單的put操作,利用斷點跟蹤一下整個put過程。

// Write data.
status = db->Put(leveldb::WriteOptions(), "name", "zxl");

WriteOptions 控制著我們是否需要 sync,也就是刷到磁碟上

根據斷點執行,上述的put操作要先進入db/db_impl.cc裡面的Put函式。這裡可以發現的key和value都是以Slice形式來儲存,也就是切片來儲存。因此在往下追溯之前我們先來看一下切片

// Convenience methods
Status DBImpl::Put(const WriteOptions& o, const Slice& key, const Slice& val) {
  return DB::Put(o, key, val);
}

切片的實現

切片的整體程式碼位於include/Slice.h。整體由一個類組成。其中含有一個c字串和一個大小變數

class LEVELDB_EXPORT Slice {
  // ......
 private:
  const char* data_;
  size_t size_;
}

整體關於Slice的建構函式有幾種不同的過載,下面我們仔細來看一下

// Create an empty slice.
Slice() : data_(""), size_(0) {}

// Create a slice that refers to d[0,n-1].
Slice(const char* d, size_t n) : data_(d), size_(n) {}

// Create a slice that refers to the contents of "s"
Slice(const std::string& s) : data_(s.data()), size_(s.size()) {}

// Create a slice that refers to s[0,strlen(s)-1]
Slice(const char* s) : data_(s), size_(strlen(s)) {}

上面四種分別對應了不同的情況

  1. 是對於空字串的初始化
  2. 對於給定長度的c字串的初始化
  3. 對於string的初始化
  4. 對於不給定長度的c字串的初始化

對於拷貝構造和拷貝賦值則都採用了c++11的預設方法=default來實現

關於c++11的預設拷貝構造與拷貝賦值

// Intentionally copyable.
Slice(const Slice&) = default; // 預設淺拷貝
Slice& operator=(const Slice&) = default;

同樣關於切片還有一些特殊作用的函式,來分析一下

starts_with函式用來判斷x是不是當前Slice的一個字首。這裡用到了memcmp這個c語言庫函式。

int memcmp (const void *s1, const void *s2, size_t n); 用來比較s1 和s2 所指的記憶體區間前n 個字元。

如果返回值為0則表示相同,否則會返回差值,這裡是按照ascll的順序來進行比較的

 // Return true iff "x" is a prefix of "*this"
  bool starts_with(const Slice& x) const {
    return ((size_ >= x.size_) && (memcmp(data_, x.data_, x.size_) == 0));
  }

下面還有兩個切片的比較函式

  1. 假如說我們呼叫a.compare(b)
  2. 那麼比較的邏輯就是先看a和b誰比較長一點。
  3. 然後取較小的長度去進行比較
  4. 如果在較小的長度內a和b是相同的,那麼就是誰長誰就更大
  5. 如果在較小的長度內a和b不是想同的,那麼就以較小長度內的比較為準
inline int Slice::compare(const Slice& b) const {
  const size_t min_len = (size_ < b.size_) ? size_ : b.size_;
  int r = memcmp(data_, b.data_, min_len);
  if (r == 0) {
    if (size_ < b.size_)
      r = -1;
    else if (size_ > b.size_)
      r = +1;
  }
  return r;
}

寫一個測試程式碼

 auto a =  new leveldb::Slice("123");
  leveldb::Slice b;
  b = leveldb::Slice("21");
  std:: cout << "a 與 b 的比較結果" << a->compare(b) << std::endl;

上述程式碼會輸出-1表示a < b這符合我們的預期

好了我們上面知道了key和value都是以切片的形式進行儲存的。ok下面繼續分析寫操作

隨後進入db/db_imp.cc中的DB::Put函式

// Default implementations of convenience methods that subclasses of DB
// can call if they wish
Status DB::Put(const WriteOptions& opt, const Slice& key, const Slice& value) {
  WriteBatch batch;
  batch.Put(key, value);
  return Write(opt, &batch);
}

這裡會定義一個writeBatch來進行寫入操作,它會呼叫batch.put來實現原子寫。

不過我們前面有說寫操作會先寫Log來防止出現意外。而資料則會先寫入memtable

Write Log操作

在呼叫write(opt, &batch)的時候

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

  MutexLock l(&mutex_);
  writers_.push_back(&w);
  //  排隊寫入,直到我們在 front
  while (!w.done && &w != writers_.front()) {
    w.cv.Wait();
  }
  if (w.done) {
    return w.status;
  }
{
  mutex_.Unlock();
  status = log_->AddRecord(WriteBatchInternal::Contents(write_batch));

上述操作就是寫入log的操作。而下面的程式碼則是寫入memTable

 if (status.ok()) {
        status = WriteBatchInternal::InsertInto(write_batch, mem_); // 這裡寫入,mem_ 是 MemTable,
     }

關於log寫入的具體分析後面會在log詳解的時候分析

寫入memtable

Status WriteBatchInternal::InsertInto(const WriteBatch* b, MemTable* memtable) {
  MemTableInserter inserter;
  inserter.sequence_ = WriteBatchInternal::Sequence(b);
  inserter.mem_ = memtable;
  return b->Iterate(&inserter);
}

上面的程式碼最終會執行到。memtable.cc

void MemTable::Add(SequenceNumber s, ValueType type, const Slice& key,
                   const Slice& value) {
  // Format of an entry is concatenation of:
  //  key_size     : varint32 of internal_key.size()
  //  key bytes    : char[internal_key.size()]
  //  value_size   : varint32 of value.size()
  //  value bytes  : char[value.size()]
  size_t key_size = key.size();
  size_t val_size = value.size();
  size_t internal_key_size = key_size + 8;
  const size_t encoded_len = VarintLength(internal_key_size) +
                             internal_key_size + VarintLength(val_size) +
                             val_size;
  // 為要put的key value 建立空間
  char* buf = arena_.Allocate(encoded_len); 
  char* p = EncodeVarint32(buf, internal_key_size);
  // copy進去
  std::memcpy(p, key.data(), key_size);
  p += key_size;
  EncodeFixed64(p, (s << 8) | type);
  p += 8;
  p = EncodeVarint32(p, val_size);
  std::memcpy(p, value.data(), val_size);
  assert(p + val_size == buf + encoded_len);
  // 將索引寫入SkipList
  table_.Insert(buf);
}

前面說過當memtable滿了之後會寫入磁碟也就是sstable。對應的程式碼在MakeRoomForWrite後面再仔細分析了

Status status = MakeRoomForWrite(updates == nullptr);
uint64_t last_sequence = versions_->LastSequence();

2. 讀操作的實現

同樣的還是通過debug的方式追蹤程式碼

程式碼位於db_impl.cc:DBImpl::Get

// Unlock while reading from files and memtables
{
  mutex_.Unlock();
  // First look in the memtable, then in the immutable memtable (if any).
  LookupKey lkey(key, snapshot);
  if (mem->Get(lkey, value, &s)) {
    // Done
  } else if (imm != nullptr && imm->Get(lkey, value, &s)) {
    // Done
  } else {
    s = current->Get(options, lkey, value, &stats);
    have_stat_update = true;
  }
  mutex_.Lock();
}

if (have_stat_update && current->UpdateStats(stats)) {
  MaybeScheduleCompaction();
}
mem->Unref();
if (imm != nullptr) imm->Unref();
current->Unref();
return s;

可以發現讀操作會先根據key值做一個查詢操作loocupKey

隨後去memtable中查詢。如果memtable沒有則會去 immutable中尋找

如果上面兩個地方都查不到的話最後則要去sstable中查詢。

4. 總結

借鑑了很多大佬們的資料, 分析了一下leveldb的整體結構,以及讀和寫操作的簡單實現,當然後面還會進一步分析。更詳細的講解讀和寫操作的實現。下一篇就分析一下memtable的實現以及是如何寫入memtableimmutable的。

5. 參考資料

https://youjiali1995.github.io/rocksdb/io/

https://github.com/google/leveldb/tree/v1.20/doc

http://catkang.github.io/2017/01/07/leveldb-summary.html

https://www.zhihu.com/column/c_1327581534384230400

https://www.youtube.com/watch?v=PSna05F5fL4&list=PLBokfyNIQPIR2EOXnpemSqzXkkZylAmsD

http://blog.yanick.site/2020/11/08/algorithm/lsm-tree/

https://mp.weixin.qq.com/s/RmyBUUrNVUrmHBJ-7ujM3w

相關文章