[跟吉姆一起讀LevelDB]5.資料庫恢復(2)

上篇說了LevelDB如何在沒有日誌的情況下, 恢復(新建)資料庫. 這篇正兒八經開始分析讀日誌的代碼了. 在這裡, 我覺得很有必要區分清楚, log在LevelDB中究竟有幾種語義. 相當相當多關於LevelDB的文章都在這裡有點含糊不清.

  • 人類可讀的日誌, 存於"LOG"文件

比如, "2017/06/16-11:09:03.295840 7fffb990d3c0 Recovering log #18". 在代碼中是用"Log"函數來觸發的, 相關的類是"Logger".

  • 機讀二進位日誌, 存於".log"文件

這個是真正意義上用於恢複數據的日誌. 數據啟動時, 如果有沒清空的日誌, 就說明上次關閉不成功, 須回放一遍.

  • leveldb::log, 這是一個namespace, 用於把二進位數據安全地序列化, 反序列化

::log不僅負責(反)序列化機讀日誌, VersionEdit在"MANIFEST"文件內也復用了這個組件. 現在提出一個很重要也很常見的問題, 如何保證非原子性的一連串操作的原子性? 有點繞? 來個情景.

資料庫現在要開始寫Log了, 一條一條又一條, 這時候突然崩潰了. 下次再開, 日誌回放的時候, 會得到啥? 形象的說, 這可以叫做"薛定諤的資料庫". 最後一條記錄處於成功和失敗的疊加態, 只有觀測的一瞬間才知道. 大部分用戶可以容許的是丟日誌, 但絕對不容忍錯誤的日誌被當成正常的寫入資料庫. 比如, 往A賬戶轉入10000W, 這條寫到一半, 最後變成了往A賬戶轉入10W...

解決方案大概有兩個,

1. sentinel

確保之前的數據都不存在某個特定字元(可能需要轉義), 然後在結尾寫上這個終止符, 表示順利完成.

2. checksum

在數據寫入完成之後, 再多寫一段hash. 再次讀取時, 只有hash和內容對上了, 這段數據才是合法的.

sentinel的問題在於如果有來自宇宙的輻射讓硬碟/CPU的電路發生比特翻轉, 錯誤的數據還是能被合法地接受. 可能宇宙射線這個太罕見了, 更常見的是硬碟壞道. 還有怎麼保證sentinel的唯一性也是個問題.

一般理性的程序員都選擇checksum, LevelDB對此有一個高度優化的crc32c hash函數在crc32c.cc文件內.

所以, 一條機讀日誌從內存到硬碟是這樣的, 內存對象 => 二進位數組(Slice對象) => leveldb::log切割成小塊並打上hash => 寫入硬碟.

------

上篇讀到了db_impl.cc的305行,

s = versions_->Recover(save_manifest); // 開始讀之前的manifestn if (!s.ok()) {n return s;n }n

基於LSM Tree的資料庫在恢復時一定分兩步, 第一是恢復SSTable, 第二是恢復memtable/immemtable. "versions_->Recover"是前者, 跟入version_set.cc第905行,

Status VersionSet::Recover(bool *save_manifest) {n struct LogReporter : public log::Reader::Reporter { // 作用類似別的語言的委託/回調n Status* status;n virtual void Corruption(size_t bytes, const Status& s) {n if (this->status->ok()) *this->status = s;n }n };n

這段代碼再次表明了Google對於虛函數的熱愛, "LogReporter"的功能完全可以用函數指針替代. save_manifest在99%的情況下都應該是true, 意為是否要覆寫"MANIFEST"文件. 由於"MANIFEST"文件積存著很多VersionEdit, 合併重寫對性能總是有好處的.

939-961行,

Builder builder(this, current_); // VersionSet *, Version *n // 初始化狀態下構造至CURRENT狀態n {n LogReporter reporter;n reporter.status = &s;n log::Reader reader(file, &reporter, true/*checksum*/, 0/*initial_offset*/);n Slice record;n std::string scratch;n while (reader.ReadRecord(&record, &scratch) && s.ok()) {n VersionEdit edit; // 先由::log反序列化成slicen s = edit.DecodeFrom(record); // 再由類自身從slice反序列化成數據n if (s.ok()) {n if (edit.has_comparator_ &&n edit.comparator_ != icmp_.user_comparator()->Name()) {n s = Status::InvalidArgument(n edit.comparator_ + " does not match existing comparator ",n icmp_.user_comparator()->Name());n }n }nn if (s.ok()) {n builder.Apply(&edit);n }n

這段基本就做了一件事, 不斷回放VersionEdit, 然後餵給Builder, 最終apply疊加成崩潰(關閉)之前的Version. 我自己本身是寫過KV資料庫的, 感覺這些都沒什麼驚奇的, 有疑惑的可以細看下. Builder可以理解為時光機, 讓資料庫在不同的時間點無縫遷移.

1005-1009行,

Version* v = new Version(this);n builder.SaveTo(v); // 把Builder的信息合併到一個新的Version中去n // Install recovered versionn Finalize(v); // 補充Version需要的額外信息n AppendVersion(v); // 將最新的Version插入鏈表.n

------

LevelDB受制於其完成時間遠早於C++ 11標準, 個人感覺的非最佳實踐:

  • 不用異常

哇... 坑爹啊... 一層套一層的s.ok(). 又因為返回值一定要是狀態, 那麼函數間數據交互就只能靠指針/引用了.

  • 不用智能指針

然後又加了一個低配引用計數器, 來回手動ref(), unref().

------

下章應該是資料庫恢復的最終章, 要寫如何恢復memtable/immemtable.


推薦閱讀:

當 TiDB 遇上 Jepsen
如何從零開始參與大型開源項目
十七年的潤乾,壯士斷腕的變革,報表到計算的轉身

TAG:LevelDB | 数据库 | CC |