目录
正在加载目录…

ES 分页

发布于 2026-05-26 10:25 · 最后编辑于 2026-07-31 15:52 · 字数 3,220 👁 74 次阅读

本文源于一个实际问题:几亿条商品数据,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/SizeSearch AfterScroll
跳页支持
深翻页性能差(协调节点聚合)中(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
  • id 是 B+ 树索引,id > 1000 直接定位到叶子节点,O(log N) 找到起点
  • 顺序读取 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)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论