我的编程空间,编程开发者的网络收藏夹
学习永远不晚

LevelDB 代码撸起来!

短信预约 -IT技能 免费直播动态提醒
省份

北京

  • 北京
  • 上海
  • 天津
  • 重庆
  • 河北
  • 山东
  • 辽宁
  • 黑龙江
  • 吉林
  • 甘肃
  • 青海
  • 河南
  • 江苏
  • 湖北
  • 湖南
  • 江西
  • 浙江
  • 广东
  • 云南
  • 福建
  • 海南
  • 山西
  • 四川
  • 陕西
  • 贵州
  • 安徽
  • 广西
  • 内蒙
  • 西藏
  • 新疆
  • 宁夏
  • 兵团
手机号立即预约

请填写图片验证码后获取短信验证码

看不清楚,换张图片

免费获取短信验证码

LevelDB 代码撸起来!

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

LevelDB 代码撸起来!

Maven 依赖

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

<dependencies>
  <dependency>
    <groupId>org.fusesource.leveldbjni</groupId>
    <artifactId>leveldbjni-linux64</artifactId>
    <version>1.8</version>
  </dependency>
</dependencies>
LevelDB 代码撸起来!

增查删 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 都创建了那些东西

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 文件没有了

LevelDB 代码撸起来!


如果我们没有手工调用数据整理 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 的宏观结构开

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

LevelDB 代码撸起来!

下载Word文档到电脑,方便收藏和打印~

下载Word文档

猜你喜欢

JavaScript 代码优化技巧:让你的代码飞起来!

JavaScript 代码优化技巧可以帮助开发者提高代码运行效率、优化用户体验。通过合理使用数据结构、选择合适的算法、优化代码结构、利用浏览器特性等方式,可以显著提升 JavaScript 代码的性能。
JavaScript 代码优化技巧:让你的代码飞起来!
2024-02-27

PHPStorm 大揭秘:让你的 PHP 代码飞起来

PHPStorm 是一款强大且全面的 PHP 集成开发环境 (IDE),可显著提高 PHP 开发人员的效率和生产力。通过其先进的功能和直观的界面,PHPStorm 赋能开发者快速构建、调试和维护高质量的 PHP 代码。
PHPStorm 大揭秘:让你的 PHP 代码飞起来
2024-03-03

iOS撸一个简单路由Router的实现代码

平常开发中用户点击头像, 进入个人主页,这看似平常的操作, 背后极有可能会牵扯到多个模块。 再如: 视频模块的播放页, 有与视频相关的音乐,点击这些音乐,需要跳转到音乐模块的播放页, 这样视频与音乐模块之间,不可避免的会产生依赖或耦合。 这
2022-05-24

揭秘 Python 性能优化秘籍,让你的代码飞起来!

Python 作为一门优雅、高效的编程语言,在性能优化方面蕴藏着巨大的潜力。本文将深入揭秘 Python 性能优化秘籍,助你将代码提升至新的高度,让你的代码飞起来!
揭秘 Python 性能优化秘籍,让你的代码飞起来!
2024-02-15

iOS使用核心的50行代码撸一个路由组件

目录组件化和路由路由的实现路由注册实现客户端的使用一些小想法使用组件化是为了解耦处理,多个模块之间通过协议进行交互。而负责解析协议,找到目的控制器,或者是返回对象给调用者的这个组件就是路由组件。本文讲解如何使用核心的50行代码实现一个路由组
2022-05-31

VUE父子组件通信的艺术:让你的代码飞起来

在Vue中,父子组件通信是开发过程中不可避免的问题,本文将介绍Vue父子组件通信的多种方法,并通过演示代码讲解如何实现。
VUE父子组件通信的艺术:让你的代码飞起来
2024-02-07

JavaScript 控制流程:让代码动起来,掌控程序的节奏

JavaScript 的控制流程是让代码按顺序执行,还是按照特定的条件反复执行,从而实现程序的逻辑和功能。
JavaScript 控制流程:让代码动起来,掌控程序的节奏
2024-02-04

怎么写出让人看起来很优雅舒服的python代码

这篇文章将为大家详细讲解有关怎么写出让人看起来很优雅舒服的python代码,文章内容质量较高,因此小编分享给大家做个参考,希望大家阅读完这篇文章后对相关知识有一定的了解。很多新手在开始学一门新的语言的时候,往往会忽视一些不应该忽视的细节,比
2023-06-02

html中如何把密码隐藏起来

这篇文章给大家分享的是有关html中如何把密码隐藏起来的内容。小编觉得挺实用的,因此分享给大家做个参考,一起跟随小编过来看看吧。在HTML中,可以使用type属性将密码隐藏起来,只需要给input元素添加“type="password"”代
2023-06-15

Java Lambda 表达式的性能优化技巧:让你的代码飞起来

Java Lambda 表达式作为一种简洁而强大的语法糖,因其匿名函数的特性而深受开发人员的喜爱。但 Lambda 表达式也存在着一定的性能瓶颈,尤其是在处理大量数据时,可能会导致应用程序性能下降。本文将介绍一些针对 Java Lambda 表达式的性能优化技巧,帮助开发人员充分发挥 Lambda 表达式的优势,让代码飞起来。
Java Lambda 表达式的性能优化技巧:让你的代码飞起来
2024-02-26

编程热搜

目录