铁门关市网站建设_网站建设公司_表单提交_seo优化
2026/1/10 2:17:00 网站建设 项目流程

一文讲透 Elasticsearch 分页与深度分页:从原理到实战

你有没有遇到过这样的场景?

前端同学说:“用户点了第500页,怎么卡住了?”
运维报警:“ES节点CPU爆了,查一下是不是有人在翻几万条数据!”
面试官问:“Elasticsearch 为什么不能深度分页?from/size到底慢在哪?”

这些问题的背后,其实都指向同一个核心机制——Elasticsearch 的分页实现方式及其在分布式环境下的代价

今天我们就来彻底搞清楚:
-Elasticsearch 是怎么执行一次分页查询的?
-为什么越往后翻越慢?
-search_afterscroll真的能解决这个问题吗?它们之间又有什么区别?
-实际项目中到底该用哪种方案?

别急,我们一步步拆解。这不仅是一篇技术解析文,更是一份你可以直接拿去用的生产级分页选型指南


from/size 不是“跳过”,而是“先拉再切”

我们先来看最常见的分页写法:

{ "from": 9990, "size": 10, "query": { "match_all": {} } }

看起来很简单:跳过前9990条,取接下来的10条。就像 SQL 中的LIMIT 9990, 10

但问题来了——Elasticsearch 真的是“跳过”了吗?

答案是:不是

分布式系统里的“全局排序”有多贵?

Elasticsearch 是分布式的。你的索引可能被分成 5 个分片,分布在不同的节点上。

当你发起一个from=9990, size=10的请求时,协调节点(coordinating node)会做这几件事:

  1. 向每个分片发送子查询,要求返回本地排序后的 top 10000 条结果
  2. 每个分片自己执行查询、打分、排序,返回自己的前 10000 条;
  3. 协调节点把所有分片返回的结果合并起来,重新排序,形成全局有序列表;
  4. 最后截取[9990, 10000)这 10 条数据返回给客户端。

📌 关键点:每个分片都要处理from + size条记录,哪怕其中 9990 条最终都会被丢弃!

这意味着什么?

偏移量每个分片需返回总传输数据量(假设5分片)
from=01050
from=90100500
from=99901000050,000

看到没?随着from增大,资源消耗几乎是线性增长的。CPU、内存、网络带宽全都被浪费在“搬运无效中间结果”上。

这也是为什么 ES 默认设置了:

index.max_result_window: 10000

一旦from + size > 10000,就会报错:

Result window is too large, from + size must be less than or equal to 10000

这是保护机制——防止一个请求拖垮整个集群。

所以,下次面试官问你:“ES 为什么不支持深度分页?”
你可以这样答:

“因为from/size在分布式环境下需要各分片返回大量中间结果,在协调节点进行全局排序和截断。当偏移量很大时,这种‘拉取-合并-丢弃’模式会造成严重的性能浪费,甚至导致 OOM。”


search_after:用“游标”代替“跳过”

既然from/size太重,那有没有办法只拿“真正需要的数据”?

有,就是search_after

它不靠数字偏移,而是靠“上一页最后一个文档的位置”作为锚点,继续往下读。有点像数据库里的“键集分页”(Keyset Pagination),也像翻书时记住“上次看到哪一页”。

它是怎么工作的?

假设你要按时间倒序查看日志:

{ "size": 10, "sort": [ { "@timestamp": "desc" }, { "_id": "asc" } ], "query": { "range": { "@timestamp": { "lte": "now" } } } }

第一次请求没有search_after,直接返回最新的10条。

拿到结果后,取出最后一条文档的排序值,比如:

[1680000000000, "log_001"]

下一次请求带上这个值:

{ "size": 10, "sort": [ { "@timestamp": "desc" }, { "_id": "asc" } ], "query": { "range": { "@timestamp": { "lte": "now" } } }, "search_after": [1680000000000, "log_001"] }

ES 就知道:“哦,你要找比(1680000000000, log_001)更大的记录”,于是每个分片只需扫描后续数据,无需加载前面成千上万条。

性能对比惊人

方案查询延迟(from=9990)内存占用是否可扩展
from/size800ms+
search_after~50ms极低

差距不止十倍。

而且search_after不受max_result_window限制,理论上可以一直翻到一百万页。

但它也有前提条件

  1. 必须指定明确的排序规则
  2. 排序字段组合必须能唯一标识顺序,否则可能出现漏读或重复。

举个例子:

如果你只按@timestamp desc排序,而很多文档时间戳相同,那么 ES 无法确定“下一个”是谁。这时候就必须补充一个唯一字段,比如_id或业务主键:

"sort": [ { "@timestamp": "desc" }, { "_id": "asc" } ]

这样才能保证每次都能精准定位“下一个”。

Java 实现示例

// 第一次请求 SearchSourceBuilder builder = new SearchSourceBuilder(); builder.query(QueryBuilders.rangeQuery("@timestamp").lte("now")); builder.sort("@timestamp", SortOrder.DESC); builder.sort("_id", SortOrder.ASC); builder.size(10); SearchRequest request = new SearchRequest("logs"); request.source(builder); SearchResponse response = client.search(request, RequestOptions.DEFAULT); List<Object[]> sortValuesList = new ArrayList<>(); for (SearchHit hit : response.getHits()) { System.out.println(hit.getSourceAsString()); sortValuesList.add(hit.getSortValues()); } // 如果还有数据,构造下一页 if (!sortValuesList.isEmpty()) { Object[] lastSortValues = sortValuesList.get(sortValuesList.size() - 1); SearchSourceBuilder nextBuilder = new SearchSourceBuilder(); nextBuilder.query(QueryBuilders.rangeQuery("@timestamp").lte("now")); nextBuilder.sort("@timestamp", SortOrder.DESC); nextBuilder.sort("_id", SortOrder.ASC); nextBuilder.size(10); nextBuilder.searchAfter(lastSortValues); // 设置游标 SearchRequest nextRequest = new SearchRequest("logs"); nextRequest.source(nextBuilder); SearchResponse nextPage = client.search(nextRequest, RequestOptions.DEFAULT); // 处理 nextPage ... }

这就是典型的“连续拉取”逻辑。注意:它是无状态的,翻页依赖客户端维护上一次的排序值。


scroll API:为批量任务而生的“数据快照”

如果说search_after是为“高效顺滑地浏览数据”设计的,那scroll就是为“一次性搬完所有数据”服务的。

它适用于数据导出、迁移、报表生成等离线任务。

核心思想:保存搜索上下文

当你发起第一个scroll请求时:

POST /logs/_search?scroll=1m { "size": 100, "query": { "match_all": {} }, "sort": ["_doc"] }

ES 会:
- 创建一个“搜索上下文”(search context);
- 基于当前 Lucene 版本创建一个数据快照;
- 返回第一批数据和一个scroll_id

之后你拿着scroll_id不断请求:

POST /_search/scroll { "scroll": "1m", "scroll_id": "DnF1ZXJ5VGhlbkZldGNo..." }

ES 就会根据上下文继续返回下一批,直到数据全部读完。

优势很明显

  • 数据一致性强:你在遍历过程中看到的始终是初始时刻的数据视图,不会因新写入而“抖动”;
  • 适合大数据量导出:可稳定处理百万级文档;
  • 推荐使用_doc排序:这是 Lucene 内部顺序,不需要额外排序,速度最快。

但它也有硬伤

  1. 占用服务器资源:每个 scroll 上下文会锁定缓存、文件句柄,消耗堆内存;
  2. 不能长期持有:默认 1 分钟超时,需及时拉取;
  3. 不支持随机访问:只能顺序读;
  4. 不适合高并发交互场景:大量活跃的 scroll 会导致集群负载升高。

更关键的是:自 Elasticsearch 7.10 起,官方已明确建议用 PIT(Point in Time)替代传统 scroll


scroll 正在被淘汰?PIT 才是未来

没错,scroll虽然还在用,但已经是“老古董”了。

它的替代者叫Point in Time(PIT),结合search_after使用,既能保持快照一致性,又能享受游标的高性能。

PIT 的工作流程

  1. 先打开一个 PIT,获取一个pit_id
POST /logs/_pit?keep_alive=1m
  1. 在查询中使用 PIT,并配合search_after分页:
{ "size": 10, "sort": [ { "@timestamp": "desc" }, { "_id": "asc" } ], "query": { "range": { "@timestamp": { "gte": "now-1h" } } }, "pit": { "id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxKgZub2RlXzEAAAAAAAAAAAEBYQACAWh1aWRqaBZub2RlXzIAAAAAAAAAAAI", "keep_alive": "1m" }, "search_after": [1680000000000, "log_001"] }
  1. 每次返回新的pit信息,用于下一轮请求;
  2. 完成后显式关闭 PIT 释放资源。

为什么 PIT 更好?

对比项scrollPIT + search_after
数据一致性✅ 快照✅ 快照
性能一般高(无需维护上下文)
资源占用高(context 占内存)
可扩展性
是否推荐❌(逐渐废弃)✅(官方推荐)

简单说:PIT 把“快照能力”和“高效分页”完美结合,还不占资源

新项目强烈建议直接上PIT + search_after组合拳。


到底该怎么选?一张表说清适用场景

方案支持跳页性能稳定性数据一致性适用场景推荐指数
from/size❌(随偏移恶化)⚠️ 实时变化前台分页展示(<1万条)、简单列表⭐⭐☆
search_after❌(仅顺序)✅(稳定)⚠️ 实时变化深度分页、日志流、消息中心、实时查询⭐⭐⭐⭐
scroll✅(批次稳定)✅(快照)数据导出、迁移、旧系统兼容⭐⭐
PIT + search_after✅✅✅(快照)大数据量一致性遍历(新项目首选)⭐⭐⭐⭐⭐

实战建议:别让分页拖垮你的系统

1. 前端分页尽量加过滤条件

与其让用户无脑翻到第1000页,不如引导他们通过关键词、时间范围、分类筛选缩小结果集。

用户真需要看一万条以前的商品吗?大概率不需要。

2. 深度分页坚决不用from/size

如果业务允许,强制限制最大页码(如最多显示100页),超出提示“请优化搜索条件”。

或者干脆改成分页模式:只允许“上一页/下一页”,背后用search_after实现。

3. 导出任务优先考虑 PIT

不要用scroll写脚本跑百万数据!用 PIT 更安全、更轻量。

4. 监控活跃的 search context 数量

设置告警规则,监控nodes.stats.indices.search.open_contexts,防止scroll泛滥导致内存溢出。

5. 排序字段一定要唯一

search_after时,务必组合时间戳 + ID 或其他唯一字段,避免因排序模糊导致数据跳跃。


面试题也能轻松应对

现在回过头来看那些高频 es 面试题:

Q1:Elasticsearch 为什么不适合深度分页?

因为from/size在分布式环境下需要每个分片返回from + size条数据,协调节点合并后再排序截断。当偏移量很大时,会产生大量无效计算和数据传输,性能急剧下降。因此默认限制max_result_window=10000

Q2:如何优化深度分页?

改用search_after,基于上一页最后一个文档的排序值作为游标,避免全局排序和中间结果加载,性能几乎不受偏移影响。

Q3:search_afterscroll有什么区别?

  • search_after是无状态的,适合实时交互式分页;
  • scroll有状态,维护搜索上下文,提供数据快照,适合批量导出;
  • scroll资源消耗高,已被 PIT + search_after 取代。

写在最后

分页看似简单,但在分布式系统中却藏着很深的设计权衡。

from/size简单直观,但代价高昂;
search_after高效稳定,但放弃随机跳转;
scroll曾经强大,如今正被 PIT 取代。

作为开发者,我们要做的不是死记硬背语法,而是理解每种方案背后的成本模型适用边界

当你下次面对“第500页加载慢”的问题时,希望你能从容地说:

“我们换个分页方式吧,用search_after,保证流畅到底。”

这才是真正的技术底气。

如果你正在做搜索、日志、监控类系统,欢迎收藏本文,也可以转发给团队一起讨论:你们现在的分页方案,真的合适吗?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询