leveldb原始碼閱讀分析筆記
這是本人在閱讀leveldb原始碼的基礎上寫的讀書筆記,現貢獻出來供大家交流之用。
內容主要來源於leveldb官網的文件和閱讀leveldb的原始碼。
轉載請註明出處,謝謝
Leveldb的實現
1. 調研目的
網際網路業務中大量的資料都是簡單地key-value型別,查詢時不需要複雜的關係資料塊支援。在大量湧現出來的NoSql開源產品中,Leveldb是一個輕量級的,快速的以儲存為目的的key-value儲存引擎。
調研leveldb中各種儲存機制的實現,調研leveldb的適用場景,為後續key-value儲存系統開發提供儲存引擎方面的參考。
2. 調研內容
2.1 leveldb和LSM
Leveldb是Google開源的一個快速,輕量級的key-value儲存引擎,以庫的形式提供,沒有提供上層c/s架構和網路通訊的功能。
Leveldb儲存引擎的功能如下:
ü 提供key-value儲存,key和value是任意的二進位制資料。
ü 資料時按照key有序儲存的,可以修改排序規則。
ü 只提供了Put/Get/Delete等基本操作介面,支援批量原子操作。
ü 支援快照功能。
ü 支援資料的正向和反向遍歷訪問。
ü 支援資料壓縮功能(Snappy壓縮)。
ü 支援多執行緒同步,但不支援多程序同時訪問。
Leveldb和傳統的儲存引擎,比如Innodb,BerkeleyDb最大的區別是leveldb的資料儲存方式採用的是LSM(log-structured-merge)的實現方法,傳統的儲存引擎大多采用的是B+樹系列的方法。
磁碟的效能主要受限於磁碟的尋道時間,優化磁碟資料訪問的方法是儘量減少磁碟的IO次數。磁碟資料訪問效率取決於磁碟IO次數,而磁碟IO次數又取決於資料在磁碟上的組織方式。
磁碟資料儲存大多采用B+樹型別資料結構,這種資料結構針對磁碟資料的儲存和訪問進行了優化,減少訪問資料時磁碟IO次數。
B+樹是一種專門針對磁碟儲存而優化的N叉排序樹,以樹節點為單位儲存在磁碟中,從根開始查詢所需資料所在的節點編號和磁碟位置,將其載入到記憶體中然後繼續查詢,直到找到所需的資料。
目前資料庫多采用兩級索引的B+樹,樹的層次最多三層。因此可能需要5次磁碟訪問才能更新一條記錄(三次磁碟訪問獲得資料索引及行ID,然後再進行一次資料檔案讀操作及一次資料檔案寫操作)。
但是由於每次磁碟訪問都是隨機的,而傳統機械硬碟在資料隨機訪問時效能較差,每次資料訪問都需要多次訪問磁碟影響資料訪問效能。
LSM樹可以看作是一個N階合併樹。資料寫操作(包括插入、修改、刪除)都在記憶體中進行,並且都會建立一個新記錄(修改會記錄新的資料值,而刪除會記錄一個刪除標誌),這些資料在記憶體中仍然還是一棵排序樹,當資料量超過設定的記憶體閾值後,會將這棵排序樹和磁碟上最新的排序樹合併。當這棵排序樹的資料量也超過設定閾值後,和磁碟上下一級的排序樹合併。合併過程中,會用最新更新的資料覆蓋舊的資料(或者記錄為不同版本)。
在需要進行讀操作時,總是從記憶體中的排序樹開始搜尋,如果沒有找到,就從磁碟上的排序樹順序查詢。
在LSM樹上進行一次資料更新不需要磁碟訪問,在記憶體即可完成,速度遠快於B+樹。當資料訪問以寫操作為主,而讀操作則集中在最近寫入的資料上時,使用LSM樹可以極大程度地減少磁碟的訪問次數,加快訪問速度。
2.2 Memtable及SkipList(跳錶)
Leveldb採用LSM思想設計儲存結構。各個儲存檔案也是分層的,新插入的值放在記憶體表中,稱為memtable,該表寫滿時變為immutable table,並建立新的memtable接收寫操作,而immutable table是不可變更的,會通過Compaction過程寫入level0,資料被組織成sst的資料檔案。level0的檔案會通過後臺的Compaction過程寫入level1,level1的檔案又會寫入level2,依次類推。
sst(sortedtable)檔案格式見後續的leveldb檔案格式章節。
記憶體中的memtable的實現是通過SkipList(跳錶)的資料結構實現的。
跳錶是平衡樹的一種替代的資料結構,但是和紅黑樹不相同的是,跳錶對於樹的平衡的實現是基於一種隨機化的演算法的,這樣也就是說跳錶的插入和刪除的工作是比較簡單的。
簡單說一下跳錶的核心思想:
如果是說連結串列是排序的,並且節點中還儲存了指向前面第二個節點的指標的話,那麼在查詢一個節點時,僅僅需要遍歷N/2個節點即可。
這基本上就是跳錶的核心思想,其實也是一種通過“空間來換取時間”的一個演算法,通過在每個節點中增加了向前的指標,從而提升查詢的效率。
在跳錶中,每個節點除了儲存指向直接後繼的節點之外,還隨機的指向後代節點。如果一個節點存在k個向前的指標的話,那麼陳該節點是k層的節點。一個跳錶的層MaxLevel義為跳錶中所有節點中最大的層數。而每個節點的層數是在插入該節點時隨機生成的,範圍在[0,MaxLevel]。
下面給出一個完整的跳錶的圖示:
在leveldb中,跳錶的實現程式碼在/db/skiplist.h中,而在該結構的實現中,記憶體管理使用了arena記憶體管理,是leveldb實現的一個簡易記憶體池。在memtable操作中分配的記憶體會在memtable被刪除時統一釋放,leveldb沒有提供刪除單個skiplist節點的介面。
2.3 leveldb的檔案及檔案結構
leveldb中有這幾類檔案,
ü 資料檔案以.sst結尾,用來儲存資料和索引。
ü 日誌檔案以.log結尾,用來儲存最近的資料更新操作。採用追加寫的形式,每次更新操作都被追加到log檔案的末尾,每個log檔案對應當前的memtable,更新操作先寫入log檔案,然後更新memtable。當memtable被寫入sst資料檔案後,相應的log檔案會被刪除。
ü Manifest檔案,Manifest檔案列出了每個層(level)的資料檔案構成,每個檔案的key的範圍以及其他的元資訊。Manifest檔案格式同log檔案,對leveldb的資料檔案級別更改(資料檔案的增加和刪除等)操作會追加到Manifest檔案末尾。每次開啟leveldb會對Manifest檔案進行重構,重新生成一份精簡的Manifest檔案。
ü Current檔案是一個簡單的文字檔案,其內容是當前使用的Manifest檔名。
ü 資訊日至檔案,存放leveldb的操作日誌。
ü 其他檔案,比如LOCK檔案等,用來保證同一時刻只有一個程序開啟該資料庫。
2.3.1 leveldb日誌檔案結構
leveldb的日誌檔案包括寫資料的日誌檔案和Manifest檔案,這些檔案均採用追加寫的方式增加記錄。
其中寫操作日誌檔案儲存leveldb的寫操作日誌,用於在leveldb啟動時進行記憶體資料(memtable)恢復,寫日誌檔案是和memtable一一對應的,當memtable被Compact成一個表資料檔案之後,對應的寫操作日誌檔案會被刪除。
Manifest檔案也是一個日誌檔案,儲存對leveldb對資料檔案(sst)在檔案級別所做的更改,比如增加了資料檔案,刪除了資料檔案等。用於在leveldb啟動時恢復當前的資料檔案元資訊。
日誌檔案被劃分成32K大小的block,改大小可調。每個block中是一系列的記錄。每條記錄的格式是
record:=
checksum:uint32 //存放該記錄data的crc32校驗值
length:uint16 //data長度
type:uint8 //FULL,FIRST,MIDDLE,LAST,記錄型別
data:uint8[length] //記錄內容
寫操作日誌和Manifest檔案寫入的內容格式是LogRecord和VersionEdit格式化後形成的字串,存放在每條記錄的data欄位中。
一條日誌記錄的頭部資訊佔用7個位元組,如果一個Block的剩餘空間不足7個位元組時,該Block尾部被填補為0,新的記錄從下一個Block開始;如果一個Block尾部恰好只剩下7個位元組,則填出一個長度為0的起始Record在該Block。
2.3.2 sst資料檔案格式
leveldb的資料存放在資料表中,對應於磁碟上的資料檔案,以.sst結尾。資料檔案一旦寫入完成,其內容不可更改,當sst的資料被合併到新的sst檔案之後,無用的sst檔案會被刪除。
sst檔案的檔案格式如下:
<beginning_of_file>
[data block 1]
[data block 2]
...
[data block N]
[meta block 1]
...
[meta block K]
[metaindex block]
[index block]
[Footer] (fixed size; starts at file_size - sizeof(Footer))
<end_of_file>
一個sst資料檔案包含多個data block,多個種類的metablock,一個metaindex block和一個index block,檔案的末尾存放的是一個固定長度的Footer型別。
其中,data block存放的是使用者儲存的key-value資料,data block是leveldb磁碟IO讀取資料的基本單位,data block的預設大小為4kB,可以通過引數調整,此處的4kB大小指的是Block中未壓縮的資料大小,資料存入sst之前預設會進行snappy壓縮,壓縮後的資料會小於4KB。各個Block資料在sst檔案中是有序存放的。
Meta block存放另外的元資訊資料,當前的leveldb(1.12.0版本)使用該block存放filterpolicy產生的filter資料,在查詢資料時利用bloomfilter演算法可以顯著減少不存在的key導致的磁碟IO。
各個metablock會存放index到metaindexblock中。
每個block會存放一個index到index Block中,相當於該block的一個索引,存放的也是key/value結果,其中key是大於該block最大key的最短字首,而value則是該Block的一個位置索引,結構為struct{offset,size};
Footer是一個固定的結構,存放了indexBlockHander+metaBlockHandler和一個8位元組的magicNumber。
dataBlock中儲存的每個key/value對兒也有自身的結構。每個key/value對兒稱為一個record,需要注意的是,其中儲存的key不同於使用者提供的key,內部儲存的key是將使用者提供的key,操作的sequencenum和儲存的值型別(普通value還是刪除)進行編碼之後形成的internal key。
Leveldb中的記錄是key有序儲存的,所以相鄰的key有很大概率共享字首。為了減少儲存空間,leveldb的key採用字首壓縮儲存,同時為了加快檢索速度和避免丟失大量資料,每隔一定數量的record就會儲存一個完整的key字串,這種完整的record地址被稱為Restart Point預設是每16個記錄儲存一個完整的key。
每個record的結構如下:
record:
shared_bytes: varint32 //key共享字首長度,完整的key為0
unshared_bytes: varint32 //key私有資料長度
value_length: varint32 //value資料長度
key_delta: char[unshared_bytes] //key私有資料
value: char[value_length] //value
每個未壓縮block結尾的結構如下:
blocktailer:
restarts: uint32[num_restarts] //restart pointer陣列
num_restarts: uint32 //restart point陣列長度
在存入sst資料檔案之前,還要對以上結果進行snappy壓縮,壓縮後的資料作為content,再加上type和crc32作為block的完整資料存入sst檔案,其中type標記該block是否進行了壓縮。
2.3.3 Current檔案結構
Current檔案時一個文字檔案,檔案內容是當前使用的Manifest檔案的檔名,leveldb初始化時通過Current檔案找到上次關閉時使用的manifest檔案,然後進行資料初始化和恢復。Leveldb初始化完成時會將當前快照寫入新的manifest檔案並更新Current檔案內容。
2.4 leveldb版本控制
leveldb可以保留資料的對個版本,每次查詢操作預設總是在最新版本上進行,而所有的寫操作都是在最新版本進行。Leveldb的多版本控制通過Version,VersionSet和VersionEdit結構來實現。每個Version結構代表了leveldb所儲存資料的一個檢視,或者說一個版本。從本質上來看,Version是由磁碟檔案構成的,注意Version不包含記憶體中的memtable和immutable table。
因為leveldb的資料檔案一旦寫入完成,其內容不會發生更改,每個Compaction過程會將現有的sst檔案merge生成新的sst檔案,或者將記憶體中的immutable table生成sst檔案,每個Compaction過程會產生新的資料檔案,舊的資料檔案不再需要。這種檔案級別的更改會形成一個VersionEdit結構寫入manifest檔案,供leveldb下次啟動時回放;同時會將生成的VersionEdit結構和當前的Version進行合併操作形成新的Version來反映leveldb最新的資料檔案資訊,可以看出VersionEdit包含的是相鄰版本之間的變化資訊。
Leveldb可以儲存多個版本,當形成新的Version時,舊的Version有可能正在被檢索,所以無法必須保留。Leveldb採用VersionSet結構來管理多個Version結構,VersionSet本質上就是一個Version結構的雙向連結串列。
為了管理Version和sst檔案的生命週期,leveldb廣泛採用了引用計數的方法。每生成一個新的Version,Version的引用計數被標記為1,當檢索該Version時,該Version的引用計數會增加1;當產生了新的Version後,當前Version的引用計數會被減1,Version引用計數為0時會被刪除,並且將其管理的資料檔案的引用計數減少1,當資料檔案的引用計數變為0時,資料檔案會被刪除。
這幾個資料結構的關係簡單描述如下:
VersionSet=Version1+Version2+Version3+...+currentVersion
Vn=Vn-1+VersionEdit
2.5 leveldb快照實現
Leveldb支援快照功能。可以通過db->GetSnapshot()來建立新的當前快照。在檢索時通過option->snapshot欄位來指明檢索的快照,預設是在當前資料上進行檢索。
Levendb的快照實現並不是新建Version或者增加當前的Version的引用計數來實現的。因為Version只是反映了磁碟檔案資訊,並沒有反映記憶體中的memtable和immutable table資訊。
Leveldb的快照功能是通過寫入序列號來實現的,leveldb將所有的寫入操作(包括刪除操作)都進行了序列號,每個寫入操作都會有一個序列號,一個uint64型別的整數,高56位儲存的是寫入操作的序列號,低8位儲存的是操作型別(Put/Delete)。Leveldb會將使用者提供的key和序列號格式化後作為internalkey儲存,我們記寫入操作的序列號為seqw,使用者查詢操作會帶上一個當前序列號或者快照的序列號進行查詢,記查詢時帶的序列號為seqr,查詢過程中只會查詢序列號小於seqr的最大序列號的記錄,如果某個記錄的seqw>seqr,則在查詢時該記錄會被忽略。
這種機制是的leveldb可以保留多個版本的資料,保留的資料版本數理論上不受限制,另外,這種快照機制實現簡單。
使用序列號來實現快照的方法對leveldb資料Compact過程也會造成影響,見Compaction相關部分。
2.6 leveldb的Compaction
leveldb的資料儲存採用LSM的思想,LSM思想和log&dump思想很相近,大體來講都是變隨機寫入為順序寫入,記錄寫入操作日誌,一旦日誌被以追加寫的形式寫入硬碟,就返回寫入成功,由後臺執行緒將寫入日誌作用於原有的磁碟檔案生成新的磁碟資料。
Leveldb在記憶體中維護一個數據結構memtable,採用skiplist來實現,儲存當前寫入的資料,當資料達到一定規模後變為不可寫的記憶體表immutable table。新的寫入操作會寫入新的memtable,而immutable table會被後臺執行緒寫入到資料檔案。
Leveldb的資料檔案時按層存放的,level0,level1,…,level7。預設配置的最高層級是7,記憶體中的immutable總是寫入level0,除level0之外的各個層leveli的所有資料檔案的key範圍都是互相不相交的。
當滿足一定條件時,leveli的資料檔案會和leveli+1的資料檔案進行merge,產生新的leveli+1層級的檔案,這個磁碟檔案的merge過程和immutable的dump過程叫做Compaction,在leveldb中是由一個單獨的後臺執行緒來完成的。
進行Compaction操作的條件如下:
1> 產生了新的immutable table需要寫入資料檔案;
2> 某個level的資料規模過大;
3> 某個檔案被無效查詢的次數過多;
a) 在檔案i中查詢key,沒有找到key,這次查詢稱為檔案i的無效查詢。
4> 手動compaction;
滿足以上條件是會啟動Compaction過程,詳細的Compaction過程見程式碼分析章節。
2.7 Leveldb的快取機制
為了提高leveldb的資料讀寫效率,leveldb內部實現了cache功能模組。
Leveldb的Cache分為兩個,block cache和table cahce。Block cache存放的是sst檔案中的一個data block,這個cache的作用是快取sst檔案中的資料,減少磁碟IO。按照LRU(least-recently-used)的策略進行淘汰,這個cache由使用者指定大小,要配置該Cache,則需要使用者在初始化用特定Cache結構初始化options.block_cache欄位,否則leveldb會自動建立一個8M的Blockcache使用。
Leveldb實現的另一個cache是tablecache,該cache是leveldb內部管理的,沒有對外提供配置結構,每個table(sst檔案)在開啟時需要讀indexblock,metaindexblock,建立相應的記憶體結構,leveldb會將這些結構進行快取,以備將來使用,也是採用LRU的刪除策略。
2.8 Leveldb核心框架程式碼分析
由於leveldb的程式碼比較多,有17000行左右,並且leveldb是一個儲存引擎庫,程式碼組織比較鬆散,本節先分析leveldb的程式碼結構及典型的訪問流程,然後以流程為切入點,重點分析leveldb的初始化過程,讀寫流程及Compact過程,為了使得流程更加清晰,分析過程中使用的程式碼在原有的leveldb原始碼基礎上刪掉了很多與理解流程關係不大的程式碼。
2.8.1 Leveldb的原始碼結構
Leveldb的原始碼組織結構如下:
核心目錄包括
db目錄,存放leveldb的實現程式碼。
include目錄,存放leveldb的對位介面標頭檔案。
port目錄,存放leveldb的平臺相關程式碼,鎖和條件變數,原子操作的平臺實現。
util目錄,存放工具類程式碼,記憶體池,bloomfilter,cache,整形變長編碼,crc32,hash,隨機數,以及env的平臺相關程式碼。
table目錄,存放table相關的讀寫程式碼。
總的原始碼行數在17000行左右。
2.8.2 典型的leveldb訪問流程
對leveldb的典型訪問是先開啟db,進行讀寫操作,然後關閉db,程式碼示例如下:
#include <assert>
#include"leveldb/db.h"
leveldb::DB* db;
leveldb::Options options;
options.create_if_missing = true;
//開啟db
leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db);
assert(status.ok());
std::string value;
//讀寫db
leveldb::Status s = db->Get(leveldb::ReadOptions(), key1, &value);
if (s.ok()) s = db->Put(leveldb::WriteOptions(), key2, value);
if (s.ok()) s = db->Delete(leveldb::WriteOptions(), key1);
//關閉db
delete db;
DB::Open函式進行資料塊初始化,開啟或新建資料庫,並且進行資料庫恢復操作。
2.8.3 Leveldb的初始化流程
leveldb是C++編寫的,db.h中定義了leveldb對外介面,定義了class DB,這個類只是一個介面類,具體的實現是在DBImpl類中實現的。DB::Open函式建立的是一個DBImpl類,具體的操作由DBImpl類來處理。
//open
Status DB::Open(const Options& options, const std::string& dbname,DB** dbptr) {
DBImpl* impl = new DBImpl(options, dbname);
//呼叫DBImpl的恢復資料介面,讀取元資料,恢復日誌資料
Status s = impl->Recover(&edit);
uint64_t new_log_number = impl->versions_->NewFileNumber();
//建立新的寫操作日誌檔案
options.env->NewWritableFile(LogFileName(dbname, new_log_number),&lfile);
//新增VersionEdit,初始化時會將現在的VersionSet的狀態寫入新的manifest檔案,並更新Current檔案
impl->versions_->LogAndApply(&edit, &impl->mutex_);
//刪除無用檔案
impl->DeleteObsoleteFiles();
//檢視是否啟動後臺打包程序
impl->MaybeScheduleCompaction();
}
Class DB只是leveldb對外的介面類,具體的實現是在類DBImpl中。DB::Open介面先建立了DBImpl例項,然後呼叫DBImpl的recove介面恢復資料,資料恢復完成後,建立新的寫操作日誌檔案,並將現有的Version資料結構寫入新的manifest檔案,刪除無用的檔案,然後檢視是否需要進行Compaction過程。
下邊我們看下DBImpl::Recover的流程:
//recover
Status DBImpl::Recover(VersionEdit* edit) {
//建立程序鎖,防止別的程序開啟
env_->LockFile(LockFileName(dbname_), &db_lock_);
s = versions_->Recover();
//查找出有用的檔案
versions_->AddLiveFiles(&expected);
//恢復其中尚未格式化成sst的log檔案
for (size_t i = 0; i < logs.size(); i++) {
//讀取日誌檔案,重建memtab,並將滿的memtab格式化成sst,
//有可能修改edit,增加檔案
s = RecoverLogFile(logs[i], edit, &max_sequence);
versions_->MarkFileNumberUsed(logs[i]);
}
}
DBImpl::Recover會呼叫VersionSet::Recover介面進行磁碟資料恢復,然後恢復寫操作日誌檔案,重建memtable並寫入磁碟。
我們看下VersionSet::Recover介面:
// versionsetrecover,從Manifest檔案中恢復磁碟檔案。
Status VersionSet::Recover() {
std::string current;
//讀取current
Status s = ReadFileToString(env_, CurrentFileName(dbname_), ¤t);
std::string dscname = dbname_ + "/" + current;
//獲得manifestfile
SequentialFile* file;
s = env_->NewSequentialFile(dscname, &file);
//讀取Manifest檔案,將各個VersionEdit的修改聚合到builder中,
while (reader.ReadRecord(&record, &scratch) && s.ok()){
VersionEdit edit;
s = edit.DecodeFrom(record);
//利用builder來記錄各個VersionEdit
builder.Apply(&edit);
}
delete file;
//建立新的Version並加入當前的VersionSet成為Current
Version* v = new Version(this);
builder.SaveTo(v);
AppendVersion(v);
}
VersionSet::Recover函式先讀取curent檔案,獲取manifest檔名,然後讀取manifest檔案,獲取各個VersionEdit,然後進行回放,生成leveldb上次關閉時的CurrentVersion作為當前的Version。
綜合以上的流程程式碼片段,leveldb的資料初始化流程簡略如下:
呼叫VersionSet::Recove介面,通過Current檔案讀取manifest檔案並回放其中的VersionEdit,取得leveldb關閉時的磁碟檔案狀態作為當前Version,並將該狀態寫入新的manifest檔案。
然後再恢復上次關閉leveldb時還沒寫入sst的log檔案,生成新的memtable並且dump到磁碟。
然後再開啟新的log檔案,刪除磁碟上的無用資料,完成leveldb的初始化操作。
2.8.4 Leveldb的寫資料流程
Leveldb的寫資料流程入口為DBImpl::Put和DBImpl::Delete,這兩個檔案時DBImpl::Write介面的封裝,將寫操作封裝成WriteBatch傳入DBImpl::Write進行操作,可見leveldb在內部是將單獨的寫操作也作為只有一個操作的批量寫操作來進行的。
DBImpl::Write流程如下:
// Put,Delete最終呼叫Write進行寫
DBImpl::Write(const WriteOptions& options, WriteBatch* my_batch){
//將寫任務加入等待佇列
Writer w(&mutex_);
w.batch = my_batch;
writers_.push_back(&w);
//檢視是否可以寫,如果mem,imm沒有空間寫,則呼叫後臺打包程序
//有可能阻塞在這裡。
MakeRoomForWrite(my_bat