Elasticsearch

ES 深度分页:问题、代价与 search_after

翻到第 1000 页时,ES 会把前 1000 页的数据全部捞出来重新排序,开销随 from 线性增长。这篇讲清 from + size 的分布式代价、1 万条默认限制的由来,以及 search_after + PIT 的用法与局限。

2026-03-28 约 17 分钟 6710 字 Elasticsearch 分页 search_after 聚合

一、为什么翻到后面几页就变慢

同一个查询,from=0 只要十几毫秒,from=10000 却要几百毫秒甚至超时。数据没变、条件没变,变的只是偏移量。

单机数据库里,深分页也慢,但慢得有限——LIMIT 10000, 10 大不了多扫一万行。ES 的问题要严重得多,因为它是分片架构:协调节点不知道全局第 10000 条数据在哪个分片上,只能让每个分片都把它自己的一部分交出来,再全局合并。

而每个分片交出的量,不是 10 条,是 from + size 条。

二、from + size 的执行过程

假设索引有 5 个分片,查询 from=10000, size=10:

协调节点
  │  ① 广播查询到 5 个分片
  ├─→ 分片1: 排序后返回前 10010 条
  ├─→ 分片2: 排序后返回前 10010 条
  ├─→ 分片3: 排序后返回前 10010 条
  ├─→ 分片4: 排序后返回前 10010 条
  └─→ 分片5: 排序后返回前 10010 条
  │  ② 收集 5 × 10010 ≈ 50050 条
  │  ③ 在协调节点内存里全局二次排序
  │  ④ 丢掉前 10000 条,取第 10001~10010 条返回

关键在为什么分片必须返回 from + size 条:全局第 10001 到 10010 条,可能全部来自同一个分片。 如果某个分片只返回前 10 条,而全局前 10010 条恰好都是它贡献的,那么结果就是错的。为了保证正确性,每个分片都必须交出「有可能进入最终结果集」的全部候选——也就是它自己排序后的前 from + size 条。

代价随之而来:

资源 消耗 与什么成正比
内存 协调节点要同时持有 分片数 × (from + size) 条文档的排序字段 分片数 × from
CPU 对约 5 万条做全局二次排序 分片数 × from
网络 5 万条文档的字段值在网络上传输 分片数 × from

而且最反直觉的一点是:第 10001 到 10010 条这 10 条数据,在过程中被完整地排序、传输、然后丢掉了 10000 条。 用户只看到 10 条,成本却是 5 万条的。

深分页的本质是分布式排序合并的代价问题,from 越深,协调节点压力越大。

from 从 10000 涨到 20000,成本大致翻倍。这是一个线性增长的坑,from 到十万级时协调节点很容易 OOM——注意是协调节点OOM,一个节点的内存问题会影响整个集群的查询,不只是这一个请求失败。

三、10000 这个限制是怎么来的

ES 默认拒绝 from + size > 10000 的请求:

{
  "error": {
    "root_cause": [{
      "type": "illegal_argument_exception",
      "reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]"
    }]
  }
}

这个保护是刻意的:如果真的放开了跑,代价会随 from 线性放大,最终拖垮协调节点。与其让集群挂掉,不如直接拒绝请求。

可以通过 index.max_result_window 调大这个值:

PUT /sku/_settings
{
  "index.max_result_window": 50000
}

但调大它只是把雷埋得更深。它没有降低单次查询的代价,只是把触发 OOM 的阈值往后推了。真正的解法是换分页方式,或者从业务上不做深分页。

四、search_after + PIT

search_after 的思路完全不同:不指定偏移量,而是指定「从哪一条之后开始」。

它需要一个能唯一确定顺序的排序字段(通常是业务字段 + _id 作为决胜位),然后用上一页最后一条的排序值作为下一页的游标:

GET /sku/_search
{
  "size": 10,
  "query": { "match": { "title": "洗地机" } },
  "sort": [
    { "sales": "desc" },
    { "_id": "asc" }        // 决胜字段,保证全局唯一有序,避免同销量时分页错乱/重复
  ],
  "search_after": [8823, "10086"]
}

响应里每条命中都带 sort 数组,下一页把它原样塞进 search_after:

{
  "hits": {
    "hits": [
      { "_id": "10086", "sort": [8823, "10086"] },
      { "_id": "10231", "sort": [8790, "10231"] }
    ]
  }
}

为什么必须加决胜字段? 假设只按 sales 排序,第 10 条和第 11 条销量都是 8823。search_after: [8823] 无法区分它们,下一页要么重复返回、要么直接跳过。加上 _id 后排序值全局唯一,游标才精确。

为什么它快

from + size search_after
每个分片返回 from + size 条 size 条
协调节点处理量 分片数 × (from + size) 分片数 × size
翻到第 1000 页 约 5 万条 约 50 条
复杂度 随 from 线性增长 与 from 无关,恒定

分片只需要返回它自己排序后的前 10 条,协调节点合并 50 条取前 10——不管翻到第几页,开销都一样。这是它和 from + size 的本质区别:不是在优化常数,而是换掉了算法。

PIT:解决翻页过程中的数据漂移

单独用 search_after 有个问题:翻页过程中索引还在被写入。如果期间有一条新数据插入,或者分片发生迁移,排序结果就可能变化,导致漏数据或重复数据。

解决办法是 PIT(Point In Time),先创建一个时间点快照,后续所有翻页请求都基于这个快照:

POST /sku/_pit?keep_alive=1m

// 返回
{ "id": "48myAWAWUzR4..." }

然后带着 PIT 查询,注意 PIT 查询时不能再指定 index,索引信息已经包含在 PIT 里了:

GET /_search
{
  "size": 10,
  "query": { "match": { "title": "洗地机" } },
  "sort": [
    { "sales": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [8823, "10086"],
  "pit": {
    "id": "48myAWAWUzR4...",
    "keep_alive": "1m"
  }
}

keep_alive 是滑动窗口:每次请求都会把快照的有效期延长 1m,用户一直翻就一直有效。翻完记得主动关闭:

DELETE /_pit
{ "id": "48myAWAWUzR4..." }

不关闭的话,PIT 会一直持有 Lucene 段的引用,阻止段合并和删除,磁盘占用和内存压力都会上升。这是个容易被忽略的运维问题。

search_after 的局限

  • 不能随机跳页。 它只能一页页往下翻,因为没有偏移量的概念。用户想直接跳第 50 页,做不到。
  • 不能回退。 游标是单向的,翻过头只能重新开始。
  • 需要稳定的排序键。 排序字段值重复时必须加决胜字段,否则结果会错乱。
  • 需要客户端保存游标。

所以它的适用场景是明确的:无限滚动、移动端上拉加载、数据导出、游标式 API。这些场景天然是一页页往下读的。

而传统的「页码 + 跳页」式分页,本质上要求随机访问,search_after 满足不了,只能限制页数或从产品设计上改。

五、业务上避免深度分页

技术方案之外,更有效的是让用户不需要翻那么深。

做法 说明
限制最大页数 搜索页一般只给到前 100 页(1000 条),再深的结果用户几乎不点
收窄查询条件 引导用户加筛选条件(品牌、价格区间),结果集变小,自然到不了深页
二次检索 翻到边界时提示「结果太多,请增加筛选条件」,而不是继续提供下一页
换用游标式交互 无限滚动替代页码跳转,前端不暴露「第 N 页」的概念
导出走异步任务 大量数据导出不在搜索接口里做,用 scroll 或离线任务生成文件

关于 scroll:它适合离线全量遍历(比如把索引数据导出到文件、重建下游数据),会在服务端持有一个快照上下文,不适合线上实时分页——因为它占用资源时间长,并发一多就把集群拖垮。实时分页的场景用 search_after。

三种方式的定位:

方式 实时性 能否跳页 适用场景
from + size 实时 能 前几页,from + size ≤ 10000
search_after + PIT 实时 不能 深度翻页、滚动加载
scroll 快照,非实时 不能 离线全量遍历、数据导出

六、筛选与排序怎么配合

深分页本来就慢,如果查询本身还写得不合理,只会雪上加霜。搜索页通常是「关键词 + 多维筛选 + 自定义排序」的组合,这三部分的放置位置有讲究:

部分 放哪 为什么
品牌、价格、参数等筛选条件 filter context 不算分、有 filter cache,性能好,且不干扰相关性排序
关键词检索 must(query context) 参与相关性打分
排序 function_score 销量(field_value_factor)+ 新品(gauss 时间衰减)合成一个分,boost_mode 用 multiply

filter 和 must 的区别值得单独说。两者都要求条件必须满足,但:

  • must 在 query context:计算相关性得分,结果进入 _score,不走缓存
  • filter 在 filter context:只判断是/否,不计算得分,结果可以被 filter cache 复用

筛选条件(品牌是品牌A、价格在 2000-3000 之间)都是明确的布尔判断,不需要参与打分。放进 must 会白白多算一遍分,还会让这些条件影响相关性排序——用户勾了「品牌A」,不代表他想让所有品牌A的商品得分变高。放进 filter 就没有这个问题,而且高频筛选条件的缓存命中率很高。

一个组合起来的查询:

GET /sku/_search
{
  "size": 10,
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "洗地机" } }        // 关键词:参与打分
      ],
      "filter": [
        { "term":  { "brand": "品牌A" } },          // 筛选:不算分、可缓存
        { "range": { "price": { "gte": 2000, "lte": 3000 } } },
        { "term":  { "on_sale": true } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "_id": "asc" }                              // 决胜字段,配合 search_after
  ],
  "search_after": [3.42, "10086"],
  "pit": { "id": "48myAWAWUzR4...", "keep_alive": "1m" }
}

如果排序规则是「综合排序 = 相关性 × 销量权重 × 新品加权」,就用 function_score 干预:

GET /sku/_search
{
  "size": 10,
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must":   [ { "match": { "title": "洗地机" } } ],
          "filter": [ { "term": { "brand": "品牌A" } } ]
        }
      },
      "functions": [
        {
          "field_value_factor": {              // 销量越高分越高
            "field": "sales",
            "factor": 0.01,
            "modifier": "log1p",               // 取对数,防止爆款销量碾压一切
            "missing": 0
          }
        },
        {
          "gauss": {                           // 越新分越高,90 天衰减到 0.5
            "on_sale_time": {
              "origin": "now",
              "scale": "90d",
              "decay": 0.5
            }
          }
        }
      ],
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
  },
  "sort": [
    { "_score": "desc" },
    { "_id": "asc" }
  ]
}

modifier: log1p 是针对销量分布做的处理:销量从 100 涨到 10000 是 100 倍差距,直接乘会变成「销量决定一切」,相关性完全失去意义。取对数后差距被压缩到合理区间。

score_mode 和 boost_mode 容易混淆,区分一下:

  • score_mode:多个 functions 之间如何合并(multiply / sum / max)
  • boost_mode:函数结果与查询得分之间如何合并(multiply / replace / sum)

七、聚合统计的额外代价

搜索结果页左侧的筛选面板,数量是 ES 聚合算出来的:

品牌A (6)   品牌B (2)   品牌C (1)   品牌D (1)   品牌E (1)   品牌F (1)   品牌G (1)   品牌H (1)

用户勾选「品牌A」后,面板还要重算品牌A下面各价格区间的数量。这种「筛选后重新统计每个维度」就是 ES 的 aggregation;在 MySQL 里要为每个维度写一条 GROUP BY,维度一多就组合爆炸。

GET /sku/_search
{
  "size": 0,                                     // 只要聚合结果,不要文档,省掉取文档的开销
  "query": {
    "bool": {
      "must":   [ { "match": { "title": "洗地机" } } ],
      "filter": [ { "term": { "brand": "品牌A" } } ]
    }
  },
  "aggs": {
    "brand_panel": {
      "terms": { "field": "brand", "size": 20 }
    },
    "price_panel": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 1000 },
          { "from": 1000, "to": 3000 },
          { "from": 3000, "to": 5000 },
          { "from": 5000 }
        ]
      }
    }
  }
}

size: 0 是个容易被忽略的优化:面板统计只需要聚合数字,不需要文档本身,设成 0 后协调节点不用回取 _source,深分页场景下能省掉大量网络和反序列化开销。

聚合本身也有代价——它要在每个分片上统计局部结果,再在协调节点做全局合并,涉及的桶越多(比如品牌基数上万、价格区间几十个),合并成本越高。所以:

  • 只统计真正要展示的维度,不要顺手把能算的都算上
  • terms 聚合的 size 不要设太大,它直接决定每个分片要返回多少个桶
  • 面板上的聚合跟随同一套 filter 条件,但要排除掉该维度自身的筛选(用户勾选了品牌A,品牌面板仍应显示其他品牌以便切换)

小结

  • 深分页的根因是分片架构下的排序合并:协调节点必须让每个分片返回 from + size 条,再全局二次排序
  • 5 个分片 + from=10000 = 每个分片 10010 条,协调节点处理约 5 万条,最终只返回 10 条
  • 内存、CPU、网络开销都与 分片数 × from 成正比,from 越深越危险,严重时导致协调节点 OOM
  • 默认 10000 的限制是保护机制,调大 index.max_result_window 只是把雷埋得更深,不解决问题
  • search_after 把每个分片返回量降到 size 条,开销与 from 无关,是真正的算法替换
  • 排序必须带唯一决胜字段(如 _id),否则同值排序会导致重复或漏数据
  • 配合 PIT 保证翻页期间数据漂移不影响结果,用完要主动 DELETE /_pit,否则阻止段合并
  • search_after 不能随机跳页,适合滚动加载、导出等场景;离线全量遍历用 scroll
  • 更根本的办法是业务上避免深分页:限页数、引导加筛选、无限滚动替代页码
  • 筛选条件放 filter context(不算分、有缓存),关键词放 must(参与打分),排序用 function_score(boost_mode 控制与查询分的合成方式)
  • 聚合统计加 size: 0 省掉取文档开销,并只统计真正要展示的维度

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。