【读】RocksDB: Evolution of Development Priorities in a Key-value Store Serving Large-scale Applications
本文是 Facebook
对 RocksDB
8年开发历程的回顾,重点讨论了为支持大规模分布式系统所做的开发优先级取舍与演进,以及在生产环境中运行大规模应用的经验。
介绍
RocksDB
是 Facebook
在2012年创建的高性能 KV
持久存储引擎,代码衍生自 Google
的 LevelDB
。它针对 SSD
的某些特性进行了优化,目标是服务于大型(分布式)应用,在使用上则以类库的方式和上层应用集成。每个 RocksDB
实例是个单机版程序,本身不提供跨主机间的操作,例如副本管理和负载均衡,同时也不提供高阶 API
,例如不支持 checkpoint
,这些都留给上层应用自行实现。
RocksDB
及其高度可定制的组件设计使其能够从容应对不同的业务需求和工作负载。除了作为数据库系统的存储引擎外,RocksDB
还被用于以下几种不同类型的服务:
- 流式处理:典型代表如
Flink
借助RocksDB
保存checkpoint
的状态数据 - 日志/队列服务:依托于
RocksDB
可定制化的合并策略,这些服务能够以不亚于追加写单个文件的效率实现高吞吐的写入,同时有着较低的写放大,以及享受内置索引带来的便利 - 索引服务:
RocksDB
的bulk loading
特性能够为索引服务提供大规模加载离线数据的能力,同时也有着高效的查询性能 - 基于
SSD
的二级缓存:因为RocksDB
针对SSD
进行了优化,所以某些内存式的缓存服务会借助RocksDB
在内存不够时将部分数据置换到SSD
。这些服务往往要求存储引擎有着足够高的写入速度和优秀的点查询性能
背景
RocksDB
的设计极大的受到了 SSD
特性的影响,SSD
不对称的读写性能和有限的耐用性给 RocksDB
的数据结构设计和系统架构带来了机遇和挑战。
基于 SSD 的嵌入式存储
相比于机械硬盘,SSD
读写的 IOPS
可以达到十万至百万,读写速度可以达到几百至几千 MB/s
。一方面,这给如何设计软件从而能充分利用 SSD
的性能带来了挑战;另一方面,受限于 SSD
有限的擦除次数,同时也需要考虑如何设计合理的数据结构,避免提前耗尽 SSD
的寿命。
正因为 SSD
有着出色的性能,在大多数情况下,应用的性能瓶颈也从设备 I/O
转向了网络;应用架构设计时也更倾向于将数据存储在本地 SSD
而不是远程存储服务,因此,能够内嵌在应用中的本地 KV
存储引擎的需求就日渐上涨。
在这个背景下,Facebook
实现了 RocksDB
,其中 LSM
树扮演了重大的角色。
RocksDB 的架构和 LSM 树的使用
RocksDB
使用 LSM
树作为主要的数据结构来保存数据并支持以下核心的操作。
写
写入时会先将数据写入到名为 MemTable
的内存写缓冲中,同时也会在磁盘上记录 Write Aghead Log (WAL)
。MemTable
由跳表(skiplist
)实现,插入和查询的时间复杂度都是 O(logn)
。WAL
可按需开启,用于 RocksDB
从崩溃后恢复数据。当 MemTable
的大小达到所配置的阈值时:
- 当前接受写入的
MemTable
和WAL
变为只读 - 后续新的写入转到新创建的
MemTable
和WAL
- 系统会将变为只读的
MemTable
和WAL
的内容落盘到Sorted String Table (SSTable)
内 - 已落盘的
MemTable
和WAL
则可以丢弃
SSTable
中的数据按序存储,并以等大小的块(block
)组织。SSTable
生成后同样只能只读,同时,其内部会维护一个索引块,索引块中会给每个数据块维护一条索引,从而能借助二分查找快速搜索。
合并
如上图所示,一个 LSM
树分为多层。最新的 SSTable
由 MemTable
刷盘生成,并放置在 Level-0
。其他层的 SSTable
则统一由合并程序维护。当第 L
层的 SSTable
大小触及了配置值,合并程序会选择该层的部分 SSTable
,并将其和第 L + 1
层内键的范围存在重合的 SSTable
进行合并,从而在第 L + 1
层生成一个新的 SSTable
。通过这个操作,RocksDB
就可以将已删除和过时的数据清除,同时新生成的 SSTable
也进行了瘦身,节省了磁盘空间,最终写入的数据会逐渐从 Level-0
迁移到最后一层。整个合并过程的 I/O
效率也比较高,一方面不同层的合并可以并行执行,另一方面 I/O
操作只涉及整个 SSTable
文件的批量读和写。
MemTable
和 Level-0
层的 SSTable
键的范围可能会存在重合,而 Level-1
及其之后的每一层内,RocksDB
会确保每个 SSTable
之间键的范围不会重合(但是不同层之间的 SSTable
键的范围是有可能重合的)。
RocksDB
支持不同类型的合并策略:
Leveled Compaction
:借鉴自LevelDB
并加以改进。每一层可容纳的文件大小呈指数级放大。系统会积极的触发合并以确保每层的文件大小不会超过指定阈值Tiered Compaction
:在RocksDB
中也被称为Universal Compactioin
,与Apache Cassandra
或HBase
采取的合并策略类似。当Level-0
层文件的个数或者非Level-0
层的个数超过指定的阈值时,又或者整个数据库的大小和最深层文件大小之比超过指定的阈值时,就会触发合并多个SSTable
。有别于Leveled Compaction
,Tiered Compaction
是惰性合并,实际的合并会推迟到读性能或者空间效率发生衰减时进行,从而能够一次性合并更多的数据FIFO Compaction
:当数据库大小触及到指定阈值时,丢弃最老的SSTable
,且只进行轻量级的合并。适合于基于内存的缓存应用
RocksDB
的读写性能在不同的合并策略下有着不同的表现,应用开发者需要结合自身服务的工作负载来选择合适的合并策略。
读
读取时,RocksDB
首先在所有的 MemTable
中查找,如果没有找到则继续在位于 Level-0
层的所有 SSTable
中查找,如果还没有找到,则继续向下一层中键的范围包含要查找的键的 SSTable
中查找,所有的查找都借助了二分搜索。另外还有两项辅助查找的优化:
- 频繁被访问的
SSTable
块会在内存中缓存从而减少文件I/O
,以及解压缩的开销 - 布隆过滤器用于快速排除一定不包含要查找的键的
SSTable
Column Family
RocksDB
在2014年引入了 column family
功能,不同的 column family
下可以包含相同的键,每个 column family
有独立的 MemTable
和 SStable
,但是共享 WAL
。其优势在于:
- 每个
column family
可独立配置,如合并,压缩,merge operators
以及compaction filters
- 共享的
WAL
能够原子性的记录多个column family
的更新 column family
可动态高效的删除和创建
资源优化目标的演进
写放大
RocksDB
最初的资源优化目标在于减少 SSD
的擦除周期以及写放大,写放大包含两方面:
SSD
本身的写放大:SSD
不能直接覆盖已有的数据,需要先将其擦除,再写入,写入的粒度为page
,但是擦除的粒度是block
,一个block
包含多个page
;同时SSD
的垃圾回收也会造成数据移动和擦除;最后SSD
的Wear Leveling
特性会保证各个memory cell
均衡的写入,也引入了数据移动- 数据库软件带来的写放大
在这两个因素下有时候写放大能达到100倍。
Leveled Compaction
的写放大倍数基本在10到30,在大多数情况能够数倍优于 B
树的实现。更进一步,Tiered Compaction
能将写放大倍数降至4到10,不过缺点是读性能会有一定的下降。一般来说,当应用的写负载较高时,可以配合写放大较低的合并策略,而当写负载不高时,则可以采用更激进的合并策略,从而有更好的空间效率和读性能。
空间放大
经过了多年的开发后,RocksDB
团队认为对于大多数应用来说,空间使用率远比写放大重要,因为这些场景下还没有触及 SSD
本身的限制,不恰当的比喻来说就是:
以大多数应用程序的稳定性来说,还远没有到比拼不同的操作系统稳定性的地步。
而实际上,应用本身也没有充分利用 SSD
提供的读写吞吐,因此这一阶段的优化重心就转移到了磁盘空间上。
由于 LSM
树无碎片的数据组织方式,天然的避免了由于数据碎片带来的磁盘空间浪费。另一方面,RocksDB
也引入了新的合并策略 Dynamic Leveled Compaction
,其中 LSM
树每一层的大小上限会动态的根据最深层文件的大小调整,而不是固定值。这么做的原因是为了减少 LSM
树中无效的数据(已删除和已被覆盖),而和最深层文件大小的比值则可作为有多少无效数据的度量指标。最终的结果也表明相比于 Leveled Compaction
,Dynamic Leveled Compaction
有着更稳定的空间效率。
CPU 利用率
随着 SSD
的发展,一种潜在的担忧是应用程序已不能完全充分利用 SSD
的潜能。因此,系统的瓶颈也从设备 I/O
转移到了 CPU
。不过,RocksDB
的开发人员不这么看,因为:
- 只有少部分的应用受限于
SSD
的IOPS
,大部分应用受限于磁盘空间 - 一个高端
CPU
足够服务于一个高端SSD
。在Facebook
的生产环境中还没有遇到RocksDB
不能充分利用SSD
能力的情况。当然,如果一个CPU
配备多个SSD
还是有可能会有CPU
瓶颈的,不过这属于系统配置层面的资源不均衡问题。另一方面,写密集型的应用也有可能存在CPU
瓶颈的问题,不过这可以通过使用更轻量级的合并策略解决。而在这之外的场景,其工作负载则可能不适合使用SSD
,因为有可能提前让SSD
的寿命完结
不过,优化 CPU
利用率也不等于说是无用功,因为空间放大的优化余地已经不多了。优化了 CPU
也等同于省钱,毕竟 CPU
和内存的价格也在节节攀升。一些针对 RocksDB
的 CPU
优化的尝试包括前缀布隆过滤器,在查找索引前先用布隆过滤器判断,以及其他的一些布隆过滤器优化。
适配新技术
一些 SSD
的新技术例如 open-channel SSDs
,multi-stream SSDs
,ZNS
能让 SSD
有着更低的查询延迟以及更少的擦除周期损耗。不过,如前面所述,RocksDB
的开发团队认为大部分应用的瓶颈在于磁盘空间,适配这些新技术反而会给 RocksDB
的一致性体验带来挑战,所以这项的优先级不高。
In-storage computing
可能会给应用带来巨大的提升,不过 RocksDB
的开发团队目前还不确定 RocksDB
能从这项技术中受益多少,而且对 API
的改动可能也比较大。
Disaggregated (remote) storage
则更具吸引力,并且也是当前的一个优化重点。前文的优化背景都是应用直接访问本地 SSD
,不过,如今更快的网络带宽使得远程访问 SSD
成为了可能,因此,如何优化 RocksDB
使其更好的适配远程 SSD
也变得有意义。在远程存储模式下,CPU
和 SSD
资源可以同时做到充分利用以及独立扩展,相反本地 SSD
的模式则较难实现。目前 RocksDB
的开发团队正在优化远程模式下的 I/O
延迟。
最后,non-volatile memory (NVM)
(它相比于 SSD
有着更高的 IO
读写吞吐)这项技术也在考量中:
- 将
NVM
作为DRAM
的扩展- 如何实现核心数据结构(
block cache
还是MemTable
)从而结合NVM
和DRAM
一起使用 - 会引入哪些额外的开销
- 如何实现核心数据结构(
- 将
NVM
作为数据库的主要存储:不过实践表明RocksDB
的瓶颈主要在于磁盘空间或者CPU
,而不是I/O
- 用
NVM
保存WAL
:其成本是否值得有待考虑,毕竟WAL
中的数据量不大,并且会刷盘到SSD
再次审视 RocksDB 使用 LSM 树的合理性
LSM
树依然是最适合的,因为 SSD
还没有到白菜价的地步,对于大多数应用来说,其有限的寿命依然是无法忽略的因素。而另一方面,RocksDB
的开发团队也发现某些写密集型的应用会写大量的大对象,如果能分别存储键值对则能减少 SSD
的写入,其功能实现为 BlobDB
。
运行大规模系统的经验总结
资源管理
大规模分布式数据服务往往会将数据以 shard
的粒度分区到多个节点上,一个节点可能会持有几十上百个 shard
。不过 shard
的大小有限,因为 shard
是负载均衡和副本的最小单位,需要在各节点之间进行拷贝。在 Facebook
的环境内,一个 shard
由一个 RocksDB
实例提供服务,因此一个节点会运行很多 RocksDB
实例,它们可能会共享一个地址空间,也有可能会独享。
在上述背景下,就需要考虑如何进行资源管理,包括:
- 分配给
write buffer
,MemTable
,block cache
的内存 - 合并程序占用的
I/O
带宽 - 合并程序线程数
- 磁盘使用量
- 文件删除速率
资源管理包括两个维度,全局(分配给每个节点的资源)和局部(分配给每个 RocksDB
实例的资源)。对后者来说,RocksDB
允许应用程序创建 resource controller
(以 C++
对象实现并传递给多个 RocksDB
实例)来对上述提到的资源进行分配。例如,一个实现了对合并程序占用的 I/O
带宽限流的 C++
对象可以传递给多个 RocksDB
实例,从而保证任一时刻所有 RocksDB
实例的合并程序占用的 I/O
带宽之和不会超过指定值。另外,资源管理需要能够支持按优先级分配,使得最迫切需要资源的实例能够优先获取资源。
另一个在一个进程内运行多个 RocksDB
实例的经验总结是将各实例中执行相似任务的线程统一以一个线程池进行管理,而不是每个实例各自维护线程池。这些线程执行的往往是后台任务,统一了线程池也变相的限制了后台任务执行时占用的 I/O
,使得资源使用更具预测性。独立维护线程池的情况下有可能会有瞬时的 CPU
或者 I/O
毛刺,造成服务不稳定。不过,有得则有失,共享线程池的缺点就在于某些实例有可能无法及时的获取线程,从而阻塞后台任务,例如无法及时执行 SSTable
的合并,甚至造成写停顿(write stall
)。
相比而言,当不同的 RocksDB
实例运行在多个进程时,全局的资源管理则更具有挑战性,毕竟各进程之间没有信息交互。文中提出了两种策略:
- 为每个
RocksDB
实例配置较为保守的资源额度,缺点就是全局资源利用率不一定最优 - 各进程间交换资源使用的情况,从而动态调整资源配比
支持副本和备份
RocksDB
本身不提供开箱即用的副本和备份的支持,需要应用自行实现,不过 RocksDB
为实现这两个功能提供了必要的支持。
副本
从一个节点复制出一个全新的副本节点有两种方式:
- 逻辑复制(
logical copying
):遍历源节点的所有键值对,然后写入到目标节点。在源节点端,借助RocksDB
的快照功能保证了数据的读一致性。同时,RocksDB
支持scan
操作从而在数据复制时减少对在线查询的影响。在目标节点端,RocksDB
提供了bulk loading
的功能来批量加载数据 - 物理复制(
physical copying
):直接复制SSTable
和其他辅助文件到目标节点。RocksDB
在复制时会确保没有文件被修改或删除
备份
备份对于数据库来说至关重要,和副本复制一样,备份的实现同样有逻辑备份和物理备份两种。副本和备份的其中一个区别在于上层应用经常会需要同时管理多个备份。RocksDB
也内置了一个备份引擎针对简易的备份场景。
更新副本面临的挑战
在多副本场景下,如何将主节点的更新以一致的顺序同步到各个副本是一个挑战。直白的做法是依次按序向各个副本写入,当然缺点就是性能很差,无法利用多线程。另外,当某个副本停止同步很久之后,需要有相应的机制能让其快速同步至最新的状态。
而无序写的问题在于读取时有可能数据不一致,一种解决方法是引入快照读,客户端读取时指定序列号,RocksDB
会返回执行快照时对应时间点的数据,而不会受当前正在进行中的写入的影响。
WAL 处理
传统的数据库一般要求每次写入前先写 write-ahead-log (WAL)
来保证数据的持久性。相反,大型分布式存储系统一般使用多副本来提升性能和可用性,例如,如果某个副本的数据损坏或者无法访问,那么系统可以基于其他完好的副本重新构建损坏的副本。对于这些系统来说,WAL
就不是那么重要。另外,分布式系统一般也有自己的一致性协议日志(如 Paxos
协议),这种情况下 WAL
就可以不需要了。
因此,RocksDB
需要能够针对不同的场景灵活配置 WAL
,RocksDB
提供了三种选项:
- 同步刷盘写
WAL
- 先将
WAL
写入到缓冲区,然后定期由后台低优先级线程刷盘 - 无
WAL
数据格式兼容性
大型分布式应用往往运行在诸多节点上,并且最好不发生服务中断。因此,软件更新往往是逐台(或者小批量同时)发布,出现问题时再回滚。因此,RocksDB
需要能够保证存储在磁盘上的数据能够后向和前向兼容。另外,出于副本构建或者负载均衡的需要,系统会在各节点之间复制数据,因此整个集群可能运行着多个版本格式的数据。
对于后向兼容来说,RocksDB
需要能够识别之前的所有数据格式,这无疑增加了实现了维护的复杂度。对于前向兼容来说,RocksDB
需要能识别新的数据格式,并且至少要支持一年的前向兼容,这方面的技术手段借助于 Protocol Buffer
或者 Thrift
。对于配置项的兼容性来说,RocksDB
需要能够识别未知的配置,并尽最大可能尝试猜测配置的含义或者忽视。
错误处理的经验总结
RocksDB
的开发团队通过产线的实践总结了三条关于错误处理的经验:
- 数据损坏越早监测到越好,从而最低程度的避免数据不可用或丢失,同时也能精确定位数据损坏的源头。
RocksDB
通过在系统各层级计算数据的校验和并在数据传输时验证校验和来识别数据是否损坏 - 完整性保护必须覆盖整个系统,从而避免由于静默的硬件数据损坏传递给
RocksDB
客户端或者其他副本。仅仅在数据未使用或者传输时检测是不够的,因为数据损坏有可能由异常的软件,异常的CPU
或者其他异常的硬件引入。不过,即使基础设施一直扫描系统中是否有异常的硬件,某些硬件异常也不一定能够被发现 - 错误需要能够区别对待。
RocksDB
的开发团队最开始将所有非EINTR
(系统调用中断)类型的文件系统错误统一处理。如果错误发生在读取操作,那么RocksDB
直接将错误传递给客户端。如果错误发生在写操作,那么RocksDB
认为这是一个不可恢复的错误,然后永久中断所有的写入;RocksDB
需要重启才能恢复写入,并且可能还需要额外的运维操作。为了减少这种粗暴的重启,RocksDB
的开发团队开始对错误按照严重性分门别类,并且只有在遇到确实是不可恢复的错误时才中断操作
静默损坏的频率
在真实的 RocksDB
使用场景中,多久会发生一次静默的数据损坏?这很难直接给出答案。出于成本的考虑,应用所使用的存储设备一般不提供端到端的数据保护,相反,应用依赖 RocksDB
提供的块校验和来检测数据损坏。另一方面,基于 RocksDB
的应用本身也会运行数据校验程序来对比副本间的数据,不过这个过程识别出的数据损坏既有可能是 RocksDB
引入的,也有可能是应用本身引入的。
通过比较 MyRocks
中主键和二级索引的使用情况,RocksDB
的开发团队推断出每 100 PB
数据在每三个月内会发生一次由 RocksDB
本身引起的数据损坏。其中40%的情况下,这些数据损坏已经扩散到了其他副本上。
另一方面,数据损坏也有可能发生在数据传输中,这经常是由于软件 bug
导致。例如,底层存储系统在处理网络异常时的一个 bug
会导致一段时间后,每传输 1 PB
数据大约有17个校验和不匹配。
多级保护
数据损坏需要尽早识别,以免扩大影响范围,并尽可能的减少服务中断时间和数据丢失。大多数的 RocksDB
应用会持有一份数据的多个副本,并定期检测副本的校验和来识别损坏的副本,一旦发现损坏的副本,应用就可以丢弃该副本并替换为正确的备份。不过,这种做法的前提是系统中始终持有有效数据的副本。
如下图所示,RocksDB
启用了多级校验和保护,从而能尽早的发现数据损坏。
块完整性
块校验和继承自 LevelDB
,是为了避免文件系统层的数据损坏传递到客户端。这里的块不仅仅指 SSTable
块,也包括了 WAL
段(fragment
),在块生成时会同时生成校验和。每当一个块被读取时,RocksDB
都会检验它的校验和。
SSTable 完整性
每个 SSTable
文件也保存了一个校验和,该功能在2020年引入,是为了避免 SSTable
在传输时造成损坏,校验和会在生成 SSTable
时同时生成,并保存在 SSTable
的元数据中,RocksDB
会在传输 SSTable
时检验校验和。不过,这篇文章发表时,还没有 WAL
文件级别的校验和。
Handoff 完整性
在往文件系统写入数据前,会同时生成一个 handoff
校验和,然后将数据和校验和一起传递给下一层,由下一层进行数据校验。RocksDB
的开发团队期望用这种方式对 WAL
进行校验,因为 WAL
都是增量的追加写,不过可惜的是,很少有本地文件系统支持这种校验方式。不过,当 RocksDB
结合远程存储使用时,可以修改 write
接口使其接收额外的校验和,然后将其添加到存储服务内部的 ECC
(Error Correction Code
,用于校验数据完整性)中,最后远程存储服务在收到写请求时就可以进行校验。
端到端的键值对完整性保护
上述的完整性校验依然存在不足,其中一个不足在于文件系统之外的数据没有完整性保护,例如 MemTable
和 block cache
中的数据。因此,在这一层的数据损坏就无法被监测并有可能最终扩散到上层应用。而如果此时发生了 MemTable
的刷盘或者合并操作,则会将损坏的数据永久的持久化到磁盘上。
因此,RocksDB
的开发团队的解决方案是实现每个键值对级别的校验和,从而在文件系统层之外发现数据损坏。当某个键值对被复制时,其校验和也会随之复制,不过在写入到文件时这部分校验和会忽略,因为在文件系统级别已经有其他校验和机制来保证完整性了,从而减少数据冗余。
基于严重性的错误处理
大部分情况下,RocksDB
遇到的故障都是底层存储系统返回的错误。这些错误可能来自于各种各样的问题,从比较严重的问题例如文件系统变成了只读,到短暂的问题例如磁盘空间满了或者访问远程存储时网络异常。在早些时候,如果是读操作时发生的错误,RocksDB
就简单的将错误信息返回给客户端,而如果是写操作时发生的错误,RocksDB
则会永久性的暂停所有写操作。
而优化后 RocksDB
仅在遇到无法本地恢复的错误时才中断操作,例如暂时的网络错误不应该要求重启 RocksDB
实例。对于暂时性的错误,RocksDB
会周期性的重试。
配置管理和可定制化的经验总结
配置管理
一开始,RocksDB
的配置管理继承自 LevelDB
,所有的配置都写死在代码中。这带来两个问题:
- 某些配置和保存的数据强相关,因此,由某项配置生成的数据文件可能无法由其他配置的
RocksDB
实例打开 - 没有在代码中声明的配置会采用默认值,一旦
RocksDB
版本更新并修改了某些配置的默认值,上层应用可能会遇到不可预知的问题
为了解决配置的问题,RocksDB
可以在打开某个数据库的同时额外接受某些参数配置,之后 RocksDB
又支持将配置持久化到文件中。RocksDB
也提供了额外的两个辅助工具:
- 验证配置参数是否和要打开的数据库兼容
- 将数据库按照期望的参数配置进行迁移(不过存在使用限制)
配置管理的另一个严峻的问题就是配置项太多了,用户很难知道每个配置参数的影响,进而不知道如何根据自身应用找到最优的配置。但是又很难找到一套放之四海皆准的默认配置,因为每个应用的使用场景,工作负载都不同。另一方面,对于集成了 RocksDB
的应用,例如 MySQL
,数据库管理员可能对 RocksDB
了解不多也不知道如何优化。
在这个背景下,RocksDB
的开发团队花费了大量时间去优化默认配置下 RocksDB
的性能以及简化配置。同时,当前的重点在于提供配置的自适应性(automatic adaptivity
),另一方面也持续提供 RocksDB
可自定义配置的能力,从而能适配不同类型的应用。同时做到这两方面会显著的增加代码维护的负担,不过一个统一的存储引擎的重要性大于代码的复杂度。
回调函数的威力
RocksDB
需要周期性的合并底层的 LSM
树来清理已删除和过期的数据,如果能在合并时为应用提供额外的接口则能方便的为应用做功能扩展,而不需要额外的标准读写操作。因此,RocksDB
提供了两个在合并时的回调方法 compaction filter
和 merge operator
。
Compaction Filter
在合并时,RocksDB
提供了执行合并时针对每个被处理的键值对的回调函数,应用可以自行决定:
- 丢弃这个键值对
- 修改值
- 不做任何修改
一个典型的应用场景是实现 time-to-live (TTL)
,每个键值对在写入时保存了过期时间,然后在合并期间判断是否过期从而删除数据。另一个应用场景是实现 multi-version concurrency control (MVCC)
中的垃圾回收。另外,compaction filter
也可以用于修改数据,例如,从旧数据格式迁移到新的数据格式,或者根据时间来修改数据。最后,compaction filter
有时候也可以用来收集统计信息。
compaction filter
也非常适合需要扫描全部数据的管理任务,虽然也可以遍历整个数据集然后通过 delete()
或 put()
操作,但是使用 compaction filter
更高效且使用更少的 I/O
操作。借助 compaction filter
,用户无需额外维护定时任务,也不用担心由自定义实现可能造成的写入毛刺。
不过,compaction filter
在使用上也有些限制。例如,错误的使用 compaction filter
可能会破坏基本的数据一致性保证,多次快照读也可能返回不一致的结果(如果数据在两次读之间发生了修改)。因此,compaction filter
在不要求一致性的场景下更容易使用。另一个限制是 compaction filter
无法原子的丢弃或者修改一批键值对,例如,无法原子的删除一个键值对并丢弃相应的二级索引中的数据。
Merge Operator
RocksDB
原生支持三类操作:put()
,delete()
,和 merge()
。每一个操作都会写入到相应的 MemTable
,然后刷盘到 SSTable
。merge()
方法使得应用不需要先读取键就能更新键的值,也不需要写入完整的键的内容。在随后的读操作或者合并操作时,如果 RocksDB
遇到了一个 merge record
以及之前调用 put()
写入的记录,或者是多个 merge record
,RocksDB
会调用 merge operator
回调函数,应用可以将这些记录合并成一个,既可以是一个 put record
,也可以是一个 merge record
。
merge operator
的一个显著的应用是实现 read-modify-write
操作,例如实现一个计数器或者更新某个复杂对象中的单个字段。相比于用 get()
和 put()
整个键值对来实现,用 merge operator
来实现则更为轻量。不过,这会影响读的性能,因为找到一个 merge record
不代表查询结束,最坏的情况可能需要遍历 LSM
树的所有层,或者直到找到一个 put record
为止(更频繁的合并能缓解这个影响)。
优化删除
删除往往是 LSM
树中被忽略的一个操作。RocksDB
中无法直接删除键值对,删除操作本质上是插入一条标记删除的记录,这使得删除操作很快,但后续对该键的查询有可能变慢。在执行合并操作时,如果遇到标记删除的键值对,并不能直接将其物理删除,因为无法保证该键值对是否还存在于更深层的 SSTable
中。因此,RocksDB
针对删除场景也做了一些优化。
假设应用对同一个键依次执行了三次操作:
put()
,delete()
,put()
,前两个操作属于MemTable1
,后一个操作属于MemTable2
,之后刷盘成SSTable1
和SSTable2
。因为合并操作只是选取一部分SSTable
,所以有可能SSTable2
先合并到了更深层。
支持对大范围标记删除的数据范围扫描
应用经常会大批量删除连续或者临近的键,在这种场景下,调用 scan()
遍历每个键时就会遇到一堆已被标记删除的数据需要被跳过,从而浪费 CPU
和 I/O
资源。例如,某个应用可能用 RocksDB
保存文件系统中每个文件的绝对路径,而如果删除了文件夹则会导致一大批键被删除。再例如,使用 RocksDB
模拟队列时,每个出队的元素都会被删除,那么队首的元素则天然的挨着一批被删除的元素。遍历这些被删除的键一方面加重了资源负担,另一方面对查询结果也没有影响。在极端情况下,RocksDB
的开发团队在实践中遇到扫描了几百万个标记删除的键,最终只为了返回几个键值对。
一种解决思路是当出现大量连续标记删除的键时,触发合并操作。RocksDB
提供了几个功能:
- 当标记删除的键占所有键之比超过50%时,合并会更积极的发生,并且随着标记删除的键占比增加而更频繁。不过,不能很好的处理标记删除的键占比不超过50%的情况
- 允许应用自己标记哪些
SSTable
需要执行合并。在执行合并生成新的SSTable
时,RocksDB
提供了插件机制能够访问每个被处理的键值对,当新的SSTable
创建后,RocksDB
会调用该插件从而判断是否需要将该SSTable
放入下次的合并操作中。RocksDB
的统计信息中也包含了一次查询涉及了多少个标记删除的键,从而辅助应用更好的判断是否需要发起合并 - 执行
scan()
操作时,如果遇到了指定数量的标记删除的键,则提前中止遍历。当然,这样做的结果就是返回的数据不全,不过应用就能知道遇到了大量被标记删除的键,需要应用自行决定是否需要继续扫描还是放弃
上述措施一定程度上能缓解前述的问题,不过依然有局限性:
- 合并需要时间,在这期间
scan()
的性能依然受限 - 更频繁的合并意味着更大的写放大,这对于某些应用来说是不可接受的
目前,这方面的优化工作仍然在进行中。
回收磁盘空间
一般来说,如果数据被删除了,那么其所占用的磁盘空间也应当被释放。不过在 RocksDB
中数据不是立即删除,需要等待一段时间,而应用可能会要求在指定的时间内就需要释放磁盘空间。因此,RocksDB
提供了一个功能保证在指定时间内所有被标记删除的键都会移动到 LSM
树的最后一层,那么这些数据在随后的合并中就可以被清理。RocksDB
通过在 SSTable
的元数据中维护每个键首次添加到系统中的时间来实现该功能。
文件删除限流
RocksDB
一般构建于能够感知 SSD
(flash-SSD-aware
)的文件系统之上,当某个文件被删除时,它会发送一个 TRIM
命令给 SSD
。TRIM
的性能较好且对于 SSD
的寿命影响较小。不过,它可能会造成其他的性能问题:除了更新地址映射(大多数位于 SSD
的内部内存中)之外,SSD
固件还需要将这些变更作为 FTL
日志写到闪存上,这又会触发 SSD
内部的垃圾回收,从而造成大量的数据迁移,并最终影响上层应用的 I/O
延迟。所以,RocksDB
增加了文件删除的限流来控制同一时刻删除的文件个数。
内存管理
RocksDB
对于内存的使用主要在于 SSTable
的 block cache
以及保存 MemTable
。相比于其他数据库自己维护缓冲池,RocksDB
则依托于 jemalloc
进行内存分配。
尽管块的大小是可配置的,RocksDB
的实际实现则是采用变长的块,不过其大小会尽可能的接近所配置的值。例如,如果某个键值对的大小超过了指定的块大小,那么 RocksDB
会为其创建较大的块。类似的,如果某个块中已经存在一部分键值对,而此时再放入一个键值对就会超过块的大小时,则该键值对不会被放入该块中,并且 RocksDB
会选择一个较小的块来存放原来的那批键值对。另外,SSTable
的索引块和布隆过滤器的块大小也没有采用固定大小。出于这么做的原因是因为 RocksDB
采用的数据结构不支持就地更新,采用固定大小的块收益不大。
不过,在实践中,借助 jemalloc
来管理内存在分配和回收时都存在不可忽视的开销,其外部的内存碎片和元数据带来的额外内存开销也值得应用注意。这种情况下应用开发者可以选择换一个内存分配器,或者对 jemalloc
进行调优。
另外,RocksDB
的用户也经常对如何高效的调优内存参数感到迷茫。RocksDB
能够精确限制 block cache
和 MemTable
的内存参数,但是对 jemalloc
的外部内存碎片和元数据无法掌控,所以用户需要自行判断应该给这部分预留多少内存。因此,实验是检验真理的唯一标准。
尽管如此,RocksDB
的开发团队认为使用 jemalloc
仍然是一个合理的决定,因为可以将精力放到其他更重要的方面上。不过未来可能也会将这个内存管理问题提上日程。
Key-Value 接口设计的经验教训
RocksDB
的核心接口就四个:
put()
delete()
get()
iterators (scans)
很少有应用无法基于这四个接口实现需要的功能,KV
接口的键和值都是变长的字节数组,因此应用程序可以很自由的存储想要的数据,只需要做好序列化和反序列化。另外一个好处是可移植性,应用可以轻易的从一个 KV
系统迁移到另一个。
不过,天下没有完美的事物,部分应用的性能反而会受限于这精简的接口。例如,在 RocksDB
之外处理并发控制就很难做的高效,尤其是两阶段提交场景下需要在事务提交前先持久化一部分数据的场景。因此,RocksDB
增加了事务的功能,并持续添加新的功能,例如对某个范围内的数据加锁,以及支持大事务。
在其他场景下,应用则受限于过于精简的接口,为此 RocksDB
增加了两项扩展:
- 由应用定义的时间戳
- 列支持
版本和时间戳
为了支持诸如 multi-version concurrency control (MVCC)
和从历史某个时间点读取(point-in-time reads
)的功能,RocksDB
需要能够支持数据的版本管理,并能高效的访问各个版本。
目前,RocksDB
内部使用一个56位长度的序列号来标识键值对的每个版本。客户端的每一次写请求都会对版本号加1,不过客户端无法直接修改这个版本号。RocksDB
允许应用对其执行快照,RocksDB
保证只要这个快照没有被应用释放,那么在这个快照执行的时间点时的数据就都能始终被访问。
不过,对很多应用来说这依然不够,为了读取历史上的数据,前提是应用必须先曾经做过快照,RocksDB
不支持在当前时间对历史的某个时间点执行快照,因为根本没有这样的接口。另外,RocksDB
的版本号是每个实例各自维护,快照也是各实例粒度。因此,对于多 shard
的应用来说,很难对所有节点同时做一致的快照。
虽然应用可以将时间戳写入到键或者值中,不过这会影响应用的性能。如果将时间戳写入到键中,则点查询的性能会很差,因为实际保存的键和用户查询的键已经不同,需要做前缀扫描遍历;如果将时间戳写入到值中,则会影响对同一个键乱序写入的性能,因为乱序写入时如果不考虑相互间的时间戳顺序则有可能发生数据覆盖,并且读取旧版本的数据也变得复杂。因此,RocksDB
需要提供在键值之外由应用自行指定时间戳的能力。
经过实验,由应用指定时间戳的情况下,RocksDB
相比于将时间戳写入到键的方案有1.2倍的吞吐提升。提升的原因在于:
- 时间戳是键值对元数据的一部分,因此点查询依然高效
- 布隆过滤器可以继续发挥作用
- 每个
SSTable
在元数据中同时也维护了所有键所覆盖的时间戳范围,因此在搜索时有可能直接忽略整个SSTable
当然缺点就是磁盘使用空间会变大以及移植性变差。
列支持
一些基于 SQL
的数据库实现会以列的形式组织 RocksDB
的数据,虽然应用可以将数据库中一整行的数据以单条 KV
的形式保存在 RocksDB
,但是如果能直接在 RocksDB
层面支持列则对应用的性能提升有很大帮助。
假设有些大对象的某些列更新非常频繁,那么在整行数据保存的方案下更新就非常不高效。如果支持列,则只需要更新部分列。另外,如果数据库的某个查询也只涉及部分列,那么也不必读取完整的一行数据。
某些应用已经尝试对上述的问题进行优化。例如 Rocksandra
借助 merge operator
来进行部分列的更新,不过代价就是读性能较差,因为需要读完所有的 merge record
或者遇到一个 put record
才能知道最终的结果。另一种方案是将一行数据的每一列保存为一个键值对,缺点在于:
- 读取一行数据需要进行范围扫描(比如所有的列数据的键都以主键为前缀)
- 删除和更新一整行数据变得困难
因此,如果能在 RocksDB
层面直接支持列,则能大大提高应用的性能:
- 更新和读取单列的数据变得高效
- 当应用发起针对某些列的过滤查询时,某些过滤条件可以下推到
SSTable
- 某些列可以用不同配置的
column family
保存 - 可以像列数据库一样高效压缩保存列数据
另外,支持列也能够让 RocksDB
在主键索引和二级索引间校验数据完整性。
来自失败的提案的经验总结
一路走来,RocksDB
实现了很多的功能,其中也有些失败的案例。
支持基于 DRAM 的存储设备
在2014年,RocksDB
的开发团队决定将 RocksDB
适配到 Ramfs
(RAM File System
)上,从而有比 SSD
更低的访问延迟。为此,RocksDB
将 SSTable
和 MemTable
的格式改为插件式,从而针对 Ramfs
进行了特定的优化。
这个结果本身是成功的并且也应用到了某些服务上。不过,在战略上来说这个功能提的太早了。对于大型纯内存式的持久化存储系统来说,RocksDB
的这套方案并未像预期的那样获得关注。而对于内存式的应用来说,一般也不会考虑集成 RocksDB
,因为完全直接自己操作内存来的更快和便捷。
支持混合式存储设备
SSD
比 HDD
更快,不过也更贵而且寿命也有限。因此,如果能结合 SSD
和 HDD
一起使用,那么对于大多数的应用来说可以在性能和成本之间做到更好的平衡。同样是在2014年,RocksDB
支持能将 LSM
树的不同层保存到不同的存储设备上。
不过,在该功能推出的时候用户并没有买账。另一方面,在实践中将 SSD
和 HDD
同时配置到一个节点上的情况也比较罕见,RocksDB
的开发团队认为混合存储方案的潮流应该对远程存储服务更有吸引力,而这要到2018年才开始着手。不过,最近RocksDB
的开发团队又重拾了混合式存储项目,因为要支持混合式的本地/远程存储服务,不过依然还有额外的工作要做。
更丰富,高层次的接口
RocksDB
最开始支持的是传统的 KV
接口,不过,在过去的几年中也尝试过扩展支持更为丰富的接口,从而更方便某些应用使用。例如,在2013年 RocksDB
添加了类似于 Redis
的 lists
的接口;在2014年添加了2个与空间相关的接口;在2015年支持了文档类型的数据。所有这些接口都基于核心的 KV
接口实现。
但是,这些接口同样没有被广泛采纳,并最终废弃和移除。因此,对于 RocksDB
的开发团队来说,将精力放在核心功能上更有价值。大部分 RocksDB
的用户都能借助简单的 KV
接口构建更为丰富的上层接口,而不需要由 RocksDB
来提供。用户的首要痛点在于效率和易管理性。总结来说,扩展核心接口的前提在于能够显著提升性能。
附录
经验总结
- 对于存储引擎来说,能够调优适配不同的工作场景至关重要
- 大多数使用
SSD
的应用的瓶颈在于空间效率 - 降低
CPU
开销对系统的高效运行越来越重要 - 如果一台机器上运行了多个
RocksDB
的实例,那么全局的资源管理就是必须的 WAL
的可配置性(同步刷盘写WAL
;先将WAL
写入到缓冲区,然后定期刷盘;无WAL
)能够给上层应用带来性能提升- 需要正确的支持数据副本和备份
RocksDB
需要对数据和配置文件提供后向和前向兼容- 越早识别数据损坏越好,而不是在最后检测
- 完整性保护必须能够覆盖整个系统来避免数据损坏(例如,由
CPU
或内存引起的bitflip
)扩散给客户端或者其他副本;只在数据空闲时或者传输时进行损坏检测是不够的 - 错误处理需要能够根据类别和严重性分别处理
- 即使概率很低,
CPU
和内存造成数据损坏也有可能发生,因此数据副本并不一定总是能解决这种情况 - 自适应的配置对简化配置管理大有益处
- 可以通过用户自定的回调函数来提升性能,不过当前的技术方案仍有进步的空间
- 在
LSM
树中删除连续的键会带来性能问题 SSD
的TRIM
对性能大有帮助,不过需要对文件删除作限流来避免偶发的性能问题- 借助第三方内存分配器来管理内存使得开发团队能将精力放在其他重要的功能上,不过缺点是带来了可管理性问题
- 目前的
KV
接口已足够有用,不过对于某些应用场景来说可能会有性能问题;在键值之外添加时间戳能够在性能和简洁性上达到较好的平衡 - 应用可以在
RocksDB
提供的KV
接口之上实现列数据并且有着较好的性能,不过如果存储引擎本身支持列则会有更好的性能
设计抉择回顾
- 可定制化对用户始终有用:结果就是用户迷失在大量的配置里,而且很难找到最优的配置
RocksDB
无法感知CPU
的bitflip
:完整性保护需要端到端覆盖- 遇到任何
I/O
错误时可以直接中断操作:过于粗暴,所以需要根据错误的类别和严重性做不同的处理