在 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 (原始列表)
跳表合并三个列表的流程(求交集):
-
初始化指针:三个列表的跳表指针均指向各自头部(A=123,B=456,C=1456)。
-
第一次跳跃:快速对齐最小值
- 三个指针的当前值:123(A)、456(B)、1456(C),最小值是 123(A);
- 用 A 的跳表快速跳到 "不小于 1456" 的元素(因为 C 的当前值是 1456,这是三个值中的最大值,交集必须大于等于 1456);
- A 的跳表从 123 直接跳到 1456(跳过 456、789、1112 三个元素),此时 A=1456。
-
第二次跳跃:验证是否相等
- 现在三个指针的值:1456(A)、456(B)、1456(C),最小值是 456(B);
- 用 B 的跳表快速跳到 "不小于 1456" 的元素,B 从 456 直接跳到 1456(跳过 2456 之前的所有元素),此时 B=1456。
-
找到交集,继续跳跃
- 三个指针的值均为 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 条,直接逐元素比较更高效)。
五、跳表的底层设计:为什么能 "跳" 得快?
- 分层索引:跳表的索引层是随机生成的(每层索引的元素间隔随机),但整体遵循 "上层间隔大,下层间隔小" 的规则,确保快速定位;
- 平衡结构:ES 中的跳表会动态调整索引层,避免出现 "某一层索引间隔过小" 的情况,保证查询效率稳定;
- 内存友好:跳表的索引层仅存储少量关键元素的指针,内存占用远低于 Bitset(无需初始化全量位图)。
六、总结:跳表是有序合并的 "性能天花板"
跳表的核心逻辑的是 "用空间换时间"------ 通过增加少量索引层,换来了有序列表合并的指数级速度提升。在 ES 中,它是文本类型多条件查询的 "性能基石",也是为什么文本查询的多条件合并比数值查询更快的关键原因之一。
简单记:有序用跳表,无序用 Bitset------ 这是 ES 结果合并的黄金法则。理解了这一点,你就能明白为什么有些查询需要优化字段类型(如将数值类型转为 keyword 类型,利用跳表合并提升性能),也能更精准地定位多条件查询的性能瓶颈。
编辑分享