设为首页 加入收藏

TOP

key / value 数据库的选型(一)
2018-07-18 09:21:54 】 浏览:418
Tags:key value 数据库 选型

引言

一直以来在我的观念中,key/value 数据库就三种选项:

  • 内存可存放:Redis
  • 单机磁盘可存放:RocksDB
  • 超过 TB 级:Cassandra、HBase……

然而在实际项目中使用 RocksDB 时,才发现了一堆问题,折腾许久才搞定。

使用 RocksDB 的背景

先介绍下我使用 RocksDB 的背景。

这个项目有很多 key/value 数据(约 100 GB)需要使用,使用时基本是只读的,偶尔更新时才会批量导入,且可以忍受短暂的停机导入。我一想 TiKV 和 Pika 等很多 key/value 数据库都选用了 RocksDB,应该是比较靠谱的,于是就选它了。

接着就发现这东西的编译依赖有点多。我的项目是用 Go 写的,而这个玩意需要安装一堆 C 库,并且不能交叉编译到其他平台。不但不能在 Mac OS X 上编译 Linux 的版本,甚至 Ubuntu 16.04 上编译的都不能在 CentOS 7 上运行(后者的 GCC 版本较低,动态链接库版本也低)。因为懒得降级 GCC,最后我选择用 docker 来编译了。

而且我发现数据量小时还挺快,但是数据量大了就越来越慢。平时插入 10 万条数据(约 50 MB)大概 0.2 ~ 0.5 秒,但一段时间后就会持续遇到需要几秒甚至几十秒的情况。

于是我又开始寻找其他的替代品,诸如 LMDBBadgerDB 和 TerarkDB 等。在这个过程中我开始了解到它们实现的原理,也算有不少收获。

传统的关系型数据库大多是使用 B+ 树,这种数据结构可以很快地进行顺序读写,也能以 O(log(N)) 的时间复杂度来进行随机读,但不适合随机写(会导致 B+ 树重新调整平衡,造成写放大)。

而 LevelDB 引入了 LSM 树,就是为了解决 B+ 树随机写性能低的问题,它把随机写以跳跃表的形式保留在内存中(memtable),积累到足够的大小就不再改写它了,并将其写入到磁盘(L0 SST file),这样就只有顺序写了。因为 memtable 和 L0 中的数据可能会重复,而且 key 很分散,所以搜索时需要遍历它们。如果没找到的话,还要向下层查找(关于层数下文会解释),不过 L1 之后的 SST file 都是有序分段的,因此可以用二分查找来找到 key 所在的数据文件,再在这个文件中用二分查找来找到这个 key。为了降低搜索的代价,RocksDB 还使用了 Bloom filter 来判断数据是否在某个文件中(有误判,但能显著减少需要搜索的文件数)。由此可见,LSM 树对写入做了优化,但降低了随机读的性能,顺序读则和 B+ 树差不多。

此外,L0 的数据可能会有很多过期数据(被更新或删除过),因此需要在达到阈值后进行合并(compact),去掉这些重复和无效的数据,并写入 L1。而 L1 也可能会有过期的数据,也需要被合并写入 L2……这就相当于数据要多次写入不同的文件中,也就造成了写放大。而合并不重叠的数据文件是很快的,因此顺序写还是要比随机写快,但合并可以在其他线程中执行,在不会持续随机写入大量数据的情况下,基本能保持 O(1) 的写入。

事实上,我遇到的 RocksDB 变慢的问题就是 compact 引起的,默认配置下它只使用一个线程来 compact,如果 compact 跟不上写入的速度,RocksDB 就会降低写入速度,甚至停止写入。考虑到我的电脑有 4 个核,于是我把线程数改成了 4:

bbto := gorocksdb.NewDefaultBlockBasedTableOptions()
opts := gorocksdb.NewDefaultOptions()
opts.SetBlockBasedTableFactory(bbto)
opts.SetCreateIfMissing(true)
opts.OptimizeLevelStyleCompaction(1 << 30)
opts.IncreaseParallelism(4)
opts.SetMaxBackgroundCompactions(4)
env := gorocksdb.NewDefaultEnv()
env.SetBackgroundThreads(4)
opts.SetEnv(env)
db, err := gorocksdb.OpenDb(opts, "db")

修改后就发现插入时间基本稳定在 0.2 ~ 0.5 秒之间了,CPU 占用率超过了 400%,磁盘写入速度超过了 800 MB/s……
另外,RocksDB 还提供了 db.GetProperty("rocksdb.stats") 这个方法来查看状态,需要关注的数据主要有 W-Amp(写入放大倍数)和 Stalls(因为 compact 而降速)。

RocksDB 有 3 种 compact 的方式:leveled、universal 和 FIFO。

Leveled

Leveled 是从 LevelDB 继承过来的传统形式,也就是当一层的数据文件超过该层的阈值时,就往它的下层来 compact。L0 之间因为可能有重复的数据,因此需要全合并后写入 L1。而 L1 之后的数据文件不会有重复的 key,因此在 key 范围不重合的情况下,可以并发地向下合并。RocksDB 默认有 L0 ~ L6 这 7 层,L1 容量是 256 MB(建议把 L0 和 L1 大小设为一样,可以减小写入放大),之后每层都是上一层容量的 10 倍。很显然,层数越高就表示写入放大倍数越高。

Universal

那么可不可以不分这么多层,以减小写入放大倍数呢?Universal 这种风格就是尽量只用 L0,并将新的 SST 不断合并到老的 SST,因此数据文件的大小是不等的。但单个 SST 也是有上限的,不然内存扛不住,二分查找也会变慢,于是达到上限时,就往 L6 写,而 L0 以外的层不会有重叠的 key 范围,所以合并时只需要简单地拼接就行了。如果 L6 也不够用,就继续往 L5、L4 等层写入。这种策略增大了每层能容纳的大小,并且因为先写 L6,而 L6 是容量最大的,数据量较小时就不需要用到 L5 等其他层了,也就减少了层数,对应着也就降低了写入放大倍数。但是因为 L0 的上限变大了,单个 SST 的上限也变大了,所以读性能可能会稍微下降(部分情况下因为层数和 SST 少,读取速度可能更快)。此外,L0 变大也会影响打开数据库的耗时,因为需要读取到内存中。

FIFO

FIFO 严格来说不算是合并策略,它的做法是所有的数据都放在 L0,当数据量达到上限时,就把最老的 SST 删掉。它还能搭配 TTL 使用,也就是优先把过期时间较早的数据删掉。这种策略一般只用于缓存,但是对于不超过内存容量的缓存,我更倾向于放 Redis 里。
TiKV 和 Pika 都选择了 leveled 风格,也是 RocksDB 的默认值,应该是适合大部分情况的。但如果需要更高的写入性能,并且总数据容量不大(例如少于 100 GB),可以选择 universal。

其实 RocksDB 还有挺多可以调优的参数,但是都需要做测试,在 SSD 和 HDD 上表现也可能不一样,这里我只列几点:
在我的电脑上(用 SSD),允许 MMAP 读取会稍微拖慢读取速度,允许 MMAP 写入可以稍微加快写入速度。设置合理的 block cache 可以加快读取

首页 上一页 1 2 下一页 尾页 1/2/2
】【打印繁体】【投稿】【收藏】 【推荐】【举报】【评论】 【关闭】 【返回顶部
上一篇从JVM heap dump里查找没有关闭文.. 下一篇CodeReview常见代码问题

最新文章

热门文章

Hot 文章

Python

C 语言

C++基础

大数据基础

linux编程基础

C/C++面试题目