前言
目前主流的数据库引擎可按照实现方式分为以下几种:B+ Tree、LSM、Delta Main。在类 LSM 的存储方案中,RocksDB 和 Kudu 之流通过 append 的方式写日志并定期重新组织,从而将写数据的压力平衡到读取的过程中。由于相邻的数据可能落在 Memtable 以及不同的 SST 中,在涉及到范围扫描时,需要对结构化/半结构化数据进行排序输出。因此,对于数据块排序过程的优化,也会对读性能产生重要影响。本文将基于类LSM引擎,由浅入深地介绍几种相关方案。
诸多存储相关的背景知识可以参照这篇论文:
Fast Scans on Key-Value Storeswww.vldb.org多路合并
简单案例:定义数据块(Mark)为内存中的有序数组,若干个无相交的数据块有序排列组成一个分区(Part),如何对 n 个分区进行全局排序输出?
最朴素的方式即使用优先队列(堆)进行多路合并,如下图所示:
- 以 n 个游标指向各 Part 首个 Mark 的首行,并将所有游标压入堆
- 弹出顶部游标并找到对应的数据
- 如果游标指向 Mark 的底部,则指向下一个 Mark 的首行,否则直接下移游标,再压入游标
- 依照 2 ~ 3 循环致结束
![48939acd2ca75866256dff0147a6f0a0.png](https://i-blog.csdnimg.cn/blog_migrate/725ba3c11298b97e37b11fa89d3a51bd.jpeg)
如果合并的路数较少(例如只有 1 或 2 路),则可以实现特化版本以减少分支和堆操作,以获得更好的局部性。
根据数据分布状态优化:
如下图所示,
![843e802059a707c65306619f71d2fcc2.png](https://i-blog.csdnimg.cn/blog_migrate/14aad62f721522c8b63773b9c1f39b41.jpeg)
具体方案如下:
- 如果有 Part 的当前 Mark 的值域范围落后于其它 Part 的当前 Mark,那么排序堆不需要压入该 Mark 相关的游标,从而减少堆大小以及堆操作过程中的比较次数
- 当前只有 1 个 Part 在读取时,如果下一个将遇到的边界是本 Part 当前 Mark 的下边界,则可以直接跳到下边界不需要逐行压入堆再弹出
- 当前只有 1 个 Part 在读取时,如果下一个将遇到的边界是其他 Part 当前 Mark 的上边界,则可以通过二分查找跳至目标上边界
多核环境下优化:
如果需要对上述案例在多核环境下做优化,则关键在于平衡多核的负载,以防止单核瓶颈拖慢整体性能。
![6f820e4f5e2e1222b5ca973f2cdc43bd.png](https://i-blog.csdnimg.cn/blog_migrate/52452ce1cba73865de8906bca88227be.jpeg)
- 首先可以将数据按值域划分成多个范围( Range),Range 之间可以存在 overlap,应尽可能令每个 Range 的数据总量相等
- Range 的数量与核数相关,例如等于核数
- 下层合并:对每个 Range 使用一个多路合并流程,运行在独立的线程上
- 上层合并:将下层合并的输出作为输入,再运行一个多路合并
- 由于 Range 是按照值域划分的,上层合并的输入数据重叠几率很小,可以大量跳段优化。由于上层合并运行在单核,该优化非常重要
- 如果可以干净地切分 Range 边界(若块跨了 Range,则切成两个块,保证无 overlap),那么上层合并可以直接省略
- 上层从下层读取数据应做异步+等待
磁盘存储相关优化
在实际的类 LSM 引擎中,数据源主要都存于磁盘的 SST 之中,少量存在于内存的 Memtable 之上。针对以上的排序案例,需要考虑磁盘操作对整体负载的影响,主要在于以下几点:
- 读盘与计算之间需要做异步,避免 CPU 和 IO 设备互相等待
- 流式数据读取和操作,避免内存爆炸
- 分段策略的数据边界预读,用于计算分段
可以令每一个 Mark 由最多 N 行(N 为不可变值,例如8K)数据,每 M 个 Mark 形成作为数据压缩的最小单元。Part 中以 Mark 为单位构建粗索引并持久化,例如:每个 Mark 的最小值,整个 Part 的最大/小值等。
引入流算子机制,例如读盘算子每读 N 行数据则向上输出,多个读盘算子可成为一个多路合并算子的输入,并照此不断衔接。每个算子都要尽可能合理控制 读取->处理->输出 这 3 个步骤的数据批大小,以达到更好的 pipeline 效果。
从磁盘中读取的数据可以组织成列格式,以便于同数据簇批处理和拷贝。如果数据簇本身就以列格式存盘,则会取得更好的压缩比和 IO 效果。同时数据压缩算法和缓存机制的选择也至关重要。
多版本控制相关优化
多版本并发控制(MVCC)是实现数据库引擎 update 功能的重要途径,其具体定义可参照维基百科:
Multi-version concurrency controlen.wikipedia.org令磁盘中的每条数据都是以<key, value>形式存在的,key 实际可以被拆解为 3 部分 {handle, version, delmark} 依次代表 key 比较的排序键,handle 代表该数据唯一索引,version 代表其在不同时刻被修改所对应的版本号,若修改为删除操作则 delmark 为 1,否则为 0。数据采取按范围 sharding 的方案。令 region 代表 key 值域是 [start, end) 的一段数据,彼此互不相交且空间占用相对一致。
如果一个查询请求包含一组 region,一个特定的版本号
- 可以按 region 排序后按核数分组,每组由单核处理。考虑到数据压缩,合并组内相邻的 region 并转化为多个范围查询以减少读冗余数据
- 对于每个范围查询,需要先过滤一遍粗索引,二分定位到相关的 Mark 构成成数据流。然后在此基础上添加范围过滤算子来细粒度地清除无效数据。其后是版本过滤算子以去掉版本号大于
的数据。将多个版本过滤算子输入到一个多路合并算子作为该范围查询的输出
- 多路合并算子可以结合上述排序优化算法,找到每个 handle 最终对应的版本号并根据其是否被删除输出结果
![5d855150d07037c37884c10f6fba643d.png](https://i-blog.csdnimg.cn/blog_migrate/2bc7d06a430becd93d0131a811643c96.jpeg)
有几个细节可以优化:
- 当版本号为整型数据时,版本过滤算子可以实现乐观检测的优化,即利用 SIMD 快速判断当前批次数据的版本号是否都小于等于
- 当多路合并算子在做跳段时,也可以乐观检测一段数据的 handle 是否完全不同且 delmark 全都是 0,这样就能直接输出到上层算子
- 当数据分布较为糟糕,以上几个算子可以压缩成一个,使用较为朴素的 snapshot read 方式以减少流算子间数据拷贝的开销
总结:
本文从几个案例介绍了面向数据块读流程的相关排序优化。实际上,读的效果很大程度上取决于写入模式和整理机制,通常的数据存储方案都是在这几者之间寻求满足业务需求的平衡点。在分布式环境下,上文给出的优化策略也比较适合按范围 sharding 的方案以及大量写入少量更新/删除的场景。