本文源于一个实际问题:几亿条商品数据,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 能解决遍历问题吗 | 几亿条数据的遍历场景分析 |
| 选型建议 | 什么场景用什么工具 |
三种分页方式
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 消耗大;数据写入时有漏/重风险 |
Search After
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
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 的执行过程:
- 每个 shard 执行查询,从头开始收集满足条件的文档并排序
- 找到排序值 >
search_after的位置 - 返回后续 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 的局限
-
只存字段值,没有顺序位置信息:能快速拿到某个文档的字段值,但不知道"该文档在全局排序中排第几位",排序时仍需把所有候选文档的值加载进来比较
-
text 字段默认不开启:
text类型不支持 Doc Values(分词后无法做列存),需要排序/聚合的字段要用keyword -
内存压力大:排序、聚合时 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 的边界,不要强迫它做不擅长的事。
参考资料
- Paginate search results — Elasticsearch 官方文档
- Scroll API(已不推荐)— Elasticsearch 官方文档
- Point in time API — Elasticsearch 官方文档
- 《Designing Data-Intensive Applications》第 3 章 — B+ 树 vs LSM 树数据结构对比
- 《High Performance MySQL》— Keyset Pagination 章节
评论 (0)
发表评论