千家信息网

LevelDB 代码撸起来!

发表于:2024-09-22 作者:千家信息网编辑
千家信息网最后更新 2024年09月22日,LevelDB 的大致原理已经讲完了,本节我们要亲自使用 Java 语言第三方库 leveldbjni 来实践一下 LevelDB 的各种特性。这个库使用了 Java Native Interface
千家信息网最后更新 2024年09月22日LevelDB 代码撸起来!

LevelDB 的大致原理已经讲完了,本节我们要亲自使用 Java 语言第三方库 leveldbjni 来实践一下 LevelDB 的各种特性。这个库使用了 Java Native Interface 计数将 C++ 实现的 LevelDB 包装成了 Java 平台 的 API。其它语言同样也是采用了类似 JNI 的技术来包装的 LevelDB。

Maven 依赖

下载下面的依赖包地址,你就可以得到一个支持全平台的 jar 包。LevelDB 在不同的操作系统平台会编译出不同的动态链接库形式,这个 jar 包将所有平台的动态链接库都包含进来了。

<dependencies>
<dependency>
<groupId>org.fusesource.leveldbjnigroupId>
<artifactId>leveldbjni-linux64artifactId>
<version>1.8version>
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("d", 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("d", 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("d", 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 的宏观结构开

数据 性能 快照 文件 过滤器 布隆 普通 明显 时间 磁盘 缓存 版本 运行 就是 数据库 还是 同步 例子 原理 差异 数据库的安全要保护哪些东西 数据库安全各自的含义是什么 生产安全数据库录入 数据库的安全性及管理 数据库安全策略包含哪些 海淀数据库安全审计系统 建立农村房屋安全信息数据库 易用的数据库客户端支持安全管理 连接数据库失败ssl安全错误 数据库的锁怎样保障安全 中创应用服务器 现在大家常用的软件开发模型 在线软件开发实验室网站 互联网科技公司薪酬体系 常用的网络安全包括哪些方面 微信软件开发类图片素材 科学技术振兴机构数据库 软件开发怎样做的 您试图访问的网络安全存在问题 外部网络安全保障措施 电子口岸数据库软件 cod16ps4无法连接服务器 澜起科技第四代津逮服务器 吉林运营网络技术服务怎么样 金格数据库 中间件 管理32台电脑的服务器 江苏项目软件开发哪家正规 网络安全优质主题班会课视频 请求服务器时间戳误差 数据库实验 查询优化 虹口区信息网络技术收费标准 戴尔服务器管理口做系统 服务器管理口无法连接 数据库系统 数据库软件 摩杜纳服务器空房 新版通达信如何迁移用户数据库 软件开发中ap什么意思 安装打印机出现rpc服务器错误 近五年软件开发专业就业报告 苏州阿里云服务器特征
0