📣 极限科技诚招搜索运维工程师(Elasticsearch/Easysearch)- 全职/北京 👉 : 立即申请加入

分页是几乎所有搜索应用都具备的基础功能。在数据量较小时,开发者习惯使用 fromsize 参数进行翻页,这简单直观。然而,随着数据量增长和页码变深,查询响应时间往往呈指数级上升,甚至引发集群性能抖动。

本文将深入 Easysearch 的分布式原理,解释为什么深度分页会变慢,介绍平台内置的保护机制,并给出高效、稳定的替代方案。

原理:from/size 是如何工作的? #

在 Easysearch 中,from 参数表示跳过的结果数(偏移量),size 表示返回的结果数。

当一个搜索请求被发送到集群时,它通常包含两个阶段:Query(查询)和 Fetch(取回)。
在分布式环境下,数据分布在多个分片(Shard)上。为了返回第 1000 页的数据(假设每页 10 条,即 from=9990, size=10),每个分片都必须先查询出各自的前 10000 条记录(TopDocs)。

随后,这些分片会将结果返回给协调节点(Coordinating Node)。协调节点必须收集所有分片返回的文档 ID 和排序值(如果分片数为 5,则总共收集 50000 条),在内存中进行全局排序,最后丢弃前 9990 条,只保留最后的 10 条返回给客户端。

为什么会慢?深度分页的真实成本 #

理解了上述流程,就不难发现 from/size 在深度分页时的性能杀手:

  1. CPU 与内存的线性增长from 值越大,协调节点需要排序和处理的数据量就越大。这是一个线性增长的过程,过大的 from 会导致 CPU 飙升和大量的内存占用。
  2. 跨分片归并开销:若指定了自定义排序(如按时间或特定字段),各分片需要返回 TopFieldDocs。协调节点进行的全局归并工作量随着 from 的增加而剧增。
  3. Fetch 阶段的 I/O 压力:虽然 Query 阶段只返回 ID 和排序值,但在 Fetch 阶段,系统需要根据 ID 去拉取文档的 _source 内容。如果请求的 size 很大,或者命中的文档分布在不同的段中,随机 I/O 和反序列化的成本依然不可忽视。
  4. Total Hits 计算成本:为了告诉前端“一共有多少页”,Easysearch 默认需要统计匹配的总行数。在海量数据下,精确计算 track_total_hits 会强制所有分片遍历所有匹配项,这也是一个耗时点。

平台限制:10,000 条的保护线 #

为了防止恶意或无意的深度分页请求拖垮集群,Easysearch 默认设置了一个硬性限制:

index.max_result_window = 10000

这意味着 from + size 的总和不能超过 10,000。如果你的应用尝试请求第 10,001 条数据,系统会直接报错,提示你使用 scroll 或调整索引设置。

此外,使用 from/size 进行深分页还是无状态的。如果在翻页过程中有新数据写入或旧数据删除,可能会导致结果“漂移”(即同一条数据在不同页码重复出现,或某些数据被漏掉)。

最佳实践:如何高效分页? #

针对不同的业务场景,Easysearch 提供了更高效的替代方案:

1. 深度分页:使用 search_after #

这是目前推荐的主流深度分页方式。

原理
search_after 不使用“跳过前 N 条”的逻辑,而是使用“上一页最后一条数据的排序值”作为游标(Cursor)。查询时,告诉 Easysearch:“请给我排在这个值之后的数据”。

优势

  • 无论翻到多深,每个分片都只需返回 size 条数据。
  • 协调节点只需合并 shard_num * size 条数据,内存和 CPU 开销极低且恒定。

示例
假设我们按 publish_time_id 排序。

第一页(正常查询):

GET my-index/_search
{
  "size": 10,
  "query": { "match": { "text": "easysearch" } },
  "sort": [{ "publish_time": "desc" }, { "_id": "desc" }]
}

获取第二页(带入上一页最后一条的 sort 值):

GET my-index/_search
{
  "size": 10,
  "query": { "match": { "text": "easysearch" } },
  "sort": [{ "publish_time": "desc" }, { "_id": "desc" }],
  "search_after": [1704110400000, "doc-id-abc"]
}

2. 全量导出:使用 scroll #

如果你需要导出数百万条数据进行分析,而不是给用户看,请使用 scroll
它会建立一个临时的快照上下文,保证在遍历过程中数据的一致性(即使期间有新数据写入,导出结果也不会变)。但请注意,scroll 维护上下文有资源开销,不适合用于实时的用户端搜索。

3. 稳定视图:使用 PIT (Point In Time) #

结合 search_after 和 PIT,可以实现既高效又具备一致性视图(不漂移)的深度分页。

性能优化清单 #

在设计搜索功能时,建议遵循以下清单:

  • 浅分页(前几页):继续使用 from/size,简单且够用。
  • 深分页(无限滚动/手机端瀑布流):切换为 search_after
  • 数据导出:使用 scroll 接口。
  • 不需要精确总数时:将 track_total_hits 设为 false 或固定数值(如 10000),避免全量计数。
  • 减少网络开销:通过 _source 参数仅返回业务需要的字段,避免拉取大字段。
  • 避免修改限制:尽量不要轻易调大 index.max_result_window,除非你非常清楚集群的硬件承载能力。

通过合理选择分页策略,我们可以在保证用户体验的同时,最大程度地保护 Easysearch 集群的稳定性。