1. 程式人生 > 其它 >leveldb程式碼閱讀(一)

leveldb程式碼閱讀(一)

leveldb是一個由谷歌開源的高效的單機key-value儲存系統,該系統提供了key到value的有序對映,現有的主流的kv儲存系統有很多基於或者借鑑了leveldb的思想。主體程式碼大約1w行。基於 LSM(LOG Structured Merge Tree) 實現,將所有的 Key/Value 按照 Key 的詞法序有序地儲存在檔案中,具有很高的隨機/順序寫效能,非常適用於寫多讀少的環境。

DBImpl是leveldb資料庫的結構體,一個DBImpl就是一個數據庫,DBImpl的結構體在db_impl.h中。DBImpl和DB是友元類(friend class),DB可以直接訪問DBImpl中的私有成員和保護成員,DB類是leveldb的對外介面。DBImpl中主要的成員主要有:

  port::Mutex mutex_; 
  MemTable* mem_;
  MemTable* imm_ GUARDED_BY(mutex_);  // Memtable being compacted
  WritableFile* logfile_;
  log::Writer* log_;
  // Queue of writers.
  std::deque<Writer*> writers_ GUARDED_BY(mutex_);
  SnapshotList snapshots_ GUARDED_BY(mutex_);

上圖簡單展示了 LevelDB 的整體架構。

  1. MemTable:記憶體資料結構,具體實現是 SkipList。 接受使用者的讀寫請求,新的資料會先在這裡寫入。
  2. Immutable MemTable:當 MemTable 的大小達到設定的閾值後,會被轉換成 Immutable MemTable,只接受讀操作,不再接受寫操作,然後由後臺執行緒 flush 到磁碟上 —— 這個過程稱為 minor compaction。
  3. Log:資料寫入 MemTable 之前會先寫日誌,用於防止宕機導致 MemTable 的資料丟失。一個日誌檔案對應到一個 MemTable。
  4. SSTable:Sorted String Table。分為 level-0 到 level-n 多層,每一層包含多個 SSTable,檔案內資料有序。除了 level-0 之外,每一層內部的 SSTable 的 key 範圍都不相交。
  5. Manifest:Manifest 檔案中記錄 SSTable 在不同 level 的資訊,包括每一層由哪些 SSTable,每個 SSTable 的檔案大小、最大 key、最小 key 等資訊。
  6. Current:重啟時,LevelDB 會重新生成 Manifest,所以 Manifest 檔案可能同時存在多個,Current 記錄的是當前使用的 Manifest 檔名。
  7. TableCache:TableCache 用於快取 SSTable 的檔案描述符、索引和 filter。
  8. BlockCache:SSTable 的資料是被組織成一個個 block。BlockCache 用於快取這些 block(解壓後)的資料。

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

C++多執行緒讀寫問題

如果不使用任何同步機制(例如mutex或atomic),在多執行緒中讀寫同一個變數,那麼程式的結果是難以預料的。編譯器和CPU的行為會影響到程式執行的結果:

  1. C++不保證一條沒有任何機制的語句是原子操作,其他執行緒可能看見指令執行的中間結果
  2. 為了優化程式執行效能,CPU可能會調整指令的執行順序(如果兩條指令不互相依賴)

如果CPU沒有亂序執行指令,那麼Thread-2將輸出100,然而,對於Thread-1來說,x=100和y=200兩個語句之間沒有依賴關係,CPU可能調整兩者的執行順序,Thread-2將輸出0或者100。

  1. 在CPU cache的影響下,一個CPU執行了某個指令,不會立即被其他CPU看見。

儘管A可能先於B執行,但CPU cache的影響下,Thread-2不能保證立即看到A執行的結果,所以Thread-2可能輸出0或者100。

std::atomic

C++中對共享資料的存取在並法條件下可能引發data race的行為,需要限制併發程式以某種特定的順序執行,有兩種方式,使用mutex保護共享資料,或者原子操作,針對原子型別的操作要麼一步完成,要麼不完成,不會在中途切換cpu,這樣可以防止多執行緒指令交叉執行帶來的可能錯誤。非原子操作下,某個執行緒可能看見的是其他執行緒未操作完成的資料。

atomic標頭檔案聲明瞭兩個C++類,atomic和atomic_flag。

https://www.jianshu.com/p/8c1bb012d5f8

C++中的memory order

memory order解決的是多執行緒讀寫中的問題2,是單個執行緒中的操作造成多執行緒出現的問題。為了解決2中重排造成的問題,C++中有6種可以應用於原子變數的記憶體次序。(思考:memory fence保證的是執行的順序,但是這並不能解決問題3中cache產生的問題:cache重新整理的順序沒有受到約束)

舉例如下。這種寫法可以讓data=42先於flag=true執行,讓while迴圈先於assert執行。

#include <atomic>
 
std::atomic<int> data;
std::atomic<bool> flag;
 
void thread1() {
    data.store(42, std::memory_order_relaxed);
    flag.store(true, std::memory_order_release);
}
 
void thread2() {
    while (!flag.load(std::memory_order_acquire)) {
    }
    assert(data.load(std::memory_order_relaxed) == 42);
}

Slice切片

Slice類中有兩個成員,data_和size_,前者是資料,後者是大小。

關於C++11預設的拷貝構造和拷貝賦值#

default拷貝建構函式是淺拷貝

需要注意的是兩個slice比較大小的函式。只比較共同長度的那一部分,如果那一部分相同,那麼誰長誰大。例如,“123”<"23"

SkipList跳錶

SkipList 是平衡樹的一種替代資料結構,但是和紅黑樹不相同的是,SkipList 對於樹的平衡的實現是基於一種隨機化的演算法的,這樣也就是說 SkipList 的插入和刪除的工作是比較簡單的。SkipList 不僅是維護有序資料的一個簡單實現,而且相比較平衡樹來說,在插入資料的時候可以避免頻繁的樹節點調整操作,所以寫入效率是很高的,LevelDB 整體而言是個高寫入系統,SkipList 在其中應該也起到了很重要的作用。Redis 為了加快插入操作,也使用了 SkipList 來作為內部實現資料結構。leveldb中的跳錶是執行緒安全的,寫資料要加鎖,讀資料不需要加鎖,只要保證跳錶不會在讀的過程中被銷燬。

宣告新的node的時候,是一個node加一個next_陣列大小,Node類中成員的排列是連續的。next_陣列儲存了各層的後繼節點,next_陣列的長度取決於生成節點時跳錶的高度height,node類中有一個私有成員next_[1],用Node*陣列來代替Node**,實際上就是個指標。

Arena記憶體池

使用了單鏈表的記憶體池。用blocks陣列管理已經分配出去的記憶體塊。回收記憶體的時候將其逐一回收。(todo:為什麼使用array delete,為什麼沒有對remaining記憶體回收?)

需要注意的是採取了記憶體對齊的措施。

Memtable/Immutable Memtable

memtable的主要功能是將內部編碼、記憶體分配(arena)和SkipList封裝在一起, 提供了將 KV 資料寫入,刪除以及讀取 KV 記錄的操作介面,但是事實上 Memtable 並不存在真正的刪除操作,刪除某個 key 的 value 在 Memtable 內是作為插入一條記錄實施的,但是會打上一個 key 的刪除標記(tag的ValueType置為kTypeDeletion),真正的刪除操作是 Lazy 的,會在以後的 Compaction 過程中去掉這個 KV。

memtable使用了引用計數,維護了一個refs_變數。每次呼叫Ref(),引用計數++,呼叫unref(),引用計數--,若引用計數為0,則delete此物件。

memtable中定義了兩個友元類:memtableIterator和memtableBackWardIterator。定義了自己的記憶體池、比較器和跳錶。

memtable中有兩個主要的函式:Add和Get。

memtable中的資料用編碼之後的格式儲存,其中sequence是序列號(快照是根據sequence生成的),type標誌著這條entry是否被刪除。

Log

//todo:

SSTable

// todo:

VersionSet, Version, VersionEdit

  • Version儲存當前磁碟以及記憶體中的檔案資訊,一般只有一個version為”current version”。同時還儲存了一系列的歷史version,這些version的存在是因為有讀操作還在引用(iterator和get,Compaction操作後會產生新的version作為current version

  • VersionSet就是一系列Version的集合

  • VersionEdit表示Version之間的變化,表示增加了多少檔案,刪除了多少檔案

Snapshot

  • 快照提供了一個當前KV儲存的一個可讀檢視,使得讀取操作不受寫操作影響,可以在讀操作過程中始終看到一致的資料
  • 一個快照對應當前儲存的最新資料版本號

寫操作

  1. Put操作會將(key,value)轉化成writebatch後,通過write介面來完成
  2. 在write之前需要通過MakeRoomForWrite來保證MemTable有空間來接受write請求,這個過程中可能阻塞寫請求,以及進行compaction。
  3. BuildBatchGroup就是儘可能的將多個writebatch合併在一起然後寫下去,能夠提升吞吐量
  4. AddRecord就是在寫入MemTable之前,現在操作寫入到log檔案中
  5. 最後WriteBatchInternal::InsertInto會將資料寫入到MemTable中

讀操作

  1. 首先判斷options.snapshot是否為空,如果為不為空,快照值就取這個值,否則取最新資料的版本號
  2. 然後依次嘗試去MemTable, Immutable MemTable, VersionSet中去查詢。VersionSet中的查詢流程:
    1. 逐層查詢,確定key可能所在的檔案
    2. 然後根據檔案編號,在TableCache中查詢,如果未命中,會將Table資訊Load到cache中
    3. 根據Table資訊,確定key可能所在的Block
    4. 在BlockCache中查詢Block,如果未命中,會將Block load到Cache中。然後在Block內查詢key是否命中
  3. 更新讀資料的統計資訊,作為一根檔案是否應該進行Compaction的依據,後面講述Compaction時會說明
  4. 最後釋放對Memtable,Immutable MemTable,VersionSet的引用

設計模式

memtabe的寫入過程從WriteBatchInternal::InsertInto()函式開始。模式很奇怪,定義了memTableInserter的handler,用iterate的方式處理?

tips:

  • 用統一的status類作為open、write、get等函式的返回值,以此檢視執行是否成功。如果是get等需要執行結果資料的函式,用一個地址傳參作為承接。

【參考】
https://developer.aliyun.com/article/618109

https://blog.csdn.net/sjc2870/article/details/112342573