FEATURED · 精选文章

Elasticsearch深度分页性能优化:从原理到四种实战解决方案

发布时间 / 2026/8/2 5:54:40
来源 / 创域科博编辑部
栏目 / 资讯中心
Elasticsearch深度分页性能优化:从原理到四种实战解决方案 1. 项目概述当分页查询成为性能“黑洞”在数据驱动的业务场景里分页查询几乎是所有系统的标配功能。无论是后台管理系统的用户列表还是电商网站的商品展示用户早已习惯了“上一页/下一页”的操作。然而当数据量从百万级跃升至亿级一个看似简单的深度分页请求就可能瞬间将你的Elasticsearch集群拖入泥潭轻则查询超时重则直接导致节点内存溢出OOM而宕机。这就是Elasticsearch深度分页问题的典型表现。我遇到过不少团队在业务初期数据量不大时直接使用from和size参数进行分页一切运行顺畅。但随着数据指数级增长某天突然收到报警发现某个查询耗时从几十毫秒飙升到几十秒甚至彻底失败追查下去根源往往就是深度翻页。比如用户想查询第10000页的数据假设每页10条使用from99990, size10。这个请求在ES内部的实际工作方式远比你想象的要“笨重”得多。简单来说Elasticsearch的深度分页问题本质上是其分布式检索模型与用户直观的“页码”概念之间的根本性冲突。理解这个冲突并掌握几种核心的解决方案是从业者构建稳健搜索服务的必修课。本文将彻底拆解这个问题背后的原理并为你提供四种经过实战检验的解决方案从适用场景、实现细节到避坑指南一次性讲透。2. 深度分页问题的根源分布式系统的“一致性视图”代价要解决问题必须先理解问题为何产生。很多人知道深度分页慢但未必清楚它到底慢在哪里。这需要我们从Elasticsearch的基本架构和查询流程说起。2.1 传统from/size分页的工作原理当你执行一个类似GET /my_index/_search?from99990size10的查询时Elasticsearch内部并不是直接去定位第99990条记录。其处理流程可以分解为以下几个步骤查询阶段Query Phase客户端请求发送到协调节点。协调节点将查询转发给索引的所有相关分片主分片或副本分片。每个分片在本地执行查询各自独立地计算出满足条件的前from size条数据的文档ID和排序值如_score然后返回给协调节点。注意每个分片返回的是自己本地排名前(9999010)100000的结果而不是全局的前100000。收集与排序阶段Fetch Phase协调节点收集所有分片返回的结果。假设索引有5个主分片那么协调节点将收到5 * 100000 500000条文档的元信息。它需要在内存中对这50万条结果进行全局排序以找出真正全局排名在99990到100000之间的那10条文档。取回阶段协调节点确定了最终的10条文档后再根据其文档ID和所在分片向对应的分片发送第二次请求获取这10条文档的完整内容_source最后组装返回给客户端。问题的核心暴露了为了获取全局第99990到100000条数据ES需要在协调节点内存里对分片数 * (from size)条数据进行排序。这个开销随着from值的增大而线性增长消耗大量的CPU和堆内存。更致命的是每个分片需要构建一个长度为fromsize的优先级队列并维护它这同样消耗分片节点上的内存。2.2 深度分页带来的具体风险基于上述原理深度分页会引发三大风险性能断崖式下跌查询耗时与from值成正比。翻页越深需要排序的中间结果集越庞大响应时间从毫秒级增至秒级甚至分钟级。内存溢出风险协调节点需要聚合和排序海量中间结果极易撑爆JVM堆内存导致节点崩溃。分片节点上的优先级队列也可能过大。结果不一致性在分页过程中如果索引有数据写入新增、更新、删除由于各分片执行查询的快照时间点可能有细微差异可能导致后续页出现重复数据或丢失数据。例如第一页查询后一条数据被更新并排序位置后移它可能既出现在第一页又出现在第二页。注意Elasticsearch官方明确警示from size的默认限制是10000。这并不是一个硬性上限你可以通过index.max_result_window设置调大它但官方强烈不建议这么做因为这相当于放大了上述风险很可能导致集群不稳定。所以当业务提出“需要支持无限滚动或任意深度跳页”的需求时我们必须清醒地认识到沿用传统数据库的分页思维是行不通的必须寻求更适合分布式搜索引擎的解决方案。3. 解决方案一Scroll API —— 一次性快照遍历ScrollAPI的设计初衷并非为了分页而是为了高效地导出大量数据或进行全量处理。但它提供了一种“游标”机制恰好可以用来安全地遍历深度结果集。3.1 原理与适用场景Scroll查询会在初始查询时为当前索引数据创建一个快照视图。这个快照在指定的“存活时间”内保持不变无视期间的数据变更。然后它返回一个_scroll_id客户端通过反复使用这个_scroll_id来获取下一批结果直到遍历完成。它的核心价值在于除了第一次查询后续的每次“翻页”请求成本极低因为ES不需要再像from/size那样重复进行全局排序它只需要根据上次的位置继续向后获取即可。最适合的场景大数据导出需要将索引中所有符合条件的数据导出到文件或数据库。后台全量处理比如对全量数据重新计算标签、生成报表。需要深度遍历但不需要实时性的分页例如运营后台需要手动核查大量历史数据。最不合适的场景实时、高并发的用户界面分页。因为Scroll会占用大量的服务器资源来维护快照上下文且生命周期较长如果用于用户频繁翻页会快速耗尽资源。3.2 实操步骤与核心参数假设我们需要导出所有“状态为活跃的用户”。初始化Scroll查询POST /users/_search?scroll5m { size: 100, query: { term: { status: active } }, sort: [_doc] }scroll5m设置Scroll上下文在服务器端的存活时间为5分钟。每次使用_scroll_id都会刷新这个时间。务必根据数据量大小合理设置太短可能遍历不完太长则占用资源。size指定每次返回的批次大小。这里设为100。sort: [_doc]这是一个关键优化。使用_doc排序即文档的索引顺序是最高效的因为它避免了相关性算分_score的计算。如果业务必须按其他字段排序性能会有所下降。获取结果及scroll_id 上述请求的返回结果中除了第一批数据还会包含一个_scroll_id字段。保存这个值。遍历后续数据 使用上一步得到的_scroll_id来获取下一批数据。POST /_search/scroll { scroll: 5m, scroll_id: DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAC...很长的字符串 }重复此步骤直到返回的hits.hits数组为空。清理Scroll上下文重要 遍历完成后必须主动释放资源。即使不手动清理上下文在超时后也会自动释放但主动清理是好习惯。DELETE /_search/scroll { scroll_id: [上述的_scroll_id] }3.3 注意事项与避坑指南资源吸血鬼每个Scroll上下文都会在ES集群中占用内存和文件句柄。高并发地创建大量Scroll上下文是致命的。务必在客户端逻辑中确保用完即焚及时清理。非实时性这是快照遍历期间的新增、更新、删除数据不会被包含在内。这对于需要实时性的业务是不可接受的。不适合跳页Scroll只能顺序遍历无法直接跳到第N页。它是“游标”不是“页码”。size不宜过大虽然可以设置很大的size一次性拉取更多数据但这会增加单次请求的网络传输压力和协调节点的内存压力。通常建议在100-1000之间权衡。实操心得我曾用Scroll实现过一个千万级用户数据的夜间批量打标任务。最初没有设置sort: [_doc]并且scroll时间设了30分钟结果任务运行缓慢且偶尔超时。后来改为_doc排序并将size从500调整为1000同时严格控制Scroll上下文的创建和销毁任务时间缩短了60%。关键在于要把Scroll看作一个“批处理工具”而不是“交互式查询工具”。4. 解决方案二Search After —— 实时游标分页Search After是Elasticsearch为解决深度分页问题而提供的“官方推荐”方案旨在克服Scroll API的实时性缺陷和资源占用问题。4.1 原理与适用场景Search After的思路是使用上一页结果中的排序值作为“游标”或“书签”来获取下一页。它不维护一个服务端的快照上下文因此没有Scroll那样的资源开销并且能实时反映索引的变更。工作原理首次查询时指定一个或多个具有唯一性或高度区分度的字段作为排序条件例如_id或时间戳。在返回的结果中取出最后一条数据的排序值。下一次查询时将这些排序值作为search_after参数传入ES会定位到该位置之后的数据。最适合的场景无限滚动Infinite Scroll移动端或Web端常见的“上拉加载更多”。需要实时性的深度分页后台管理系统需要查看实时数据列表并且可能翻很多页。替代from/size进行深度查询。4.2 实操步骤与排序技巧假设我们有一个文章索引需要按创建时间倒序分页并且支持无限滚动。首次查询第一页GET /articles/_search { size: 10, query: { match_all: {} }, sort: [ {create_time: desc}, {_id: asc} ] }关键点排序条件中除了业务字段create_time必须追加一个唯一字段如_id作为降级排序条件。这是因为create_time很可能不是唯一的同一毫秒可能有多篇文章如果没有唯一字段当两篇文章create_time相同时分页可能会丢失数据或出现重复。_id是唯一的可以保证排序结果的确定性。处理结果并获取“游标” 假设返回的最后一条数据是{ _id: article_123, _source: {...}, sort: [1640995200000, article_123] }注意结果中的sort数组它包含了我们指定的排序字段的值。1640995200000是create_time的时间戳article_123是_id。查询下一页第二页 使用上一条结果的sort值作为search_after参数。GET /articles/_search { size: 10, query: { match_all: {} }, sort: [ {create_time: desc}, {_id: asc} ], search_after: [1640995200000, article_123] }ES会找到create_time 1640995200000且_id article_123因为_id是asc的所有文档然后取前10条。这样就精准地定位到了下一页的起始位置。后续翻页重复步骤2和3每次都用当前页最后一条的sort值作为下一次的search_after。4.3 注意事项与避坑指南必须保证排序字段值的唯一性组合这是最重要的原则。如果只用时间戳排序在时间戳相同的情况下翻页会混乱。_id是最可靠的保底字段。不支持随机跳页和Scroll一样Search After只支持顺序遍历下一页。你无法直接跳到第50页除非你保存了第49页最后一条的排序值。这意味着客户端需要维护这个“游标”状态。查询条件必须一致翻页过程中query、sort、size必须保持不变否则结果无法衔接。数据变更的影响在翻页过程中如果数据发生变更例如一条数据的排序字段值被修改它可能会在新的位置出现从而导致重复或丢失。但由于每次查询都是实时的这比Scroll的不变性更符合多数业务场景。实操心得在实现一个新闻Feed流时我们采用了Search After。前端的典型模式是首次加载后将返回的sort值缓存在本地或服务端会话中。当用户滚动到底部时携带这个sort值请求“下一页”。这里有一个坑如果排序字段是“热度分”一个动态计算的值那么在两页请求的间隙热度分可能变化导致少量数据顺序微调但用户体验上基本无感知。对于需要绝对顺序稳定的场景如按ID分页则要使用静态字段。5. 解决方案三折叠查询Collapse与去重后分页有些深度分页的需求源于数据本身存在大量重复或需要按某个维度聚合后展示。例如一个电商搜索“手机”结果中可能包含同一款手机的不同颜色、配置用户希望先按商品SPU标准产品单元去重分页点击后再看SKU库存量单位详情。这时Collapse折叠功能就派上用场了。5.1 原理与适用场景Collapse允许你根据一个或多个字段对搜索结果进行折叠去重每个折叠组即相同字段值的文档组只返回排序最靠前的那一条文档。你还可以通过inner_hits获取该组内其他文档的信息。它的价值在于将海量的、重复的文档集合压缩成一个更具代表性的视图进行分页极大地减少了需要处理的数据量从而间接解决了深度分页的性能问题。因为分页是基于折叠后的结果集进行的。最适合的场景商品列表去重展示按商品ID折叠展示最热门或最新的一个SKU。论坛/社区主题帖列表按主题帖ID折叠只展示最新回复或最热门的回复。日志分析中按错误类型聚合按错误码折叠查看每种错误的最新一条实例。5.2 实操步骤与inner_hits使用假设有一个商品SKU索引字段包括spu_id商品ID、sku_id规格ID、price、sales销量等。我们需要按SPU去重并展示每个SPU中销量最高的那个SKU然后对这个列表进行分页。基础折叠查询GET /skus/_search { from: 0, size: 10, query: { match_all: {} }, collapse: { field: spu_id }, sort: [ {sales: desc} ] }这个查询会将所有spu_id相同的文档归为一组。在每个组内按sales降序排序。只返回每个组里销量最高的那条文档。最后对这个“折叠后”的文档列表进行分页from0, size10。注意这里我们仍然使用了from/size但因为数据先被折叠了假设10亿个SKU属于100万个SPU那么实际参与排序分页的文档量从10亿降到了100万性能提升巨大。但from值很大时依然有深度分页问题只是阈值被大大推后了。结合Search After进行深度折叠分页 为了彻底解决深度分页可以将Collapse与Search After结合。GET /skus/_search { size: 10, query: { match_all: {} }, collapse: { field: spu_id }, sort: [ {sales: desc}, {spu_id: asc} ] }首次查询后获取最后一条结果的sort值例如[1500, spu_100]1500是销量spu_100是SPU_ID。下一页查询时使用search_afterGET /skus/_search { size: 10, query: { match_all: {} }, collapse: { field: spu_id }, sort: [ {sales: desc}, {spu_id: asc} ], search_after: [1500, spu_100] }使用inner_hits获取组内详情 折叠后如果我们还想知道每个SPU下有哪些SKU可以使用inner_hits。collapse: { field: spu_id, inner_hits: { name: skus_in_spu, size: 5, sort: [{price: asc}] } }在返回结果中每个折叠后的文档会包含一个inner_hits对象里面是这个SPU下按价格升序排列的前5个SKU详情。5.3 注意事项与避坑指南折叠字段的选择折叠字段必须是单值字段如keyword且最好有较高的基数不同值较多。如果折叠字段值大量重复折叠效果有限。排序决定代表文档每个折叠组返回哪条文档完全由全局sort决定。确保你的排序逻辑符合业务“代表”的选取规则如最新、最热、最便宜。性能考量折叠操作本身有开销。inner_hits会显著增加响应大小和计算成本需谨慎设置size。结果数估算不准使用折叠后hits.total.value表示的是折叠前的总命中数而不是折叠后的组数。获取准确的组数需要额外的聚合查询成本较高。实操心得在一个电商搜索项目中我们最初尝试直接用from/size对SKU分页在深度翻页时性能极差。引入按spu_id折叠后性能立竿见影。但遇到了新问题用户有时想按“价格最低”的SKU作为SPU代表展示。这要求我们动态调整全局排序例如sort: [{price: asc}]而inner_hits里可以再按销量排序展示其他SKU。这种“外层一个排序内层另一个排序”的灵活性很好地满足了复杂的产品需求。记住collapse是一种用“业务逻辑”换取“性能提升”的典型方案。6. 解决方案四业务折衷与前端优化 —— 放弃“精准跳页”很多时候技术方案的瓶颈可以通过调整产品交互来巧妙规避。对于面向用户的搜索系统“精准跳页到第10000页”本身可能就是一个伪需求。Google、百度等搜索引擎也早已放弃了传统的页码跳转。6.1 原理与设计思路这种方案的核心思想是不对海量结果集进行全局的、精确的偏移量计算而是通过更智能的筛选和排序将用户可能感兴趣的结果提前或者让用户通过更具体的条件来缩小结果集从而避免深度翻页。常见的设计模式“无限滚动” “搜索条件优化”交互放弃页码采用上拉加载更多即Search After的完美前端搭档。优化提供强大的排序按时间、热度、价格和筛选器分类、品牌、价格区间、标签。引导用户通过筛选来将结果集从百万级减少到千级以内然后再进行浏览。一个活跃的筛选器组合是避免深度分页的最佳手段。“近似分页”与“游标缓存”对于后台管理系统等确实需要跳页的场景可以不完全放弃页码但接受其“近似性”。例如估算总条数ES的track_total_hits设置为一个合理上限如10000然后使用Search After。当用户输入页码时系统根据page * size估算出一个大概的search_after位置可能需要一些预查询或近似计算而不是精确跳转。并提示用户“当前为近似位置”。“时间切片”分页对于按时间序列的数据日志、新闻、动态分页完全可以被“按时间范围查询”替代。第一页查询“今天”的数据。点击“加载更早”查询“昨天”的数据。通过一个日期选择器用户可以快速定位到任何时间段这比翻成千上万页高效得多。6.2 实操策略与前端配合策略一强化排序与筛选这是成本最低、效果最显著的方案。与产品经理紧密合作分析用户真实的查询意图。将“最相关”综合相关性、销量、评分、上新时间的结果排在前面。暴露关键的、有区分度的筛选维度。例如在商品搜索中“品牌”、“价格区间”、“配送方式”的筛选效率远高于翻页。技术实现这需要你在ES的查询DSL上下功夫设计合理的function_score查询来提升排序质量并利用filter来高效处理筛选条件。策略二实现安全的游标分页前端示例前端需要配合Search After工作。首次搜索请求参数不带search_after。接收结果保存最后一条数据的sort值例如一个由时间戳和ID组成的数组。加载更多用户滚动到底部时将保存的sort值作为search_after参数发起下一次请求。更新游标用新结果集的最后一条sort值更新本地游标。关键点游标状态可以保存在前端内存、URL哈希或服务端会话中。要处理用户中途修改搜索条件的情况——此时必须重置游标发起一次全新的搜索。策略三限制最大查询窗口并友好提示这是一种防御性策略。在业务代码层或ES设置层硬性限制from size的最大值例如5000。当用户请求超出此范围时不执行查询而是返回友好提示“您查询的数据过深请尝试添加筛选条件或使用更具体的关键词缩小范围。” 这直接教育了用户也保护了集群。6.3 注意事项与避坑指南产品共识是关键技术方案变更必须获得产品和业务方的理解与支持。你需要用性能数据如深度分页的响应时间曲线和用户体验如Google的案例来说服他们。前端状态管理使用Search After时前端需要妥善管理游标状态。在单页应用SPA中要确保浏览器前进/后退操作不会破坏游标逻辑。总条数的处理Search After无法高效获取精确的总条数。对于“无限滚动”可以不再显示总条数和总页数或者显示一个估算值“约10万结果”。对于需要精确总数的后台可以单独用一个开了track_total_hits的count查询但要明白这个查询本身可能很重。排序字段的稳定性用于Search After的排序字段组合必须是稳定的。如果业务上允许数据修改排序字段值如更新商品价格那么用户在翻页过程中可能会看到数据“跳动”。需要根据业务容忍度来评估。实操心得在我们的一次系统重构中最大的挑战不是技术实现而是改变固有的产品思维。我们通过A/B测试将旧版带页码和新版无限滚动强化筛选进行对比。数据显示使用新版的用户平均搜索点击率提升了15%而平均翻页深度从3.5页下降到了1.8页。这说明更好的排序和筛选确实能让用户更快找到目标从而根本不需要深度翻页。这个数据成为了我们推动方案落地的最有力武器。技术方案最终要服务于业务目标和用户体验有时改交互比改代码更有效。7. 方案选型速查与决策指南面对四种方案如何选择下表提供了一个快速决策参考特性/方案传统from/sizeScroll APISearch AfterCollapse业务折衷核心原理全局排序内存开销大创建快照顺序遍历使用游标实时查询按字段去重后分页优化交互规避问题实时性实时非实时快照实时实时实时支持跳页是否仅顺序否仅顺序结合from/size可跳页但有深度限制通常放弃或近似跳页资源占用高协调节点内存高服务端维护上下文低中折叠计算开销低适用场景浅分页1000页大数据导出、全量处理无限滚动、实时深度遍历按维度去重后列表用户端搜索、后台管理结果一致性可能不一致数据变更时强一致快照期内可能不一致数据变更时可能不一致取决于底层实现客户端复杂度简单中需管理scroll_id中需管理sort游标简单中前端交互复杂决策流程建议首先问业务用户真的需要跳到第10000页吗能否通过更好的排序、筛选、搜索建议来减少结果集如果能业务折衷方案是最优解。如果需要深度遍历对实时性有要求如用户操作 - 选择Search After。对实时性无要求如离线任务 - 选择Scroll API但务必注意资源管理和设置sort: [_doc]。如果数据需要按维度聚合展示如商品按SPU去重 - 选择Collapse并可结合Search After应对深度。最后一道防线对于任何方案都应在业务层或网关层设置合理的分页深度上限和超时时间防止恶意或异常的查询拖垮集群。8. 常见问题与排查技巧实录在实际开发和运维中除了方案选型还会遇到各种具体问题。这里记录几个典型案例和排查思路。问题1使用Search After翻页时偶尔出现重复数据。排查首先检查排序条件。重复数据几乎总是因为排序字段组合不具备唯一性。例如只按create_time排序而同一毫秒内插入了多条数据。解决确保排序条件中至少包含一个唯一性字段如_id作为最后的降级排序。sort: [{create_time: desc}, {_id: asc}]。更深层原因在翻页间隙如果文档的排序字段值被更新例如一条数据的评分被改变它可能在新页面重新出现。这需要根据业务一致性要求来权衡。问题2Scroll查询运行一段时间后超时失败。排查检查scroll参数设置的时间是否足够长以完成整个遍历。大数据集导出需要更长时间。检查客户端逻辑是否在每次滚动请求后都刷新了scroll_id有时旧的scroll_id会失效。使用_nodes/statsAPI 查看集群的“scroll上下文”数量是否过多导致内存压力。解决适当增加scroll时间如从5m到30m并在客户端逻辑中每次收到响应后用返回的新_scroll_id进行下一次请求。为Scroll任务设置一个独立的、负载较低的客户端并确保任务完成后调用清除API。在ES配置中可以设置search.max_open_scroll_context来限制全局Scroll上下文数量防止滥用。问题3Collapse后如何获取折叠字段如SPU下的文档总数挑战hits.total反映的是折叠前的文档总数。要获取折叠后的组数Collapse本身不提供。解决方案近似值高效使用cardinality聚合来估算折叠字段的唯一值数量。这对于分页导航栏显示“约XX条结果”通常足够。GET /skus/_search { size: 0, aggs: { unique_spus: { cardinality: { field: spu_id, precision_threshold: 10000 } } } }精确值昂贵使用composite聚合或terms聚合设置size为一个大数来获取所有唯一的折叠键然后统计数量。警告这在大数据集上非常消耗资源可能不适用于生产环境高频查询。问题4业务方坚持要提供“总页数”和“精确跳页”怎么办沟通与教育展示性能测试数据说明深度跳页对集群稳定性的风险。举出主流互联网产品搜索引擎、电商、社交平台都已放弃该模式的例子。提供替代方案“加载更多”模式说明这是更流畅的移动端体验。“时间轴”或“筛选器”导航对于日志、新闻等按时间或分类导航比页码更高效。“近似跳页”实现一个功能允许输入页码但实际使用Search After进行一个“最近似”的定位并提示用户“已定位到附近位置”。这需要在业务层做一些额外的逻辑处理。设定硬性限制作为妥协可以提供一个较大的但有限的分页窗口比如最多500页并与运维一起设定好监控确保集群在极限情况下仍能承受。一次线上事故复盘我们曾有一个后台功能使用了简单的from/size分页并默认track_total_hits为true。某天运营人员导出一个包含数千万条数据的报表并请求了最后一页。这个查询试图在协调节点内存中计算和排序数千万条数据的总数以及偏移量直接导致该节点OOM宕机引发连锁反应。教训对于任何面向内部或外部的分页接口都必须有防御性设计限制最大fromsize谨慎使用track_total_hits对于导出类需求强制走ScrollAPI 异步任务通道。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻