ElasticsearchNotes

第 13 章:搜索分页与排序

zjc 于 2026-01-13 发布

这是《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

适合:

  1. 搜索结果前几页;
  2. 后台管理小数据集;
  3. 查询过滤后数量确定较小;
  4. 用户不会持续翻深页;
  5. 产品愿意限制最大页数。

不适合:

  1. 爬虫式全量遍历;
  2. 第 1000 页;
  3. 无时间范围的大索引;
  4. 高并发导出;
  5. 排序不稳定的随机结果。

13.3 排序稳定性

分数或排序值相同时,如果没有唯一排序键,翻页可能出现重复或漏数据。

建议业务列表加稳定排序键:

GET /products/_search
{
  "query": {
    "match": { "title": "笔记本" }
  },
  "sort": [
    { "_score": { "order": "desc" } },
    { "sales": { "order": "desc" } },
    { "product_id": { "order": "asc" } }
  ]
}

常见稳定键:

  1. id
  2. created_at
  3. updated_at
  4. 版本号;
  5. 唯一业务编号。

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" } }
  ]
}

要求:

  1. 排序字段必须稳定;
  2. search_after 值必须与排序字段一一对应;
  3. 只能向后翻页;
  4. 不能直接跳转到任意页;
  5. 查询条件应保持不变。

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。

生产原则:

  1. 排序和聚合字段使用 keyword 或数值;
  2. 不对高基数字段随意开启 fielddata;
  3. text 排序使用 keyword 子字段;
  4. 大字段不参与排序;
  5. 新 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" } }
  ]
}

限制:

  1. 设置最大深度,例如 1000 条;
  2. 前端提示推荐刷新;
  3. 记录最后游标;
  4. 游标过期后回到第一页;
  5. 避免同一用户并发翻深页。

13.10 导出与全量遍历

小数据量:

search_after + PIT

大导出:

异步任务 -> 按时间或 ID 分片 -> 并发受控 -> 输出到对象存储 -> 记录进度

按时间切分:

{
  "query": {
    "range": {
      "created_at": {
        "gte": "2026-08-25T00:00:00Z",
        "lt": "2026-08-25T01:00:00Z"
      }
    }
  }
}

导出建议:

  1. 限制并发;
  2. 限制字段;
  3. 关闭不需要的高亮;
  4. 使用离线集群;
  5. 记录任务进度;
  6. 失败可重试;
  7. 不影响在线查询。

13.11 分页治理清单

  1. 是否限制最大页数;
  2. 是否使用 search_after;
  3. 排序是否有唯一 tie-breaker;
  4. 是否需要 PIT;
  5. from+size 是否可能超过 10000;
  6. 是否监控深分页请求;
  7. 导出是否异步;
  8. 查询是否有时间范围;
  9. _source 是否只取必要字段;
  10. 是否存在并发导出任务冲突。

13.12 本章小结

13.13 思考题

  1. 为什么 from=100000 会拖垮集群?
  2. search_after 为什么不能跳页?
  3. PIT 解决了翻页中的什么问题?
  4. 如何设计一个稳定的商品搜索排序?
  5. 需要导出一亿条订单时,你会采用什么方案?