这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 分页和排序看起来简单,却是最容易把集群打垮的地方之一。深分页会让协调节点请求大量分片、取回大量候选文档,并在内存中归并排序。
本章讲解 from/size、search_after、PIT、scroll、排序稳定性、导出方案和分页治理。
13.1 from/size
GET /products/_search
{
"from": 0,
"size": 20,
"query": {
"match_all": {}
}
}
第 100 页:
{
"from": 1960,
"size": 20
}
如果索引有 5 个主分片,协调节点最坏需要从每个分片取 from + size = 1980 条,再归并排序取 20 条。
默认限制:
from + size <= index.max_result_window,默认 10000
查看配置:
GET /products/_settings/index.max_result_window
不建议为了深分页直接调大该值。
13.2 哪些场景适合 from/size
适合:
- 搜索结果前几页;
- 后台管理小数据集;
- 查询过滤后数量确定较小;
- 用户不会持续翻深页;
- 产品愿意限制最大页数。
不适合:
- 爬虫式全量遍历;
- 第 1000 页;
- 无时间范围的大索引;
- 高并发导出;
- 排序不稳定的随机结果。
13.3 排序稳定性
分数或排序值相同时,如果没有唯一排序键,翻页可能出现重复或漏数据。
建议业务列表加稳定排序键:
GET /products/_search
{
"query": {
"match": { "title": "笔记本" }
},
"sort": [
{ "_score": { "order": "desc" } },
{ "sales": { "order": "desc" } },
{ "product_id": { "order": "asc" } }
]
}
常见稳定键:
id;created_at;updated_at;- 版本号;
- 唯一业务编号。
13.4 search_after
search_after 使用上一页最后一条的排序值作为游标。
第一页:
GET /products/_search
{
"size": 20,
"query": {
"match": { "title": "笔记本" }
},
"sort": [
{ "sales": { "order": "desc" } },
{ "product_id": { "order": "asc" } }
]
}
假设最后一条:
{
"sort": [1200, "10042"]
}
下一页:
GET /products/_search
{
"size": 20,
"query": {
"match": { "title": "笔记本" }
},
"search_after": [1200, "10042"],
"sort": [
{ "sales": { "order": "desc" } },
{ "product_id": { "order": "asc" } }
]
}
要求:
- 排序字段必须稳定;
search_after值必须与排序字段一一对应;- 只能向后翻页;
- 不能直接跳转到任意页;
- 查询条件应保持不变。
13.5 Point In Time
搜索过程中数据可能变化,导致翻页重复或漏掉文档。PIT 保存一个时间点视图。
打开 PIT:
POST /products/_pit?keep_alive=2m
返回:
{
"id": "pit-id-base64"
}
带 PIT 查询:
GET /_search
{
"size": 20,
"pit": {
"id": "pit-id-base64",
"keep_alive": "2m"
},
"query": {
"match": { "title": "笔记本" }
},
"sort": [
{ "sales": { "order": "desc" } },
{ "product_id": { "order": "asc" } }
],
"search_after": [1200, "10042"]
}
关闭 PIT:
DELETE /_pit
{
"id": "pit-id-base64"
}
PIT 会占用资源,keep_alive 应设置合理过期时间,应用异常退出时也要有清理任务。
13.6 scroll
scroll 是旧版本常用的批量遍历方式:
POST /products/_search?scroll=2m
{
"size": 1000,
"query": { "match_all": {} }
}
返回 scroll_id 后继续:
POST /_search/scroll
{
"scroll": "2m",
"scroll_id": "..."
}
清理:
DELETE /_search/scroll
{
"scroll_id": ["..."]
}
scroll 适合离线全量导出或重建索引,不适合用户实时分页。新系统优先使用 PIT + search_after。
13.7 排序与 Doc Values
排序通常依赖 Doc Values。
{
"sort": [
{ "price": { "order": "asc" } },
{ "sales": { "order": "desc" } }
]
}
如果字段没有 Doc Values,ES 可能需要 fielddata,把倒排结构反转到堆内存,容易导致 OOM。
生产原则:
- 排序和聚合字段使用 keyword 或数值;
- 不对高基数字段随意开启 fielddata;
- text 排序使用 keyword 子字段;
- 大字段不参与排序;
- 新 Mapping 阶段确定排序需求。
13.8 多级排序设计
商品搜索示例:
{
"sort": [
{ "in_stock": { "order": "desc" } },
{ "_score": { "order": "desc" } },
{ "sales_7d": { "order": "desc" } },
{ "product_id": { "order": "asc" } }
]
}
常见排序因子:
| 因子 | 说明 |
|---|---|
_score |
文本相关性 |
sales_7d |
近期销量 |
in_stock |
是否可售 |
updated_at |
新鲜度 |
quality_score |
商品质量分 |
ctr |
点击率 |
conversion_rate |
转化率 |
product_id |
稳定 tie-breaker |
排序因子应在写入时预计算,不要在查询时用脚本实时计算复杂分数。
13.9 无限滚动
移动端常用无限滚动:
{
"size": 20,
"search_after": ["2026-08-25T10:00:00Z", "product_10001"],
"sort": [
{ "created_at": { "order": "desc" } },
{ "id": { "order": "asc" } }
]
}
限制:
- 设置最大深度,例如 1000 条;
- 前端提示推荐刷新;
- 记录最后游标;
- 游标过期后回到第一页;
- 避免同一用户并发翻深页。
13.10 导出与全量遍历
小数据量:
search_after + PIT
大导出:
异步任务 -> 按时间或 ID 分片 -> 并发受控 -> 输出到对象存储 -> 记录进度
按时间切分:
{
"query": {
"range": {
"created_at": {
"gte": "2026-08-25T00:00:00Z",
"lt": "2026-08-25T01:00:00Z"
}
}
}
}
导出建议:
- 限制并发;
- 限制字段;
- 关闭不需要的高亮;
- 使用离线集群;
- 记录任务进度;
- 失败可重试;
- 不影响在线查询。
13.11 分页治理清单
- 是否限制最大页数;
- 是否使用 search_after;
- 排序是否有唯一 tie-breaker;
- 是否需要 PIT;
from+size是否可能超过 10000;- 是否监控深分页请求;
- 导出是否异步;
- 查询是否有时间范围;
_source是否只取必要字段;- 是否存在并发导出任务冲突。
13.12 本章小结
- from/size 只适合浅分页;
- 深分页使用 search_after,实时一致性要求高时配合 PIT;
- scroll 适合离线导出,不适合用户分页;
- 排序字段必须有稳定唯一键,否则翻页可能重复或漏数据;
- 排序和聚合依赖 Doc Values,不要对 text 开启 fielddata;
- 大导出应异步、分片、限流并输出到对象存储。
13.13 思考题
- 为什么
from=100000会拖垮集群? - search_after 为什么不能跳页?
- PIT 解决了翻页中的什么问题?
- 如何设计一个稳定的商品搜索排序?
- 需要导出一亿条订单时,你会采用什么方案?