分页与排序 #
分页与排序看似简单,但在分布式搜索里细节很多。本页聚焦几种常见分页方式的适用场景,以及排序字段选择上的坑。
from/size:小页场景的首选 #
from + size 适合:
- 页码较小(例如前几十页以内)的分页
- 结果集总量不特别大
特点:
- 使用简单,语义直观(类似 SQL 的 OFFSET/LIMIT)
- 页码越大,开销越大(需要跳过前面的结果)
参数说明:
size:显示应该返回的结果数量,默认是10from:显示应该跳过的初始结果数量,默认是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 里传递,或者作为查询参数传递。
注意:
scrollAPI 创建了一个快照,它不会看到在初始搜索请求后对索引所做的更改。它通过保留旧的数据文件来实现这一点,这样即使索引正在被更新,文档看起来仍然像它们在进行初始搜索时一样。
不适合用 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" }}
}
你会注意到结果中的两个不同点:
_score和max_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。你可以根据一些不同的字段进行排序,如地理距离或是脚本计算的特定值。
多值字段的排序 #
一种情形是字段有多个值的排序,需要记住这些值并没有固有的顺序;一个多值的字段仅仅是多个值的包装,这时应该选择哪个进行排序呢?
对于数字或日期,你可以将多值字段减为单值,这可以通过使用 min、max、avg 或是 sum 排序模式。例如你可以按照每个 date 字段中的最早日期进行排序:
"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 字段上排序往往会导致性能和行为问题
下一步可以继续阅读: