Skip to content

[feat] Implement multi-level skiplist for havenask indexlib#310

Open
Taylor-lagrange wants to merge 1 commit intoalibaba:mainfrom
Taylor-lagrange:feat/multi-level-skiplist
Open

[feat] Implement multi-level skiplist for havenask indexlib#310
Taylor-lagrange wants to merge 1 commit intoalibaba:mainfrom
Taylor-lagrange:feat/multi-level-skiplist

Conversation

@Taylor-lagrange
Copy link

一、背景

当前 havenask 使用单层跳表加速倒排链 seek,每隔 128 个 doc 构建一个跳表索引。这种实现方式简单实用,因为在大部分情况下,倒排链求交得到的下一个 doc 通常距离当前位置不远,不需要跨越大量 doc 去 seek 数据。

然而,单层跳表在以下场景存在性能瓶颈:

  • 场景一:长短链不对称求交
    当超长倒排链与短倒排链求交时,以短链 doc 为探针在长链中查找,由于单层跳表的跳跃步长固定,往往需要多次前向扫描才能确认 doc 是否存在,定位效率低下。
  • 场景二:倒排链点查
    在已知目标 docid 集合的情况下,需要快速验证这些 doc 是否命中特定 term。单层跳表的顺序扫描特性使得倒排链的随机访问代价较高,缺乏高效的点查能力。

在这两类场景中,目标 docid 往往分布稀疏,要求能够在倒排链上进行高效的跳转与遍历;仅靠单层跳表难以满足快速 seek 的需求。因此,我们引入多级跳表来加速定位与求交。

从复杂度角度看:

  • 单层跳表:查找特定元素的时间复杂度约为 O(n/k),本质仍接近线性。
  • 多级跳表:查找时间复杂度可降为 O(log n),实现对数级的性能提升。

二、多级跳表实现原理

2.1 多级跳表介绍

我们实现的多级跳表主要借鉴了开源搜索引擎 lucene 中的一些设计思路,并结合实际场景进行了优化。

整个多级跳表有几个参数:

  • skipInterval:level 0 每隔多少个数据记录一个跳跃值。havenask 默认值为 128
  • skipMultiplier:需要多少个 level i-1 层的元素才会累积一个 level i 层的索引,默认值为 8
  • maxSkipLevels:最大 level 数量,默认为 10 层
  • df:doc 数量

一个典型的多级跳表结构:

  • Level 0:每 128 个 doc 生成一条记录
  • Level 1:每 128 × 8 = 1,024 个 doc 生成一条记录
  • Level 2:每 128 × 8² = 8,192 个 doc 生成一条记录
  • Level 3:每 128 × 8³ = 65,536 个 doc 生成一条记录

根据上述条件可以知道:
第 level i 层跳表,每隔 $skipInterval*skipMultiplier^i$ 个 doc 会生成一条记录。
对于有 df 篇 doc 的一条倒排链,总共有 $min(maxSkipLevels, 1 + log_{skipMultiplier}{df \over skipInterval})$ 层跳表。

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 跳表存储结构优化

在跳表的存储结构上做了以下优化:

  • level 数量无需存储:可以由 df 动态计算得到
  • 不存储第 1 层的长度:外部已经保存了跳表总长度,因此有一层的长度属于冗余信息,可由其他层的长度计算得到。而第 1 层又是最长的一层,存储成本最高,所以将其省略。
  • max skip level = 10 并且 skipMultiplier = 8:这部分限制和 lucene 相同。max skip level = 10 主要为了限制防止建立的 skiplist 的范围过大,但这个应该不会达到,如果建到 10 层跳表,最起码有 $128*8^{10} = 2^{37}$ 个元素,这已经超过了倒排链表示的范围(docid 是 uint32,32 位就够了)。
  • 测试了几个集群发现 skipMultiplier 对索引的性能差别不大,这里和 lucene 里面的设置对齐。

2.3 存储结构介绍

因为 havenask 中有两种跳表结构,分别存储 key / offset ,key / value / offset,所以也存在两种跳表结构。结构总体上是类似的,以 skipMultiplier = 2 为例,画出示意图(实际上实现中 skipMultiplier = 8)。

存储 key / offset 结构的 skiplist,主要用于:

  • 索引没有 pos 信息的 doclist
  • 索引 poslist
    1040025031rsp69rc1k02tp5ekk

存储 key / value / offset 的存储结构,主要用于:

  • 索引包含 pos 信息的 doclist
    1040025031rsmbp8jhk0bektdrk

在上述多级跳表示意图中,星号(*) 标记表示该节点值采用了 delta 编码。整个跳表结构中,除了 childpointer 因需要随机查询能力而无法压缩外,其余所有数据均存在单调递增特性,故使用 delta 编码进行压缩。每一层的数据维护独立的 delta 基准值。在线查询时,reader 会为每个层级维护一个独立的状态机,通过累加 delta 值来正确还原原始数据。

以 key4 为例,其真实值的还原方式如下:

  • 第三层:直接取 key4 的值(该层仅有一个节点,无需 delta 编码)
  • 第二层:key4 = key2 + key4*
  • 第一层:key4 = key1 + key2* + key3* + key4*

整个跳表的序列化结构为(假设有三层跳表):

  • meta 区域存储每个 level 的数据长度:level 1 的长度不存,因为可以由其他 level 的数据计算得到,meta 中的数据都使用 vint 压缩。
  • data 区域存储从 level 1 一直到最高 level 的数据,每个 level 的数据按节点存储,节点内使用 Group vint 压缩算法。
    1040025031rsp63jo1k06cfoj68

2.4 实时多级跳表实现

为了满足 havenask 实时索引“单写多读”的并发安全需求,我们设计了一种基于原子边界发布的无锁实时多级跳表。

为了精细化控制各层索引的可见性,系统维护以下核心原子变量:

  • end_pos[MAX_LEVEL]:一个原子变量数组,记录每一层(Level)已构建数据的逻辑结束位置。
  • height:一个原子变量,记录当前跳表对外暴露的最高有效层级。

2.4.1 写入流程(Writer)

Writer 负责物理节点的插入以及可见性边界的维护,保证 Reader 不会访问到未完全初始化的高层节点。
操作步骤:

  1. 结构更新:完成新节点的内存分配及物理指针的修改。此时,新节点在物理上已存在,但在逻辑边界外。
  2. 边界传播(自底向上):从 level 0 开始,依次向高层更新 end_pos[level]。通过原子写操作将每一层的有效结束位置推移至新插入的节点位置。保证:在更新第 N 层边界时,N-1 层的边界已经就绪。
  3. 高度提交(原子写):在所有层级的 end_pos 更新完成后,最后原子性地更新 height 变量。一旦 Reader 观测到 height 增加,对应的各层 end_pos 必然已经处于有效状态。

2.4.2 读取流程(Reader)

Reader 旨在无锁状态下获取一个自洽的索引快照。为了防止在遍历过程中因 Writer 更新导致指针悬挂或逻辑空洞。
操作步骤:

  1. 确定视图高度:首先原子读取 height 变量,确定当前可访问的最高层级 h。
  2. 捕获边界快照(自顶向下):依据确定的高度 h,从 h-1 层开始向下依次原子读取各层的 end_pos[level]。逻辑保证:通过先读 height 再读 end_pos 的顺序,确保读取到的层级高度与该层级的数据边界是匹配的。
  3. 快照化查询:基于获取的一组边界 {h, end_pos[0...h-1]},在跳表中进行标准检索。查询过程中,任何超出对应层 end_pos 的节点均被视为不可见。

2.4.3 总结

本算法的核心安全性建立在写入与读取顺序的非对称性之上:

  • 写入时(底→顶):保证了底层结构的可见性永远早于或等于高层索引的可见性。这意味着,只要高层索引指向某个节点,该节点在底层索引中必然已经存在且可达。
  • 读取时(顶→底):Reader 先锁定高度,再锁定各层边界。即使在读取过程中 Writer 正在更新更高层的数据,Reader 也会因为早期捕获的 height 和边界限制,忽略那些尚未完全发布的更新。

三、使用方式

可以使用的索引类型:除了主键索引 PRIMARYKEY 之外的所有倒排索引类型,包括:

  • text
  • string
  • number
  • range
  • pack
  • expack
  • spatial

使用方式:在需要添加多级跳表的索引项中添加 "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 部署在一个物理机上。容器资源限制如下:

  • QRS:16 核 / 40 GB 内存
  • Searcher:16 核 / 120 GB 内存

压测方式:固定 QPS=5000,连续压测 5 分钟;以每次请求返回结果中的 total_time 字段作为该请求的耗时统计口径,并计算/对比 P50、P90、P95、P99 等分位延迟。

指标 未使用多级跳表 使用多级跳表 耗时下降
平均耗时 5.3ms 4.8ms 9.4%
P50 5ms 4.5ms 10%
P90 7.7ms 7ms 9.1%
P95 8.6ms 7.8ms 9.3%
P99 10.6ms 9.7ms 8.5%

函数级开销分析(上:未启用多级跳表;下:启用多级跳表):在压测过程中采样 30s 生成火焰图。未启用多级跳表时,跳表遍历相关函数约占 searcher 端 CPU 的 12%;启用多级跳表后,该部分占比降至约 1%。可见跳表遍历开销显著降低。
1040025031rtqnk091k07487lng
1040025031rtqn2p1hk00a6pjj8

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

五、结论

多级跳表通过在倒排链上增加多层跳跃指针,能够在定位目标 doc 在倒排链中的位置时显著减少 seek 步数,从而加速查询。在我们对若干索引进行测试和对比发现,多级跳表的加速效果、收益分布与查询模式、索引大小高度相关。

按查询模式区分

  1. 长短链不对称求交

    • 场景描述:在长短倒排链求交时,通常以短链中的 docid 作为候选,需要在长倒排链中执行多次 seek 来定位这些 docid。由于长链跨度大、目标点分布稀疏,定位过程中常伴随较长的前向推进(跳过大量无关 doc),从而使求交开销显著增大。
    • 收益分布:加速效果主要集中在中长尾请求(大约 P90–P95 区间)。这些请求在倒排链上需要较长距离的 seek,多级跳表能显著减少跳数。
    • 对超长尾(P99 及更极端情况)的影响有限:P99 的超长尾通常源于求交后产生大量候选文档、或者后续的打分开销占主导。在这种情况下,倒排链 seek 并非整体耗时的主要部分,单纯使用多级跳表难以带来显著改善。
  2. 倒排链点查

    • 场景描述:已知 docid 或仅需定位并检查倒排链上的命中信息。
    • 收益分布:此类场景中,doc 数量与后续处理相对稳定,seek 成本在总体 RT 中占比较高。多级跳表对中长尾与超长尾(P90 及更远尾端)均能带来明显收益,能有效压缩长尾响应时间。

索引大小变化:在内部多个集群的测试中,单个索引开启多级跳表后,索引体积的平均膨胀率约为 1%。在少量小索引场景下,膨胀率可能达到 6%~7%;但这类索引本身体量较小(通常不超过 300 MB),因此即使开启多级跳表,对整体存储空间的占用影响也不明显。

如何判断是否应启用多级跳表

  • 对于倒排链点查场景,建议默认开启多级跳表,可以显著减少请求的长尾延迟。
  • 对于长短链不对称求交场景,加速效果与索引大小和具体查询模式密切相关,需要结合线上火焰图进行判断。如果火焰图显示集群在跳表上的CPU耗时占比超过 5%,开启多级跳表通常能带来明显收益,有效降低中长尾请求的 RT。这类情况在索引规模特别大的集群中更容易出现(比如单列索引 doc 数量超 1 亿的情况下)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant