[feat] Implement multi-level skiplist for havenask indexlib #310
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
一、背景
当前 havenask 使用单层跳表加速倒排链 seek,每隔 128 个 doc 构建一个跳表索引。这种实现方式简单实用,因为在大部分情况下,倒排链求交得到的下一个 doc 通常距离当前位置不远,不需要跨越大量 doc 去 seek 数据。
然而,单层跳表在以下场景存在性能瓶颈:
当超长倒排链与短倒排链求交时,以短链 doc 为探针在长链中查找,由于单层跳表的跳跃步长固定,往往需要多次前向扫描才能确认 doc 是否存在,定位效率低下。
在已知目标 docid 集合的情况下,需要快速验证这些 doc 是否命中特定 term。单层跳表的顺序扫描特性使得倒排链的随机访问代价较高,缺乏高效的点查能力。
在这两类场景中,目标 docid 往往分布稀疏,要求能够在倒排链上进行高效的跳转与遍历;仅靠单层跳表难以满足快速 seek 的需求。因此,我们引入多级跳表来加速定位与求交。
从复杂度角度看:
二、多级跳表实现原理
2.1 多级跳表介绍
我们实现的多级跳表主要借鉴了开源搜索引擎 lucene 中的一些设计思路,并结合实际场景进行了优化。
整个多级跳表有几个参数:
一个典型的多级跳表结构:
根据上述条件可以知道:$skipInterval*skipMultiplier^i$ 个 doc 会生成一条记录。$min(maxSkipLevels, 1 + log_{skipMultiplier}{df \over skipInterval})$ 层跳表。
第 level i 层跳表,每隔
对于有 df 篇 doc 的一条倒排链,总共有
2.2 设计关键点
2.2.1 多级跳表压缩算法的选择
决策:放弃使用 PForDelta 压缩算法,改用 Group VInt 算法 + delta 压缩来保存跳表数据。
原因分析:
原单层跳表采用 PForDelta 算法压缩倒排数据,该算法基于固定大小的 block 进行批量压缩。多级跳表采用点查访问模式,需要支持单点解压的压缩算法。虽然 Lucene 使用的 vint 算法满足单点解压需求,但其解压性能欠佳。
针对单个多级跳表节点通常存储 2~4 个数据的特点,本方案采用 Group vint 算法以提升解压效率。同时利用跳表层内数据单调递增的特性,先对数据进行 delta 编码,再使用 Group vint 压缩存储,在保证解压性能的同时有效降低存储开销。
另外,我们没有直接复用 HA3 内置的 Group VInt 库。原因是:在跳表场景下我们以“节点”为单位解压,而每个节点通常只包含 2~4 个数据;HA3 的 Group VInt 主要面向大批量数据压缩,应用到这种小批量节点会引入较多额外元信息与冗余开销,和实际访问模式不匹配。因此,我们在多级跳表内部实现了更贴合节点粒度的 Group VInt 编解码方案。
2.2.2 跳表存储结构优化
在跳表的存储结构上做了以下优化:
2.3 存储结构介绍
因为 havenask 中有两种跳表结构,分别存储 key / offset ,key / value / offset,所以也存在两种跳表结构。结构总体上是类似的,以 skipMultiplier = 2 为例,画出示意图(实际上实现中 skipMultiplier = 8)。
存储 key / offset 结构的 skiplist,主要用于:
存储 key / value / offset 的存储结构,主要用于:
在上述多级跳表示意图中,星号(*) 标记表示该节点值采用了 delta 编码。整个跳表结构中,除了 childpointer 因需要随机查询能力而无法压缩外,其余所有数据均存在单调递增特性,故使用 delta 编码进行压缩。每一层的数据维护独立的 delta 基准值。在线查询时,reader 会为每个层级维护一个独立的状态机,通过累加 delta 值来正确还原原始数据。
以 key4 为例,其真实值的还原方式如下:
整个跳表的序列化结构为(假设有三层跳表):
2.4 实时多级跳表实现
为了满足 havenask 实时索引“单写多读”的并发安全需求,我们设计了一种基于原子边界发布的无锁实时多级跳表。
为了精细化控制各层索引的可见性,系统维护以下核心原子变量:
2.4.1 写入流程(Writer)
Writer 负责物理节点的插入以及可见性边界的维护,保证 Reader 不会访问到未完全初始化的高层节点。
操作步骤:
2.4.2 读取流程(Reader)
Reader 旨在无锁状态下获取一个自洽的索引快照。为了防止在遍历过程中因 Writer 更新导致指针悬挂或逻辑空洞。
操作步骤:
2.4.3 总结
本算法的核心安全性建立在写入与读取顺序的非对称性之上:
三、使用方式
可以使用的索引类型:除了主键索引 PRIMARYKEY 之外的所有倒排索引类型,包括:
使用方式:在需要添加多级跳表的索引项中添加
"multi_level_skip_list": 1{ "columns": [ { "analyzer": "simple_analyzer", "type": "TEXT", "name": "content" } ], "indexes": [ { "name": "content", "index_config": { "index_params": { "multi_level_skip_list": "1" }, "index_fields": [ { "field_name": "content" } ] }, "index_type": "TEXT" } ] }四、性能测试对比
数据集与索引规模:选取内部线上集群的一个分片作为数据来源。抽取 1 亿条数据构建 havenask 测试集群。原始索引文件约 19 GB,构建后的 havenask 索引约 14 GB。
测试目标:使用线上真实 query 进行查询压测,对比引入多级跳表优化前后的查询延迟指标(P50、P90、P95、P99)。
测试环境:单机 32 核 CPU、128 GB 内存,通过 hape 的 Docker 模式将 QRS 和 Searcher 部署在一个物理机上。容器资源限制如下:
压测方式:固定 QPS=5000,连续压测 5 分钟;以每次请求返回结果中的 total_time 字段作为该请求的耗时统计口径,并计算/对比 P50、P90、P95、P99 等分位延迟。
函数级开销分析(上:未启用多级跳表;下:启用多级跳表):在压测过程中采样 30s 生成火焰图。未启用多级跳表时,跳表遍历相关函数约占 searcher 端 CPU 的 12%;启用多级跳表后,该部分占比降至约 1%。可见跳表遍历开销显著降低。


机器级开销分析(上:未启用多级跳表;下:启用多级跳表):通过 top 监控 CPU 使用情况,qrs 节点 CPU 基本无变化;searcher 节点 CPU 使用率下降约 12%。这一结果与火焰图中跳表遍历开销的下降趋势一致,可相互印证。


五、结论
多级跳表通过在倒排链上增加多层跳跃指针,能够在定位目标 doc 在倒排链中的位置时显著减少 seek 步数,从而加速查询。在我们对若干索引进行测试和对比发现,多级跳表的加速效果、收益分布与查询模式、索引大小高度相关。
按查询模式区分:
长短链不对称求交
倒排链点查
索引大小变化:在内部多个集群的测试中,单个索引开启多级跳表后,索引体积的平均膨胀率约为 1%。在少量小索引场景下,膨胀率可能达到 6%~7%;但这类索引本身体量较小(通常不超过 300 MB),因此即使开启多级跳表,对整体存储空间的占用影响也不明显。
如何判断是否应启用多级跳表: