1. 程式人生 > >BigTable與LevelDB架構簡介

BigTable與LevelDB架構簡介

在 2006 年的 OSDI 上,Google 釋出了名為 Bigtable: A Distributed Storage System for Structured Data 的論文,其中描述了一個用於管理結構化資料的分散式儲存系統 - Bigtable 的資料模型、介面以及實現等內容。

leveldb-logo

本文會先對 Bigtable 一文中描述的分散式儲存系統進行簡單的描述,然後對 Google 開源的 KV 儲存資料庫 LevelDB 進行分析;LevelDB 可以理解為單點的 Bigtable 的系統,雖然其中沒有 Bigtable 中與 tablet 管理以及一些分散式相關的邏輯,不過我們可以通過對 LevelDB 原始碼的閱讀增加對 Bigtable 的理解。

Bigtable

Bigtable 是一個用於管理結構化資料的分散式儲存系統,它有非常優秀的擴充套件性,可以同時處理上千臺機器中的 PB 級別的資料;Google 中的很多專案,包括 Web 索引都使用 Bigtable 來儲存海量的資料;Bigtable 的論文中聲稱它實現了四個目標:

Goals-of-Bigtable

在作者看來這些目標看看就好,其實並沒有什麼太大的意義,所有的專案都會對外宣稱它們達到了高效能、高可用性等等特性,我們需要關注的是 Bigtable 到底是如何實現的。

資料模型

Bigtable 與資料庫在很多方面都非常相似,但是它提供了與資料庫不同的介面,它並沒有支援全部的關係型資料模型,反而使用了簡單的資料模型,使資料可以被更靈活的控制和管理。

在實現中,Bigtable 其實就是一個稀疏的、分散式的、多維持久有序雜湊。

A Bigtable is a sparse, distributed, persistent multi-dimensional sorted map.

它的定義其實也就決定了其資料模型非常簡單並且易於實現,我們使用 rowcolumn 和 timestamp 三個欄位作為這個雜湊的鍵,值就是一個位元組陣列,也可以理解為字串。

Bigtable-DataModel-Row-Column-Timestamp-Value

這裡最重要的就是 row 的值,它的長度最大可以為 64KB,對於同一 row 下資料的讀寫都可以看做是原子的;因為 Bigtable 是按照 row

 的值使用字典順序進行排序的,每一段 row 的範圍都會被 Bigtable 進行分割槽,並交給一個 tablet 進行處理。

實現

在這一節中,我們將介紹 Bigtable 論文對於其本身實現的描述,其中包含很多內容:tablet 的組織形式、tablet 的管理、讀寫請求的處理以及資料的壓縮等幾個部分。

tablet 的組織形式

我們使用類似 B+ 樹的三層結構來儲存 tablet 的位置資訊,第一層是一個單獨的 Chubby 檔案,其中儲存了根 tablet 的位置。

Chubby 是一個分散式鎖服務,我們可能會在後面的文章中介紹它。

Tablet-Location-Hierarchy

每一個 METADATA tablet 包括根節點上的 tablet 都儲存了 tablet 的位置和該 tablet 中 key 的最小值和最大值;每一個 METADATA 行大約在記憶體中儲存了 1KB 的資料,如果每一個 METADATA tablet 的大小都為 128MB,那麼整個三層結構可以儲存 2^61 位元組的資料。

tablet 的管理

既然在整個 Bigtable 中有著海量的 tablet 伺服器以及資料的分片 tablet,那麼 Bigtable 是如何管理海量的資料呢?Bigtable 與很多的分散式系統一樣,使用一個主伺服器將 tablet 分派給不同的伺服器節點。

Master-Manage-Tablet-Servers-And-Tablets

為了減輕主伺服器的負載,所有的客戶端僅僅通過 Master 獲取 tablet 伺服器的位置資訊,它並不會在每次讀寫時都請求 Master 節點,而是直接與 tablet 伺服器相連,同時客戶端本身也會儲存一份 tablet 伺服器位置的快取以減少與 Master 通訊的次數和頻率。

讀寫請求的處理

從讀寫請求的處理,我們其實可以看出整個 Bigtable 中的各個部分是如何協作的,包括日誌、memtable 以及 SSTable 檔案。

Tablet-Serving

當有客戶端向 tablet 伺服器傳送寫操作時,它會先向 tablet 伺服器中的日誌追加一條記錄,在日誌成功追加之後再向 memtable 中插入該條記錄;這與現在大多的資料庫的實現完全相同,通過順序寫向日志追加記錄,然後再向資料庫隨機寫,因為隨機寫的耗時遠遠大於追加內容,如果直接進行隨機寫,可能由於發生裝置故障造成資料丟失。

當 tablet 伺服器接收到讀操作時,它會在 memtable 和 SSTable 上進行合併查詢,因為 memtable 和 SSTable 中對於鍵值的儲存都是字典順序的,所以整個讀操作的執行會非常快。

表的壓縮

隨著寫操作的進行,memtable 會隨著事件的推移逐漸增大,當 memtable 的大小超過一定的閾值時,就會將當前的 memtable 凍結,並且建立一個新的 memtable,被凍結的 memtable 會被轉換為一個 SSTable 並且寫入到 GFS 系統中,這種壓縮方式也被稱作 Minor Compaction

Minor-Compaction

每一個 Minor Compaction 都能夠建立一個新的 SSTable,它能夠有效地降低記憶體的佔用並且降低服務程序異常退出後,過大的日誌導致的過長的恢復時間。既然有用於壓縮 memtable 中資料的 Minor Compaction,那麼就一定有一個對應的 Major 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-Put

從總體上看,LevelDB 在對資料庫執行寫操作時,會有三個步驟:

  1. 呼叫 MakeRoomForWrite 方法為即將進行的寫入提供足夠的空間;
    • 在這個過程中,由於 memtable 中空間的不足可能會凍結當前的 memtable,發生 Minor Compaction 並建立一個新的 MemTable 物件;
    • 在某些條件滿足時,也可能發生 Major Compaction,對資料庫中的 SSTable 進行壓縮;
  2. 通過 AddRecord 方法向日志中追加一條寫操作的記錄;
  3. 再向日誌成功寫入記錄後,我們使用 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 物件。

Immutable-MemTable

你可以看到,與 Bigtable 中論文不同的是,LevelDB 中引入了一個不可變的 memtable 結構 imm,它的結構與 memtable 完全相同,只是其中的所有資料都是不可變的。

LevelDB-Serving

在切換到新的 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 校驗、記錄的長度等資訊:

LevelDB-log-format-and-recordtype

上圖中一共包含 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 中新增一條記錄。

LevelDB-Memtable-Key-Value-Format

新增和刪除的記錄的區別就是它們使用了不用的 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-MemTable-SkipList

在序列號的幫助下,我們並不需要對歷史資料進行刪除,同時也能加快寫操作的速度,提升 LevelDB 的寫效能。

資料的讀取

從 LevelDB 中讀取資料其實並不複雜,memtable 和 imm 更像是兩級快取,它們在記憶體中提供了更快的訪問速度,如果能直接從記憶體中的這兩處直接獲取到響應的值,那麼它們一定是最新的資料。

LevelDB 總會將新的鍵值對寫在最前面,並在資料壓縮時刪除歷史資料。

LevelDB-Read-Processes

資料的讀取是按照 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

FileMetaData 中包含了整個檔案的全部資訊,其中包括鍵的最大值和最小值、允許查詢的次數、檔案被引用的次數、檔案的大小以及檔案號,因為所有的 SSTable 都是以固定的形式儲存在同一目錄下的,所以我們可以通過檔案號輕鬆查詢到對應的檔案。

LevelDB-Level0-Laye

查詢的順序就是從低到高了,LevelDB 首先會在 Level0 中查詢對應的鍵。但是,與其他層級不同,Level0 中多個 SSTable 的鍵的範圍有重合部分的,在查詢對應值的過程中,會依次查詢 Level0 中固定的 4 個 SSTable。

LevelDB-LevelN-Layers

但是當涉及到更高層級的 SSTable 時,因為同一層級的 SSTable 都是沒有重疊部分的,所以我們在查詢時可以利用已知的 SSTable 中的極值資訊 smallest/largest 快速查詢到對應的 SSTable,再判斷當前的 SSTable 是否包含查詢的 key,如果不存在,就繼續查詢下一個層級直到最後的一個層級 kNumLevels(預設為 7 級)或者查詢到了對應的值。

SSTable 的『合併』

既然 LevelDB 中的資料是通過多個層級的 SSTable 組織的,那麼它是如何對不同層級中的 SSTable 進行合併和壓縮的呢;與 Bigtable 論文中描述的兩種 Compaction 幾乎完全相同,LevelDB 對這兩種壓縮的方式都進行了實現。

無論是讀操作還是寫操作,在執行的過程中都可能呼叫 MaybeScheduleCompaction 來嘗試對資料庫中的 SSTable 進行合併,當合並的條件滿足時,最終都會執行 BackgroundCompaction 方法在後臺完成這個步驟。

LevelDB-BackgroundCompaction-Processes

這種合併分為兩種情況,一種是 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-Pick-Compactions

LevelDB 中的 DoCompactionWork 方法會對所有傳入的 SSTable 中的鍵值使用歸併排序進行合併,最後會在高高層級(圖中為 Level2)中生成一個新的 SSTable。

LevelDB-After-Compactions

這樣下一次查詢 17~40 之間的值時就可以減少一次對 SSTable 中資料的二分查詢以及讀取檔案的時間,提升讀寫的效能。

儲存 db 狀態的 VersionSet

LevelDB 中的所有狀態其實都是被一個 VersionSet 結構所儲存的,一個 VersionSet 包含一組 Version 結構體,所有的 Version 包括歷史版本都是通過雙向連結串列連線起來的,但是隻有一個版本是當前版本。

VersionSet-Version-And-VersionEdit

當 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 是以這種格式儲存資料的:

SSTable-Format

當 LevelDB 讀取 SSTable 存在的 ldb 檔案時,會先讀取檔案中的 Footer 資訊。

SSTable-Foote