深入 LevelDB 資料檔案 SSTable 的結構

碼洞發表於2019-02-19

LevelDB 的鍵值對內容都儲存在副檔名為 sst 的 SSTable 檔案中,SSTable 的磁碟檔案結構比較複雜,讀者在閱讀本節之前要做好心理準備。如果有任何看得不明白的地方,一定要在下方的問答區及時提問。

圖片
SSTable 檔案的內容分為 5 個部分,Footer、IndexBlock、MetaIndexBlock、FilterBlock 和 DataBlock。其中儲存了鍵值對內容的就是 DataBlock,儲存了布隆過濾器二進位制資料的是 FilterBlock,DataBlock 有多個,FilterBlock 也可以有多個,但是通常最多隻有 1 個,之所以設計成多個是考慮到擴充套件性,也許未來會支援其它型別的過濾器。另外 3 個部分為管理塊,其中 IndexBlock 記錄了 DataBlock 相關的元資訊,MetaIndexBlock 記錄了過濾器相關的元資訊,而 Footer 則指出 IndexBlock 和 MetaIndexBlock 在檔案中的偏移量資訊,它是元資訊的元資訊,它位於 sstable 檔案的尾部。下面我們至頂向下挨個分析每個結構

Footer 結構

它的佔用空間很小隻有 48 位元組,內部只存了幾個欄位。下面我們用虛擬碼來描述一下它的結構

// 定義了資料塊的位置和大小
struct BlockHandler {
  varint offset;
  varint size;
}

struct Footer {
  BlockHandler metaIndexHandler;  // MetaIndexBlock的檔案偏移量和長度
  BlockHandler indexHandler; // IndexBlock的檔案偏移量和長度
  byte[n] padding;  // 記憶體墊片
  int32 magicHighBits;  // 魔數後32位
  int32 magicLowBits; // 魔數前32位
}
複製程式碼

Footer 結構的中間部分增加了記憶體墊片,其作用就是將 Footer 的空間撐到 48 位元組。結構的尾部還有一個 64位的魔術數字 0xdb4775248b80fb57,如果檔案尾部的 8 位元組不是這個數字說明檔案已經損壞。這個魔術數字的來源很有意思,它是下面返回的字串的前64bit。

$ echo http://code.google.com/p/leveldb/ | sha1sum
db4775248b80fb57d0ce0768d85bcee39c230b61
複製程式碼

IndexBlock 和 MetaIndexBlock 都只有唯一的一個,所以分別使用一個 BlockHandler 結構來儲存偏移量和長度。

Block 結構

除了 Footer 之外,其它部分都是 Block 結構,在名稱上也都是以 Block 結尾。所謂的 Block 結構是指除了內部的有效資料外,還會有額外的壓縮型別欄位和校驗碼欄位。

struct Block {
  byte[] data;
  int8 compressType;
  int32 crcValue;
}
複製程式碼

每一個 Block 尾部都會有壓縮型別和迴圈冗餘校驗碼(crcValue),這會要佔去 5 位元組。如果是壓縮型別,塊內的資料 data 會被壓縮。校驗碼會針對壓縮和的資料和壓縮型別欄位一起計算迴圈冗餘校驗和。壓縮演算法預設是 snappy ,校驗演算法是 crc32。

crcValue = crc32(data, compressType)
複製程式碼

在下面介紹的所有 Block 結構中,我們不再提及壓縮和校驗碼。

DataBlock 結構

DataBlock 的大小預設是 4K 位元組(壓縮前),裡面儲存了一系列鍵值對。前面提到 sst 檔案裡面的 Key 是有序的,這意味著相鄰的 Key 會有很大的概率有共同的字首部分。正是考慮到這一點,DataBlock 在結構上做了優化,這個優化可以顯著減少儲存空間。

Key = sharedKey + unsharedKey
複製程式碼

Key 會劃分為兩個部分,一個是 sharedKey,一個是 unsharedKey。前者表示相對基準 Key 的共同字首內容,後者表示相對基準 Key 的不同字尾部分。

圖片
比如基準 Key 是 helloworld,那麼 hellouniverse 這個 Key 相對於基準 Key 來說,它的 sharedKey 就是 hello,unsharedKey 就是 universe。
圖片
DataBlock 中儲存的是連續的一系列鍵值對,它會每隔若干個 Key 設定一個基準 Key。基準 Key 的特點就是它的 sharedKey 部分是空串。基準 Key 的位置,也就是它在塊中的偏移量我們稱之為「重啟點」RestartPoint,在 DataBlock 中會記錄所有「重啟點」位置。第一個「重啟點」的位置是零,也就是 DataBlock 中的第一個 Key。

struct Entry {
  varint sharedKeyLength;
  varint unsharedKeyLength;
  varint valueLength;
  byte[] unsharedKeyContent;
  byte[] valueContent;
}

struct DataBlock {
  Entry[] entries;
  int32 [] restartPointOffsets;
  int32 restartPointCount;
}
複製程式碼

DataBlock 中基準 Key 是預設每隔 16 個 Key 設定一個。從節省空間的角度來說,這並不是一個智慧的策略。比如連續 26 個 Key 僅僅是最後一個字母不同,DataBlock 卻每隔 16 個 Key 強制「重啟」,這明顯不是最優的。這同時也意味著 sharedKey 是空串的 Key 未必就是基準 Key。

一個 DataBlock 的預設大小隻有 4K 位元組,所以裡面包含的鍵值對數量通常只有幾十個。如果單個鍵值對的內容太大一個 DataBlock 裝不下咋整?

這裡就必須糾正一下,DataBlock 的大小是 4K 位元組,並不是說它的嚴格大小,而是在追加完最後一條記錄之後發現超出了 4K 位元組,這時就會再開啟一個 DataBlock。這意味著一個 DataBlock 可以大於 4K 位元組,如果 value 值非常大,那麼相應的 DataBlock 也會非常大。DataBlock 並不會將同一個 Value 值分塊儲存。

FilterBlock 結構

如果沒有開啟布隆過濾器,FilterBlock 這個塊就是不存在的。FilterBlock 在一個 SSTable 檔案中可以存在多個,每個塊存放一個過濾器資料。不過就目前 LevelDB 的實現來說它最多隻能有一個過濾器,那就是布隆過濾器。

布隆過濾器用於加快 SSTable 磁碟檔案的 Key 定位效率。如果沒有布隆過濾器,它需要對 SSTable 進行二分查詢,Key 如果不在裡面,就需要進行多次 IO 讀才能確定,查完了才發現原來是一場空。布隆過濾器的作用就是避免在 Key 不存在的時候浪費 IO 操作。通過查詢布隆過濾器可以一次性知道 Key 有沒有可能在裡面。

圖片
單個布隆過濾器中存放的是一個定長的點陣圖陣列,該點陣圖陣列中存放了若干個 Key 的指紋資訊。這若干個 Key 來源於 DataBlock 中連續的一個範圍。FilterBlock 塊中存在多個連續的布隆過濾器點陣圖陣列,每個陣列負責指紋化 SSTable 中的一部分資料。

struct FilterEntry {
  byte[] rawbits;
}

struct FilterBlock {
  FilterEntry[n] filterEntries;
  int32[n] filterEntryOffsets;
  int32 offsetArrayOffset;
  int8 baseLg;  // 分割係數
}
複製程式碼

其中 baseLg 預設 11,表示每隔 2K 位元組(2<<11)的 DataBlock 資料(壓縮後),就開啟一個布隆過濾器來容納這一段資料中 Key 值的指紋。如果某個 Value 值過大,以至於超出了 2K 位元組,那麼相應的布隆過濾器裡面就只有 1 個 Key 值的指紋。每個 Key 對應的指紋空間在開啟資料庫時指定。

// 每個 Key 佔用 10bit 存放指紋資訊
options.SetFilterPolicy(levigo.NewBloomFilter(10))
複製程式碼

這裡的 2K 位元組的間隔是嚴格的間隔,這樣才可以通過 DataBlock 的偏移量和大小來快速定位到相應的布隆過濾器的位置 FilterOffset,再進一步獲得相應的布隆過濾器點陣圖資料。

至於為什麼 LevelDB 的布隆過濾器資料不是整個塊而是分成一段一段的,這個原因筆者也沒有完全整明白。期待有讀者可以提供思路。

MetaIndexBlock 結構

MetaIndexBlock 儲存了前面一系列 FilterBlock 的元資訊,它在結構上和 DataBlock 是一樣的,只不過裡面 Entry 儲存的 Key 是帶固定字首的過濾器名稱,Value 是對應的 FilterBlock 在檔案中的偏移量和長度。

key = "filter." + filterName
// value 定義了資料塊的位置和大小
struct BlockHandler {
  varint offset;
  varint size;
}
複製程式碼

就目前的 LevelDB,這裡面最多隻有一個 Entry,那麼它的結構非常簡單,如下圖所示

圖片

IndexBlock 結構

它和 MetaIndexBlock 結構一樣,也儲存了一系列鍵值對,每一個鍵值對儲存的是 DataBlock 的元資訊,SSTable 中有幾個 DataBlock,IndexBlock 中就有幾個鍵值對。鍵值對的 Key 是對應 DataBlock 內部最大的 Key,Value 是 DataBlock 的偏移量和長度。不考慮 Key 之間的字首共享,不考慮「重啟點」,它的結構如下圖所示

圖片

SSTable 的結構就講到這裡,下一節我們繼續觀察日誌檔案的結構

深入 LevelDB 資料檔案 SSTable 的結構

相關文章