Elasticsearch 跳表(Skip List):有序结果合并的 “性能电梯”

在 Elasticsearch(ES)的多条件查询中,结果合并是决定性能的关键步骤。当你执行 "标题包含 A 且包含 B" 这类查询时,ES 需要合并两个倒排列表(Posting List)的结果。此时,跳表(Skip List) 就像 "性能电梯" 一样,跳过大量无关元素,让合并速度飙升 ------ 而这一点,用普通链表合并根本无法实现。

本文将通过一个更直观的例子,拆解跳表的工作原理、优势,以及它在 ES 中的实际应用,让你彻底明白 "为什么有序列表合并必须用跳表"。

一、先澄清:跳表的核心优势是什么?

跳表的核心价值只有一个:在有序列表中,以 "跳跃式遍历" 替代 "逐元素遍历",将合并时间复杂度从 O (n) 降至 O (log n)

举个极端对比:

  • 普通链表合并:100 万条数据的两个列表,需逐元素比较,最多要 200 万次操作;
  • 跳表合并:同样 100 万条数据,仅需约 20 次比较(log₂(100 万)≈17),速度提升 10 万倍。

之所以之前的例子没凸显优势,是因为数据量太小。这次我们用 "10 万条 docID" 的场景,让跳表的 "电梯效应" 一目了然。

二、场景重构:10 万条数据的多条件查询

场景假设

我们有一个新闻索引 news,包含 10 万篇文档 (docID 从 1 到 100000),字段 title(text 类型,分词后建立倒排索引)。现在执行查询:「金龙鱼 AND 18 亿 AND 罚单」(标题同时包含这三个关键词的新闻)。

对应的 DSL:

json 复制代码
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "金龙鱼" } },
        { "match": { "title": "18亿" } },
        { "match": { "title": "罚单" } }
      ]
    }
  }
}

关键前提:倒排列表是有序的

ES 的倒排索引在构建时,会将每个关键词对应的 docID 按 递增顺序 存储(因为 docID 是文档写入时分配的连续整数,匹配同一关键词的文档会按写入顺序排序)。

查询后,我们得到三个按 docID 有序的 Posting List:

  • 列表 A(金龙鱼):[123, 456, 789, 1112, 1456, 1789, 2112, ..., 99789](共 5000 条)
  • 列表 B(18 亿):[456, 1456, 2456, 3456, ..., 98456](共 3000 条)
  • 列表 C(罚单):[1456, 3456, 5456, ..., 97456](共 2000 条)

现在需要合并这三个列表,找到它们的 交集(同时包含三个关键词的 docID)。

三、普通链表合并 vs 跳表合并:差距一目了然

1. 普通链表合并:低效的 "逐元素爬行"

普通链表合并三个有序列表,需要三个指针分别遍历三个列表,逐元素比较是否相等:

  • 指针 A 初始指向 123,指针 B 指向 456,指针 C 指向 1456 → 123 < 456 < 1456,指针 A 移至 456;
  • 指针 A=456,指针 B=456,指针 C=1456 → 456 < 1456,指针 A 移至 789,指针 B 移至 1456;
  • 指针 A=789,指针 B=1456,指针 C=1456 → 789 < 1456,指针 A 移至 1112;
  • 指针 A=1112,指针 B=1456,指针 C=1456 → 1112 < 1456,指针 A 移至 1456;
  • 指针 A=1456,指针 B=1456,指针 C=1456 → 相等,加入结果集;
  • 后续重复此逻辑,直到遍历完某个列表。

问题:三个列表共 10000 条数据,普通合并需要遍历大部分元素(可能上万次比较),数据量越大,耗时越长。

2. 跳表合并:高效的 "电梯跳跃"

跳表在普通有序链表之上,建立了 多层索引层(类似电梯),可以跳过大量无关元素。我们以列表 A 为例,看看跳表结构:

plaintext 复制代码
第3层(顶层):123 → 1456 → 2112 → ... → 99789 (间隔大,快速跨段)
第2层(中层):123 → 789 → 1456 → 1789 → ... → 99789 (间隔中等)
第1层(下层):123 → 456 → 789 → 1112 → 1456 → ... → 99789 (间隔小)
第0层(底层):123 → 456 → 789 → 1112 → 1456 → 1789 → 2112 → ... → 99789 (原始列表)

跳表合并三个列表的流程(求交集):

  1. 初始化指针:三个列表的跳表指针均指向各自头部(A=123,B=456,C=1456)。

  2. 第一次跳跃:快速对齐最小值

    • 三个指针的当前值:123(A)、456(B)、1456(C),最小值是 123(A);
    • 用 A 的跳表快速跳到 "不小于 1456" 的元素(因为 C 的当前值是 1456,这是三个值中的最大值,交集必须大于等于 1456);
    • A 的跳表从 123 直接跳到 1456(跳过 456、789、1112 三个元素),此时 A=1456。
  3. 第二次跳跃:验证是否相等

    • 现在三个指针的值:1456(A)、456(B)、1456(C),最小值是 456(B);
    • 用 B 的跳表快速跳到 "不小于 1456" 的元素,B 从 456 直接跳到 1456(跳过 2456 之前的所有元素),此时 B=1456。
  4. 找到交集,继续跳跃

    • 三个指针的值均为 1456,加入结果集;
    • 三个指针同时跳到下一个元素(A=1789,B=2456,C=3456);
    • 最小值是 1789(A),用 A 的跳表快速跳到 "不小于 3456" 的元素(C 的当前值),A 从 1789 跳到 3456(跳过多个元素);
    • 重复上述逻辑,直到某个指针无下一个元素。

跳表的优势体现:

  • 合并三个列表仅需 几十次比较(而非上万次),因为大部分无关元素都被跳表 "跳过" 了;
  • 数据量越大,优势越明显:100 万条数据的列表合并,跳表可将耗时从秒级降至毫秒级。

四、跳表在 ES 中的应用场景:仅用于有序列表合并

跳表不是万能的,它的应用场景有严格限制 ------必须是有序的 Posting List 合并,这在 ES 中对应以下场景:

1. 多条件文本查询(must/should 子句)

  • 例:title:金龙鱼 AND title:18亿(must 子句,求交集);
  • 例:title:金龙鱼 OR title:18亿(should 子句,求并集);
  • 核心:文本类型(text/keyword)的倒排索引 Posting List 是有序的,适合跳表合并。

2. 带过滤的文本查询(filter + must)

  • 例:filter: {term: {biz_id:1001}} AND must: {match: {title:金龙鱼}}
  • 核心:filter 子句的 Bitset 先筛选出小范围有序 docID,再与 must 子句的有序 Posting List 用跳表合并。

3. 不适用的场景

  • 数值 / 日期类型的范围查询(BKD 树返回的 docID 是无序的,用 Bitset 合并);
  • 稀疏结果集的合并(如匹配文档数少于 100 条,直接逐元素比较更高效)。

五、跳表的底层设计:为什么能 "跳" 得快?

  1. 分层索引:跳表的索引层是随机生成的(每层索引的元素间隔随机),但整体遵循 "上层间隔大,下层间隔小" 的规则,确保快速定位;
  2. 平衡结构:ES 中的跳表会动态调整索引层,避免出现 "某一层索引间隔过小" 的情况,保证查询效率稳定;
  3. 内存友好:跳表的索引层仅存储少量关键元素的指针,内存占用远低于 Bitset(无需初始化全量位图)。

六、总结:跳表是有序合并的 "性能天花板"

跳表的核心逻辑的是 "用空间换时间"------ 通过增加少量索引层,换来了有序列表合并的指数级速度提升。在 ES 中,它是文本类型多条件查询的 "性能基石",也是为什么文本查询的多条件合并比数值查询更快的关键原因之一。

简单记:有序用跳表,无序用 Bitset------ 这是 ES 结果合并的黄金法则。理解了这一点,你就能明白为什么有些查询需要优化字段类型(如将数值类型转为 keyword 类型,利用跳表合并提升性能),也能更精准地定位多条件查询的性能瓶颈。

编辑分享

相关推荐
Penge6661 小时前
Elasticsearch BKD 树与 PointRangeQuery:为何数值查询会有性能瓶颈
后端
木木一直在哭泣1 小时前
【收藏级】Java Stream.reduce 全面解析:从零到通透(原理图 + 实战 + 最佳实践)
后端
Penge6661 小时前
Elasticsearch Filter 缓存:Bitset 如何让查询速度飙升
后端
用户84913717547161 小时前
ThreadLocal 源码深度解析:JDK 设计者的“妥协”与“智慧”
java·后端
木木一直在哭泣1 小时前
Java Stream.filter 全面解析:定义、原理与最常见使用场景
后端
用户0304805912631 小时前
# 【Maven避坑】源码去哪了?一文看懂 Maven 工程与打包后的目录映射关系
java·后端
绫语宁1 小时前
以防你不知道LLM小技巧!为什么 LLM 不适合多任务推理?
人工智能·后端
q***18842 小时前
Spring Boot中的404错误:原因、影响及处理策略
java·spring boot·后端
用户69371750013842 小时前
17.Kotlin 类:类的形态(四):枚举类 (Enum Class)
android·后端·kotlin