ES 系列 #02:ES 分页

发布于 2026-05-26 10:25 👁 7 次阅读
#elasticsearch#pagination#deep-dive

本文源于一个实际问题:几亿条商品数据,ES 能否胜任深分页和遍历? 结论是:ES 是搜索引擎,分页是附带能力,深分页和遍历是它的弱项。理解底层原理,才能做出正确的技术选型。

⚠️ 版本说明:PIT(Point In Time)需要 Elasticsearch 7.10+;Scroll 在 8.x 已不推荐使用。


目录

章节 说明
三种分页方式 From/Size、Search After、Scroll 的原理与对比
PIT(Point In Time) 7.10+ 推荐的深分页方案
为什么 ES 深分页慢 倒排索引 vs B+ 树,本质原因
Doc Values 的局限 正排结构好用吗?
ES 能解决遍历问题吗 几亿条数据的遍历场景分析
选型建议 什么场景用什么工具

三种分页方式

es pagination comparison

From/Size

GET /index/_search
{
  "from": 0,
  "size": 10,
  "query": { "match_all": {} }
}

原理:跳过前 N 条,返回后续 size 条。每个 shard 返回 from+size 条数据,协调节点汇总排序后丢弃前 from 条。

维度 说明
适用场景 随机跳页、小数据量、用户界面翻页(页数 < 100)
最大深度 默认 index.max_result_window = 10000,超出报错
性能 深翻页代价高,协调节点内存压力大
实时性 实时查询,结果随数据变化
缺点 深翻页内存和 CPU 消耗大;数据写入时有漏/重风险

GET /index/_search
{
  "size": 10,
  "sort": [{ "timestamp": "asc" }, { "_id": "asc" }],
  "search_after": [1617000000000, "last_doc_id"]
}

原理:基于上一页最后一条记录的排序值作为游标,继续向后查询。

维度 说明
适用场景 深翻页、无限滚动、顺序遍历
最大深度 无硬性限制,但性能随深度下降(见下文)
实时性 实时查询
缺点 只能顺序翻页,不支持跳页;需要唯一排序字段(通常加 _id

⚠️ 常见误解:Search After 并不是"无限制深翻页"的银弹。每次查询时,每个 shard 仍需从头扫描并排序,直到找到 search_after 指定的位置。翻页越深,shard 层面的扫描代价越大,性能线性下降。


Scroll

# 初始化(保留 1 分钟快照)
GET /index/_search?scroll=1m
{ "size": 100, "query": { "match_all": {} } }

# 后续翻页
GET /_search/scroll
{ "scroll": "1m", "scroll_id": "DXF1ZXJ5..." }

# 用完释放(重要!否则占用堆内存)
DELETE /_search/scroll
{ "scroll_id": "DXF1ZXJ5..." }

原理:快照查询时的索引状态,保持一个上下文窗口,顺序遍历。

维度 说明
适用场景 全量数据导出、数据迁移、批处理
最大深度 无硬性限制
实时性 快照时刻的数据,不反映查询期间的写入/删除
缺点 维护 scroll context 占用堆内存;大量并发 scroll 会压垮集群;ES 8.x 已不推荐

⚠️ 另一个常见误解:Scroll 并没有完全解决深分页问题。初始化 scroll 时,ES 内部仍需构建完整的排序结果集,第一次查询的代价和 from/size 一样高。Scroll 解决的是"顺序遍历全量数据"的场景,不是"任意深度跳页"。


三种方式横向对比

特性 From/Size Search After Scroll
跳页支持
深翻页性能 差(协调节点聚合) 中(shard 扫描随深度增加) 中(初始化代价高)
数据实时性 实时 实时 快照
资源占用 深翻页时高 需维护 context,堆内存压力大
ES 推荐度 浅分页首选 深翻页首选 8.x 逐渐废弃

PIT(Point In Time)

ES 7.10 引入,是 Scroll 的现代替代方案,配合 Search After 使用,兼顾快照一致性和深翻页性能。

# 1. 创建 PIT(保留 1 分钟)
POST /index/_pit?keep_alive=1m
# 返回 { "id": "pit_id_here" }

# 2. 首次查询
GET /_search
{
  "size": 10,
  "pit": { "id": "pit_id_here", "keep_alive": "1m" },
  "sort": [{ "timestamp": "asc" }, { "_id": "asc" }]
}

# 3. 翻页(使用上一页最后一条的 sort 值)
GET /_search
{
  "size": 10,
  "pit": { "id": "pit_id_here", "keep_alive": "1m" },
  "sort": [{ "timestamp": "asc" }, { "_id": "asc" }],
  "search_after": [1617000000000, "last_doc_id"]
}

# 4. 用完释放
DELETE /_pit
{ "id": "pit_id_here" }

PIT 的核心优势:在 search_after 翻页过程中保持索引快照一致性,避免翻页期间数据写入导致的漏/重问题,同时没有 Scroll 的大量 context 内存消耗。


为什么 ES 深分页慢

关系型数据库 id > xxx 为什么快

SELECT * FROM table WHERE id > 1000 ORDER BY id LIMIT 10
B+ 树叶子节点(天然有序,支持随机访问):
[id=1] → [id=2] → ... → [id=1000] → [id=1001]
                              ↑
                         直接跳到这里,O(log N)

ES 倒排索引为什么做不到

ES 是倒排索引,为全文检索设计:

倒排索引结构:
词项 → [doc1, doc5, doc23, ...]

search_after 的执行过程:

  1. 每个 shard 执行查询,从头开始收集满足条件的文档并排序
  2. 找到排序值 > search_after 的位置
  3. 返回后续 size 条

没有"直接跳到第 N 条"的能力,必须扫描前面的数据才能确定位置。

本质区别

维度 关系型数据库(B+ 树) ES(倒排索引)
设计目标 结构化数据存储与查询 全文搜索
数据结构 有序叶子节点,支持随机访问 词项 → 文档列表,无全局排序位置
深分页定位 O(log N),代价恒定 需扫描前 N 条,代价线性增长
遍历能力 天然擅长 天然不擅长

ES 的 search_after 更接近数据库的 OFFSET(逻辑跳过),而不是 id > xxx(索引定位)。


Doc Values 的局限

ES 有类似正排的结构 Doc Values,但它并不能解决深分页问题:

结构 作用
倒排索引 词项 → 文档列表(用于搜索)
Doc Values 文档 → 字段值(用于排序、聚合)
_id 索引 文档 ID → 物理位置(用于 GET)

Doc Values 存储结构

Doc Values 是列存(column-oriented),按字段竖切,与 _source 的行存形成互补:

原始数据(_source,行存):
  doc_id | name    | age | price
  -------|---------|-----|------
  1      | Alice   | 25  | 100
  2      | Bob     | 30  | 200
  3      | Charlie | 25  | 150
  4      | Dave    | 35  | 300

Doc Values(列存):
  age 列:  doc1→25, doc2→30, doc3→25, doc4→35
  price 列:doc1→100, doc2→200, doc3→150, doc4→300

数值列的压缩(以 age 为例):Lucene 不存原始值,而是排序去重 + 序号映射 + bit-packing:

唯一值表:[25, 30, 35]
序号映射:25→0, 30→1, 35→2
每个 doc 存序号:[0, 1, 0, 2]  ← 只需 2 bits,而非 4×int(16 bytes)

为什么 sort/aggs 快

ORDER BY age → 只顺序读 age 列文件,完全不碰其他字段
AVG(price)   → 只顺序读 price 列文件

顺序读列文件 = 磁盘顺序 IO + OS 文件缓存(不占 JVM 堆),远快于行存随机读。

倒排索引与 Doc Values 的分工

WHERE age = 25   → 走倒排索引:term "25" → [doc1, doc3]  ✅ 擅长
ORDER BY age ASC → 走 Doc Values:顺序读 age 列 → 排序    ✅ 擅长
倒排索引做排序   → 需要反查每个 doc 的值,随机 IO          ❌ 不适合

Doc Values 的局限

  1. 只存字段值,没有顺序位置信息:能快速拿到某个文档的字段值,但不知道"该文档在全局排序中排第几位",排序时仍需把所有候选文档的值加载进来比较

  2. text 字段默认不开启text 类型不支持 Doc Values(分词后无法做列存),需要排序/聚合的字段要用 keyword

  3. 内存压力大:排序、聚合时 Doc Values 会被加载到 fielddata 缓存(堆内存),数据量大时容易触发 OOM 或 circuit breaker

Doc Values 的设计目标是列存加速聚合/排序,不是为了解决分页定位问题:

设计目标 实际效果
加速 aggs 聚合 ✅ 明显提升
加速 sort 排序 ✅ 比 fielddata 好
解决深分页定位 ❌ 没有帮助
替代 B+ 树随机访问 ❌ 完全不同的数据结构

ES 能解决遍历问题吗

几亿条数据意味着什么

方案 问题
Scroll 大量并发 scroll context 堆积,容易把集群打垮
Search After 翻到第百万页时,每个 shard 仍需扫描前百万条,性能灾难
分段 + Search After 缓解但治标不治本,总体 IO 压力依然很大

遍历需求应该先问"为什么遍历"

遍历目的 推荐方案
数据导出/备份 直接读 MySQL/数仓,别走 ES
全量重建索引 ES reindex API
批量计算/分析 Hive/Spark
用户翻页查询 搜索条件缩小结果集,不应该几亿条都翻
定时任务全量扫描 分段 + 时间窗口,走数据库 keyset
一致性监控 MySQL 做 checksum 对比,ES 只做抽样验证

真正没有深分页问题的方案

只有基于物理游标/索引的方案才能做到

-- MySQL keyset pagination,每批代价恒定
SELECT * FROM product_view
WHERE id > #{lastId}
ORDER BY id
LIMIT 1000
方案 第 1 批 第 100 万批 资源占用
ES Scroll 慢(context 堆积)
ES Search After 很慢(shard 全量扫描)
MySQL id > xxx 一样快

选型建议

职责分离原则

搜索需求 → ES(条件过滤 + 少量分页,结果集缩小到几百/几千条)
遍历需求 → MySQL 索引表(id > xxx 游标,代价恒定)
全量分析 → 数仓 / Hive / Spark

具体场景速查

场景 方案
用户界面翻页(页数 < 100) ES From/Size
无限滚动 / 实时深翻页 ES Search After + PIT(7.10+)
全量数据导出 / 数据迁移 ES Scroll(或 Search After + PIT)
几亿条数据遍历 MySQL id > xxx keyset
批量计算 / 离线分析 数仓 / Hive / Spark

ES 的正确用法

用搜索条件把结果集缩小到几百/几千条,再做分页——而不是对全量数据做分页遍历。

ES 本质上是倒排索引,天然不擅长深分页和遍历,三种分页方式都是在不同维度做权衡,没有银弹。选型时要清楚 ES 的边界,不要强迫它做不擅长的事。


参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论