
先说个让我印象很深的场景有次线上反馈运营后台一页只查 20 条翻到第 800 页的时候接口直接 5 秒超时查了下慢查询日志问题不在 ES 集群 CPU而是from跳到了 16000。这个案例基本就是 ElasticSearch 分页的缩影——大多数人一开始都用from size等数据量大起来才发现这玩意儿根本不是“翻页”而是一次次把前 N 条数据全翻出来再丢掉。这篇文章就围绕 ElasticSearch 的分页过程和深度分页方案展开把from/size、scroll、search_after PIT这几个主流做法掰开讲适合正在做中间件设计、接口性能优化或者刚接手 ES 查询模块的同学参考。1. 为什么分页在 ElasticSearch 里是个“设计题”1.1 分页本质是什么从关系型数据库的思维说起在 MySQL 里做LIMIT 100000, 20如果表上有合适的索引数据库只需要定位到第 100000 行附近再往后读 20 行就行。即使 offset 很大大多数时候也能接受顶多吃 IO。这套思维直接搬到 ElasticSearch 上就会出问题因为 ES 的from size不是“跳过前面 N 条”而是“查到前面 N size 条然后丢弃前 N 条”。ElasticSearch 底层是倒排索引加 Lucene 分段查询时每个分片都要独立执行 query、排序、打分最后由协调节点做全局归并。你请求from10000size20本质上协调节点要等每个分片先返回 10020 条结果聚合成一个大列表排序后截取第 10000 到 10020 条。也就是说翻得越深每个分片要传给协调节点的数据就越大排序成本也跟着涨。这个过程的计算复杂度大约就是O(分片数 × (from size) × 排序字段代价)跟 offset 呈线性关系而不是像 MySQL 那样可以靠 B 树直接“走到”目标位置。1.2 深度分页的成本到底高在哪很多人以为深度分页只是慢其实它还会带来三个隐性成本。第一是内存和 GC 压力。协调节点要把每个分片返回的from size条结果全部放进内存做堆排序如果并发请求多堆外内存和 JVM 堆都会涨得很快严重时直接触发 circuit breaker。第二是重复计算。翻到第 2 页和第 100 页前面的查询过程几乎 100% 重复每一页都在重新执行一次完整的深度扫描没有任何缓存可复用。第三是数据一致性陷阱。用户在翻页过程中如果索引里有新增、删除或更新前面页和后面页的数据可能错位而from size不会给你任何快照保护。所以深度分页从来都不是“加个参数”的事它是查询模型的选择题。先用这个认知去审视业务再谈具体方案。2. 核心分页方案拆解fromsize、scroll、search_after2.1 fromsize最直观但别乱翻from size是 ES 默认的查询方式适合前几百页、数据总量几十万以内的中小场景。它的写法最简单表达式也直观GET /order/_search { from: 0, size: 20, query: { match_all: {} }, sort: [ { create_time: desc } ] }默认情况下from size最多只能查到第 10000 条超过会报Result window is too large, from size must be less than or equal to: [10000]这个限制来自索引设置index.max_result_window默认 10000。注意它不是 ES 的硬性上限而是防止你写出那种“翻到底”的搜索请求。真要调大也能调比如PUT /order/_settings { index.max_result_window: 100000 }但我不建议你在生产环境靠这个解决深度分页原因后面单独说。from size适合的场景非常明确后台列表、前台搜索前几页、管理端按条件筛选。这类需求翻页深度一般不超过几十页性能完全没问题。2.2 scroll快照式遍历适合导出不适合交互scroll是 ES 针对“一次性取大量数据”设计的方案。第一次请求带上scroll1mES 会为这次查询生成一个快照并返回一个_scroll_id。后续请求拿这个 id 去捞数据每次捞完再拿新的 id 继续直到数据取完。# 第一次请求 GET /order/_search?scroll1m { size: 500, query: { match_all: {} } } # 后续请求 POST /_search/scroll { scroll: 1m, scroll_id: DnF1ZXJ5VGhpcyGBh... }关键点scroll 不是实时数据它基于发起 scroll 那一刻的索引快照。所以如果你正在写入数据scroll 遍历过程中新增的文档不会被看到。这一点在导出场景里反而可能是优点因为导出的数据不会因为中间写入而改变逻辑上更一致。但它有个明显的坑scroll 上下文是有成本的。每个 scroll 请求会在 ES 节点上保留一个 search context占用内存和文件句柄scroll1m不是“1 分钟后自动释放”而是“这个上下文 1 分钟没被续期就释放”。如果你开了十个 scroll 又忘了关闭节点内存就默默被吃掉了肉眼很难查。用完一定要主动调DELETE /_search/scroll { scroll_id: DnF1ZXJ5VGhpcyGBh... }所以我的结论scroll 是“批量导出工具”不是“分页组件”。任何面向用户实时翻页的接口都不该用 scroll。2.3 search_afterPIT真正推荐的做法search_after的思想很简单不告诉你我要第几页而是告诉你“我上一页最后一条数据是哪个”然后从这个位置往后查。它需要配合一个全局稳定的排序字段组合最常用的是_id或业务唯一键。但 search_after 有个前提排序字段的唯一性。如果只按create_time排序而同一秒内有大量相同时间的记录那 search_after 会丢失数据或产生重复。所以实践中必须用一个“唯一值”作为最终排序兜底比如sort: [ { create_time: desc }, { _id: asc } ]光有唯一排序还不够。ES 默认是“无状态”的两次查询之间索引数据变了排序结果可能漂移。为了固定一个查询视角Elasticsearch 7.10 提供了 PITPoint In Time。你可以先创建一个快照点之后每次 search_after 都基于这个 PIT 查询保证整页遍历期间数据视图一致。# 创建 PIT保留 5 分钟 POST /order/_pit?keep_alive5m # 返回 pit_id然后每次查询带着pit和sortGET /_search { size: 20, query: { match_all: {} }, pit: { id: uH2Vd2hJcmJ..., keep_alive: 5m }, sort: [ { create_time: desc }, { _id: asc } ] }拿返回结果里最后一条的sort数组作为下一次请求的search_afterGET /_search { size: 20, search_after: [1721729000000, abc123], pit: { id: uH2Vd2hJcmJ..., keep_alive: 5m }, query: { match_all: {} }, sort: [ { create_time: desc }, { _id: asc } ] }比较麻烦的是_id在排序里返回的是字符串如果_id是雪花 ID 或 UUID排序没问题但如果你的业务 ID 是纯数字且以字符串形式存排序会按字典序排结果可能和你预期不一致。这种场景建议在索引里额外存一个seq_no或sort_key数值字段专门用来做排序。3. 深度分页的完整实操过程含关键参数与设计取舍3.1 需求界定先搞清楚你到底要“翻页”还是“取数”做技术方案之前一定要先把需求问清楚。我见过太多团队直接上手调参结果发现需求是“导出全量数据”却用了 search_after 一页页查最后不仅慢还把应用内存打爆。所以先分两类交互式翻页用户点击第 1、2、3...N 页可以跳页也可以上一页/下一页。这种需求只能用from size浅分页或者search_after深分页。全量遍历/导出要把符合条件的数据全部拉出来比如凌晨同步数据、导出报表。这种需求用scroll或者search_after PIT做流式拉取最合适。判断清楚了再选型否则后面全是坑。拿我做过的订单中心来说前台订单列表只允许看最近 90 天数据翻页最多 50 页我直接用了from size配合索引按天滚动和别名查询性能很稳。但运营后台有个“按外部单号查全链路明细”的功能数据量可能上万就改成了search_after PIT一页 500 条往下拉体验完全不一样。3.2 search_after PIT 落地步骤下面给一个能直接抄的落地路径假设业务是订单查询索引为order_v1需要按create_time倒序且支持深翻。第一步确认索引里有没有可靠的全局唯一排序字段。最省事的是用_id但前提是_id的字典序满足你的排序预期。如果不满足建议在 mapping 里加一个sort_id类型为long写入时填自增 ID 或雪花 ID。PUT /order_v1/_mapping { properties: { sort_id: { type: long } } }第二步创建 PIT注意keep_alive不是越长越好。太短会导致下一页还没查完就失效太长会让 PIT 对应的 Lucene 段不能及时回收占用磁盘和内存。一般建议和单次遍历窗口绑定比如每页查 100 条用户平均 5 秒翻一页给 1m 到 2m 就够了。整批任务可以每 1000 条续一次 PIT而不是一次性开 10 分钟。第三步查询第一页。注意带上track_total_hits如果不需要精确总数可以设成false或10000否则 ES 可能会为了算出total把全部命中数都统计一遍白白增加开销。前面几页可以用from size拿总数后面跳页就用search_after。第四步解析返回结果里的sort数组。ES 返回的每个 hit 里都有该条结果的排序值hits: { hits: [ { _id: order_10086, _source: { create_time: 2025-06-01 10:00:00 }, sort: [1748764800000, order_10086] } ] }下一次请求取最后一条的 sort 值作为search_after传入即可无缝衔接下一页。3.3 参数与索引设计避坑点实操中有几个设计细节特别容易踩坑我重点说三个。第一排序字段必须字段值稳定。如果某个文档的排序字段在翻页过程中被更新它可能从第 5 页“瞬移”到第 2 页导致你下一次 search_after 无法定位。要避免这个问题排序字段尽量选不会变更的字段比如创建时间、业务单号。第二PIT 的 keep_alive 要客户端主动续期。很多客户端实现只是每次请求把同一个 PIT id 传上去却忽略了在请求里重新设置keep_alive。正确姿势是每次 query 里都带上keep_alive: 1mES 会以最后一次请求为准顺延生命周期。另外任务结束记得主动删除 PITDELETE /_pit { pit_id: uH2Vd2hJcmJ... }第三避免在 search_after 查询里不要带大 offset 逻辑。有人会把from和search_after混用这是没必要的。search_after本身就是通过游标定位再加from等于又要求 ES 额外丢弃前面 N 条逻辑乱且性能差。要用就用纯search_after别混。4. 常见问题与排查经验实录4.1 10000 条限制到底是怎么回事这个问题是出现频率最高的。首先确认你是不是真的需要查超过 10000 条。如果是用户分页查询一般不应该超过 10000 条因为用户不可能有耐心翻几百页。如果真有业务需要比如“查看全部历史操作日志”那更合适的是 scroll 或导出任务而不是放开 max_result_window。请不要一上来就调index.max_result_window。我见过有人把它调到 100 万结果线上 OOM。原因很简单ES 的 query 流程里协调节点必须把所有分片返回的数据先收齐这个集合的大小跟着窗口大小涨。窗口开到 100 万一个并发请求就可能占掉几百 MB 堆内存多来几个请求直接打挂节点。如果真的只是要一个总数不需要返回所有数据用track_total_hits配合size0只做聚合计算即可。比如GET /order/_search { size: 0, track_total_hits: true, query: { match_all: {} } }响应里能拿到准确的 total而不会产生大结果集。4.2 为什么 max_result_window 调大不是好方案逻辑上你可能会想那我把 max_result_window 调大不就能用 from 翻到底了吗这个方案在数据量小的时候可能能跑但数据和并发上来之后必然出问题。举个例子索引有 20 个分片某查询命中了 100 万条每页 10 条用户翻到第 5 万条时from49990。协调节点需要从 20 个分片各取 5 万条也就是 100 万条记录全部进入协调节点的内存做全局排序然后只截取最后 10 条返回。这个开销是灾难级的。而且这还没算 query 阶段在每个分片上的扫描成本。所以调大 max_result_window 只是把限制往后推并没有解决性能模型的问题。真正解决深度分页只有两条路要么限制访问深度要么用游标定位代替 offset 跳跃。4.3 实测中容易踩的坑我在实际项目里踩过的坑可以列一个小清单scroll 上下文泄漏。代码里如果抛异常没有走到 DELETE scroll 的逻辑search context 会一直挂着。排查方法很简单节点内存一直涨JVM 老年代涨得明显通过GET /_nodes/stats/indices/search看open_contexts数量如果这个值居高不下基本可以确认 scroll 没关。search_after 结果重复。原因多半是排序字段不唯一。比如只按create_time排序同一秒里有很多条数据你取最后一条的 sort 值传下去时ES 会把所有等于这个值的数据再从第一条开始排导致上一页尾部数据和下一页头部数据重叠。解决办法是加唯一字段做二级排序。跨索引查询的 PIT 不支持。如果你的业务是查多个索引 alias 或multi-indexPIT 的创建路径可能需要单独处理。实测中发现对 alias 建 PIT 没问题但如果你用_all或逗号分隔多索引名ES 7.x 某些版本会校验不严。最好直接对着 alias 或单一具体索引创建。时间字段排序的性能。如果排序字段是date类型ES 会把它转成 long 排序没问题但如果字段是text或者keyword会有额外内存消耗。排序字段尽量用数值或日期不要用大字符串。5. 分页方案选型速查与我的体会5.1 选型判断表我把几种方案放一起对比方便你以后直接对照选型方案适合场景是否支持跳页数据实时性性能瓶颈使用建议from size浅分页、前几百条、管理后台列表支持实时深度越大越慢调大 max_result_window 需谨慎scroll全量导出、离线同步不支持跳页快照上下文占用内存用完必须关闭search_after深度分页、实时列表仅“下一页”实时依赖 sort 稳定性加唯一排序字段search_after PIT深度分页且要求一致性仅“下一页”快照PIT 占用段资源合理设置 keep_alive从这个表能看出一个规律ES 的分页方案本质上是拿“跳页能力”换“性能”。from size能跳页但深了不稳scroll 和 search_after 不能跳页但能深拉。如果业务一定要同时支持深分页和任意跳页那 ES 本身就不是好的技术选型你应该考虑把数据同步到支持索引覆盖的存储或者在业务层做全局快照缓存。5.2 一些个人经验补充最后分享一个我后来一直沿用的做法对外接口统一使用search_after PIT但把页码这个概念屏蔽在业务层。比如前端传lastIdxxx和size20后端解析成 search_after。这样既避免用户直接翻到 100000 页拖垮 ES又能保证接口的响应时间稳定。对于必须展示总页数的场景后端单独做一次 count 查询前端只展示一个总量然后通过“加载更多”的方式代替页码跳转。另一个细节是别忘记监控。ES 的慢查询日志能帮你定位分页问题但更好的做法是在入口层记录每个查询的took_millis和from/size/search_after值。我遇到过一种诡异情况同样的接口用户从第 3 页翻到第 4 页耗时突然翻倍查下来发现是因为索引里新增了大量数据导致某天的分片不均匀。后来我给查询链路加了日志埋点每次翻页都记录分片命中数和 took这才把问题定位到分片倾斜上。分页不只是查询的事索引和分片设计也会决定你能不能翻得动。如果你正在做中间件设计建议把分页能力当作对外 API 的一部分来设计而不是临时加参数。先把深度分页的模型定下来再决定对外暴露什么参数会比在出问题时反复改客户端稳妥很多。