[feat] Implement multi-level skiplist for havenask indexlib#310
Open
Taylor-lagrange wants to merge 1 commit intoalibaba:mainfrom
Open
[feat] Implement multi-level skiplist for havenask indexlib#310Taylor-lagrange wants to merge 1 commit intoalibaba:mainfrom
Taylor-lagrange wants to merge 1 commit intoalibaba:mainfrom
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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),因此即使开启多级跳表,对整体存储空间的占用影响也不明显。
如何判断是否应启用多级跳表: