ES 深度分页:问题、代价与 search_after
翻到第 1000 页时,ES 会把前 1000 页的数据全部捞出来重新排序,开销随 from 线性增长。这篇讲清 from + size 的分布式代价、1 万条默认限制的由来,以及 search_after + PIT 的用法与局限。
一、为什么翻到后面几页就变慢
同一个查询,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_* 四个变量后自动开启。