LevelDB 程式碼擼起來!
LevelDB 的大致原理已經講完了,本節我們要親自使用 Java 語言第三方庫 leveldbjni 來實踐一下 LevelDB 的各種特性。這個庫使用了 Java Native Interface 計數將 C++ 實現的 LevelDB 包裝成了 Java 平臺 的 API。其它語言同樣也是採用了類似 JNI 的技術來包裝的 LevelDB。
Maven 依賴
下載下面的依賴包地址,你就可以得到一個支援全平臺的 jar 包。LevelDB 在不同的作業系統平臺會編譯出不同的動態連結庫形式,這個 jar 包將所有平臺的動態連結庫都包含進來了。
<dependencies>
<dependency>
<groupId>org.fusesource.leveldbjni</groupId>
<artifactId>leveldbjni-linux64</artifactId>
<version>1.8</version>
</dependency>
</dependencies>
增查刪 API
這個例子中我們將自動建立一個 LevelDB 資料庫,然後往裡面塞入 100w 條資料,再取出來,再刪掉所有資料。這個例子在我的 Mac 上會執行了大約 10s 的時間。也就是說讀寫平均 QPS 高達 30w/s,完全可以媲美 Redis,不過這大概也是因為鍵值對都比較小,在實際生產環境中效能應該沒有這麼高,它至少應該比 Redis 稍慢一些。
import static org.fusesource.leveldbjni.JniDBFactory.factory;
import java.io.File;
import java.io.IOException;
import org.iq80.leveldb.DB;
import org.iq80.leveldb.Options;
public class Sample {
public static void main(String[] args) throws IOException {
Options options = new Options();
options.createIfMissing(true);
DB db = factory.open(new File("/tmp/lvltest"), options);
try {
for (int i = 0; i < 1000000; i++) {
byte[] key = new String("key" + i).getBytes();
byte[] value = new String("value" + i).getBytes();
db.put(key, value);
}
for (int i = 0; i < 1000000; i++) {
byte[] key = new String("key" + i).getBytes();
byte[] value = db.get(key);
String targetValue = "value" + i;
if (!new String(value).equals(targetValue)) {
System.out.println("something wrong!");
}
}
for (int i = 0; i < 1000000; i++) {
byte[] key = new String("key" + i).getBytes();
db.delete(key);
}
} finally {
db.close();
}
}
}
我們再觀察資料庫的目錄中,LevelDB 都建立了那些東西
這個目錄裡我們看到了有很多 sst 副檔名的檔案,它就是 LevelDB 的磁碟資料檔案,有些 LevelDB 的版本資料檔案的副檔名是 ldb,不過內部格式一樣,僅僅是副檔名不同而已。其中還有一個 log 副檔名的檔案,這就是操作日誌檔案,記錄了最近一段時間的操作日誌。其它幾個大些名稱檔案我們先不必去了解,後續我們再仔細解釋。
將這個目錄裡面的檔案全部刪掉,這個庫就徹底清空了。
也許你會想到上面的例子中不是所有的資料最終都被刪除了麼,怎麼還會有如此多的 sst 資料檔案呢?這是因為 LevelDB 的刪除操作並不是真的立即刪除鍵值對,而是將刪除操作轉換成了更新操作寫進去了一個特殊的鍵值對,這個鍵值對的值部分是一個特殊的刪除標記。
待 LevelDB 在某種條件下觸發資料合併(compact)時才會真的刪除相應的鍵值對。
資料合併
LevelDB 提供了資料合併的手動呼叫 API,下面我們手動整理一下,看看整理後會發生什麼
public void compactRange(byte[] begin, byte[] end)
這個 API 可以選擇範圍進行整理,如果 begin 引數為 null,那就表示從頭開始,如果 end 引數為 null,那就表示一直到尾部。
public static void main(String[] args) throws IOException {
Options options = new Options();
options.createIfMissing(true);
DB db = factory.open(new File("/tmp/lvltest"), options);
try {
db.compactRange(null, null);
} finally {
db.close();
}
}
執行了大約 1s 多點時間,完畢後我們看到目錄中 sst 檔案沒有了
如果我們沒有手工呼叫資料整理 API,LevelDB 內部也有一定的策略來定期整理。
讀效能
如果我們將上面的程式碼加上時間打點,觀察讀寫效能差異,你會發現一個有趣的現象,那就是寫效能比讀效能還要好,雖然本例中所有的讀操作都是命中的。
put: 3150ms
get: 4128ms
delete: 1983ms
這是因為寫操作記完操作日誌將資料寫進記憶體後就返回了,而讀操作有可能記憶體讀 miss,然後要去磁碟讀。之所以讀寫效能差距不是非常明顯,是因為 LevelDB 會快取最近一次讀取的資料塊,而且我的個人電腦的磁碟是 SSD 磁碟,讀效能都好。如果是普通磁碟,就會看出明顯的效能差異了。
下面我們將讀操作改成隨機讀,就會發現讀寫效能發生很大的差別
for (int i = 0; i < 1000000; i++) {
int index = ThreadLocalRandom.current().nextInt(1000000);
byte[] key = new String("key" + index).getBytes();
db.get(key);
}
--------
put: 3094ms
get: 9781ms
delete: 1969ms
這時要改善讀效能就可以藉助塊快取了
// 設定 100M 的塊快取
options.cacheSize(100 * 1024 * 1024);
------------
put: 2877ms
get: 4758ms
delete: 1981ms
同步 vs 非同步
上一節我們提到 LevelDB 還提供了同步寫的 API,確保操作日誌落地後才 put 方法才返回。它的效能會明顯弱於普通寫操作,下面我們來對比一下兩者的效能差異。
public static void main(String[] args) throws IOException {
long start = System.currentTimeMillis();
Options options = new Options();
options.createIfMissing(true);
DB db = factory.open(new File("/tmp/lvltest"), options);
try {
for (int i = 0; i < 1000000; i++) {
byte[] key = new String("key" + i).getBytes();
byte[] value = new String("value" + i).getBytes();
WriteOptions wo = new WriteOptions();
wo.sync(true);
db.put(key, value, wo);
}
} finally {
db.close();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
上面這個同步寫操作足足花了 90s 多的時間。將 sync 選項去掉後,只需要 3s 多點。效能差距高達 30 倍。下面我們來簡單改造一下上面的程式碼,讓它變成間隔同步寫,也就是每隔 N 個寫操作同步一次,取 N = 100。
WriteOptions wo = new WriteOptions();
wo.sync(i % 100 == 0);
執行時間變成了不到 5s。再將 N 改成 10,執行時間變成了不到 12s。即使是 12s,寫的平均 QPS 也高達 8w/s,這還是很客觀的。
普通寫 VS 批次寫
LevelDB 提供了批次寫操作,它會不會類似於 Redis 的管道可以加快指令的執行呢,下面我們來嘗試使用 WriteBatch,對比一下普通的寫操作,看看效能差距有多大。
public static void main(String[] args) throws IOException {
long start = System.currentTimeMillis();
Options options = new Options();
options.createIfMissing(true);
DB db = factory.open(new File("/tmp/lvltest"), options);
try {
WriteBatch batch = db.createWriteBatch();
for (int i = 0; i < 1000000; i++) {
byte[] key = new String("key" + i).getBytes();
byte[] value = new String("value" + i).getBytes();
batch.put(key, value);
if (i % 100 == 0) {
db.write(batch);
batch.close();
batch = db.createWriteBatch();
}
}
db.write(batch);
batch.close();
} finally {
db.close();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
將批次數量 N 分別改成 10、100、1000,執行後可以發現耗時差不多,大約都是 2s 多點。這意味著批次寫並不會大幅提升寫操作的吞吐量。但是將 N 改成 1 後你會發現耗時和普通寫操作相差無幾,大約是 3s 多,再將 N 改成 2、5 等,耗時還是會有所降低,到 2s 多 左右就穩定了,此時提升 N 值不再有明顯效果。這意味著批次寫操作確實會比普通寫快一點,但是相差也不會過大。它不同於 Redis 的管道可以大幅減少網路開銷帶來的明顯效能提升,LevelDB 是純記憶體資料庫,根本談不上網路開銷。
那為什麼批次寫還是會比普通寫快一點呢?要回答這個問題就需要追蹤 LevelDB 的原始碼,還在這部分邏輯比較簡單,大家應該都可以理解,所以這裡就直接貼出來了。
Status DB::Put(WriteOptions& opt, Slice& key, Slice& value) {
WriteBatch batch;
batch.Put(key, value);
return Write(opt, &batch);
}
很明顯,每一個普通寫操作最終都會被轉換成一個批次寫操作,只不過 N=1 。這正好解釋了為什麼當 N=1 時批次寫操作和普通寫操作相差無幾。
我們再繼續追蹤 WriteBatch 的原始碼我發現每一個批次寫操作都需要使用互斥鎖。當批次 N 值比較大時,相當於加鎖的平均次數減少了,於是整體效能就提升了。但是也不會提升太多,因為加鎖本身的損耗佔比開銷也不是特別大。這也意味著在多執行緒場合,寫操作效能會下降,因為鎖之間的競爭將導致內耗增加。
為什麼說批次寫可以保證內部一系列操作的原子性呢,就是因為這個互斥鎖的保護讓寫操作單執行緒化了。因為這個粗粒度鎖的存在,LevelDB 寫操作的效能被大大限制了。這也成了後來居上的 RocksDB 重點最佳化的方向。
快照和遍歷
LevelDB 提供了快照讀功能可以保證同一個快照內同一個 Key 讀到的資料保持一致,避免「不可重複讀」的發生。下面我們使用快照來嘗試一下遍歷操作,在遍歷的過程中順便還修改對應 Key 的值,看看快照讀是否可以隔離寫操作。
public static void main(String[] args) throws IOException {
Options options = new Options();
options.createIfMissing(true);
DB db = factory.open(new File("/tmp/lvltest"), options);
try {
for (int i = 0; i < 10000; i++) {
String padding = String.format("%04d", i);
byte[] key = new String("key" + padding).getBytes();
byte[] value = new String("value" + padding).getBytes();
db.put(key, value);
}
Snapshot ss = db.getSnapshot();
// 掃描
scan(db, ss);
// 修改
for (int i = 0; i < 10000; i++) {
String padding = String.format("%04d", i);
byte[] key = new String("key" + padding).getBytes();
byte[] value = new String("!value" + padding).getBytes(); // 修改
db.put(key, value);
}
// 再掃描
scan(db, ss);
ss.close();
} finally {
db.close();
}
}
private static void scan(DB db, Snapshot ss) throws IOException {
ReadOptions ro = new ReadOptions();
ro.snapshot(ss);
DBIterator it = db.iterator(ro);
int k = 0;
// it.seek(someKey); // 從指定位置開始遍歷
it.seekToFirst(); // 從頭開始遍歷
while (it.hasNext()) {
Entry<byte[], byte[]> entry = it.next();
String key = new String(entry.getKey());
String value = new String(entry.getValue());
String padding = String.format("%04d", k);
String targetKey = new String("key" + padding);
String targetVal = new String("value" + padding);
if (!targetKey.equals(key) || !targetVal.equals(value)) {
System.out.printf("something wrong");
}
k++;
}
System.out.printf("total %d\n", k);
it.close();
}
--------------------
total 10000
total 10000
前後兩次遍歷從快照中獲取到的資料還是一致的,也就是說中間的寫操作根本沒有影響到快照的狀態,這就是我們想要的結果。那快照的原理是什麼呢?
快照的原理其實非常簡單,簡單到讓人懷疑人生。對於庫中的每一個鍵值對,它會因為修改操作而存在多個值的版本。在資料庫檔案內容合併之前,同一個 Key 可能會存在於多個檔案中,每個檔案中的值版本不一樣。這個版本號是由資料庫唯一的全域性自增計數值標記的。快照會記錄當前的計數值,在當前快照裡讀取的資料都需要和快照的計數值比對,只有小於這個計數值才是有效的資料版本。
既然同一個 Key 存在多個版本的資料,對於同一個 Key,遍歷操作是如何避免重複的呢?關於這個問題我們後續再深入探討。
布隆過濾器
leveldbjni 沒有封裝 LevelDB 提供的布隆過濾器功能。所以為了嘗試布隆過濾器的效果,我們需要試試其它語言,這裡我使用 Go 語言的 levigo 庫。
// 安裝 leveldb和snappy庫
$ brew install leveldb
// 再安裝 levigo
$ go get github.com/jmhodges/levigo
這個例子中我們將寫入更多的資料 —— 1000w 條,當資料量增多時,LevelDB 將形成更深的層級。同時為了構造出讀 miss 的效果,我們寫入偶數的鍵值對,然後再隨機讀取奇數的鍵值對。再對比增加布隆過濾器前後的讀效能差異。
package main
import (
"fmt"
"math/rand"
"time"
"github.com/jmhodges/levigo"
)
func main() {
options := levigo.NewOptions()
options.SetCreateIfMissing(true)
// 每個 key 佔用 10個bit
// options.SetFilterPolicy(levigo.NewBloomFilter(10))
db, _ := levigo.Open("/tmp/lvltest", options)
start := time.Now().UnixNano()
for i := 0; i < 10000000; i++ {
key := []byte(fmt.Sprintf("key%d", i*2))
value := []byte(fmt.Sprintf("value%d", i*2))
wo := levigo.NewWriteOptions()
db.Put(wo, key, value)
}
duration := time.Now().UnixNano() - start
fmt.Println("put:", duration/1e6, "ms")
start = time.Now().UnixNano()
for i := 0; i < 10000000; i++ {
index := rand.Intn(10000000)
key := []byte(fmt.Sprintf("key%d", index*2+1))
ro := levigo.NewReadOptions()
db.Get(ro, key)
}
duration = time.Now().UnixNano() - start
fmt.Println("get:", duration/1e6, "ms")
start = time.Now().UnixNano()
for i := 0; i < 10000000; i++ {
key := []byte(fmt.Sprintf("key%d", i*2))
wo := levigo.NewWriteOptions()
db.Delete(wo, key)
}
duration = time.Now().UnixNano() - start
fmt.Println("get:", duration/1e6, "ms")
}
-----------
put: 61054ms
get: 104942ms
get: 47269ms
再去掉註釋,開啟布隆過濾器,觀察結果
put: 57653ms
get: 36895ms
get: 57554ms
可以明顯看出,讀效能提升了 3 倍,這是一個非常了不起的效能提升。在讀 miss 開啟了布隆過濾器的情況下,我們再試試開啟塊快取,看看是否還能再繼續提升讀效能
put: 57022ms
get: 37475ms
get: 58999ms
結論是在讀 miss 開啟了布隆過濾器場景下塊快取幾乎不起作用。但是這並不是說塊快取沒有用,在讀命中的情況下,塊快取的作用還是很大的。
布隆過濾器在顯著提升效能的同時,也是需要浪費一定的磁碟空間。LevelDB 需要將布隆過濾器的二進位制資料儲存到資料塊中,不過布隆過濾器的空間佔比相對而言不是很高,完全在可接受範圍之內。
壓縮
LevelDB 的壓縮演算法採用 Snappy,這個演算法解壓縮效率很高,在壓縮比相差不大的情況下 CPU 消耗很低。官方不建議關閉壓縮演算法,不過經過我的測試發現,關閉壓縮確實可以顯著提升讀效能。不過關閉了壓縮,這也意味著你的磁碟空間要浪費好幾倍,這代價也不低。
public static void main(String[] args) throws IOException {
Options options = new Options();
options.createIfMissing(true);
options.compressionType(CompressionType.None);
DB db = factory.open(new File("/tmp/lvltest"), options);
try {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
byte[] key = new String("key" + 2 * i).getBytes();
byte[] value = new String("value" + 2 * i).getBytes();
db.put(key, value);
}
long duration = System.currentTimeMillis() - start;
System.out.printf("put:%dms\n", duration);
start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
int index = ThreadLocalRandom.current().nextInt(1000000);
byte[] key = new String("key" + (2 * index + 1)).getBytes();
db.get(key);
}
duration = System.currentTimeMillis() - start;
System.out.printf("get:%dms\n", duration);
start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
byte[] key = new String("key" + 2 * i).getBytes();
db.delete(key);
}
duration = System.currentTimeMillis() - start;
System.out.printf("delete:%dms\n", duration);
} finally {
db.close();
}
}
----------------
put:3785ms
get:6475ms
delete:1935ms
下面我們再開啟壓縮,對比一下結果,讀效能差距接近 1 倍
options.compressionType(CompressionType.SNAPPY);
---------------
put:3804ms
get:11644ms
delete:2750m
下一節將開始深入 LevelDB 實現原理,先從 LevelDB 的宏觀結構開
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31561269/viewspace-2374672/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 前端框架擼起來——概述前端框架
- 前端框架擼起來——根元件前端框架元件
- 一起來擼個簡易的小程式框架框架
- 前端框架擼起來——元件和路由前端框架元件路由
- leveldb 程式碼閱讀三
- leveldb程式碼精讀 插入操作
- leveldb程式碼精讀 lru cache
- 小程式這件事 擼起袖子加油幹
- leveldb程式碼精讀 記憶體池Arena記憶體
- 一起來寫段JS drag程式碼JS
- Leveldb原始碼分析--1原始碼
- 有沒得老闆給活幹,擼擼程式碼,義工!!!
- LevelDB 原始碼解析之 Arena原始碼
- LevelDB 原始碼解析之 Varint 編碼原始碼
- LevelDB原始碼分析:理解Slice實現 - 高效的LevelDB引數物件原始碼物件
- 你試過不用if擼程式碼嗎?
- DRF類檢視讓你的程式碼DRY起來
- 程式碼複審1234跟我一起來
- leveldb原始碼分析(2)-bloom filter原始碼OOMFilter
- LSM-Tree - LevelDb 原始碼解析原始碼
- 九宮格抽獎–手擼程式碼
- 介面測試之DDT,純程式碼實戰,學起來
- 來來來,快速擼 Redis 一遍!Redis
- 把小程式連結起來
- leveldb程式碼精讀 資料庫啟動和初始化資料庫
- [續更]一起來擼一下Flex佈局裡面的那些屬性Flex
- Playground中擼Swift程式碼很慢怎麼辦?Swift
- SpringBoot程式碼生成器,從此不用手擼程式碼Spring Boot
- Android 擼起袖子,自己封裝 DialogFragmentAndroid封裝Fragment
- LevelDB學習筆記 (1):初識LevelDB筆記
- LevelDB 原始碼解析之 Random 隨機數原始碼random隨機
- 一行程式碼讓你的TableView動起來-iOS動畫行程ViewiOS動畫
- javascript擼來擼去(1)-事件監聽與物件屬性JavaScript事件物件
- LevelDB 入門 —— 全面瞭解 LevelDB 的功能特性
- 一起擼個環形 Android 圖表Android
- 快刀斬亂麻,DevOps讓程式碼評審也自動起來dev
- 擼了那麼多程式碼,你真的瞭解字型?
- 終於可以愉快的擼Java非同步程式碼了!Java非同步