做者:郑志铨linux
Titan 是由 PingCAP 研发的一个基于 RocksDB 的高性能单机 key-value 存储引擎,其主要设计灵感来源于 USENIX FAST 2016 上发表的一篇论文 WiscKey。WiscKey
提出了一种高度基于 SSD 优化的设计,利用 SSD 高效的随机读写性能,经过将 value 分离出 LSM-tree
的方法来达到下降写放大的目的。c++
咱们的基准测试结果显示,当 value 较大的时候,Titan 在写、更新和点读等场景下性能都优于 RocksDB。可是根据 RUM Conjecture
,一般某些方面的提高每每是以牺牲其余方面为代价而取得的。Titan 即是以牺牲硬盘空间和范围查询的性能为代价,来取得更高的写性能。随着 SSD 价格的下降,咱们认为这种取舍的意义会愈来愈明显。git
Titan 做为 TiKV 的一个子项目,首要的设计目标即是兼容 RocksDB。由于 TiKV 使用 RocksDB 做为其底层的存储引擎,而 TiKV 做为一个成熟项目已经拥有庞大的用户群体,因此咱们须要考虑已有的用户也能够将已有的基于 RocksDB 的 TiKV 平滑地升级到基于 Titan 的 TiKV。github
所以,咱们总结了四点主要的设计目标:算法
LSM-tree
中分离出来单独存储,以下降写放大。Titan 的基本架构以下图所示:缓存
图 1:Titan 在 Flush 和 Compaction 的时候将 value 分离出LSM-tree
,这样作的好处是写入流程能够和 RockDB 保持一致,减小对RocksDB
的侵入性改动。
Titan 的核心组件主要包括:BlobFile
、TitanTableBuilder
、Version
和 GC
,下面将逐一进行介绍。架构
BlobFile
BlobFile
是用来存放从 LSM-tree
中分离出来的 value 的文件,其格式以下图所示:并发
图 2:BlobFile
主要由 blob record 、meta block、meta index block 和 footer 组成。其中每一个 blob record 用于存放一个 key-value 对;meta block 支持可扩展性,能够用来存放和BlobFile
相关的一些属性等;meta index block 用于检索 meta block。
BlobFile
有几点值得关注的地方:app
BlobFile
中的 key-value 是有序存放的,目的是在实现 Iterator
的时候能够经过 prefetch 的方式提升顺序读取的性能。BlobFile
支持 blob record 粒度的 compression,而且支持多种 compression algorithm,包括 Snappy
、LZ4
和 Zstd
等,目前 Titan 默认使用的 compression algorithm 是 LZ4
。TitanTableBuilder
TitanTableBuilder
是实现分离 key-value 的关键。咱们知道 RocksDB 支持使用用户自定义 table builder 建立 SST
,这使得咱们能够不对 build table 流程作侵入性的改动就能够将 value 从 SST
中分离出来。下面将介绍 TitanTableBuilder
的主要工做流程:dom
图 3:TitanTableBuilder
经过判断 value size 的大小来决定是否将 value 分离到BlobFile
中去。若是 value size 大于等于min_blob_size
则将 value 分离到BlobFile
,并生成 index 写入SST
;若是 value size 小于min_blob_size
则将 value 直接写入SST
。
Titan 和 Badger
的设计有很大区别。Badger
直接将 WAL
改形成 VLog
,这样作的好处是减小一次 Flush 的开销。而 Titan 不这么设计的主要缘由有两个:
LSM-tree
的 max level 是 5,放大因子为 10,则 LSM-tree
总的写放大大概为 1 + 1 + 10 + 10 + 10 + 10,其中 Flush 的写放大是 1,其比值是 42 : 1,所以 Flush 的写放大相比于整个 LSM-tree 的写放大能够忽略不计。WAL
可使 Titan 极大地减小对 RocksDB 的侵入性改动,而这也正是咱们的设计目标之一。Version
Titan 使用 Version
来表明某个时间点全部有效的 BlobFile
,这是从 LevelDB
中借鉴过来的管理数据文件的方法,其核心思想即是 MVCC
,好处是在新增或删除文件的同时,能够作到并发读取数据而不须要加锁。每次新增文件或者删除文件的时候,Titan
都会生成一个新的 Version
,而且每次读取数据以前都要获取一个最新的 Version
。
图 4:新旧Version
按顺序首尾相连组成一个双向链表,VersionSet
用来管理全部的Version
,它持有一个current
指针用来指向当前最新的Version
。
Garbage Collection (GC) 的目的是回收空间,一个高效的 GC 算法应该在权衡写放大和空间放大的同时,用最少的周期来回收最多的空间。在设计 GC 的时候有两个主要的问题须要考虑:
Titan 使用 RocksDB 提供的两个特性来解决这两个问题,这两个特性分别是 TablePropertiesCollector
和 EventListener
。下面将讲解咱们是如何经过这两个特性来辅助 GC 工做的。
BlobFileSizeCollector
RocksDB 容许咱们使用自定义的 TablePropertiesCollector
来搜集 SST
上的 properties 并写入到对应文件中去。Titan
经过一个自定义的 TablePropertiesCollector
—— BlobFileSizeCollector
来搜集每一个 SST
中有多少数据是存放在哪些 BlobFile
上的,咱们将它收集到的 properties 命名为 BlobFileSizeProperties
,它的工做流程和数据格式以下图所示:
图 5:左边SST
中 Index 的格式为:第一列表明BlobFile
的文件 ID,第二列表明 blob record 在BlobFile
中的 offset,第三列表明 blob record 的 size。右边BlobFileSizeProperties
中的每一行表明一个BlobFile
以及SST
中有多少数据保存在这个BlobFile
中,第一列表明BlobFile
的文件 ID,第二列表明数据大小。
EventListener
咱们知道 RocksDB 是经过 Compaction 来丢弃旧版本数据以回收空间的,所以每次 Compaction 完成后 Titan 中的某些 BlobFile
中即可能有部分或所有数据过时。所以咱们即可以经过监听 Compaction 事件来触发 GC,经过搜集比对 Compaction 中输入输出 SST
的 BlobFileSizeProperties
来决定挑选哪些 BlobFile
进行 GC。其流程大概以下图所示:
图 6:inputs 表明参与 Compaction 的全部SST
的BlobFileSizeProperties
,outputs 表明 Compaction 生成的全部SST
的BlobFileSizeProperties
,discardable size 是经过计算 inputs 和 outputs 得出的每一个BlobFile
被丢弃的数据大小,第一列表明BlobFile
的文件 ID,第二列表明被丢弃的数据大小。
Titan 会为每一个有效的 BlobFile
在内存中维护一个 discardable size 变量,每次 Compaction 结束以后都对相应的 BlobFile
的 discardable size 变量进行累加。每次 GC 开始时就能够经过挑选 discardable size 最大的 BlobFile
来做为做为候选的文件。
每次进行 GC 前咱们都会挑选一系列 BlobFile
做为候选文件,挑选的方法如上一节所述。为了减少写放大,咱们能够容忍必定的空间放大,因此咱们只有在 BlobFile
可丢弃的数据达到必定比例以后才会对其进行 GC。咱们使用 Sample 算法来获取每一个候选文件中可丢弃数据的大体比例。Sample 算法的主要逻辑是随机取 BlobFile
中的一段数据 A,计其大小为 a,而后遍历 A 中的 key,累加过时的 key 所在的 blob record 的 size 计为 d,最后计算得出 d 占 a 比值 为 r,若是 r >= discardable_ratio
则对该 BlobFile
进行 GC,不然不对其进行 GC。上一节咱们已经知道每一个 BlobFile
都会在内存中维护一个 discardable size,若是这个 discardable size 占整个 BlobFile
数据大小的比值已经大于或等于 discardable_ratio
则不须要对其进行 Sample。
咱们使用 go-ycsb 测试了 TiKV 在 Txn Mode 下分别使用 RocksDB 和 Titan 的性能表现,本节我会简要说明下咱们的测试方法和测试结果。因为篇幅的缘由,咱们只挑选两个典型的 value size 作说明,更详细的测试分析报告将会放在下一篇文章。
数据集选定的基本原则是原始数据大小(不算上写放大因素)要比可用内存大,这样能够防止全部数据被缓存到内存中,减小 Cache 所带来的影响。这里咱们选用的数据集大小是 64GB,进程的内存使用限制是 32GB。
Value Size | Number of Keys (Each Key = 16 Bytes) | Raw Data Size |
---|---|---|
1KB | 64M | 64GB |
16KB | 4M | 64GB |
咱们主要测试 5 个经常使用的场景:
BlobFile
中没有可丢弃数据),所以咱们还须要经过更新来测试 GC
对性能的影响。图 7 Data Loading Performance:Titan 在写场景中的性能要比 RocksDB 高 70% 以上,而且随着 value size 的变大,这种性能的差别会更加明显。值得注意的是,数据在写入 KV Engine 以前会先写入 Raft Log,所以 Titan 的性能提高会被摊薄,实际上裸测 RocksDB 和 Titan 的话这种性能差别会更大。
图 8 Update Performance:Titan 在更新场景中的性能要比 RocksDB 高 180% 以上,这主要得益于 Titan 优秀的读性能和良好的 GC 算法。
图 9 Output Size:Titan 的空间放大相比 RocksDB 略高,这种差距会随着 Key 数量的减小有略微的缩小,这主要是由于
BlobFile
中须要存储 Key 而形成的写放大。
图 10 Random Key Lookup: Titan 拥有比 RocksDB 更卓越的点读性能,这主要得益与将 value 分离出LSM-tree
的设计使得LSM-tree
变得更小,所以 Titan 在使用一样的内存量时能够将更多的index
、filter
和DataBlock
缓存到 Block Cache 中去。这使得点读操做在大多数状况下仅须要一次 IO 便可(主要是用于从BlobFile
中读取数据)。
图 11 Sorted Range Iteration:Titan 的范围查询性能目前和 RocksDB 相比仍是有必定的差距,这也是咱们将来优化的一个重要方向。
本次测试咱们对比了两个具备表明性的 value size 在 5 种不一样场景下的性能差别,更多不一样粒度的 value size 的测试和更详细的性能报告咱们会放在下一篇文章去说明,而且咱们会从更多的角度(例如 CPU 和内存的使用率等)去分析 Titan 和 RocksDB 的差别。从本次测试咱们能够大体得出结论,在大 value 的场景下,Titan 会比 RocksDB 拥有更好的写、更新和点读性能。同时,Titan 的范围查询性能和空间放大都逊于 RocksDB 。
一开始咱们便将兼容 RocksDB 做为设计 Titan 的首要目标,所以咱们保留了绝大部分 RocksDB 的 API。目前仅有两个 API 是咱们明确不支持的:
Merge
SingleDelete
除了 Open
接口之外,其余 API 的参数和返回值都和 RocksDB 一致。已有的项目只须要很小的改动便可以将 RocksDB
实例平滑地升级到 Titan。值得注意的是 Titan 并不支持回退回 RocksDB。
#include <assert> #include "rocksdb/utilities/titandb/db.h" // Open DB rocksdb::titandb::TitanDB* db; rocksdb::titandb::TitanOptions options; options.create_if_missing = true; rocksdb::Status status = rocksdb::titandb::TitanDB::Open(options, "/tmp/testdb", &db); assert(status.ok()); ...
或
#include <assert> #include "rocksdb/utilities/titandb/db.h" // open DB with two column families rocksdb::titandb::TitanDB* db; std::vector<rocksdb::titandb::TitanCFDescriptor> column_families; // have to open default column family column_families.push_back(rocksdb::titandb::TitanCFDescriptor( kDefaultColumnFamilyName, rocksdb::titandb::TitanCFOptions())); // open the new one, too column_families.push_back(rocksdb::titandb::TitanCFDescriptor( "new_cf", rocksdb::titandb::TitanCFOptions())); std::vector<ColumnFamilyHandle*> handles; s = rocksdb::titandb::TitanDB::Open(rocksdb::titandb::TitanDBOptions(), kDBPath, column_families, &handles, &db); assert(s.ok());
和 RocksDB 同样,Titan 使用 rocksdb::Status
来做为绝大多数 API 的返回值,使用者能够经过它检查执行结果是否成功,也能够经过它打印错误信息:
rocksdb::Status s = ...; if (!s.ok()) cerr << s.ToString() << endl;
std::string value; rocksdb::Status s = db->Get(rocksdb::ReadOptions(), key1, &value); if (s.ok()) s = db->Put(rocksdb::WriteOptions(), key2, value); if (s.ok()) s = db->Delete(rocksdb::WriteOptions(), key1);
目前 Titan 在 TiKV 中是默认关闭的,咱们经过 TiKV 的配置文件来决定是否开启和设置 Titan,相关的配置项包括 [rocksdb.titan]
和 [rocksdb.defaultcf.titan]
, 开启 Titan 只须要进行以下配置便可:
[rocksdb.titan] enabled = true
注意一旦开启 Titan 就不能回退回 RocksDB 了。
Iterator
咱们经过测试发现,目前使用 Titan 作范围查询时 IO Util 很低,这也是为何其性能会比 RocksDB 差的重要缘由之一。所以咱们认为 Titan 的 Iterator
还存在着巨大的优化空间,最简单的方法是能够经过更加激进的 prefetch 和并行 prefetch 等手段来达到提高 Iterator
性能的目的。
GC
速度控制和自动调节一般来讲,GC 的速度太慢会致使空间放大严重,过快又会对服务的 QPS 和延时带来影响。目前 Titan 支持自动 GC,虽然能够经过减少并发度和 batch size 来达到必定程度限制 GC 速度的目的,可是因为每一个 BlobFile
中的 blob record 数目不定,若 BlobFile
中的 blob record 过于密集,将其有效的 key 更新回 LSM-tree
时仍然可能堵塞业务的写请求。为了达到更加精细化的控制 GC 速度的目的,后续咱们将使用 Token Bucket
算法限制一段时间内 GC 可以更新的 key 数量,以下降 GC 对 QPS 和延时的影响,使服务更加稳定。
另外一方面,咱们也正在研究自动调节 GC 速度的算法,这样咱们即可以,在服务高峰期的时候下降 GC 速度来提供更高的服务质量;在服务低峰期的时候提升 GC 速度来加快空间的回收。
TiKV 在某些场景下仅须要判断某个 key 是否存在,而不须要读取对应的 value。经过提供一个这样的 API 能够极大地提升性能,由于咱们已经看到将 value 移出 LSM-tree
以后,LSM-tree
自己会变的很是小,以致于咱们能够将更多地 index
、filter
和 DataBlock
存放到内存当中去,这样去检索某个 key 的时候能够作到只须要少许甚至不须要 IO 。