本節我們將全面了解一下 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。

推薦閱讀:

相关文章