大部分开发对 Bitmap 应该都不陌生,除了作为 Bloom Filter 实现的存储之外,许多数据库也有提供 Bitmap 类型的索引。对于内存型的存储来说,Bitmap 只是一个特殊类型(bit)的稀疏数组,操作内存不会带来读写放大问题(指的是物理读写的数据量远大于逻辑的数据量), Redis 就是在字符串类型上支持 bit 的相关操作,而对于 Kvrocks 这种基于磁盘 KV 实现的存储则会是比较大挑战,本篇文章主要讨论的其实是「基于磁盘 KV 实现 Bitmap 要如何减少磁盘读写放大」
为什么会产生读写放大
读写放大的主要是来源于两方面:
硬件层面的最小读写单元
软件层面存储组织方式
硬件层面一般是由于最小读写单元带来的读写放大,以 SSD 为例,读写的最小单位是页(一般是 4KiB/8KiB/16KiB)。即使应用层只写入一个字节,在磁盘上实际会写入一个页,这也就是我们所说的写放大,反之读也是一样。另外,SSD 修改数据不是在页内位置原地修改而是 Read-Modify-Write 的方式,修改时需要将原来的数据读出来,修改之后再写到新页,老的磁盘页由 GC 进行回收。所以,即使对同一页的一小块数据反复修改也会由于硬件本身机制而造成写放大。类似于如下:
由此可见,大量随机小 io 读写对于 SSD 磁盘来说是很不友好的,除了在性能方面有比较大的影响之外,频繁擦写也会严重导致 SSD 的寿命(随机读写对 HDD 同样不友好,需要不断寻道和寻址)。LSM-Tree 就是通过将随机写入变成顺序批量写入来缓解这类问题。
软件层面的读写放大主要来自于数据组织方式,不同的存储组织方式所带来的读写放大程度也会有很大的差异。这里以 RocksDB 为例,RocksDB 是 Facebook 基于 Google LevelDB 之上实现了多线程,Backup 以及 Compaction 等诸多很实用的功能。RocksDB 的数据组织方式是 LSM-Tree,在解决磁盘写入方法问题,本身的数据存储也带来了一些空间放大问题。下面可以简单看一下 LSM-Tree 是如何组织数据:
LSM-Tree 每次写入都会产生一条记录,比如上图 x 先后写了 4 次,分别是 0,1,2,3。如果单看 x 这个变量,这里相当于有 4 倍的空间放大,这些重复的记录会在 compaction 的时候进行回收。同样,删除也是通过插入一条 value 为空的记录来实现