ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ES深度分页全解:从报错原理到Scroll/Search After/PIT选型

ES深度分页全解:从报错原理到Scroll/Search After/PIT选型 先说说我为什么想写这篇。前两天有个同事跑过来问我ES线上一个列表接口翻到第200页突然报错一看日志是Result window is too largefromsize默认只能查10000条。这个问题其实特别典型几乎所有用ES做列表查询的团队都会撞上一次。我跟他聊完发现大多数人只知道ES分页有上限但并不知道这个限制是怎么来的也不清楚深度分页到底该怎么选型。今天就把这件事掰开揉碎聊清楚包括ES分页的内部过程、深度分页的几种方案、它们的适用场景和坑以及我这边沉淀下来的实操写法。1. 先从一次报错说起ES分页到底触发了什么1.1 “Result window is too large”是怎么出现的我们先用一个最基础的场景入手。假设有个订单索引里面有500万条文档测试环境数据量不大时没人会注意分页问题直到线上某个运营后台要导数据或者翻页你随手写了这么一段查询{ from: 100000, size: 20, query: { match_all: {} } }然后ES直接甩给你一段异常核心是这么一句Result window is too large, from size must be less than or equal to: [10000] but was [100020]这时候不少人第一反应是把index.max_result_window调大不就行了比如调到100万。确实能解决眼下的报错但这个操作其实是在给后面的性能问题埋雷因为你没有理解ES为什么限制这个窗口。ES之所以默认限制 fromsize 的窗口为10000核心原因是fromsize分页在分布式环境下的成本是指数级的。它不是为了恶心你是在保护你的集群。我先从一次正常分页查询在ES内部是怎么跑的讲起你就能理解这个限制背后的代价了。1.2 一次分页查询在ES内部的完整过程假设索引有5个主分片你发起一个from990, size10的查询期望拿到第991到第1000条数据。这个请求会先打到协调节点Coordinating Node然后ES做这么几件事协调节点把请求改写并广播到5个分片。每个分片独立执行查询先在本地筛选出命中的文档再对fromsize这条数据范围进行排序也就是每个分片都要排前1000条。每个分片把自己排好序的前1000条文档ID返回给协调节点注意这里只返回文档ID和排序字段不返回完整文档。协调节点拿到5个分片的结果后总共会有 5×1000 5000 条文档ID在内存里做一次全局归并排序。排序完成后协调节点丢弃掉前990条只取第991到第1000条对应的文档ID再发一次请求去各分片拉取完整的文档内容返回给客户端。这个过程中最容易被忽视的问题是第2步和第4步。你以为查的是第990页的10条数据但每个分片实际排序的文档数量是fromsize而不是 size。也就是说查询损耗跟页号近似成正比页号越深每个分片要排序的数据就越多协调节点内存里要归并的ID也就越多。如果把max_result_window直接调到100万你就允许客户端一次性指定 from90万这时候5个分片每个都要排序90万条文档协调节点要归并450万条文档ID。一旦查询并发上来协调节点堆内存直接被这些排序结果打爆。这还是在match_all这种轻量查询的情况下如果查询里有复杂的should条件、算分逻辑或者大字段排序代价更高。所以这里想先传达一个核心观点ES深分页问题的本质不是“ES不支持”而是“fromsize这种翻页机制天然不适合深分页”。你需要的不是调大窗口而是换一种更适合深分页的机制。1.3 两个容易忽略的细节算分和排序的副作用再来补充两个实际会踩到的细节。第一个是算分问题。ES默认的查询算分是基于词频、文档频率等因子计算的而分片在本地算分时IDF逆文档频率是基于当前分片内的统计值算的所以不同分片对同一篇文档的得分可能不同。正常浅分页无所谓但是一旦你要做深度翻页尤其是按相关度排序的搜索场景跨分片归并时可能出现排序不稳定、重复数据等问题。这也是为什么我在做深分页方案时如果排序字段是_score基本会建议换成其他确定性更强的字段作为 tie-breaker比如_doc或者时间戳。第二个是超时和资源占用。翻页越深每个分片的查询耗时越长协调节点等待所有分片返回的时间就越长。如果某个分片因为GC或者磁盘压力变慢整页查询都会被拖住。我见过一个运营后台翻到5000页时单个查询直接把协调节点的CPU打到满载最后不得不限流。2. 深度分页的三条主流路线Scroll、Search After、PIT2.1 Scroll适合导出不适合前端翻页先聊聊大家最熟悉的 Scroll。早期ES版本里的深度分页方案就是它使用方式也很简单第一次查询带上 scroll 参数ES会返回一个 scrollId之后每次通过 scrollId 向后拉取。POST /order/_search?scroll1m { size: 1000, query: { match_all: {} } }拿到 scrollId 后继续用这个ID拉下一页POST /_search/scroll { scroll: 1m, scroll_id: DnF1ZXJ5VGhlbkZldGNo... }用完之后要清理DELETE /_search/scroll { scroll_id: DnF1ZXJ5VGhlbkZldGNo... }Scroll的原理可以理解成ES在第一次查询时为这个查询条件建立一个快照上下文并把这次快照的完整结果集对应到一个游标上之后每次翻页都是从这个已经生成的快照中顺序读取下一批数据。听起来很美好但它的代价也很明确Scroll 创建后ES 会在内存中保留这个查询对应的段和上下文即使数据已经变了快照结果也不变。这意味着它是一个“时间点”视图适合导出批量数据但不适合用户实时翻页看最新数据。如果Scroll没有正确清理段和上下文会一直占用堆内存和文件句柄。你可以在GET /_nodes/stats/indices/search里看到 open_contexts 的数量排查泄漏。Scroll 的游标只能顺序往后走不能跳页不能往前翻。用户想从第1页跳到第100页Scroll做不到。所以我的经验总结是Scroll 适合“一次性拉大量数据回去”比如定时任务同步数据、数据导出、Reindex 场景不适合ToC接口的实时分页展示。在ES 7.x之后官方甚至建议用 Search After 替代Scroll大部分场景。2.2 Search After游标分页的正确打开方式Search After 是现在最推荐做深度分页的方案。它的思路跟Scroll有点像也是“记住上一页最后一条文档的位置”但实现方式完全不同。Search After 不依赖快照上下文而是通过排序值作为游标。举个例子我按订单金额和文档ID排序每页查10条{ size: 10, query: { match_all: {} }, sort: [ { amount: desc }, { _id: asc } ] }第一页正常返回。ES在返回结果里会带上每个文档的 sort 值比如最后一笔订单的 amount2999_id1048576。接下来我翻第二页只需要把这两个值塞进 search_after{ size: 10, query: { match_all: {} }, search_after: [2999, 1048576], sort: [ { amount: desc }, { _id: asc } ] }它的内部原理是上一页最后一条文档的排序值就是下一页的起始位置ES拿到这个位置之后直接跳到对应分片的排序位置上继续往后取数据而不是像 fromsize 那样把前面所有数据都排序一遍再丢弃。这个方案的优点非常明显性能跟页号无关。也就是说第1页和第100万页的查询成本是一样的因为压根不需要跳过前面的数据。实时性强。它没有快照上下文每次查询都是基于当前索引的最新数据新增或者删除的文档在下一次查询时都能体现。但缺点同样需要认识清楚不能跳页。Search After 只能顺序翻页用户想直接点到第500页是不行的因为它没有“页号”的概念只有“游标位置”。排序字段必须全局唯一否则翻页可能丢数据。比如按金额排序如果两条文档金额相同、其他排序字段不稳定就会出现下一页重复或遗漏。我在生产环境一律要求排序里加_id或者_shard_doc做兜底确保严格有序。使用 Search After 时两个相同条件的查询之间如果数据发生变化可能导致中间出现数据错位。比如新插入一条排在前面上一页最后一条在下一页里又出现一次或者漏了一条。这在纯前向翻页时很难完全避免所以一般也只适合“加载更多”这类场景。2.3 PIT把“一致性”和“游标”合二为一到了ES 7.10之后官方推出了 PITPoint In Time机制全称是搜索时间点快照。它解决的核心问题是Search After 在翻页过程中如果索引有数据变更不同分片返回的数据可能不在同一个时间点上导致结果不一致。PIT 的用法分三步。第一步创建一个快照POST /order/_pit?keep_alive1m返回一个 pit_id。第二步查询时把这个 pit_id 带上同时使用 search_after{ size: 10, query: { match_all: {} }, pit: { id: oG46ZUlNc2ljc2FsdGNvbVBpdElkOjE2ODk4..., keep_alive: 1m }, sort: [ { amount: desc }, { _id: asc } ] }第三步翻页时除了传 search_after还要继续传同一个 pit_id并且每次都要刷新 keep_alive。PIT 与 Scroll 的区别在于Scroll 是直接给整个结果集拍快照而 PIT 是给索引的底层 Lucene 段拍快照查询条件不变你可以在同一个快照上做不同的 query 和聚合。与 Search After 的区别在于Search After 每次查询的视图是“当前时刻”而 PIT 是在一个固定的时间点视图上翻页避免新增数据对中间页造成插入或偏移。注意一个细节使用 PIT 之后index 参数就不能出现在查询里了因为 PIT 已经锁定了索引。路径上可以直接写/_search。如果项目用的ES版本是7.10以上我的排位是PIT Search After Search After Scroll。但很多公司线上可能还是ES 7.6、7.8甚至6.x所以下面我按实际可落地场景把方案选型再整理一遍。2.4 三种方案核心对比维度ScrollSearch AfterPIT Search After是否支持实时数据否快照视图是快照视图但可配合新查询是否支持跳页否否否查询性能第一次开销大后续快每次开销一致每次开销一致稍微多一点资源占用持续占用上下文低需管理PIT生命周期适合场景批量导出、Reindex前端“加载更多”需要一致性快照的深度分页版本要求各版本都支持2.x之后就支持需要7.103. 实操基于Spring Boot的深度分页代码怎么写3.1 常规 fromsize 的问题代码还原先看一段很常见但线上会出事的写法。假设有个订单查询接口用RestHighLevelClientES 7.x实现public SearchResponse searchOrders(int page, int size, String keyword) throws IOException { SearchSourceBuilder sourceBuilder new SearchSourceBuilder() .from(page * size) .size(size) .query(QueryBuilders.matchQuery(remark, keyword)); SearchRequest request new SearchRequest(order) .source(sourceBuilder); return restHighLevelClient.search(request, RequestOptions.DEFAULT); }这段代码在数据量小的时候跑的挺好一旦订单量突破10万、有人翻到5000以上就会触发我开头说的Result window is too large异常。而且还有另一个隐患即使max_result_window被调大from值很大的情况下协调节点需要归并大量文档ID响应时间会指数增长。我给团队的硬性要求是搜索接口禁止直接用 from 做深翻页前端只允许游标翻页或者限制最大页号。下面给两套可以直接抄的写法。3.2 方案一前端“加载更多”场景用 Search After假设业务是移动端或后台列表的“点击加载更多”这种场景不需要跳页用 Search After 是最合适的。第一步是写一个查询方法第一次调用时没有游标public PageResultOrderDTO searchOrdersBySearchAfter(String keyword, Object[] searchAfter, int size) throws IOException { SearchSourceBuilder sourceBuilder new SearchSourceBuilder() .query(QueryBuilders.matchQuery(remark, keyword)) .size(size) // 排序字段必须稳定_id兜底保证全局唯一 .sort(SortBuilders.fieldSort(amount).order(SortOrder.DESC)) .sort(SortBuilders.fieldSort(_id).order(SortOrder.ASC)); if (searchAfter ! null searchAfter.length 0) { sourceBuilder.searchAfter(searchAfter); } SearchRequest request new SearchRequest(order).source(sourceBuilder); SearchResponse response restHighLevelClient.search(request, RequestOptions.DEFAULT); return convertToPageResult(response); }响应转换时一定要把排序值透出给前端private PageResultOrderDTO convertToPageResult(SearchResponse response) { SearchHit[] hits response.getHits().getHits(); ListOrderDTO list new ArrayList(); Object[] lastSortValues null; for (SearchHit hit : hits) { OrderDTO dto parseHit(hit); list.add(dto); // 保存每一条的排序值翻页只用最后一条 lastSortValues hit.getSortValues(); } PageResultOrderDTO result new PageResult(); result.setList(list); result.setHasMore(list.size() size); result.setSearchAfter(lastSortValues); // 前端下次带上这个 return result; }前端逻辑就变成了第一次请求POST /api/orders { keyword: xx, size: 20 } 下一次请求POST /api/orders { keyword: xx, size: 20, search_after: [2999, 1048576] }这里有几个关键点值得强调search_after数组的顺序必须跟sort的顺序一致而且类型也要一致。比如金额是keyword类型你排序值是字符串那数组里第一个也必须是字符串。类型不匹配会直接报错。一定要给排序加稳字段。如果只按amount排序多个文档金额相同ES可能因为分片间的顺序不一致导致翻页漏数据。加_id是为了引入唯一排序依据。如果查询里不关心相关性打分可以把track_scores关掉减少不必要的算分开销。3.3 方案二需要一致性快照用 PIT Search After如果业务场景是“运营后台导出一批满足条件的订单分多次拉取要求整个过程数据视图一致”这时候就用 PIT Search After。先封装一个创建PIT的方法public String createPit(String index, long keepAliveSeconds) throws IOException { CreatePitRequest pitRequest new CreatePitRequest(index, TimeValue.timeValueSeconds(keepAliveSeconds)); CreatePitResponse pitResponse restHighLevelClient.createPit(pitRequest, RequestOptions.DEFAULT); return pitResponse.getId(); }再封装翻页查询public SearchResponse searchByPit(String pitId, long keepAliveSeconds, Object[] searchAfter, int size) throws IOException { SearchSourceBuilder sourceBuilder new SearchSourceBuilder() .size(size) .sort(SortBuilders.fieldSort(amount).order(SortOrder.DESC)) .sort(SortBuilders.fieldSort(_id).order(SortOrder.ASC)) .searchAfter(searchAfter); SearchRequest request new SearchRequest() .source(sourceBuilder) .pit(new SearchRequest.Pit(pitId, TimeValue.timeValueSeconds(keepAliveSeconds))); return restHighLevelClient.search(request, RequestOptions.DEFAULT); }注意使用了 PIT 后SearchRequest不再指定index索引信息已经在创建PIT时绑定了。PIT模式有一个很强的优势当你创建一个PIT之后就算这个PIT创建后索引里有新数据写入、有文档删除你在这个PIT视图里看到的仍然只是创建时刻的索引状态。非常适合对账、统计、导出这种需要“时间点一致性”的场景。PIT 用完了记得删除public void deletePit(String pitId) throws IOException { DeletePitRequest deletePitRequest new DeletePitRequest(pitId); restHighLevelClient.deletePit(deletePitRequest, RequestOptions.DEFAULT); }3.4 版本兼容问题低版本ES怎么办很多团队还在用ES 6.x或者ES 7.0~7.9这种情况没有PIT可以用但又想避免Scroll占用上下文的问题我一般建议退而求其次直接使用 Search After不要Scroll。在ES 6.x上Scroll还有一个比较坑的地方它生成的_scroll_id在集群重启后会失效你整个导出任务就可能断掉。Search After是纯查询语义天然没有这种状态问题最多就是查到中间数据变了但不会报一个让你摸不着头脑的“scroll id失效”错误。另外ES 6.x的RestHighLevelClient里 SearchSourceBuilder 就有searchAfter方法了用法基本一样只是没有PIT可以配合需要注意翻页过程中数据变更引起的轻微错位通常这种场景下可接受。4. 常见问题与调优心得4.1 深度分页时的“排序不稳定”怎么破先给结论排序不稳定的原因是排序字段不是唯一键。最常见的就是按_score排序低版本ES中不同分片算分时基于的 term 频率是分片内的统计值跨分片归并后顺序确实可能会有细微差异。解决办法有两个。一是排序字段增加 tie-breaker。不要只按_score排而是同时加_id{ sort: [ { _score: desc }, { _id: asc } ] }这样即使两个文档的_score完全相同后续排序也会因为有唯一的_id而保持确定顺序。二是如果业务上不依赖相关度排序干脆用纯字段排序比如时间戳加_id开销更小也更稳定。我这边生产环境最常用的是(business_date desc, _id asc)或者(amount desc, _id asc)。4.2max_result_window到底能不能调能调但要明确调它解决的是什么问题。index.max_result_window默认10000它限制的是 fromsize 模式允许的最大窗口。如果你有非改不可的存量代码临时调到10万、50万作为过渡这可以理解。但我必须提醒你两个后果调大之后深分页的慢查询会开始消耗协调节点内存慢查询堆积可能导致集群整体响应变差甚至拖垮节点。max_result_window是基于索引级别的配置你可以单独给某个索引临时调大不要把集群里所有索引都放开。我见过一个比较合理的过渡做法先定位所有用到深翻页的接口把它们改成 Search After 游标模式改完再恢复默认限制。如果存量接口太多优先改消耗最大的那批接口。PUT /order/_settings { index: { max_result_window: 50000 } }如果又要保留 fromsize 模式又想防止性能黑洞折中方案是限制业务层的最大页号比如超过10000就提示“数据量过多请使用筛选条件缩小范围”这在面对数据量和检索场景可控的中小团队里也算是一种务实的取舍。4.3 慢查询排查怎么确定分页拖慢了集群出现性能问题时第一步不是看代码而是先确认慢查询到底来自哪里。我常用的排查方法有这三条打开慢日志查看哪些查询的took时间异常高。ES慢日志分index.search.slowlog和search.slowlog把阈值设置到500ms或1s就能抓到深分页查询。PUT /order/_settings { index.search.slowlog.threshold.query.warn: 1s, index.search.slowlog.threshold.fetch.warn: 1s, index.search.slowlog.level: warn }在 Kibana 的监控页面看 Search 的 p99、p95延迟曲线如果某个时间点开始曲线骤升多半是有人触发了深分页慢查询。检查协调节点的堆内存曲线。深度分页引起的OOM或频繁GC最开始都体现在堆内存的“上涨-不回落”模式上。你可以用GET /_nodes/stats/jvm观察heap_used_percent以及 GC 次数。一旦确认是某条深分页查询导致的直接给这个查询加限制限定窗口/限制页号不要靠事后调参维护集群稳定。事后处理只是救火事前在设计查询语义时就把分页模式定下来才是治本。4.4 Scroll 上下文泄漏的检查与清理如果你已经在用Scroll但没注意关闭定时任务每次跑完都会留下一批 scroll 上下文。检查方式GET /_nodes/stats/indices/search响应里看open_contexts数量。如果这个数字持续增长而且你的任务并没有同时那么多那基本就是上下文泄漏了。清掉所有未关闭的scroll上下文DELETE /_search/scroll/_allJava代码里正确关闭写法try-with-resources或者finally块try { // 循环拉取数据 } finally { ClearScrollRequest clearRequest new ClearScrollRequest(); clearRequest.addScrollId(scrollId); restHighLevelClient.clearScroll(clearRequest, RequestOptions.DEFAULT); }4.5 批次大小到底设多少合适Scroll 和 Search After 的 size 不是越大越好。size设置太大单次请求返回的数据量会变大网络和反序列化开销跟着涨size太小请求次数变多吞吐量受限。我自己的经验值Scroll 批量导出场景size 设置在 1000~5000 之间比较合适。取决于单条文档大小如果文档里有大文本字段建议压到1000如果全是短字段5000也能接受。核心限制是ES默认max_result_window不影响 scroll 和 search_after所以size大一点不会触发那根红线但内存压力还是有的。前端列表加载更多的场景size 一般 10~50 就够了后端不要给得太大。使用 Search After 做同步任务时size 可以到 500~1000按每条文档平均1KB估算500条就是500KB内存和网络都还好。另外一个容易被忽略的点如果配合 Scroll导出大量数据尽量使用_source过滤只拉需要的字段可以显著降低响应体大小和GC压力。4.6 被热词坑过非分页缓冲池和非分页缓冲池占用这个标题相关热词里有个“非分页缓冲池占用很高怎么解决”虽然不是ES分页的核心话题但它在搜索ES分页问题时经常一起出现我也顺手记录一下。在Linux上跑ES时如果发现free命令显示 cached/buffers 占用很高这是正常现象。ES重度依赖文件系统缓存Lucene 的段文件都希望被缓存到 Page Cache 里这样查询不用每次都做物理磁盘IO。buff/cache占用高通常不需要慌张反而是机器内存没有真正利用上的时候值得关注。但有一个场景需要警惕如果buff/cache始终满同时磁盘IO持续很高这可能说明索引段文件太大、内存不够用或者查询都在扫全量数据。排查思路分几步先看iostat确认读写IO再看elasticsearch.log里有没有 merge 或 flush 相关的慢日志再分析查询是否每次都在深层翻页导致大量page cache吞吐。大多数情况下问题不在ES把内存吃满了而在于你的查询模式让它无法有效利用缓存比如大范围扫全表式的查询、不分片裁剪的聚合、以及慢查询堆积导致并发IO放大。如果你确认是ES对文件系统缓存需求过高而机器内存确实紧张能做的优化通常包括减少分片数避免重复读取段文件、把经常做范围查询的字段改成更紧凑的编码、给数据量大的热索引做 rollover 压缩以及从业务侧限制深分页查询并发量。盲目清drop_caches没用因为清了之后 Lucene 重新缓存反而更慢。5. 场景化选型到底什么时候用哪种分页很多人在后台问我能不能直接给一个决策依据。我整理了一张基于业务场景的选型逻辑可以照着套业务场景推荐方案理由前端列表页自动翻页到几十页以内fromsize简单直观能高效缓存LRU排序结果前端限制页码即可前端信息流/搜索页“点击加载更多”Search After支持实时新数据性能稳定不会越翻越慢后端定时同步、数据导出、全量比对Scroll 或 PITSearch After需要游标顺序读取避免分页窗口限制对账、报表要求翻页过程中数据视图一致PIT Search After7.10在某个时间点固化了索引视图ES低于7.10但需要一致性视图Scroll虽然占用上下文但快照语义最接近需求还有两个特殊场景要提一下。第一种需要跳页的深度分页。比如一个后台管理表格用户确实需要从第10000页直接跳到第20000页这时候游标类方案都不可用。我的建议是换个思路这种深跳页需求本身就不应该由ES直接扛。你可以在业务层加筛选条件缩小范围提前聚合出符合条件的数据量把翻页深度限制在可控范围内。如果实在必须支持那就接受性能损耗把max_result_window调高但配合限流和超时保护并且单独隔离这个接口避免影响其他查询。第二种给用户提供搜索词命中统计的场景。比如“搜索”结果的关联词展示需要全量聚合结果而不是分页数据这种其实也不该用分页而应该用复合聚合composite aggregation或者直接离线预计算。分页是给列表页用的别用它来干“取全部”的活方法论错了再换任何分页方案都没用。6. 我踩过的几个坑想再说一遍最后聊几个不太容易被文档提到的细节都是我实际踩过之后才想明白的给你省点时间。第一个是Search After 的第一页和后续页要保证 sort 字段一致。我见过有人第一页只按时间排序第二页突然也想加_id做稳定排序结果查询报错或者更隐蔽一些返回了重复数据。标准做法是后端封装统一SearchSourceBuilder不要让前端传排序字段。第二个是Search After 的游标值类型千万别搞错。比如amount字段在ES里是keyword类型那么排序结果值是字符串2999你拿到Java里解析成Integer再传回去就会报错。建议hit.getSortValues()取到什么就原样传回给前端前端下次带过来时保持原始类型。第三个是Scroll第一次查询上下文创建的开销非常大别用Scroll做高频短查询。如果你在循环里每秒创建10个Scroll每次只需要几百条数据那等于自己制造GC压力。正确用法是单个Scroll拉完所有数据或者直接用Search After。第四个是ES 8.x开始类型概念消失但分页层面的行为没有变化。如果你用的是新客户端elasticsearch-javaAPI风格变了但分页原理和选型逻辑一模一样不要把新旧客户端的差异理解成分页机制的差异。第五个是不要把 Scroll 和 Search After 混着用。两者的游标格式完全不同Scroll用的是scroll_idSearch After用的是排序值数组你在同一个服务里要做封装隔离不然维护成本会翻倍。我个人的经验是新项目里默认使用 Search After 或 PIT Search After遇到真正大批量导出的场景才单独用 PIT并且给PIT设置合理的 keep_alive 并强制释放。把分页这件事理解为“数据访问模式的匹配问题”而不是“加参数调整就能搞定的配置问题”很多ES踩坑都能提前规避。分页上限10000不是ES的缺陷而是一个提醒——它在告诉你业务设计该停一下认真考虑你到底需要什么样的数据访问方式了。
返回列表