---
title: "分页与排序"
date: 0001-01-01
description: "from/size、search_after、scroll 等分页方式，以及排序的常见陷阱。"
summary: "分页与排序 #  分页与排序看似简单，但在分布式搜索里细节很多。本页聚焦几种常见分页方式的适用场景，以及排序字段选择上的坑。
from/size：小页场景的首选 #  from + size 适合：
 页码较小（例如前几十页以内）的分页 结果集总量不特别大  特点：
 使用简单，语义直观（类似 SQL 的 OFFSET/LIMIT） 页码越大，开销越大（需要跳过前面的结果）  参数说明：
 size：显示应该返回的结果数量，默认是 10 from：显示应该跳过的初始结果数量，默认是 0  如果每页展示 5 条结果，可以用下面方式请求得到 1 到 3 页的结果：
GET /_search?size=5 GET /_search?size=5&amp;from=5 GET /_search?size=5&amp;from=10 深度分页的问题 #  理解为什么深度分页是有问题的，我们可以假设在一个有 5 个主分片的索引中搜索。
当我们请求结果的第一页（结果从 1 到 10），每一个分片产生前 10 的结果，并且返回给协调节点，协调节点对 50 个结果排序得到全部结果的前 10 个。
现在假设我们请求第 1000 页——结果从 10001 到 10010。所有都以相同的方式工作，除了每个分片不得不产生前 10010 个结果以外。然后协调节点对全部 50050 个结果排序，最后丢弃掉这些结果中的 50040 个结果。"
---


# 分页与排序

分页与排序看似简单，但在分布式搜索里细节很多。本页聚焦几种常见分页方式的适用场景，以及排序字段选择上的坑。

## from/size：小页场景的首选

`from` + `size` 适合：

- 页码较小（例如前几十页以内）的分页
- 结果集总量不特别大

特点：

- 使用简单，语义直观（类似 SQL 的 OFFSET/LIMIT）
- 页码越大，开销越大（需要跳过前面的结果）

参数说明：

- `size`：显示应该返回的结果数量，默认是 `10`
- `from`：显示应该跳过的初始结果数量，默认是 `0`

如果每页展示 5 条结果，可以用下面方式请求得到 1 到 3 页的结果：

```json
GET /_search?size=5
GET /_search?size=5&from=5
GET /_search?size=5&from=10
```

### 深度分页的问题

理解为什么深度分页是有问题的，我们可以假设在一个有 5 个主分片的索引中搜索。

当我们请求结果的第一页（结果从 1 到 10），每一个分片产生前 10 的结果，并且返回给协调节点，协调节点对 50 个结果排序得到全部结果的前 10 个。

现在假设我们请求第 1000 页——结果从 10001 到 10010。所有都以相同的方式工作，除了每个分片不得不产生前 10010 个结果以外。然后协调节点对全部 50050 个结果排序，最后丢弃掉这些结果中的 50040 个结果。

可以看到，在分布式系统中，对结果排序的成本随分页的深度成线性上升。这就是为什么 web 搜索引擎对任何查询都不要返回超过 1000 个结果的原因。

建议：

- 用于"前台列表"的普通翻页（如前几页搜索结果）
- 避免深度分页（例如翻到几百页之后）

## 深度分页：search_after

当需要做“滚动式”分页，且页数很深时，推荐使用 `search_after`：

- 不再用页码，而是“记住上一页最后一条记录的排序键”
- 下一页从这个排序键之后继续取

适用场景：

- 后台批量浏览或处理大量结果
- 需要相对稳定的排序顺序

### 基本用法

第一步，执行初始搜索，指定排序字段（必须包含唯一性排序键，如 `_id`）：

```json
GET /my_index/_search
{
  "size": 10,
  "query": { "match": { "content": "搜索" } },
  "sort": [
    { "date": "desc" },
    { "_id": "asc" }
  ]
}
```

获取结果中最后一条的 `sort` 值，作为下一页的 `search_after` 参数：

```json
GET /my_index/_search
{
  "size": 10,
  "query": { "match": { "content": "搜索" } },
  "sort": [
    { "date": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [1609459200000, "doc_42"]
}
```

### 配合 Point In Time (PIT) 使用

在生产环境中，建议将 `search_after` 与 PIT 配合使用，以保证分页过程中索引视图一致：

```json
POST /my_index/_search/point_in_time?keep_alive=1m
```

返回一个 `pit_id`，后续分页时携带：

```json
GET /_search
{
  "size": 10,
  "query": { "match": { "content": "搜索" } },
  "pit": {
    "id": "<pit_id>",
    "keep_alive": "1m"
  },
  "sort": [
    { "date": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [1609459200000, "doc_42"]
}
```

> 使用 PIT 时不需要指定索引名称，因为 PIT 已绑定到特定索引。

使用要点：

- 必须有一个稳定且唯一的排序组合键（常用：业务字段 + `_id`）
- 不能直接跳到任意页，只能一页一页向后推进

## 大规模扫描：scroll

在需要对大量数据进行**离线处理或导出**时，可考虑使用 scroll API：

- 在一段时间内"固定"快照视图
- 逐批拉取数据进行处理
- 禁用排序使取回行为更有效率

scroll 查询的典型用法：

```json
GET /_search?scroll=1m
{
  "query": { "match_all": {}}
}
```

这个查询的返回包括一个 `_scroll_id`，它是一个 base64 编码的长字符串。可以用这个 `_scroll_id` 来获取下一批结果：

```json
GET /_search/scroll
{
  "scroll": "1m",
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}
```

scroll 参数告诉 Easysearch 保持搜索的上下文等待另一个 1 分钟。`scroll_id` 可以在 body 或 URL 里传递，或者作为查询参数传递。

> 注意：`scroll` API 创建了一个快照，它不会看到在初始搜索请求后对索引所做的更改。它通过保留旧的数据文件来实现这一点，这样即使索引正在被更新，文档看起来仍然像它们在进行初始搜索时一样。

不适合用 scroll 的场景：

- 在线用户翻页（会占用资源，不利于扩展）
- 对实时性要求高的前台查询
- 需要看到最新数据的场景

## 排序

默认情况下，返回的结果是按照相关性进行排序的——最相关的文档排在最前。相关性得分由一个浮点数进行表示，并在搜索结果中通过 `_score` 参数返回，默认排序是 `_score` 降序。

### 按照字段的值排序

有时，相关性评分对你来说并没有意义。例如，下面的查询返回所有 `user_id` 字段包含 `1` 的结果：

```json
GET /_search
{
  "query": {
    "bool": {
      "filter": {
        "term": {
          "user_id": 1
        }
      }
    }
  }
}
```

这里没有一个有意义的分数：因为我们使用的是 filter（过滤），这表明我们只希望获取匹配 `user_id: 1` 的文档，并没有试图确定这些文档的相关性。实际上文档将按照随机顺序返回，并且每个文档都会评为零分。

如果评分为零对你造成了困扰，你可以使用 `constant_score` 查询进行替代：

```json
GET /_search
{
  "query": {
    "constant_score": {
      "filter": {
        "term": {
          "user_id": 1
        }
      }
    }
  }
}
```

这将让所有文档应用一个恒定分数（默认为 `1`）。

### 按字段值排序

通过时间来对文档进行排序是有意义的，最新的文档排在最前。我们可以使用 `sort` 参数进行实现：

```json
GET /_search
{
  "query": {
    "bool": {
      "filter": { "term": { "user_id": 1 }}
    }
  },
  "sort": { "date": { "order": "desc" }}
}
```

你会注意到结果中的两个不同点：

- `_score` 和 `max_score` 字段都是 `null`。计算 `_score` 的花销巨大，通常仅用于排序；我们并不根据相关性排序，所以记录 `_score` 是没有意义的。如果无论如何你都要计算 `_score`，你可以将 `track_scores` 参数设置为 `true`。
- 每个结果中有一个新的名为 `sort` 的元素，它包含了我们用于排序的值。

> **提示：** 当结果集很大时，可以设置 `"track_total_hits": false` 或指定一个上限值（如 `"track_total_hits": 1000`）来避免精确计数全部命中文档的开销，提升查询性能。

一个简便方法是，你可以指定一个字段用来排序：

```json
"sort": "number_of_children"
```

字段将会默认升序排序，而按照 `_score` 的值进行降序排序。

### 多级排序

假定我们想要结合使用 `date` 和 `_score` 进行查询，并且匹配的结果首先按照日期排序，然后按照相关性排序：

```json
GET /_search
{
  "query": {
    "bool": {
      "must": { "match": { "tweet": "manage text search" }},
      "filter": { "term": { "user_id": 2 }}
    }
  },
  "sort": [
    { "date": { "order": "desc" }},
    { "_score": { "order": "desc" }}
  ]
}
```

排序条件的顺序是很重要的。结果首先按第一个条件排序，仅当结果集的第一个 `sort` 值完全相同时才会按照第二个条件进行排序，以此类推。

多级排序并不一定包含 `_score`。你可以根据一些不同的字段进行排序，如地理距离或是脚本计算的特定值。

### 多值字段的排序

一种情形是字段有多个值的排序，需要记住这些值并没有固有的顺序；一个多值的字段仅仅是多个值的包装，这时应该选择哪个进行排序呢？

对于数字或日期，你可以将多值字段减为单值，这可以通过使用 `min`、`max`、`avg` 或是 `sum` 排序模式。例如你可以按照每个 `date` 字段中的最早日期进行排序：

```json
"sort": {
  "dates": {
    "order": "asc",
    "mode": "min"
  }
}
```

### 字符串排序与多字段

被分析的字符串字段也是多值字段，但是很少会按照你想要的方式进行排序。如果你想分析一个字符串，如 `fine old art`，这包含 3 项。我们很可能想要按第一项的字母排序，然后按第二项的字母排序，诸如此类，但是 Easysearch 在排序过程中没有这样的信息。

你可以使用 `min` 和 `max` 排序模式（默认是 `min`），但是这会导致排序以 `art` 或是 `old`，任何一个都不是所希望的。

为了以字符串字段进行排序，这个字段应该是 `keyword` 类型（不分词）。但是我们仍需要 `text` 字段，这样才能以全文进行查询。

一个简单的方法是用两种方式对同一个字符串进行索引，这将在文档中包括两个字段：`text` 用于搜索，`keyword` 用于排序。这就是 multi-fields 的典型用法（详见「Mapping 模式与最佳实践」章节）。

> 警告：以 `text` 字段排序会消耗大量的内存。获取更多信息请参考相关文档。

## 排序字段选择

排序的性能和正确性高度依赖字段设计：

- 对高基数字段排序（如随机字符串）通常成本更高
- 对文本字段排序（如 text 类型）需要额外存储支持，且排序规则复杂

建议：

- 优先选择 keyword 或数值/日期字段作为排序字段
- 对需要排序的字段，在 Mapping 设计阶段就考虑好类型与是否开启 doc_values
- 使用 multi-fields 模式：`text` 字段用于搜索，`keyword` 字段用于排序

常见用法：

- 时间倒序：按 `@timestamp` 或类似字段排序
- 相关性排序 + 二次排序：先按 `_score`，再按时间或其他业务字段排序

## 常见陷阱与实践建议

- 避免在前台暴露“跳到第 N 页”的行为（特别是 N 很大时）；改用“加载更多/滚动加载”等方式
- 对需要长期浏览大量数据的内部工具，优先用 `search_after` 或专用扫描接口，而不是盲目用 from/size
- 排序字段必须在 Mapping 层面设计好，临时在 text 字段上排序往往会导致性能和行为问题

下一步可以继续阅读：

- [高亮](./highlighting.md)
- [建议与纠错](./suggestions.md)
- [相关性](./relevance/_index.md)

## 参考手册（API 与参数）

- [搜索操作概览（功能手册）]({{< relref "docs/features/fulltext-search/_index.md" >}})
- [分页与深度滚动（异步搜索）（功能手册）]({{< relref "docs/features/async-search.md" >}})
- [Point In Time / PIT（功能手册）]({{< relref "docs/features/query-dsl/pit-api.md" >}})



