作者:张金鹏,PingCAP 工程总监,专注于大型分布式存储和数据库的建设

本文转载于:https://dataturbo.medium.com/titan-engine-6x-write-performance-by-separating-large-value-from-lsm-tree-32fd2c0cb117


背景

TiKV 项目始于 2015 年,最初作为 TiDB 的存储层。在早期版本中,我们使用 2 个 RocksDB 实例分别存储raft日志和数据,以便快速开发(实际上 TiKV 的第一个版本使用单个 RocksDB 来存储 raft 日志和数据)。然后我们发现 TiKV 的写放大比较高。高写放大主要由两部分组成:1) raft 日志数据具有 FIFO 模式,使用 LSM-Tree 引擎存储它效率不高;2)在压缩过程中反复重写 LSM-Tree 中的大值,导致数据部分的写放大很多。

对于 raft 日志部分,我从 2017 年开始了 raft-engine 项目来解决这个问题。对于第二部分,我在 2017 年左右注意到了 Wisckey 论文,并进行了内部演讲。这篇论文提出了一种通过将值与 LSM-Tree 分离来减少写放大的方法,不是在 LSM-Tree 中存储键值,而是使用专用的 blob 文件来存储这些大值。



从 LSM-Tree 中分离大值

我的同事黄华超在 2018 年开始构建 Titan 引擎,以从 LSM-Tree 中分离大值。然后其他同事如 Bokang 、Xinye、Andy 等也参与了这个项目。当时没有这样的开源项目,我们不得不从头开始构建。主要设计参考了 Wisckey 论文,但有一些变化。Titan 和 Wisckey 之间的显著区别在于 Wisckey 不会将大值写入到 Write Ahead Log 中,Wisckey 直接将大值写入 blob 文件,这导致较少的写放大。在 Titan 中,我们将大值写入 WAL 文件,并仅在将内存表刷新到持久的 SST 文件时生成 blob 文件。我们选择这种策略有两个原因:1)原子写入和数据完整性仅由 WAL 保证,每个写入只需要 fsync WAL 文件;2)我们将 Titan 引擎设计为 RocksDB 的插件,因此我们没有改变关键的写入路径,并将分离大值的逻辑移动到内存表刷新阶段。压缩在每个 blob 级别实现。

Titan 引擎:通过从 LSM-Tree 中分离大值,实现 6 倍的写入性能的提升_tree



垃圾收集机制

当从 RocksDB 中删除键值对时,会向 RocksDB 写入一个删除墓碑。当墓碑在压缩过程中遇到其对应的键值时,键值将被丢弃。

对于 Titan 来说,在 LSM-Tree 中的键和指针被丢弃后,相应的 blob 变得无效。随着越来越多的 blob 变得无效,必须有一种方法来 GC 这些 blob 并回收磁盘空间。

Titan 引擎:通过从 LSM-Tree 中分离大值,实现 6 倍的写入性能的提升_rocksdb_02



重写 Blob 文件

GC 机制的首要任务是追踪哪些 blob 文件值得进行 GC 。我们使用 RocksDB SST 的用户属性块来记录相应 blob 文件中有效 blob 的大小。如下图所示,sst 文件 1 有两个对应的 blob 文件 1 和 2 。来自这个 SST 文件 1 的 blob 文件 1 和 blob 文件 2 的有效 blob 大小分别是 564K 和 1028K 。

如果我们累积所有 SST 文件的用户属性记录,我们就应该能够知道每个 blob 文件的有效 blob 大小。然后,将有效 blob 大小与它们的实际大小进行比较,如果有效 blob 大小的比例低于某个阈值(默认是 0.5 ),这表明这个 blob 文件包含太多的无效 blob ,需要进行 GC 。

当 TiKV 启动时,我们将收集所有 SST 文件的用户属性,并记录每个 blob 文件的有效大小。每次压缩完成后,这个记录将被更新。

在判断出哪些 blob 文件应该进行 GC 之后, Titan 将重写这个 blob 文件,它将检查 LSM 树中是否存在每个 blob 对应的指针,并生成一个新的只包含有效 blob 的 blob 文件。同时,将键及其新位置写回到 LSM-Tree 中。如果没有任何其他读请求需要它,旧的 blob 文件就可以被移除。

Titan 引擎:通过从 LSM-Tree 中分离大值,实现 6 倍的写入性能的提升_rocksdb_03



Punch Hole

默认的 Titan GC 阈值是 0.5 ( discardable-ratio = 0.5 ),这意味着我们将重写那些无效 blob 占用超过 50% 空间的 blob 文件。 0.5 也意味着可能有高达 50% 的磁盘空间被无效 blob 占据。一些用户抱怨说,在他们启用 Titan 引擎后,磁盘使用量几乎翻倍。用户当然可以设置一个更小的阈值,比如 0.2 ,以使 GC 更积极,减少空间放大,但这将导致写放大增加。

有没有一种方法可以在 GC 期间减少空间放大而不增加写放大呢?经过一些讨论, Bokang 和 Andy 想出了打孔( punch hole )方法。打孔 GC 的基本思想是,每个 blob 在 blob 文件中都与 4K 对齐。当 blob 文件中的无效 blob 大小超过阈值,比如 0.2 时,我们使用带有 FALLOC_FL_PUNCH_HOLE 标志的 fallocate 来释放这些无效 blob 的空间,而不是重写整个 blob 文件。这样, GC 就没有额外的写入,无效 blob 的空间可以及时回收。

Titan 引擎:通过从 LSM-Tree 中分离大值,实现 6 倍的写入性能的提升_blob_04

您可能会注意到,在我们将每个 blob 对齐为 4K 后, blob 的尾部可能会有填充。 TiKV 中默认的大值阈值为 32K ,填充可能占用 0~11% 的额外空间,这是可以接受的。



性能测试

我们将 Titan 集成到 TiKV / TiDB 中,并测试了各种工作负载,如点查询、范围扫描 100 、范围扫描 10,000 、更新,以及不同行大小从 1KB 、2KB 到 32KB 。我们发现 Titan 在像更新这样的写入密集型工作负载中可以获得显著的性能提升。并且对于像点查询和范围扫描这样的只读工作负载, Titan 也能获得竞争性的性能,这部分是因为在将大值从 LSM 树中分离出来后,更多的键可以被缓存在块缓存中。



Update

对于更新工作负载,当行大小为 1KB 时,Titan 获得的 QPS 是 RocksDB 的两倍,当行大小为 32K 时,Titan 获得的 QPS 是 RocksDB 的 6 倍。

Titan 引擎:通过从 LSM-Tree 中分离大值,实现 6 倍的写入性能的提升_tree_05



Range Scan

在 LSM-Tree 中, Range Scan 本质上是从不同级别的迭代器中获取键值并执行合并排序。在将大值分离到blob文件中之后,对每个键进行随机读取,以便从 blob 文件中检索大值。这可能会降低扫描性能。

Titan 引擎:通过从 LSM-Tree 中分离大值,实现 6 倍的写入性能的提升_tikv_06

Titan 引擎:通过从 LSM-Tree 中分离大值,实现 6 倍的写入性能的提升_rocksdb_07



Point Get

Titan 引擎:通过从 LSM-Tree 中分离大值,实现 6 倍的写入性能的提升_titan_08