RocksDB 作为最著名的 LSM 类存储引擎之一,在字节跳动内部占据非常重要的位置,大量的数据库、存储系统都在基于 RocksDB 进行构建或改进,但 LSM 系列众所周知的一些问题同样困扰着字节跳动的业务,包括性能问题、成本问题、功能问题等等。
本文首先尝试梳理和介绍我们对 RocksDB 在五个方面的改进(在内部存储引擎 TerarkDB 的基础上开发),希望能给社区带来一些参考价值,也欢迎对存储引擎感兴趣的技术专家加入我们一起为字节跳动构建更强大的底层支撑。
RocksDB 之前的某个版本引入了 PinnableSlice 作为数据在引擎内的传输载体,它的主要作用是可以 减少数据复制,即当用户所要查找的数据在 BlockCache 中的时候,只返回其引用。
但是 PinnableSlice 在一些场景下无法发挥价值,比如用户的某个操作需要触碰 大量不需要的 Value 时(如 Value 有很多版本或者有大量的 tombstone),PinnableSlice 依然会对这些无用的 Value 产生 I/O 操作,这部分开销是完全可以避免的。
我们为此构建了 LazyBuffer 替换 PinnableSlice,当用户获得 Value 的时候,并不真正进行磁盘 I/O,只有用户真正需要取值的时候才进行真正的 fetch 操作进行 I/O。
Lazy Buffer 是对 PinnableSlice 的增强,从减少数据复制更进一步减少不必要的 IO,对于大量的扫描、SeekRandom 等场景有很大好处。
自从 LSM 推广开来,针对 LSM Compaction 的各种策略优化层出不穷,其中主流的 Compaction 策略有以下几种:
从上面的分类我们可以看到,主流的 Compaction 策略主要 在不同的合并时机之间进行权衡和选择,字节跳动在这里使用了稍微改进一点的方式。
首先,我们要理解如果能够允许 SST 可以不必保持强有序,那么就可以让我们收集到更多的统计信息后再真正执行外排序(Compaction),但缺点是会增加一定程度的读放大,对读延迟会有影响,那么有没有办法让增加的读放大可控,甚至几乎不增加读放大呢?
我们尝试构建了一种新的 SST 数据结构(Adaptive Map,简称 aMap),区别于 RocksDB 默认的结构,aMap 是一个逻辑上的虚拟 SST,如下图所示:
图:aMap 结构示意图
图中 SST 1、SST 2、SST 3 是三个物理上的 SST 文件,当需要对他们进行合并的时候,我们先构建虚拟遮罩,对上层暴露逻辑上的合并好的 SST(逻辑上是一个大 SST),同时记录和标记其中的覆盖度、Key Space 等统计信息。
它的主要功能有:
读写放大和原版 RocksDB 对比,理论分析上是有优势的:
表:复杂度分析对比(读放大和写放大)
在论文 *WiscKey: Separating Keys from Values in SSD-conscious Storage* 介绍了一种 KV 分离的 SST 设计,它的主要方式是构建一个 Value Log 文件不断的在上面追加 Value 数据,同时原始的 SST 中 Value 只记录数据真实存在的位置即可。
图:KV 分离的基本原理
其实 KV 分离的思路比较直接和简单,把符合阈值的 value 从直接存储在 SST 中,改为存储文件指针,降低 Compaction、Seek 等操作的开销。
RocksDB 社区有一个 KV 分离的 BlobDB 功能,但是目前功能还不完善,还有大量的工作需要继续做,这里就暂不做对比。另一个 TitanDB 是一个实现上相对完整的 KV 分离存储引擎(以 RocksDB 插件的形式构建),我们在这里对他们进行一个简单的对比:
综合来看,在和社区的对比中,我们实现 KV 分离的大体思路是类似的,但由于我们 有 Adaptive Map 的存在,可以对真正的 GC 操作进行延迟到负载较低的时候进行,对于应对突发流量尖峰会有相当不错的效果。
但 KV 分离也带来了一些损失,最重要的就是对于范围查询造成了损害,后续可以通过逻辑层进行 Prefetch 来降低这部分的损耗。
对于原生的 RocksDB,其 SST 格式大致如下图所示:
[data block 1]
[data block 2]
...
[data block N]
[meta block 1: filter block] (see section: "filter" Meta Block)
[meta block 2: index block]
[meta block 3: compression dictionary block] (see section: "compression dictionary" Meta Block)
[meta block 4: range deletion block] (see section: "range deletion" Meta Block)
[meta block 5: stats block] (see section: "properties" Meta Block)
...
[meta block K: future extended block] (we may add more meta blocks in the future)
[metaindex block]
[Footer] (fixed size; starts at file_size - sizeof(Footer))
其中,index block 和 filter block 帮助用户快速定位目标 key 所在的 block。RocksDB 默认的 index 并没有考虑不同数据类型之间的差异,无法根据不同数据类型选择压缩效率和查询效率最高的索引结构,针对这个问题,我们构建了一种自适应的索引选择和构建机制。
目前已经支持的额外索引算法有:
通过多种索引结构的支持,为以后的长期优化提供了更多可能,甚至在 SST 中内嵌 B+ 树索引 data block 等等,更加灵活、模块化的结构让引擎的适应能力更强,面对不同的存储介质、访问模式可以有更佳的综合表现。
对于数据库应用来说,除了追求高性能外,成本控制也是一个很重要的话题,其中成本控制的重要一环就是数据压缩,对于 LSM 结构来说,默认是通过 block 进行压缩的,压缩率和块的尺寸强相关。
在这里为了解决块尺寸和压缩率的 tradeoff 问题,我们构建了一系列的压缩算法,其中最常用的是可以按记录抽取数据的全局压缩,其基本思路其实并不复杂,通过对 LZ 系列的改进,使用高效的手段保留每一个 Record 的 Offset 即可,而 Offset 表有很多种方法进行压缩存储(显然它是递增的非连续整数序列),利用 pfordelta 等方法进行优化变通存储不难办到。
图:全局压缩算法的概要流程
其大致流程是:
图:偏移表的构建和保存
偏移表采用常见的类 pfordelta 算法压缩保存即可,需要注意的是因为偏移表会被频繁访问,这里的适宜有一阶差分表和二阶差分表,根据实际情况选择即可。
索引后续可以直接映射到这里具体的 record offset 中来,方便后续的直接按记录寻址。
对目前流行的新硬件(如持久化内存、FPGA、QAT 等),我们同样进行了适配和优化,比如在在设备有 QAT 硬件的时候,我们在主机 CPU 负载较高时,选择放弃一部分 CPU 压缩 offload 到 QAT 中进行压缩,再比如我们将一部分数据放在持久化内存上实现真正的 Record 级别数据访问(我们采用的并非常见的块级别的索引结构,而是直接按记录索引)等等。
这里我们以 QAT 压缩为例来说明:
目前 QAT 的使用还在测试阶段,还没有正式上线,下一步计划对 FPGA 的应用进行深度的调研。
我们使用 RocksDB 的 bench 工具进行了一些简单的测试,对比了 RocksDB、TitanDB 和 TerarkDB 的区别和差异,需要注意是,该工具使用的是随机生成的数据,对于 TerarkDB 的压缩算法不是很友好,所以压缩率差距并不大。
这次改进,我们重点关注的是 KV 分离的表现,所以只对比较大的 Value 进行 benchmark 确认其改进效果:
fillrandom
:多线程随机写入, 存在重复 keyreadseq
:多线程顺序 Scanreadrandom
:多线程随机 Getseekrandom
:多线程随机 Seek字节跳动在单机引擎上的投入会持续加大,同时也会考虑为各类特定业务构建针对性的专用引擎,其目标是在单机内为上层业务提供更强大的性能,更灵活的功能和更可靠的服务。
为了实现这些目标,后续我们还需要做的有很多,包括卸载单机引擎的 CPU 到集群上进行分布式 Compaction、引入 SPDK 相关的技术提升 IO 效率、引入 AI Tuning 针对不同负载做更灵活的 I/O 策略、引入新硬件(如持久化内存和 FPGA)等等。
为了实现字节跳动存储引擎的多样性和走向业界前沿,我们迫切的希望有志者能够加入我们一起做新的探索,我们也希望未来在主流的期刊上、开源社区中能够看到字节跳动的活跃身影,为技术社区贡献自己的力量。
领取专属 10元无门槛券
私享最新 技术干货