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();
}
}
}
将这个目录里面的文件全部删掉,这个库就彻底清空了。
也许你会想到上面的例子中不是所有的数据最终都被删除了么,怎么还会有如此多的 sst 数据文件呢?这是因为 LevelDB 的删除操作并不是真的立即删除键值对,而是将删除操作转换成了更新操作写进去了一个特殊的键值对,这个键值对的值部分是一个特殊的删除标记。
待 LevelDB 在某种条件下触发数据合并(compact)时才会真的删除相应的键值对。
数据合并
LevelDB 提供了数据合并的手动调用 API,下面我们手动整理一下,看看整理后会发生什么
public void compactRange(byte[] begin, byte[] end)
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();
}
}
如果我们没有手工调用数据整理 API,LevelDB 内部也有一定的策略来定期整理。
读性能
如果我们将上面的代码加上时间打点,观察读写性能差异,你会发现一个有趣的现象,那就是写性能比读性能还要好,虽然本例中所有的读操作都是命中的。
put: 3150ms
get: 4128ms
delete: 1983ms
下面我们将读操作改成随机读,就会发现读写性能发生很大的差别
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);
}
WriteOptions wo = new WriteOptions();
wo.sync(i % 100 == 0);
普通写 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);
}
那为什么批量写还是会比普通写快一点呢?要回答这个问题就需要追踪 LevelDB 的源码,还在这部分逻辑比较简单,大家应该都可以理解,所以这里就直接贴出来了。
Status DB::Put(WriteOptions& opt, Slice& key, Slice& value) {
WriteBatch batch;
batch.Put(key, value);
return Write(opt, &batch);
}
我们再继续追踪 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
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
put: 57022ms
get: 37475ms
get: 58999ms
布隆过滤器在显著提升性能的同时,也是需要浪费一定的磁盘空间。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
options.compressionType(CompressionType.SNAPPY);
---------------
put:3804ms
get:11644ms
delete:2750m