BigTable與LevelDB架構簡介
在 2006 年的 OSDI 上,Google 釋出了名為 Bigtable: A Distributed Storage System for Structured Data 的論文,其中描述了一個用於管理結構化資料的分散式儲存系統 - Bigtable 的資料模型、介面以及實現等內容。
本文會先對 Bigtable 一文中描述的分散式儲存系統進行簡單的描述,然後對 Google 開源的 KV 儲存資料庫 LevelDB 進行分析;LevelDB 可以理解為單點的 Bigtable 的系統,雖然其中沒有 Bigtable 中與 tablet 管理以及一些分散式相關的邏輯,不過我們可以通過對 LevelDB 原始碼的閱讀增加對 Bigtable 的理解。
Bigtable
Bigtable 是一個用於管理結構化資料的分散式儲存系統,它有非常優秀的擴充套件性,可以同時處理上千臺機器中的 PB 級別的資料;Google 中的很多專案,包括 Web 索引都使用 Bigtable 來儲存海量的資料;Bigtable 的論文中聲稱它實現了四個目標:
在作者看來這些目標看看就好,其實並沒有什麼太大的意義,所有的專案都會對外宣稱它們達到了高效能、高可用性等等特性,我們需要關注的是 Bigtable 到底是如何實現的。
資料模型
Bigtable 與資料庫在很多方面都非常相似,但是它提供了與資料庫不同的介面,它並沒有支援全部的關係型資料模型,反而使用了簡單的資料模型,使資料可以被更靈活的控制和管理。
在實現中,Bigtable 其實就是一個稀疏的、分散式的、多維持久有序雜湊。
A Bigtable is a sparse, distributed, persistent multi-dimensional sorted map.
它的定義其實也就決定了其資料模型非常簡單並且易於實現,我們使用 row
、column
和 timestamp
三個欄位作為這個雜湊的鍵,值就是一個位元組陣列,也可以理解為字串。
這裡最重要的就是 row
的值,它的長度最大可以為 64KB,對於同一 row
下資料的讀寫都可以看做是原子的;因為 Bigtable 是按照 row
row
的範圍都會被 Bigtable 進行分割槽,並交給一個 tablet 進行處理。
實現
在這一節中,我們將介紹 Bigtable 論文對於其本身實現的描述,其中包含很多內容:tablet 的組織形式、tablet 的管理、讀寫請求的處理以及資料的壓縮等幾個部分。
tablet 的組織形式
我們使用類似 B+ 樹的三層結構來儲存 tablet 的位置資訊,第一層是一個單獨的 Chubby 檔案,其中儲存了根 tablet 的位置。
Chubby 是一個分散式鎖服務,我們可能會在後面的文章中介紹它。
每一個 METADATA tablet 包括根節點上的 tablet 都儲存了 tablet 的位置和該 tablet 中 key 的最小值和最大值;每一個 METADATA 行大約在記憶體中儲存了 1KB 的資料,如果每一個 METADATA tablet 的大小都為 128MB,那麼整個三層結構可以儲存 2^61 位元組的資料。
tablet 的管理
既然在整個 Bigtable 中有著海量的 tablet 伺服器以及資料的分片 tablet,那麼 Bigtable 是如何管理海量的資料呢?Bigtable 與很多的分散式系統一樣,使用一個主伺服器將 tablet 分派給不同的伺服器節點。
為了減輕主伺服器的負載,所有的客戶端僅僅通過 Master 獲取 tablet 伺服器的位置資訊,它並不會在每次讀寫時都請求 Master 節點,而是直接與 tablet 伺服器相連,同時客戶端本身也會儲存一份 tablet 伺服器位置的快取以減少與 Master 通訊的次數和頻率。
讀寫請求的處理
從讀寫請求的處理,我們其實可以看出整個 Bigtable 中的各個部分是如何協作的,包括日誌、memtable 以及 SSTable 檔案。
當有客戶端向 tablet 伺服器傳送寫操作時,它會先向 tablet 伺服器中的日誌追加一條記錄,在日誌成功追加之後再向 memtable 中插入該條記錄;這與現在大多的資料庫的實現完全相同,通過順序寫向日志追加記錄,然後再向資料庫隨機寫,因為隨機寫的耗時遠遠大於追加內容,如果直接進行隨機寫,可能由於發生裝置故障造成資料丟失。
當 tablet 伺服器接收到讀操作時,它會在 memtable 和 SSTable 上進行合併查詢,因為 memtable 和 SSTable 中對於鍵值的儲存都是字典順序的,所以整個讀操作的執行會非常快。
表的壓縮
隨著寫操作的進行,memtable 會隨著事件的推移逐漸增大,當 memtable 的大小超過一定的閾值時,就會將當前的 memtable 凍結,並且建立一個新的 memtable,被凍結的 memtable 會被轉換為一個 SSTable 並且寫入到 GFS 系統中,這種壓縮方式也被稱作 Minor Compaction。
每一個 Minor Compaction 都能夠建立一個新的 SSTable,它能夠有效地降低記憶體的佔用並且降低服務程序異常退出後,過大的日誌導致的過長的恢復時間。既然有用於壓縮 memtable 中資料的 Minor Compaction,那麼就一定有一個對應的 Major Compaction 操作。
Bigtable 會在後臺週期性地進行 Major Compaction,將 memtable 中的資料和一部分的 SSTable 作為輸入,將其中的鍵值進行歸併排序,生成新的 SSTable 並移除原有的 memtable 和 SSTable,新生成的 SSTable 中包含前兩者的全部資料和資訊,並且將其中一部分標記未刪除的資訊徹底清除。
小結
到這裡為止,對於 Google 的 Bigtable 論文的介紹就差不多完成了,當然本文只介紹了其中的一部分內容,關於壓縮演算法的實現細節、快取以及提交日誌的實現等問題我們都沒有涉及,想要了解更多相關資訊的讀者,這裡強烈推薦去看一遍 Bigtable 這篇論文的原文 Bigtable: A Distributed Storage System for Structured Data 以增強對其實現的理解。
LevelDB
文章前面對於 Bigtable 的介紹其實都是對 LevelDB 這部分內容所做的鋪墊,當然這並不是說前面的內容就不重要,LevelDB 是對 Bigtable 論文中描述的鍵值儲存系統的單機版的實現,它提供了一個極其高速的鍵值儲存系統,並且由 Bigtable 的作者 Jeff Dean 和 Sanjay Ghemawat 共同完成,可以說高度復刻了 Bigtable 論文中對於其實現的描述。
因為 Bigtable 只是一篇論文,同時又因為其實現依賴於 Google 的一些不開源的基礎服務:GFS、Chubby 等等,我們很難接觸到它的原始碼,不過我們可以通過 LevelDB 更好地瞭解這篇論文中提到的諸多內容和思量。
概述
LevelDB 作為一個鍵值儲存的『倉庫』,它提供了一組非常簡單的增刪改查介面:
class DB {
public: virtual Status Put(const WriteOptions& options, const Slice& key, const Slice& value) = 0; virtual Status Delete(const WriteOptions& options, const Slice& key) = 0; virtual Status Write(const WriteOptions& options, WriteBatch* updates) = 0; virtual Status Get(const ReadOptions& options, const Slice& key, std::string* value) = 0; }
Put
方法在內部最終會呼叫Write
方法,只是在上層為呼叫者提供了兩個不同的選擇。
Get
和 Put
是 LevelDB 為上層提供的用於讀寫的介面,如果我們能夠對讀寫的過程有一個非常清晰的認知,那麼理解 LevelDB 的實現就不是那麼困難了。
在這一節中,我們將先通過對讀寫操作的分析瞭解整個工程中的一些實現,並在遇到問題和新的概念時進行解釋,我們會在這個過程中一步一步介紹 LevelDB 中一些重要模組的實現以達到掌握它的原理的目標。
從寫操作開始
首先來看 Get
和 Put
兩者中的寫方法:
Status DB::Put(const WriteOptions& opt, const Slice& key, const Slice& value) { WriteBatch batch; batch.Put(key, value); return Write(opt, &batch); } Status DBImpl::Write(const WriteOptions& options, WriteBatch* my_batch) { ... }
正如上面所介紹的,DB::Put
方法將傳入的引數封裝成了一個 WritaBatch
,然後仍然會執行 DBImpl::Write
方法向資料庫中寫入資料;寫入方法 DBImpl::Write
其實是一個是非常複雜的過程,包含了很多對上下文狀態的判斷,我們先來看一個寫操作的整體邏輯:
從總體上看,LevelDB 在對資料庫執行寫操作時,會有三個步驟:
- 呼叫
MakeRoomForWrite
方法為即將進行的寫入提供足夠的空間;- 在這個過程中,由於 memtable 中空間的不足可能會凍結當前的 memtable,發生 Minor Compaction 並建立一個新的
MemTable
物件; - 在某些條件滿足時,也可能發生 Major Compaction,對資料庫中的 SSTable 進行壓縮;
- 在這個過程中,由於 memtable 中空間的不足可能會凍結當前的 memtable,發生 Minor Compaction 並建立一個新的
- 通過
AddRecord
方法向日志中追加一條寫操作的記錄; - 再向日誌成功寫入記錄後,我們使用
InsertInto
直接插入 memtable 中,完成整個寫操作的流程;
在這裡,我們並不會提供 LevelDB 對於 Put
方法實現的全部程式碼,只會展示一份精簡後的程式碼,幫助我們大致瞭解一下整個寫操作的流程:
Status DBImpl::Write(const WriteOptions& options, WriteBatch* my_batch) { Writer w(&mutex_); w.batch = my_batch; MakeRoomForWrite(my_batch == NULL); uint64_t last_sequence = versions_->LastSequence(); Writer* last_writer = &w; WriteBatch* updates = BuildBatchGroup(&last_writer); WriteBatchInternal::SetSequence(updates, last_sequence + 1); last_sequence += WriteBatchInternal::Count(updates); log_->AddRecord(WriteBatchInternal::Contents(updates)); WriteBatchInternal::InsertInto(updates, mem_); versions_->SetLastSequence(last_sequence); return Status::OK(); }
不可變的 memtable
在寫操作的實現程式碼 DBImpl::Put
中,寫操作的準備過程 MakeRoomForWrite
是我們需要注意的一個方法:
Status DBImpl::MakeRoomForWrite(bool force) { uint64_t new_log_number = versions_->NewFileNumber(); WritableFile* lfile = NULL; env_->NewWritableFile(LogFileName(dbname_, new_log_number), &lfile); delete log_; delete logfile_; logfile_ = lfile; logfile_number_ = new_log_number; log_ = new log::Writer(lfile); imm_ = mem_; has_imm_.Release_Store(imm_); mem_ = new MemTable(internal_comparator_); mem_->Ref(); MaybeScheduleCompaction(); return Status::OK(); }
當 LevelDB 中的 memtable 已經被資料填滿導致記憶體已經快不夠用的時候,我們會開始對 memtable 中的資料進行凍結並建立一個新的 MemTable
物件。
你可以看到,與 Bigtable 中論文不同的是,LevelDB 中引入了一個不可變的 memtable 結構 imm,它的結構與 memtable 完全相同,只是其中的所有資料都是不可變的。
在切換到新的 memtable 之後,還可能會執行 MaybeScheduleCompaction
來觸發一次 Minor Compaction 將 imm 中資料固化成資料庫中的 SSTable;imm 的引入能夠解決由於 memtable 中資料過大導致壓縮時不可寫入資料的問題。
引入 imm 後,如果 memtable 中的資料過多,我們可以直接將 memtable 指標賦值給 imm,然後建立一個新的 MemTable 例項,這樣就可以繼續接受外界的寫操作,不再需要等待 Minor Compaction 的結束了。
日誌記錄的格式
作為一個持久儲存的 KV 資料庫,LevelDB 一定要有日誌模組以支援錯誤發生時恢復資料,我們想要深入瞭解 LevelDB 的實現,那麼日誌的格式是一定繞不開的問題;這裡並不打算展示用於追加日誌的方法 AddRecord
的實現,因為方法中只是實現了對錶頭和字串的拼接。
日誌在 LevelDB 是以塊的形式儲存的,每一個塊的長度都是 32KB,固定的塊長度也就決定了日誌可能存放在塊中的任意位置,LevelDB 中通過引入一位 RecordType
來表示當前記錄在塊中的位置:
enum RecordType {
// Zero is reserved for preallocated files
kZeroType = 0, kFullType = 1, // For fragments kFirstType = 2, kMiddleType = 3, kLastType = 4 };
日誌記錄的型別儲存在該條記錄的頭部,其中還儲存了 4 位元組日誌的 CRC 校驗、記錄的長度等資訊:
上圖中一共包含 4 個塊,其中儲存著 6 條日誌記錄,我們可以通過 RecordType
對每一條日誌記錄或者日誌記錄的一部分進行標記,並在日誌需要使用時通過該資訊重新構造出這條日誌記錄。
virtual Status Sync() { Status s = SyncDirIfManifest(); if (fflush_unlocked(file_) != 0 || fdatasync(fileno(file_)) != 0) { s = Status::IOError(filename_, strerror(errno)); } return s; }
因為向日志中寫新記錄都是順序寫的,所以它寫入的速度非常快,當在記憶體中寫入完成時,也會直接將緩衝區的這部分的內容 fflush
到磁碟上,實現對記錄的持久化,用於之後的錯誤恢復等操作。
記錄的插入
當一條資料的記錄寫入日誌時,這條記錄仍然無法被查詢,只有當該資料寫入 memtable 後才可以被查詢,而這也是這一節將要介紹的內容,無論是資料的插入還是資料的刪除都會向 memtable 中新增一條記錄。
新增和刪除的記錄的區別就是它們使用了不用的 ValueType
標記,插入的資料會將其設定為 kTypeValue
,刪除的操作會標記為 kTypeDeletion
;但是它們實際上都向 memtable 中插入了一條資料。
virtual void Put(const Slice& key, const Slice& value) { mem_->Add(sequence_, kTypeValue, key, value); sequence_++; } virtual void Delete(const Slice& key) { mem_->Add(sequence_, kTypeDeletion, key, Slice()); sequence_++; }
我們可以看到它們都呼叫了 memtable 的 Add
方法,向其內部的資料結構 skiplist 以上圖展示的格式插入資料,這條資料中既包含了該記錄的鍵值、序列號以及這條記錄的種類,這些欄位會在拼接後存入 skiplist;既然我們並沒有在 memtable 中對資料進行刪除,那麼我們是如何保證每次取到的資料都是最新的呢?首先,在 skiplist 中,我們使用了自己定義的一個 comparator
:
int InternalKeyComparator::Compare(const Slice& akey, const Slice& bkey) const { int r = user_comparator_->Compare(ExtractUserKey(akey), ExtractUserKey(bkey)); if (r == 0) { const uint64_t anum = DecodeFixed64(akey.data() + akey.size() - 8); const uint64_t bnum = DecodeFixed64(bkey.data() + bkey.size() - 8); if (anum > bnum) { r = -1; } else if (anum < bnum) { r = +1; } } return r; }
比較的兩個 key 中的資料可能包含的內容都不完全相同,有的會包含鍵值、序列號等全部資訊,但是例如從
Get
方法呼叫過來的 key 中可能就只包含鍵的長度、鍵值和序列號了,但是這並不影響這裡對資料的提取,因為我們只從每個 key 的頭部提取資訊,所以無論是完整的 key/value 還是單獨的 key,我們都不會取到 key 之外的任何資料。
該方法分別從兩個不同的 key 中取出鍵和序列號,然後對它們進行比較;比較的過程就是使用 InternalKeyComparator
比較器,它通過 user_key
和 sequence_number
進行排序,其中 user_key
按照遞增的順序排序、sequence_number
按照遞減的順序排序,因為隨著資料的插入序列號是不斷遞增的,所以我們可以保證先取到的都是最新的資料或者刪除資訊。
在序列號的幫助下,我們並不需要對歷史資料進行刪除,同時也能加快寫操作的速度,提升 LevelDB 的寫效能。
資料的讀取
從 LevelDB 中讀取資料其實並不複雜,memtable 和 imm 更像是兩級快取,它們在記憶體中提供了更快的訪問速度,如果能直接從記憶體中的這兩處直接獲取到響應的值,那麼它們一定是最新的資料。
LevelDB 總會將新的鍵值對寫在最前面,並在資料壓縮時刪除歷史資料。
資料的讀取是按照 MemTable、Immutable MemTable 以及不同層級的 SSTable 的順序進行的,前兩者都是在記憶體中,後面不同層級的 SSTable 都是以 *.ldb
檔案的形式持久儲存在磁碟上,而正是因為有著不同層級的 SSTable,所以我們的資料庫的名字叫做 LevelDB。
精簡後的讀操作方法的實現程式碼是這樣的,方法的脈絡非常清晰,作者相信這裡也不需要過多的解釋:
Status DBImpl::Get(const ReadOptions& options, const Slice& key, std::string* value) { LookupKey lkey(key, versions_->LastSequence()); if (mem_->Get(lkey, value, NULL)) { // Done } else if (imm_ != NULL && imm_->Get(lkey, value, NULL)) { // Done } else { versions_->current()->Get(options, lkey, value, NULL); } MaybeScheduleCompaction(); return Status::OK(); }
當 LevelDB 在 memtable 和 imm 中查詢到結果時,如果查詢到了資料並不一定表示當前的值一定存在,它仍然需要判斷 ValueType
來確定當前記錄是否被刪除。
多層級的 SSTable
當 LevelDB 在記憶體中沒有找到對應的資料時,它才會到磁碟中多個層級的 SSTable 中進行查詢,這個過程就稍微有一點複雜了,LevelDB 會在多個層級中逐級進行查詢,並且不會跳過其中的任何層級;在查詢的過程就涉及到一個非常重要的資料結構 FileMetaData
:
FileMetaData
中包含了整個檔案的全部資訊,其中包括鍵的最大值和最小值、允許查詢的次數、檔案被引用的次數、檔案的大小以及檔案號,因為所有的 SSTable
都是以固定的形式儲存在同一目錄下的,所以我們可以通過檔案號輕鬆查詢到對應的檔案。
查詢的順序就是從低到高了,LevelDB 首先會在 Level0 中查詢對應的鍵。但是,與其他層級不同,Level0 中多個 SSTable 的鍵的範圍有重合部分的,在查詢對應值的過程中,會依次查詢 Level0 中固定的 4 個 SSTable。
但是當涉及到更高層級的 SSTable 時,因為同一層級的 SSTable 都是沒有重疊部分的,所以我們在查詢時可以利用已知的 SSTable 中的極值資訊 smallest/largest
快速查詢到對應的 SSTable,再判斷當前的 SSTable 是否包含查詢的 key,如果不存在,就繼續查詢下一個層級直到最後的一個層級 kNumLevels
(預設為 7 級)或者查詢到了對應的值。
SSTable 的『合併』
既然 LevelDB 中的資料是通過多個層級的 SSTable 組織的,那麼它是如何對不同層級中的 SSTable 進行合併和壓縮的呢;與 Bigtable 論文中描述的兩種 Compaction 幾乎完全相同,LevelDB 對這兩種壓縮的方式都進行了實現。
無論是讀操作還是寫操作,在執行的過程中都可能呼叫 MaybeScheduleCompaction
來嘗試對資料庫中的 SSTable 進行合併,當合並的條件滿足時,最終都會執行 BackgroundCompaction
方法在後臺完成這個步驟。
這種合併分為兩種情況,一種是 Minor Compaction,即記憶體中的資料超過了 memtable 大小的最大限制,改 memtable 被凍結為不可變的 imm,然後執行方法 CompactMemTable()
對記憶體表進行壓縮。
void DBImpl::CompactMemTable() { VersionEdit edit; Version* base = versions_->current(); WriteLevel0Table(imm_, &edit, base); versions_->LogAndApply(&edit, &mutex_); DeleteObsoleteFiles(); }
CompactMemTable
會執行 WriteLevel0Table
將當前的 imm 轉換成一個 Level0 的 SSTable 檔案,同時由於 Level0 層級的檔案變多,可能會繼續觸發一個新的 Major Compaction,在這裡我們就需要在這裡選擇需要壓縮的合適的層級:
Status DBImpl::WriteLevel0Table(MemTable* mem, VersionEdit* edit, Version* base) { FileMetaData meta; meta.number = versions_->NewFileNumber(); Iterator* iter = mem->NewIterator(); BuildTable(dbname_, env_, options_, table_cache_, iter, &meta); const Slice min_user_key = meta.smallest.user_key(); const Slice max_user_key = meta.largest.user_key(); int level = base->PickLevelForMemTableOutput(min_user_key, max_user_key); edit->AddFile(level, meta.number, meta.file_size, meta.smallest, meta.largest); return Status::OK(); }
所有對當前 SSTable 資料的修改由一個統一的 VersionEdit
物件記錄和管理,我們會在後面介紹這個物件的作用和實現,如果成功寫入了就會返回這個檔案的元資料 FileMetaData
,最後呼叫 VersionSet
的方法 LogAndApply
將檔案中的全部變化如實記錄下來,最後做一些資料的清理工作。
當然如果是 Major Compaction 就稍微有一些複雜了,不過整理後的 BackgroundCompaction
方法的邏輯非常清晰:
void DBImpl::BackgroundCompaction() { if (imm_ != NULL) { CompactMemTable(); return; } Compaction* c = versions_->PickCompaction(); CompactionState* compact = new CompactionState(c); DoCompactionWork(compact); CleanupCompaction(compact); DeleteObsoleteFiles(); }
我們從當前的 VersionSet
中找到需要壓縮的檔案資訊,將它們打包存入一個 Compaction
物件,該物件需要選擇兩個層級的 SSTable,低層級的表很好選擇,只需要選擇大小超過限制的或者查詢次數太多的 SSTable;當我們選擇了低層級的一個 SSTable 後,就在更高的層級選擇與該 SSTable 有重疊鍵的 SSTable 就可以了,通過 FileMetaData
中資料的幫助我們可以很快找到待壓縮的全部資料。
查詢次數太多的意思就是,當客戶端呼叫多次
Get
方法時,如果這次Get
方法在某個層級的 SSTable 中找到了對應的鍵,那麼就算做上一層級中包含該鍵的 SSTable 的一次查詢,也就是這次查詢由於不同層級鍵的覆蓋範圍造成了更多的耗時,每個 SSTable 在建立之後的allowed_seeks
都為 100 次,當allowed_seeks < 0
時就會觸發該檔案的與更高層級和合並,以減少以後查詢的查詢次數。
LevelDB 中的 DoCompactionWork
方法會對所有傳入的 SSTable 中的鍵值使用歸併排序進行合併,最後會在高高層級(圖中為 Level2)中生成一個新的 SSTable。
這樣下一次查詢 17~40 之間的值時就可以減少一次對 SSTable 中資料的二分查詢以及讀取檔案的時間,提升讀寫的效能。
儲存 db 狀態的 VersionSet
LevelDB 中的所有狀態其實都是被一個 VersionSet
結構所儲存的,一個 VersionSet
包含一組 Version
結構體,所有的 Version
包括歷史版本都是通過雙向連結串列連線起來的,但是隻有一個版本是當前版本。
當 LevelDB 中的 SSTable 發生變動時,它會生成一個 VersionEdit
結構,最終執行 LogAndApply
方法:
Status VersionSet::LogAndApply(VersionEdit* edit, port::Mutex* mu) { Version* v = new Version(this); Builder builder(this, current_); builder.Apply(edit); builder.SaveTo(v); std::string new_manifest_file; new_manifest_file = DescriptorFileName(dbname_, manifest_file_number_); env_->NewWritableFile(new_manifest_file, &descriptor_file_); std::string record; edit->EncodeTo(&record); descriptor_log_->AddRecord(record); descriptor_file_->Sync(); SetCurrentFile(env_, dbname_, manifest_file_number_); AppendVersion(v); return Status::OK(); }
該方法的主要工作是使用當前版本和 VersionEdit
建立一個新的版本物件,然後將 Version
的變更追加到 MANIFEST 日誌中,並且改變資料庫中全域性當前版本資訊。
MANIFEST 檔案中記錄了 LevelDB 中所有層級中的表、每一個 SSTable 的 Key 範圍和其他重要的元資料,它以日誌的格式儲存,所有對檔案的增刪操作都會追加到這個日誌中。
SSTable 的格式
SSTable 中其實儲存的不只是資料,其中還儲存了一些元資料、索引等資訊,用於加速讀寫操作的速度,雖然在 Bigtable 的論文中並沒有給出 SSTable 的資料格式,不過在 LevelDB 的實現中,我們可以發現 SSTable 是以這種格式儲存資料的:
當 LevelDB 讀取 SSTable 存在的 ldb
檔案時,會先讀取檔案中的 Footer
資訊。