LevelDB 入門 —— 全面瞭解 LevelDB 的功能特性

碼洞發表於2019-01-04

本節我們將全面瞭解一下 LevelDB 的各種特性。LevelDB 的開發語言是 C++,考慮到會使用 C++ 語言的同學不是很多,在本節我們將使用 Java 語言來描述 LevelDB 的特性。其它語言棧的同學也不必擔心,因為不同語言操縱 LevelDB 的介面 API 都是一樣的,使用起來大同小異。

開啟和關閉

LevelDB 的資料儲存在一個特定的目錄中,裡面有很多資料檔案、日誌檔案等。使用 LevelDB API 來開啟這個目錄,就得到了 db 的引用。後續我們就使用這個 db 引用來執行讀寫操作。下面的程式碼是 Java 語言描述的虛擬碼。

class LevelDB {
  public static LevelDB open(String dbDir, Options options);
  void close(); // 關閉資料庫
}
複製程式碼

開啟資料庫有很多選項可以配置,比如設定塊快取大小、壓縮等

基礎 API

LevelDB 用起來就像 HashMap,但是比 HashMap 要稍微弱一些,因為 put 方法不能返回舊值,delete 操作也不知道對應的 key 是否真的存在。

class LevelDB {
  byte[] get(byte[] key)
  void put(byte[] key, byte[] value)
  void delete(byte[] key)
  ...
}
複製程式碼

原子批處理

對於多個連續的寫操作如果因為當機有可能導致這多個連續的寫操作只完成了一部分。為此 LevelDB 提供了批處理功能,批處理操作就好比事務,LevelDB 確保這一些列寫操作的原子性執行,要麼全部生效要麼完全不生效。

class WriteBatch {
  void put(byte[] key, byte[] value);
  void delete(byte[] key);
}

class LevelDB {
  ...
  void write(WriteBatch wb);
}
複製程式碼

日誌檔案

當我們呼叫 LevelDB 的 put 方法往庫裡寫資料時,它會先將資料記錄到記憶體中,延後再通過某種特殊的策略持久化到磁碟。這就存在一個問題,如果突發當機,這些來不及寫到磁碟的資料就丟失了。所以 LevelDB 也採用了和 Redis AOF 日誌類似的策略,先講修改操作的日誌寫到磁碟檔案中,再進行實際的寫操作流程處理。

如此即使當機發生了,資料庫啟動時還可以通過日誌檔案來恢復。

安全寫(同步寫)

瞭解 Redis 的同學都知道它的 AOF 寫策略有多種配置,取決於日誌檔案同步磁碟的頻率。頻率越高,遇到當機時丟失的資料就越少。作業系統要將核心中檔案的髒資料同步到磁碟需要進行磁碟 IO,這會影響訪問效能,所以通常都不會同步的太頻繁。

LevelDB 也是類似的,如果使用前面的非安全寫,雖然 API 呼叫成功了,但是遇到當機問題,有可能對應的操作日誌會丟失。所以它提供了安全寫操作,代價就是效能會變差。

class LevelDB {
  ...
  void putSync(byte[] key, byte[] value);
  void deleteSync(byte[] key);
  void writeSync(WriteBatch wb);
}
複製程式碼

在安全和效能之間往往需要折中,所以通常我們會定時若干毫秒或者每隔若干寫操作使用一次同步寫。這樣可以在兼顧寫效能的同時儘量少丟失資料。

併發

LevelDB 的磁碟檔案會放在一個檔案目錄中,裡面有很多相關的資料和日誌檔案。它不支援多程式同時開啟這個目錄來使用 LevelDB API 進行讀寫訪問。但是對於同一個程式 LevelDB API 是支援多執行緒安全讀寫的。LevelDB 內部會使用特殊的鎖來控制併發操作。

遍歷

LevelDB 中的 Key 都是有序的,按照字典序從小到大整齊排列。LevelDB 提供了遍歷 API 可以逐個順序訪問所有的鍵值對,可以指定從中間開始遍歷。

class LevelDB {
  ...
  Iterator<KV> scan(byte[] startKey, byte[] endKey, int limit);
}
複製程式碼

快照隔離

LevelDB 支援多執行緒併發讀寫,這意味著連續的兩個同樣 key 的讀操作讀到的資料可能不一樣,因為兩個讀操作中間資料可能被其它執行緒修改了。這在資料庫理論中稱為「重複讀」。LevelDB 提供了快照隔離機制,在同一個快照範圍內保證連續的讀寫操作不受其它執行緒修改操作的影響。

class Snapshot {
  byte[] get(byte[] key)
  void put(byte[] key, byte[] value)
  void delete(byte[] key)
  void write(WriteBatch wb);
  ...
  void close();  // 關閉快照
}

class LevelDB {
  ...
  Snapshot getSnapshot();
}
複製程式碼

快照雖然很神奇,但是實際上它的原理非常簡單,這個我們後文再深入講解。

自定義 Key 比較器

LevelDB 的 key 預設使用字典序,不過它也提供了自定義排序規則。你可以自定義一個排序函式註冊進去,比如按數字排序。必須儘可能確保排序規則在整個資料庫生命週期內保持不變,因為排序會影響到磁碟鍵值對的儲存順序,磁碟儲存順序是無法動態改變的。

Options options = new Options();
options.comparator = new CustomComparator();
db = LevelDB.open("/tmp/ldb", options);
複製程式碼

自定義比較器很危險,謹慎使用。比較演算法設定不當,會嚴重影響到儲存效率。如果確實必須要改變排序規則,那就需要提前規劃,這裡會有一個特別的小技巧,理解它需要了解磁碟儲存的細節,所以我們後續再仔細探討。

資料塊

LevelDB 的磁碟資料是以資料庫塊的形式儲存的,預設的塊大小是 4k。適當提升塊大小將有益於批量大規模遍歷操作的效率,如果隨機讀比較頻繁,這時候塊小點效能又會稍好,這就要求我們自己去折中選擇。

Options options = new Options();
options.blockSize = 8092;
db = LevelDB.open("/tmp/ldb", options);
複製程式碼

塊不宜過小低於 1k,也不宜過大設定成了好幾 M,這樣過激的設定並不會給效能帶來多大的提升,反而會大幅增加資料庫在不同的讀寫場合的效能波動。我們要選擇中庸之道,在預設塊大小周邊浮動。塊大小一經初始化就不可再次更改。

壓縮

LevelDB 的磁碟儲存預設是開啟壓縮的,是業界常用的 Snappy 演算法,壓縮效率非常高,所以無需擔心效能損耗問題。如果你不想使用壓縮,也可以動態關閉。關閉壓縮開關通常不會帶來明顯的效能提升,所以我們儘可能不要去動它。

Options options = new Options();
options.compression = CompressionType.kSnappyCompression;
// options.compression = CompressionType.kNoCompression; // 關閉壓縮
db = LevelDB.open("/tmp/ldb", options);
複製程式碼

塊快取

LevelDB 的記憶體中儲存了一筆最近讀寫的熱資料,如果請求的資料在熱資料中查不到就需要去磁碟檔案中去查詢,效率就會大幅降低。LevelDB 為了降低磁碟檔案的搜尋次數,增加了塊快取,快取了近期頻繁使用的資料塊解壓縮之後的內容。

Options options = new Options();
options.blockCache = LevelDB.NewLRUCache(100 * 1024 * 1024); // 100M
db = LevelDB.open("/tmp/ldb", options);
複製程式碼

預設塊快取不開啟,開啟資料庫時可以手動設定選項。塊快取會佔據一部分記憶體,不過這通常不需要設定太大,100M 左右就差不多了,再大一些效率提升的也不明顯了。

還需要注意遍歷操作對快取的影響,為了避免遍歷操作將很多冷門資料刷到塊快取中,可以在遍歷的時候設定一個選項 fill_cache,它用來控制磁碟遍歷的資料塊是否需要同步到快取。

布隆過濾器

記憶體讀 miss 導致磁碟搜尋是一個比較耗時的操作,LevelDB 為了進一步減少磁碟讀的次數,在每個磁碟檔案上又加了一層布隆過濾器,它需要消耗一定的磁碟空間,但是在效果上可以直接將磁碟讀次數大幅減少。布隆過濾器的資料儲存在磁碟檔案中資料塊的後面。

LevelDB 的磁碟檔案是分層儲存的,它會先去 Level 0 查詢,如果找不到繼續去 Level 1 去找,一直遞迴到最底層。所以如果你去找一個不存在的 key,就需要很多次磁碟檔案讀操作,會非常耗費時間。而布隆過濾器可以幫你省去95%以上的磁碟檔案搜尋的時間。

布隆過濾器類似於一個記憶體 Set 結構,它裡面儲存了指定磁碟檔案一定範圍內所有 Key 的指紋資訊。當它發現某個 key 的指紋在 Set 集合裡找不到,它就可以斷定這個 key 肯定不存在。

如果對應的指紋可以在集合裡找到,這並不能確定它就一定存在。因為不同的 Key 可能會生成同樣的指紋,這就是布隆過濾器的誤判率。誤判率越低需要的 Key 指紋資訊越多,對應消耗的記憶體空間也就越大。

如果布隆過濾器能準確知道某個 Key 是否存在,那就不存在誤判了,這時候也就不會存在白白浪費的磁碟讀操作。這樣的極限形式的布隆過濾器就是 HashSet —— 記憶體裡儲存了所有的 Key,當然記憶體空間自然是無法接受的。

Options options = new Options();
// 每個 key 的指紋大小是 10bit
options.filterPolicy = LevelDB.NewBloomFilterPolicy(10);
db = LevelDB.open("/tmp/ldb", options);
複製程式碼

在使用布隆過濾器時,我們需要在記憶體消耗和效能之間做一個折中選擇。如果你想深入理解布隆過濾器的原理,可以去看《Redis 深度歷險》,裡面有一個單獨的章節專門講解布隆過濾器的內部原理。

預設布隆過濾器沒有開啟,需要在開啟資料庫的時候設定 filter_policy 引數才可以生效。布隆過濾器是減少磁碟讀操作的最後一層堡壘。布隆過濾器內部的點陣圖資料會儲存在磁碟檔案中,但是使用是會快取在記憶體裡面。

資料校驗

LevelDB 有嚴格的資料校驗機制,它將校驗的單位精確到了 4K 位元組的資料塊。校驗和會浪費一點儲存空間和計算時間,但是在遇到資料塊損壞時可以較為精確地恢復健康的資料。

class LevelDB {
  ...
  public void static repairDB(String dbDir, Options options);
}
複製程式碼

開啟資料庫時預設沒有開啟強制校驗選項,如果開啟了,在遇到校驗錯誤時就會報錯。如果資料真的出現了問題,LevelDB 還提供了修復資料的方法 repairDB() 可以幫我們恢復儘可能多的資料。

小結

經過了這一節的學習,同學們應該可以在腦海中形成下面這樣一張概念圖。圖中的「熱資料」是指最近被修改的鍵值對,這裡面的鍵值對讀取速度是最為快速的。如果熱資料中讀取不到,就會去塊快取中讀取。如果還讀不到,就分兩種情況,一種是真的不存在,另一個種是存在於磁碟上。如果存在於磁碟上,經過有限層次讀取就讀取到了,通常越冷的資料越在底層。如果真的不存在就要經過布隆過濾器來大幅減少磁碟搜尋 IO,布隆過濾器的資料和鍵值對資料共同放在分層的資料檔案中。

圖片

下一節我們使用真實的程式碼來親自實踐一下 LevelDB。

LevelDB 入門 —— 全面瞭解 LevelDB 的功能特性

相關文章