Leveldb二三事

摘要

閱讀這篇文章,但願你首先已經對Leveldb有了必定的瞭解,並預先知曉下列概念:html

  • LSM技術
  • 跳錶
  • WAL技術
  • Log Compaction

本文不是一篇專一於源代碼解析的文章,也不是一篇Leveldb的介紹文。咱們更但願探討的是對於通常的單機數據存儲引擎存在哪些問題,Leveldb做爲一個經典實現,是採用什麼策略並如何解決這些問題的。
Leveldb的解決方案是出於什麼考慮,如何高效實現的,作出了哪些權衡以及如何組織代碼和工程。你能夠先從如下幾篇文章對Leveldb有一個基本瞭解。git

Leveldb的實現原理github

LevelDB之LSM-Tree數據庫

LevelDB設計與實現編程

Leveldb的基本架構

數據模型和需求

首先提出幾個問題:數組

  • Leveldb在用戶視圖中的基本單元是什麼?
  • Leveldb一條記錄在內存中的形式是什麼,記錄以怎樣的方式被組織?
  • Leveldb的記錄在文件中的存儲格式是什麼,多條記錄在單文件中是如何被管理的,多文件又是如何被管理的?
  • Leveldb向用戶作出了怎樣的保證,在什麼樣的場景下提供了優化?

首先,Leveldb所處理的每條記錄都是一條鍵值對,因爲它基於sequence number提供快照讀,準確來講應該是鍵,序列號,值三元組,因爲用戶通常關心最新的數據,能夠簡化爲鍵值對。緩存

Leveldb對持久化的保證是基於操做日誌的,一條寫操做只有落盤到操做日誌中以後(暫時先這麼理解,實際上這裏有所出入,後面在優化部分會講到)纔會在內存中生效,才能被讀取到。這就保證了對於已經能見到的操做,一定能夠從操做日誌中恢復。
它對一致性的保障能夠認爲是順序一致性(這裏的一致性不是數據庫理論的一致性,不強調從安全狀態到另外一個安全狀態,而是指從各個視圖看事件發生的順序是一致的,因爲使用了write batch 競爭鎖,實際上寫入是串行化的,但同時併發的寫操做的順序取決於線程搶佔鎖的順序)。
在這裏咱們能夠稍微脫離leveldb的實現討論一下一致性,能不能實現線性一致性呢?若是咱們不支持追加操做的情形下,寫是冪等的,若是確保版本號是按照操做開始時間嚴格遞增分配的,即便併發讀寫也是能夠的,這樣作還有一個問題,就是如何支持快照讀,那就必須保留每個寫記錄,但它們是亂序的,進行查找將是困難的,咱們能夠經過設置同步點,兩個同步點之間的是寫緩衝,快照讀只有在寫緩衝中須要遍歷查找,在寫緩衝被刷入以前重排序記錄,刷入的時機是任意小於當前同步點版本號的寫操做執行完畢。上述所描述的只可能適合於對熱點key的大量併發寫。上面所討論的接近編程語言的內存模型,能夠參考JMM內存模型或者C++內存模型。安全

Leveldb對寫操做的要求是持久化到操做日誌中,其所應對的數據量也超出了內存範圍,或者說其存儲內容的存儲主體仍是在磁盤上,只不過基於最近寫的數據每每會被大量訪問的假設在內存中存儲了較新的數據。leveldb的核心作法就是保存了多個版本的數據以讓寫入操做不須要在磁盤中查找鍵的位置,將隨機寫改成順序寫,將這一部分代價某種程度上轉嫁給讀時在0層SSTable上的查找。那麼它的讀性能受到影響了嗎?我的認爲它的讀性能稍顯不足主要是受制於LSM的檢索方式而非因爲多版本共存的問題,固然寫的便利也是基於這樣的組織方式。數據結構

上面這幾段主要是我的的一些想法,可能有些混亂,剩餘的幾個問題將在下面的部分再詳細解答。架構

工程上的層次結構

leveldb的實現大體上能夠分紅如下幾層結構:

  • 向用戶提供的DB類接口及其實現,主要是DB、DbImpl、iter等
  • 中間概念層的memtable、table、version以及其輔助類,好比對應的iter、builder、VersionEdit等
  • 更底層的偏向實際的讀寫輔助類,好比block、BlockBuilder、WritableFile及其實現等
  • 最後是它定義的一些輔助類和實現的數據結構好比它用來表示數據的最小單元Slice、操做狀態類Status、memtable中用到的SkipList等

可能的性能瓶頸

首先讓咱們考慮設計一款相似於Leveldb的存儲產品,那麼面臨的主要問題主要是如下幾項:

  • 寫入磁盤時的延遲
  • 併發寫加鎖形成的競爭
  • 讀操做時如何經過索引下降查找延遲
  • 如何更好地利用cache優化查詢效率,增長命中
  • 快速地從快照或者日誌中恢復
  • 後臺工做如何保持服務可用

Leveldb的內存管理

什麼應該在內存中

在內存中存放的數據主要包含當前數據庫的元信息、memtable、ImmutableMemtable,前者顯然是必要的,後二者存放的都是最新更新的數據。那麼爲何須要有ImmutableMemtable呢。這是爲了在持久化到磁盤上的同時保持對外服務可用,若是沒有這樣一個機制,那麼咱們要麼須要持久化兩次,並在第一次持久化的中途記錄增量日誌,第二次應用上去,這是CMS垃圾回收器的作法,可是顯然十分複雜;還有一種選擇是咱們預留必定的空間,直接將要持久化的memtable拷貝一份,這樣作顯然會浪費大量可用內存,對於一個數據庫來講,這是災難性的。

那麼元信息具體應該包含哪些信息呢?

  • 當前的操做日誌句柄
  • 版本管理器、當前的版本信息(對應compaction)和對應的持久化文件標示
  • 當前的所有db配置信息好比comparator及其對應的memtable指針
  • 當前的狀態信息以決定是否須要持久化memtable和合並sstable
  • sstable文件集合的信息

上面列出了一些比較重要的元信息,可能還有遺漏

memtable詳解

memtable的結構

memtable的鍵包含三個部分:

  • Slice user ley
  • sequence number
  • value type

鍵的比較器首先按照遞增順序比較user key,而後安裝遞減順序比較sequence number,這兩個足以惟一肯定一條記錄了。把user key放到前面的緣由是,這樣對同一個user key的操做就能夠按照sequence number順序連續存放了,不一樣的user key是互不相干的,所以把它們的操做放在一塊兒也沒有什麼意義。用戶所傳入的是LookupKey,它也是由User Key和Sequence Number組合而成的,其格式爲:

| Size (int32變長)| User key (string) | sequence number (7 bytes) | value type (1 byte) |

這裏的Size是user key長度+8,也就是整個字符串長度了。value type是kValueTypeForSeek,它等於kTypeValue。因爲LookupKey的size是變長存儲的,所以它使用kstart_記錄了user key string的起始地址,不然將不能正確的獲取size和user key。

memtable自己存儲同一鍵的多個版本的數據,這一點從剛剛指出的鍵的格式也能夠看出。這裏爲何不直接在寫的時候直接將原有值替換並使用用戶鍵做爲查找鍵呢?畢竟在memtable中add和update都須要先進行查找。我的認爲除了須要支持快照讀也沒有別的解釋了,雖然這樣作會使得較老的記錄沒有被compact而較新的記錄已經compact了的奇怪現象發生,但並不影響數據庫的讀寫,在性能上也沒有損害。那麼快照讀爲什麼是必要的呢?這個問題我目前也沒有很好的回答,讀者能夠自行思考。

memtable的追加

memtable的追加操做主要是將鍵值對進行編碼操做並最後委託給跳錶處理,代碼很簡單,就放上來吧。

// KV entry字符串有下面4部分鏈接而成  
   //  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;  
char* buf = arena_.Allocate(encoded_len);  
char* p = EncodeVarint32(buf, internal_key_size);  
memcpy(p, key.data(), key_size);  
p += key_size;  
EncodeFixed64(p, (s << 8) | type);  
p += 8;  
p = EncodeVarint32(p, val_size);  
memcpy(p, value.data(), val_size);  
assert((p + val_size) - buf == encoded_len);  
table_.Insert(buf);

有關跳錶能夠參考下列文章:

Skip List(跳躍表)原理詳解與實現

memtable的查找

根據傳入的LookupKey獲得在memtable中存儲的key,而後調用Skip list::Iterator的Seek函數查找。Seek直接調用Skip list的FindGreaterOrEqual(key)接口,返回大於等於key的Iterator。而後取出user key判斷時候和傳入的user key相同,若是相同則取出value,若是記錄的Value Type爲kTypeDeletion,返回Status::NotFound(Slice())。本質上依然委託跳錶處理。

內存分配和釋放

Leveldb本身實現了基於引用計數的垃圾回收和一個簡單的內存池Arena,其實現預先分配大內存塊,劃分爲不一樣對齊的內存空間,其機制乏善可陳,在這裏就很少言,放張圖吧。

Arena示意圖

Arena主要提供了兩個申請函數:其中一個直接分配內存,另外一個能夠申請對齊的內存空間。Arena沒有直接調用delete/free函數,而是由Arena的析構函數統一釋放全部的內存。應該說這是和leveldb特定的應用場景相關的,好比一個memtable使用一個Arena,當memtable被釋放時,由Arena統一釋放其內存。

另外就是對於許多類好比memtable、table、cahe等leveldb都加上了引用計數,其實現也很是簡單,就是在對象中加入數據域refs,這也很是好理解。好比在迭代的過程當中,已經進入下一個block中了,上一個block理應能夠釋放了,但它有可能被傳遞出去提供某些查詢服務使用,在其計數不爲0時不容許釋放,同理對於immutable_memtable,當它持久化完畢時,若是還在爲用戶提供讀服務,也不能釋放。不得不說Leveldb的工程層次很清楚,幾乎沒有循環引用的問題。

Leveldb的磁盤存儲

須要存儲的內容

對於一個db,大體須要存儲下列文件

  • db的操做日誌
  • 存儲實際數據的SSTable文件
  • DB的元信息Manifest文件
  • 記錄當前正在使用的Manifest文件,它的內容就是當前的manifest文件名
  • 系統的運行日誌,記錄系統的運行信息或者錯誤日誌。
  • 臨時數據庫文件,repair時臨時生成的。

SSTable詳解

SSTable文件組織

單個SSTable文件的組織以下圖所示:

SSTable文件結構圖

大體分爲幾個部分:

  • 數據塊 Data Block,直接存儲有序鍵值對
  • Meta Block,存儲Filter相關信息
  • Meta Index Block,對Meta Block的索引,它只有一條記錄,key是meta index的名字(也就是Filter的名字),value爲指向meta index的位置。
  • Index Block,是對Data Block的索引,對於其中的每一個記錄,其key >=Data Block最後一條記錄的key,同時<其後Data Block的第一條記錄的key;value是指向data index的位置信息
  • Footer,指向各個分區的位置和大小,示意圖以下:

Footer結構示意圖

全部類型的block格式是一致的,主要包含下面幾部分:

Block結構示意圖

其中type指的是採用哪一種壓縮方式,當前主要是snappy壓縮,接下來主要講講block data部分的組織:

snappy是前綴壓縮的,爲了兼顧查找效率,在構建Block時,每隔幾個key就直接存儲一個重啓點key。Block在結尾記錄全部重啓點的偏移,能夠二分查找指定的key。Value直接存儲在key的後面,無壓縮。

普通的kv對存儲結構以下:

  • 共享前綴長度
  • 非共享鍵部分的長度
  • 前綴以後的字符串

整體的Block Data以下:

Block內部示意圖

整體來看Block可分爲k/v存儲區和後面的重啓點存儲區兩部分,後面主要是重啓點的位置和個數。Block的大小是根據參數固定的,當不能存放下一條記錄時多餘的空間將會閒置。

SSTable邏輯表達

SSTable在代碼上主要有負責讀相關的Table、Block和對應的Iterator實現;在寫上主要是BlockBuilder和TableBuilder。能夠看出來這也是個典型的二層委託結構了,上面的層次將操做委託給下面層次的類執行,本身管控住progress的信息,控制當前的下層實體。這裏咱們主要關心Table和Block中應該存放哪些信息以支持它們的操做。

先講講簡單的Block,毫無疑問除了數據(char*+size)自己之外就是重啓點了,重啓點但是查詢的利器啊,直接的思路是解析重啓點部分紅一個vector等,實際上Leveldb不是這樣作的,只是保留了一個指向重啓點部分的指針,至於爲何咱們在查詢一節裏再詳談。

再說說Table,

SSTable的寫入

首先,咱們考慮在內存中構建一個連續的內存區域表明一個block的內容,它又能夠分爲兩部分:1. 數據的寫入 2. 數據寫入完畢後附加信息的添加。 先考慮追加一條記錄,咱們須要知道哪些東西?

  • 當前block提供給數據的剩餘空間以肯定是否須要換block
  • 當前的重啓點以肯定共享前綴
  • 當前重啓點已有的key數量以肯定是否將本次寫入做爲新的重啓點
  • 確保key的有序性,因此必須知道上次添加的key

在肯定這些須要的信息後,追加的過程就是查找和維護這些信息以及單純的memcpy了。

第二步,讓咱們考慮在數據寫入完畢以後須要爲block添加其餘信息的過程:

  • 咱們須要記錄全部的重啓點和重啓點位置,咱們不得不在追加的時候來維護它們,看來得回去改上面的代碼了
  • 咱們得從配置元數據中獲得壓縮類型
  • 最後咱們得記錄CRC

如今,咱們能夠把這麼一段char[]的數據轉換成Slice表達的block了。接下來,讓咱們考慮如何批量的把數據寫入單個SSTable文件中。這一樣分爲三個步驟:1. 追加數據 2. 附加信息 3. Flush到文件。 咱們依次考慮。

追加數據須要作哪些:

  • 知道當前block及當前block可否再添加一條數據
  • 維護有序性,須要上一次的key和新加key比較
  • 若是生成新的block,爲了維護索引,須要爲將被替換的block生成索引記錄,因此必須維護一個index Block
  • 維護過濾器信息(這一部分將在布隆過濾再詳細解釋,能夠暫時忽略)
  • 爲了決定是否須要刷到文件中去,須要知道已寫的block數

實際上向文件寫入是以Block爲單位的,當咱們完成一個Block時,在將它寫入文件時須要作什麼呢?

  • 檢查工做,肯定block確實須要寫入
  • 壓縮工做
  • 通知工做,告知index Block和Filter Block的維護方
  • 重置工做,將當前block重置,準備下一次追加

最後,當數據所有添加完畢,該SSTable文件今後將不可變動,這一步須要執行的是:

  • 寫入最後一塊data block
  • 寫入Meta Block
  • 根據上文寫入時留存的位置信息構建Meta Index Block
  • 寫入Meta Index Block
  • 將最後的data block位置信息寫入Index Block中,並將Index Block寫入文件
  • 寫入Footer信息

SSTable的遍歷

SSTable的遍歷主要委託給一個two level iterator處理,咱們只須要弄清楚它的Next操做就能明白其工做原理。所謂的two level,指的是索引一層,數據一層。在拿到一個SSTable文件的時候,咱們先解析它的Index block部分,而後根據當前的index初始化data block層的iterator。接下來咱們主要關注Next的過程。

分爲兩種情形:

  1. 當前記錄不是當前Data Block的最後一條,只須要data iter向前進一步便可
  2. 當前記錄是最後一條,這時就要先前進一步index iter,獲得data block的位置信息
  3. 讀取data block,此處先暫時省略table cache的優化,簡單起見都是從文件中讀
  4. 建立新的data iter

固然,二級迭代器還作了許多的其餘工做,好比容許你傳入block function,但這和咱們討論的主線無關,這裏就不過多陳述了。

SSTable的查詢

SSTable的查詢也委託給iter處理,其主要過程就是對key的定位,也是主要分爲三部分:

  • 定位到哪一個block
  • 遷移到該block上
  • 定位到block中的哪一條

不管是index block仍是data block,它們的iter實現是一致的,其查找都遵循如下過程:

  • 經過重啓點進行二分查找
  • 跳到最大的不比目標大的重啓點,遍歷查找,一直到一個不比目標小的key出現

這裏最絕妙的是兩點

  • index block的設計和二級迭代器,一方面經過這種手段進行快速定位,另外一方面將遍歷和查找統一到一個框架下,不可謂不妙
  • 重啓點的設計,避免解析數據內容快速使用二分查找定位key的大體區域

咱們都知道磁盤的讀寫是十分耗時的,索引的手段大量減小了磁盤讀的必要。固然,還有許多加速的手段好比過濾器和緩存,咱們將在最後一節詳細解釋。

元信息存儲與管理

這裏咱們主要關注db的元信息,也即Manifest文件。

元信息文件的格式

首先,Manifest中應該包含哪些信息呢?

首先是使用的coparator名、log編號、前一個log編號、下一個文件編號、上一個序列號。這些都是日誌、sstable文件使用到的重要信息,這些字段不必定必然存在。其次是compact點,可能有多個,寫入格式爲{kCompactPointer, level, internal key}。其後是刪除文件,可能有多個,格式爲{kDeletedFile, level, file number}。最後是新文件,可能有多個,格式爲{kNewFile, level, file number, file size, min key, max key}。對於版本間變更它是新加的文件集合,對於MANIFEST快照是該版本包含的全部sstable文件集合。下面給出一張Manifest示意結構圖。

Manifest文件結構圖

Leveldb在寫入每一個字段以前,都會先寫入一個varint型數字來標記後面的字段類型。在讀取時,先讀取此字段,根據類型解析後面的信息。

元信息的邏輯表達

在代碼中元信息這一部分主要是Version類和VersionSet類。LeveDB用 Version 表示一個版本的元信息,Version中主要包括一個FileMetaData指針的二維數組,分層記錄了全部的SST文件信息。 FileMetaData 數據結構用來維護一個文件的元信息,包括文件大小,文件編號,最大最小值,引用計數等,其中引用計數記錄了被不一樣的Version引用的個數,保證被引用中的文件不會被刪除。除此以外,Version中還記錄了觸發Compaction相關的狀態信息,這些信息會在讀寫請求或Compaction過程當中被更新。在CompactMemTable和BackgroundCompaction過程當中會致使新文件的產生和舊文件的刪除。每當這個時候都會有一個新的對應的Version生成,並插入VersionSet鏈表頭部。

VersionSet是一個Version構成的雙向鏈表,這些Version按時間順序前後產生,記錄了當時的元信息,鏈表頭指向當前最新的Version,同時維護了每一個Version的引用計數,被引用中的Version不會被刪除,其對應的SST文件也所以得以保留,經過這種方式,使得LevelDB能夠在一個穩定的快照視圖上訪問文件。VersionSet中除了Version的雙向鏈表外還會記錄一些如LogNumber,Sequence,下一個SST文件編號的狀態信息。

VersionSet示意圖

元信息的修改

這裏咱們主要探討二個問題:

  • 如何描述一次修改,或者說一次修改應該包括什麼,怎樣纔算是一次合法的修改?
  • 如何應用一次修改,使得系統切換到新的配置上

描述一次變動的是VersionEdit類,而最爲直接的持久化和apply它的辦法就是

  1. 構造VersionEdit並寫入Manifest文件
  2. 合併當前Version和versionEdit獲得新version加入versionSet
  3. 將當前version指向新生成的version

首先,咱們看看VersionEdit包含哪些內容:

std::string comparator_;
  uint64_t log_number_;
  uint64_t prev_log_number_;
  uint64_t next_file_number_;
  SequenceNumber last_sequence_;
  bool has_comparator_;
  bool has_log_number_;
  bool has_prev_log_number_;
  bool has_next_file_number_;
  bool has_last_sequence_;

  std::vector< std::pair<int, InternalKey> > compact_pointers_;
  DeletedFileSet deleted_files_;
  std::vector< std::pair<int, FileMetaData> > new_files_;

對比上文Manifest的結構,咱們不難發現:Manifest文件記錄的是一組VersionEdit值,在Manifest中的一次增量內容稱做一個Block。

Manifest Block := N * VersionEdit

能夠看出恢復元信息的過程也變成了依次應用VersionEdit的過程,這個過程當中有大量的中間Version產生,但這些並非咱們所須要的。LevelDB引入VersionSet::Builder來避免這種中間變量,方法是先將全部的VersoinEdit內容整理到VersionBuilder中,而後一次應用產生最終的Version,這種實現上的優化以下圖所示:

構造version的過程

元信息的持久化

Compaction過程會形成文件的增長和刪除,這就須要生成新的Version,上面提到的Compaction對象包含本次Compaction所對應的VersionEdit,Compaction結束後這個VersionEdit會被用來構造新的VersionSet中的Version。同時爲了數據安全,這個VersionEdit會被Append寫入到Manifest中。在庫重啓時,會首先嚐試從Manifest中恢復出當前的元信息狀態,過程以下:

  • 依次讀取Manifest文件中的每個Block, 將從文件中讀出的Record反序列化爲VersionEdit;
  • 將每個的VersionEdit Apply到VersionSet::Builder中,以後從VersionSet::Builder的信息中生成Version;
  • 計算compaction_level_、compaction_score_;
  • 將新生成的Version掛到VersionSet中,並初始化VersionSet的manifest_file_number_, next_file_number_,last_sequence_,log_number_,prev_log_number_ 信息;

操做日誌存儲與管理

數據寫入Memtable以前,會首先順序寫入Log文件,以免數據丟失。LevelDB實例啓動時會從Log文件中恢復Memtable內容。因此咱們對Log的需求是:

  • 磁盤存儲
  • 大量的Append操做
  • 沒有刪除單條數據的操做
  • 遍歷的讀操做

LevelDB首先將每條寫入數據序列化爲一個Record,單個Log文件中包含多個Record。同時,Log文件又劃分爲固定大小的Block單位。對於一個log文件,LevelDB會把它切割成以32K爲單位的物理Block(能夠作Block Cache),並保證Block的開始位置必定是一個新的Record。這種安排使得發生數據錯誤時,最多隻需丟棄一個Block大小的內容。顯而易見地,不一樣的Record可能共存於一個Block,同時,一個Record也可能橫跨幾個Block。

block組織示意

Block := Record * N
Record := Header + Content
Header := Checksum + Length + Type
Type := Full or First or Midder or Last

操做日誌文件結構

Log文件劃分爲固定長度的Block,每一個Block中包含多個Record;Record的前56個字節爲Record頭,包括32位checksum用作校驗,16位存儲Record實際內容數據的長度,8位的Type能夠是Full、First、Middle或Last中的一種,表示該Record是否完整的在當前的Block中,若是不是則經過Type指明其先後的Block中是否有當前Record的前驅後繼。

Leveldb的交互流程

recovery過程

Db恢復的步驟:

  1. 首先從CURRENT讀取最後提交的MANIFEST
  2. 讀取MANIFEST內容
  3. 清除過時文件
  4. 這裏能夠打開全部的sstable文件,可是更好的方案是lazy open
  5. 把log轉換爲新的level 0sstable
  6. 將新寫操做導向到新的log文件,從恢復的序號開始

讀過程

讀的過程能夠分爲兩步:查找對應key+讀取對應值,主要問題在第一步。前面咱們在SSTable章節中已經詳細解釋了對於單個SSTable文件如何快速定位key,在MemTable章節解釋瞭如何在內存中快速定位key;咱們先大體列出查找的流程:

  1. 在MemTable中查找,沒法命中轉到2
  2. 在immutable_memtable中查找,查找不中轉到3
  3. 在第0層SSTable中查找,沒法命中轉到4
  4. 在剩餘SSTable中查找

那麼咱們接下來的問題是對於第0層以及接下來若干層,如何快速定位key到某個SSTable文件?

對於Level > 1的層級,因爲每一個SSTable沒有交疊,在version中又包含了每一個SSTable的key range,你可使用二分查找快速找到你處於哪兩個點之間,再判斷這兩個點是否屬於同一個SSTable,就能夠快速知道是否在這一層存在以及存在於哪一個SSTable。

對於0層的,看來只能遍歷了,因此咱們須要控制0層文件的數目。

寫過程

完成插入操做包含兩個具體步驟:

  1. KV記錄以順序的方式追加到log文件末尾,並調用Sync將數據真正寫入磁盤。儘管這涉及到一次磁盤IO,可是文件的順序追加寫入效率是很高的,因此並不會致使寫入速度的下降;
  2. 若是寫入log文件成功,那麼將這條KV記錄插入內存中的Memtable中。前面介紹過,Memtable只是一層封裝,其內部實際上是一個Key有序的SkipList列表,插入一條新記錄的過程也很簡單,即先查找合適的插入位置,而後修改相應的連接指針將新記錄插入便可。

log文件內是key無序的,而Memtable中是key有序的。對於刪除操做,基本方式與插入操做相同的,區別是,插入操做插入的是Key:Value 值,而刪除操做插入的是「Key:刪除標記」,由後臺Compaction程序執行真正的垃圾回收操做。

其中的具體步驟能夠參閱操做日誌管理和memtable詳解這兩部分。

Leveldb的Log Compaction

Log Compaction的經典問題

在解釋Leveldb的log compaction過程以前咱們先回顧幾個關於如何作compaction的重要問題:

  • 爲何須要compaction?
  • 什麼時候須要作compaction
  • 具體怎麼作compaction
  • 如何在compaction的同時保證服務可用
  • compaction對性能的影響
  • 如何在服務的延遲和單次compaction的收益作trade off

先回答第一個問題:,LevelDB之因此須要Compaction是有如下幾方面緣由:

  • 數據文件中的被刪除的KV記錄佔用的存儲空間須要被回收;
  • 將key存在重合的不一樣Level的SSTable進行Compaction,能夠減小磁盤上的文件數量,提升讀取效率

咱們接下來將主要圍繞這些問題給出Leveldb的答案。

compaction的時機

  • 按期後臺觸發compaction任務
  • 正常的讀寫流程中斷定系統達到了一個臨界狀態,此時必需要進行Compaction

這裏咱們主要談談二,何時判斷,如何判斷到達了這個臨界狀態?

首先了解Leveldb的兩種Compaction:

  • minor compaction:將內存immune memtable的數據dump至磁盤上的sstable文件。
  • major compaction:多個level衆多SSTable之間的合併。

什麼時候判斷是否須要compaction

  • 啓動時,Db_impl.cc::Open()在完成全部的啓動準備工做之後,會發起一次Compaction任務。這時是因爲尚未開始提供服務,不會形成任何影響,還可以提供以後全部的讀效率,一本萬利。
  • 數據寫入過程當中,使用函數MakeRoomForWrite確認memtable有足夠空間寫入數據
  • get 操做時,若是有超過一個 sstable 文件進行了 IO,會檢查作 IO 的最後一個文件是否達到了 compact 的條件( allowed_seeks 用光),達到條件,則主動觸發 compact。

在MakeRoomForWrite函數中:

  1. 先判斷是否有後臺合併錯誤,若是有,則啥都不作;若是沒有,則執行2;
  2. 若是後臺沒錯誤,則判斷mem_的大小是是否小於事先定義閾值:若是是,則啥都不作返回,繼續插入數據;若是大於事先定義的閾值,則須要進行一次minor compaction;
  3. 若是imm_不爲空,表明後臺有線程在執行合併,在此等待;
  4. 若是0層文件個數太多,則也須要等待;
  5. 若是都不是以上狀況,表示此時memtable空間不足且immu memtable不爲空,須要將immune memtable的數據dump至磁盤sstable文件中。 這就是Minor Compaction了,調用MaybeScheduleCompaction()函數執行此事。

說明下爲何會有第4點:由於每進行一次minor compaction,level 0層文件個數可能超過事先定義的值,因此會又進行一次major compcation。而此次major compaction,imm_是空的,因此纔會有第4條判斷。

如何判斷是否須要compaction

上文的MakeRoomForWrite主要針對Minor compaction,能夠看出其判斷的依據主要就是有沒有足夠的空間執行下一次寫入操做;這裏咱們將主要關注major compaction,也就是文件的合併,其執行主要是在後臺的清理線程。

major compaction的觸發方式主要有三種:

  • 某一level的文件數太多
  • 某一文件的查找次數超過容許值
  • 手動觸發

既然要判斷這幾個條件,就要維護相關信息,咱們看看Leveldb爲它們維護了哪些信息。

首先,介紹下列事實

不一樣level之間,可能存在Key值相同的記錄,可是記錄的Seq不一樣。
最新的數據存放在較低的level中,其對應的seq也必定比level+1中的記錄的seq要大。
所以當出現相同Key值的記錄時,只須要記錄第一條記錄,後面的均可以丟棄。

level 0中也可能存在Key值相同的數據,但其Seq也不一樣。數據越新,其對應的Seq越大。
且level 0中的記錄是按照user_key遞增,seq遞減的方式存儲的,相同user_key對應的記錄被彙集在一塊兒按照Seq遞減的方式存放的。
在更高層的Compaction時,只須要處理第一條出現的user_key相同的記錄便可,後面的相同user_key的記錄均可以丟棄。

刪除記錄的操做也會在此時完成,刪除數據的記錄會被直接丟棄,而不會被寫入到更高level的文件。

接下來,咱們分別對幾種觸發方式詳細介紹其機制:

  • 容量觸發Compaction:每一個Version在其生成的時候會初始化兩個值compaction_level_、compaction_score_,記錄了當前Version最須要進行Compaction的Level,以及其須要進行Compaction的緊迫程度,score大於1被認爲是須要立刻執行的。咱們知道每次文件信息的改變都會生成新的Version,因此每一個Version對應的這兩個值初始化後不會再改變。level0層compaction_score_與文件數相關,其餘level的則與當前層的文件總大小相關。這種區分的必要性也是顯而易見的:每次Get操做都須要從level0層的每一個文件中嘗試查找,所以控制level0的文件數是頗有必要的。同時Version中會記錄每層上次Compaction結束後的最大Key值compact_pointer_,下一次觸發自動Compaction會從這個Key開始。容量觸發的優先級高於下面將要提到的Seek觸發。
  • Seek觸發Compaction:Version中會記錄file_to_compact_和file_to_compact_level_,這兩個值會在Get操做每次嘗試從文件中查找時更新。LevelDB認爲每次查找一樣會消耗IO,這個消耗在達到必定數量能夠抵消一次Compaction操做消耗的IO,因此對Seek較多的文件應該主動觸發一次Compaction。但在引入布隆過濾器後,這種查找消耗的IO就會變得微不足道了,所以由Seek觸發的Compaction其實也就變得沒有必要了。
  • 手動Compaction:LevelDB提供了外部接口CompactRange,用戶能夠指定觸發某個Key Range的Compaction,LevelDB默認手動Compaction的優先級高於兩種自動觸發。

這幾個觸發條件並不是無的放矢,單個文件過大的容量會吸引大量的查詢而且這些查詢的速度因爲其容量均會減慢,考慮極端狀況,只有一個SSTable,那麼查詢最快也得經歷其全部重啓點的二分查找。容量越大,可以裝入內存的table就更少,須要發生文件讀的可能性就越大。對每一層次來講,上面的理由依然成立,一層的容量過大,要麼是文件數不少,要麼是單個文件的容量過大,後者已經分析過了,前者會致使二分變慢,並且新數據和老數據沒有區分度,不能對於這一假設(新的數據每每被更頻繁地訪問)作優化,並且對於同一key,其記錄數變多,重啓點能覆蓋的key變少,即便單個文件內的查找也變得低效。

某個文件頻繁地被查找,可能出於幾種情形:1. 它包含了太多的熱點key最新的記錄,也就是說它的查找大部分命中了。2. 它的key range 和一些長期木有更新而又被常常訪問的key重合了,這種就是出現大量未命中的查找。我的認爲compaction主要改善的是後者,這也是爲何布隆過濾器使得seek compaction無足輕重,由於判斷一個SSTable是否含有對應key所須要的IO資源變少了,但若是你命中了,該讀的仍是得讀,布隆並不能改善啥,因此我的認爲主要爲了改善第二點。

上面兩段就是Leveldb對於compaction的IO消耗與單次comapct收益權衡以後給出的答案。

compaction的過程

minor compaction

首先,咱們來說講minor compaction,它的目的是把immutable_memtable寫入0層的SSTable文件中。咱們已經只讀如何遍歷一個memtable了,也知道如何經過逐條添加構建一個SSTable了,更清楚了SSTable如何持久化到文件中。對上述步驟不明白的,請參閱上文memtable和sstable章節,因此minor compaction的過程不是理所固然的嗎?

這裏,主要仍是強調兩點:

  • 寫入過程發生在immutable_memtable上,因此絲絕不影響寫服務,memtable依然可用
  • 寫入文件過程完畢後,在交換memtable和immutable_memtabled以後,immutable_memtable正在服務的讀操做不會受到影響,這是得益於引用計數,直到服務完畢纔會刪除原來的immutable_memtable

接下來,咱們主要解析major compaction。

選取參與compaction的SSTable

除level0外,每一個level內的SSTable之間不會有key的重疊:也就是說,某一個key只會出如今該level(level > 0)內的某個SSTable中。可是某個key可能出如今多個不一樣level的SSTable中。所以,大部分情形下,Compaction應該是發生在不一樣的level之間的SSTable之間。

對level K的某個SSTable S1,Level K+1中可以與它進行Compaction的SSTable必須知足條件:與S1存在key範圍的重合

SSTable選擇示意圖

如上圖所示,對於SSTable X,其key範圍爲hello ~ world,在level K+1中,SSTable M的key範圍爲 mine ~ yours,與SSTable X存在key範圍的重合,同時SSTable N也是這樣。所以,對於 SSTable X,其Compaction的對象是Level K+1的SSTable M和SSTable N。

最後,考慮特殊情形——level0 的情況。Level 0的SSTable之間也會存在key範圍的重合,所以進行Compaction的時候,不只須要在level 1尋找可Compaction的SSTable,同時也要在level 0尋找,以下圖示:

Level0情形示意圖

major compaction的過程

先從觸發點開始考慮,咱們就先從簡單的狀況——也就是compact單個文件開始講起。先假設咱們須要compact Level K層的某個文件,首先咱們要作的就是首先找到參與compaction的全部文件,而後遍歷這些文件中的全部記錄,選取裏面有效且最新的記錄寫入到新的SSTable文件。最後用新生成的SSTable文件替換掉原來的Level K + 1層的文件。

這樣咱們就面臨一個生死攸關的問題了:當處理一條記錄的時候,如何判斷要不要將它寫入新文件中呢?答案是當有比它更新的同一key的記錄就拋棄它,那麼如何找到這個更新的記錄呢?

最簡單的作法:因爲Level k 比Level k+1新,Level k+1又不會出現key 重合,咱們很天然地能夠獲得一個重新到舊的遍歷順序,只要去新寫入的SSTable中查詢便可。但這樣每次寫入都須要一次查詢,依然太慢了。咱們能不能先按key序遍歷,在同一key內部再按seq遞減序遍歷,這樣只要保留每一個key區間的第一個。Leveldb就是這麼作的,可是如何實現呢?

Leveldb使用了一個merging iterator,它統籌控制每一個SSTable的iterator,並在它們中選取一個前進,而後跳過全部同一key的記錄。這樣處理一條記錄所需的查找代價從查詢新SSTable文件的全部內容變成了詢問幾個SSTable對應iter的當前遊標,不可謂不妙啊,使人驚歎的作法!下圖是一個簡單的流程示意:

compaction流程示意圖

關於iterator的詳細參考能夠閱讀下列文章:

庖丁解LevelDB之Iterator

下一步,咱們把它擴展到一層文件的compaction:對於大多數層,因爲文件之間的key range沒有交疊,因此你徹底能夠迭代進行上面的操做,分別對每個文件合併。實際上major compaction是按key range來的,它每次會compact一個level中的一個範圍內的SSTable,而後將這個key範圍更新,下次就compact下一範圍,控制每一層參與一次compact的SSTable數量。

接下來,咱們考慮Level 0 的情形,因爲咱們必須保證0層的總比1層新,假設0層原本有兩個同一key的記錄,較新的那個被合併到1層以後,查詢時在0層能查到較老的那個,bug出現了!因此咱們不得不找出本層全部和當前所要合併的文件有重疊的文件加入合併集合來解決。

而後,咱們來說講刪除的情形。Leveldb中的刪除是一個特殊記錄,它不會致使數據當即被刪除,而是查詢到刪除記錄後將會忽略更老的記錄。真正的刪除過程是發生在Compaction中的,這裏咱們又得問一個問題了:那麼刪除記錄須要寫入到上一層嗎?須要的,不然在上上層的記錄就有可能被查到,只有最上層的刪除記錄會真正被刪除,因此刪除是逐步逐層地進行的,一層一層刪去過去的記錄。

咱們考慮major compaction對服務可用性和性能的影響:在生成新SSTable期間,舊的SSTable依然可用。因爲SSTable本就是不可寫的,因此對寫服務不會形成任何不可用,對於讀服務,依然能夠在老的SSTable上進行。新的SSTable寫到的是一個臨時文件,當寫入完畢後會進行重命名操做,可是注意對於舊文件,必須查詢它在內存中有沒有對應的table以及該table的引用計數。只有當沒有讀服務在該文件上,才能刪除該文件。因此,綜上compaction對服務可用性沒有什麼影響。

最後,咱們還須要生成一次compact點,進行一次version edit並寫入Manifest文件,最終使當前version更新到新版本。這個過程在元信息管理中已經講述過了,就再也不贅述了。

Leveldb的工程優化

write batch

Leveldb採用write batch來優化併發寫,對每個寫操做,先經過傳入的鍵值對構造一個WriteBatch對象,這玩意裏面其實就是一個字符串,多個併發寫的write batch最後會被合併成一個。這一段的代碼確實精妙,請參閱下列文章。

leveldb - 併發寫入處理

table cache

對於Leveldb這種主要基於磁盤存儲的引擎,cache優化是很是天然的想法。levelDb中引入了兩個不一樣的Cache:Table Cache和Block Cache。其中Block Cache是配置可選的。cache主要仍是做用在讀過程當中,詳細狀況你們請參閱下列文章:

LevelDB教程9:levelDB中的Cache

源代碼實現解析:

leveldb源碼分析之Cache

如何做用在讀操做流程中的:

Leveldb源碼分析--11

布隆過濾器

先了解布隆過濾器的原理和概念:

Bloom Filter概念和原理

對實現感興趣的盆友,能夠繼續看這篇文章

leveldb源碼學習--BloomFilter布隆過濾器

增長過濾器就須要在寫入SSTable的時候向過濾器添加本身寫入的鍵,這一點能夠回頭看SSTable寫入過程。過濾器的做用在Compaction一章中也說了,主要爲了改善當發現目標key在某個SSTable的key range內,但事實上未命中時,減小IO消耗,因此你們也知道解析過濾器部分應該用在哪兒了吧。

參考文章

相關文章
相關標籤/搜索