分页与排序

分页与排序 #

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

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

from + size 适合:

  • 页码较小(例如前几十页以内)的分页
  • 结果集总量不特别大

特点:

  • 使用简单,语义直观(类似 SQL 的 OFFSET/LIMIT)
  • 页码越大,开销越大(需要跳过前面的结果)

参数说明:

  • size:显示应该返回的结果数量,默认是 10
  • from:显示应该跳过的初始结果数量,默认是 0

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

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):

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

获取结果中最后一条的 sort 值,作为下一页的 search_after 参数:

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 配合使用,以保证分页过程中索引视图一致:

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

返回一个 pit_id,后续分页时携带:

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 查询的典型用法:

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

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

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

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

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

不适合用 scroll 的场景:

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

排序 #

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

按照字段的值排序 #

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

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

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

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

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

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

按字段值排序 #

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

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

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

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

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

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

"sort": "number_of_children"

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

多级排序 #

假定我们想要结合使用 date_score 进行查询,并且匹配的结果首先按照日期排序,然后按照相关性排序:

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

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

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

多值字段的排序 #

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

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

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

字符串排序与多字段 #

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

你可以使用 minmax 排序模式(默认是 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 字段上排序往往会导致性能和行为问题

下一步可以继续阅读:

参考手册(API 与参数) #