开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第15天,点击查看活动详情
大多数开发人员应该熟悉位图,除了bloom过滤器的存储实现外,许多数据库还提供位图类型索引。对于内存存储,位图可以看作是特殊类型的稀疏位数组,它不会引起读写放大问题(意味着读/写字节远远超过请求)。Redis支持对字符串类型的位相关操作,但对于像Kvrocks这样基于磁盘kv的存储来说,这是一个很大的挑战。因此本文主要讨论“如何降低rocksdb上的磁盘读/写放大”。
为什么会发生放大
放大主要来自两个方面:
硬件级需要最小的读写单元
我们如何在软件级别上组织数据分布
以SSD为例,读写操作的最小单位是页(一般为4KiB/8KiB/16KiB),即使请求大小为1byte,它也可以读写一页。此外,SSD的修改方式是read - modify - write,而不是in-place,这意味着SSD将读取页面内容并修改,然后将其写入另一个页面,旧的页面将被GC回收。类似如下:
我们可以看到,大量的随机io对SSD盘非常不友好,除了性能问题,频繁的擦除和写入也会严重导致SSD的寿命(随机读写对hdd也不友好,需要不断的寻址)。LSM-Tree通过将随机写入更改为连续批处理写入来缓解这类问题。
软件层面的读写放大主要来自于数据组织方式,不同组织方式带来的读写放大程度也会有很大差异。以RocksDB为例,RocksDB是基于谷歌LevelDB的Facebook,它丰富了多线程、备份和压缩等许多非常有用的功能。在解决磁盘写放大问题的同时,也带来了一些空间扩大问题。让我们简单看看LSM-Tree是如何组织数据的:
LSM-Tree将在每次写入时创建一个新条目。例如上图中,变量X连续写了4次,分别是0、1、2、3。单变量X侧造成4倍空间放大,那些旧的空间会在背景压实上被回收。类似地,删除是通过插入值为空的记录来实现的。LSM-Tree的每一层空间大小逐层增加。当容量达到极限时,它将触发压缩以合并到下一层,等等。假设0级的最大存储大小为M字节,则逐层增加10倍,最大为7层。理论上,空间放大约为1.111111倍。计算公式如下:
amplification ratio = (1 + 10 + 100 +1000 + 10000 + 100000 + 1000000) * M / (1000000 * M)
但由于最后一层一般达不到最大值,放大空间率比理论值大得多。在RocksDB文档中也提到了它。详情见:rocksdb.org/blog/2015/0…
另外,由于RocksDB的读写都是基于key-value,所以值越大,读写放大可能越大。例如,假设有一个值为10 MiB的JSON。如果你想修改这个键中的一个字段,你需要读取整个JSON,修改并重新写回来,这将造成巨大的读写放大。论文“wiskey:在SSD-conscious Storage中分离键和值”,通过分离键值来优化LSM-Tree的大键值,以减少压实引起的写放大问题。TiKV中的titan基于Wiskey的论文,用于优化RocksDB在大键值场景下的写放大。RocksDB也在社区版本中实现了这个功能,但它仍处于实验阶段。
在rocksdb上实现位图
Kvrocks是与在RocksDB上实现的Redis协议兼容的磁盘存储。它需要支持位图数据结构,因此需要在rocksdb上实现位图。在大多数场景中,位图被用作稀疏数组,这意味着写入的偏移量应该是随机的,第一次可能是1,下一次偏移量可能是1000000000或更多。因此,实施将面临上述放大问题。
一种简单的方法是将整个位图视为一个值,并将该值读入内存,然后在写入时将其写回。虽然这种实现非常简单,但当价值巨大时,会造成严重的放大。除了有效的空间利用问题外,它还可能直接导致整个服务不可用,因为我们需要读取和写入整个值。Pika中的位图就是这样一个实现,但最大值限制为128 KiB。限制值大小可以避免上述极端情况,但会极大地影响位图的用户场景。
由于我们知道核心问题是由单个键值过大引起的,所以最直接的方法是将位图分割成多个键值,并将单个键值大小控制在合理的范围内,这样放大效果相对可控。在当前的Kvrocks实现中,每个键值被划分为1 KiB(8192位)。算法框图如下:
以setbit foo 8192002 1为例,实现步骤如下:
计算与offset 8192002对应的key,因为Kvrocks使用的值是1 KiB,所以key的编号是8192002/(1024*8)= 1000,所以可以知道该位应该存储在子key“foo1000”中
然后从RocksDB中获取该键对应的值,计算该段的偏移量,8192002%8192等于2,然后设置偏移量为2的位为1
最后将整个值写回RocksDB
这个实现的一个关键点是只读写我们需要的位图的限制部分。假设我们只执行了两次setbit, setbit foo 1 1和setbit foo 8192002 1,那么在rocksdb中只有foo:0和foo:1000两个键的读写,实际的读值大小总共只有2 KiB。它可以完美地适应位图这样的稀疏阵列场景,也不会因为稀疏写入而造成空间扩大的问题。
这个想法也类似于Linux的虚拟内存/物理内存映射策略。例如,我们向malloc请求1GiB,而操作系统只分配了一块虚拟内存地址空间。实际写入时分配的物理内存是否会触发页错误中断。也就是说,如果内存页没有被写入,只读不会造成物理内存分配。
GetBit也类似。它首先计算出偏移量所在的键,然后从rocksdb中读取该键。
如果不存在,表示该段还没有被写入,直接返回0。
如果存在,则读取Value并返回相应位的值。
此外,实际的键值大小也由当前写入的最大偏移量决定。当有写操作时,并不总是创建1024 KiB键值。这还可以在某种程度上帮助优化单个键-值内的读写放大问题。可以查看源代码了解更多细节:github.com/KvrocksLabs…
总结
可以看出,在内存和磁盘上实现同样的事情是完全不同的,面临的挑战也是完全不同的。对于磁盘类型的服务,必须不断优化随机读写和空间放大问题。熟悉软件是不够的,还需要了解硬件内部。