从全链路优化设计单机存储引擎
单机存储引擎是一个研发门槛非常高的基础组件,开源的项目中,只有rocksdb是功能比较全面,应用最为广泛的,但随着使用场景越来越广,硬件条件也比rocksdb设计之初发生了翻天覆地的变化,现在企业服务器的内存可以做到非常大,ssd的性能也有了飞跃提升,许多场景下,rocksdb的读性能距离理想表现有巨大的差距。另一方面,最近几年出现了非常多基于rocksdb构建的分布式存储系统,但在一些功能的衔接上也会显得生硬。本文希望基于作者多年在推荐系统中使用kv存储的经验和思考,重新设计一套单机存储引擎,在保留rocksdb主要功能的基础上,达到理想的读性能,并且能够基于它更优雅、更低损耗地构建分布式存储系统。
零拷贝读
Rocksdb现在每次读都需要将结果拷贝一份,这是一个开销比较高的操作,如果所有数据在memtable中都用share_ptr维护,查询结果只需返回智能指针,这样就可以减少一大笔开销。只是使用方持有智能指针时,需要注意内部数据可能发生修改。
自定义Value
Rocksdb支持使用方自定义MergeOperater,从而定制修改逻辑,但value和operate都是字符串,许多逻辑需要先做反序列化,然后再计算,再将结果序列化后输出,这对许多需求来说是非常低效的。对于一个完整存在于memtable的数据,没必要把结果序列化存在内存中,可以让用户自己实现value在内存中的结构,并且正常情况下,可以直接做原地修改,不必把operate序列化后,当成一条kv写进去。零拷贝读拿到的智能指针内容,也是使用方自己定义的结构。基于这个设计,使用方可以自己实现类似redis的复杂数据结构,预计内存操作性能不会逊于redis。
但rocksdb的Snapshot功能对此模式是一个麻烦,如果要保留Snapshot功能,需要做许多额外设计,这里就不详述了。
在设计Value格式的时候,可以考虑网络传输的效率,通过网络传输一个复杂结构一般是需要序列化和反序列化的计算开销的,如果能把Value的定义封装成一个类似ProtoBuf的库,从db读出到真正使用前,都是以序列化的形式传递,在真正使用的时候再做反序列化,这样可以大大降低中间链路的开销。
外置Write Ahead Log
现在主流的分布式kv存储架构,一般是在上层搭建paxos、raft之类的分布式有序队列,然后每条队列下面挂一个单机存储引擎,这与rocksdb这类存储引擎的WAL功能上有很大的重复,但要关掉WAL又需要做额外的开发,在存储引擎中专门维护一个Sequence Number,非常地生硬,所以笔者认为保证数据可靠性的关键不在于WAL,而是Sequence Number,存储引擎要求每次写入传入Sequence Number,并且检查它单调增,每次flush记录下数据库持久化部分的Sequence Number。这样使用方就可以定制自己系统的有序队列,通过Sequence Number和存储引擎对齐。
文件边界对齐
rocksdb的文件文件切分只是根据大小限制,没有做特殊策略,导致上下层的文件边界并不对齐,compaction时,不在key range交叠范围内的数据也被迫参与,增加了写放大。诸如PebblesDB等项目注意到了这个问题,将db做了切分,减少了这部分写放大,本文将给出一种设计,对数据进行更精细的切分,并且用B+树将文件组织起来。
具体设计如下:除了第0层,其它第L层的文件不会与第L+1层的超过1个文件key范围产生交叠,也就是第L+1层的所有文件的边界,将第L层分割成若干区域,这样第L层同一个区域里的文件,与第L+1层的一个文件,就会形成一个被包含关系,这个关系可以用一个B+树来维护,如下图所示:
compaction的过程与rocksdb有很大不同:compaction是以子树为单位进行的,当发现某个子树所有文件大小总和超过该子树容量阈值后,子树所有文件进行一个compact,compaction的结果输出到子树根节点所在层,compaction完成后整个子树清空,输出时按照一定策略做文件切分(文件大小、冷热区间切分),如果发生文件切分,就要删掉原节点,并插入新文件代表的节点。子树compaction过程中,子树内部禁止其它compaction,但可能发生flush,因此L0的文件可能会横跨多个区域,这种情况可以使用硬链接,让L0文件同时属于多个节点就可以了。
查询过程也是比较清楚的,memtable查过之后,就需要从B+树定位到最上层文件的节点,然后逐层到对应文件中查找。rocksdb的bloom filter是文件级别的,最近有篇论文提出了全局过滤器,和这个B+树结合,每个子树维护一个过滤器,或许会有惊喜。
上面的设计能够做到比较精细的切分,但是实现难度高,而许多场景冷热数据并没有那么强的局部性,精细切分可能并没有明显效果,下面再给另外一种容易实现的设计:
一个db用B+树管理多个region(与tikv的region类似),每个region的规模不应太大,每个region有独立的memtable,除了L0其它每层只有一个文件,region达到分裂条件,或者由外部触发,会进行分裂。
region是flush、compaction的基本单位,compaction的过程与rocksdb有很大不同:当一层数据量超出该层目标大小,并不会立刻与下层做compact,而是会继续等待上层数据溢出,当L0溢出时,会把从L0往下,所有连续的溢出层的所有文件和第一个没溢出层做compact,这样上层所有数据都被压到了第一个没溢出层,这个过程有点像汉诺塔。每个region的compaction是串行的,甚至db内region数足够多的情况下,region内的flush和compaction都可以串行,这样许多逻辑都可以简单很多。
更显著的冷热分层
Rocksdb各层数据的分布,完全是由写操作决定的,读操作额外靠block cache和page cache加速,这样设计实现相对容易,但仔细分析就会发现,许多场景下会导致额外开销。
笔者认为Block Cache是一个多余的设计,从下层文件中读到的数据,可以重新放进memtable,如果直到它变成冷数据被淘汰,都没有被更新过,那么就直接从memtable删掉就可以了。这个改进对自定义MergeOperater的场景尤为重要,因为一个key的operater可能分布在多个sst中,现在rocksdb并没有把FullMerge的结果缓存的功能,每次读都需要执行FullMerge的计算,这是非常大的开销。
对于持续有写入的热点数据,rocksdb仍然会把它的旧数据往下层compact,这笔开销是完全浪费的,对于热点数据,应该让它长期留在memtable中,不产生compaction的io。一篇叫TRIAD的论文中提出了这个问题的优化方案,每次flush只将冷数据写到L0,而热数据写入一个commit log中,不参与compaction,并且会留在memtable中。但我感觉论文的设计还是有问题的,TRIAD为了减少WAL占用空间,每次flush要将所有热数据写到commit log中,我认为用这个这个io开销换存储空间是不合算的,TRIAD可能只考虑了kv rewrite的情况,但像上文提到的自定义Value,可能绝大多数写入是增量的,一个value的大小可能是一个operate的上百倍,这样一个value写commit log的开销是很大的。因此改成每次flush只需要将保留在要被淘汰那些commit log的数据再写到新的commit log里,那些暂时安全的热数据不需要落盘,这样WAL多占用一些空间,进程重启时间更长,换来io的大幅降低,这在线上生产环境中应该是合算的。热数据写到commit log的同时,应该往L0的文件中写入这个key的删除标记,这样如果数据存在于中上层的文件中,这个删除标记可以避免这条数据在后续的compaction中产生io。
多种存储介质的组合
使用持久化内存优化LSM Tree是近期存储方向的热门方向,从已经发表的成果来看,收益非常可观,所以未来优秀的存储引擎应该能够将内存、持久化内存、固态硬盘三种存储介质都充分利用,使用方能够基于不同的硬件组合,构建各种性能和成本的存储系统。比如数据量少,但读压力特别大的场景,可以使用纯内存,而需要更大的存储量,但单位存储量的读写压力较低的场景,就可以选择搭配持久化内存和固态硬盘。
Rocksdb的纯内存方案,是基于shm的LSM Tree,ops大约只有几十万,在内存存储引擎里性能算是相当低了,而微软的faster能达到上亿。上文提到memtable的设计目标是达到内存存储的理想性能,所以纯内存模式只需要memtable就可以了,当然相比持久化存储的memtable,纯内存模式可能需要LRU淘汰这类特殊逻辑,所以它可能只是复用了memtable核心代码的另一个存储引擎,并不能和持久化引擎互相切换。