Result window is too large
搜索页齐活后,运营提了个朴实的需求:把搜索结果翻到后面几页看看有没有漏网之鱼。老王把 from 从 0 拨到 10000,页面直接报错:Result window is too large, from + size must be less than or equal to: 10000。老王第一反应是把默认限制调大,查完原理后庆幸自己没动——这个报错不是 bug,是 ES 在救他的命。
from 加 size 越翻越贵的根源
回想第 3 篇的世界观:索引拆了 3 个主分片,查询时协调节点把请求发给每个分片。翻页时发生的事情是:
GET /product/_search { "from": 10000, "size": 10 }
协调节点要求:每个分片都取回前 10010 条
3 个分片 = 30030 条数据在网络里飞
协调节点内存里归并排序 30030 条,再扔掉前 10000 条
最后只返回 10 条成本随页码线性放大:翻第 1 页每分片取 10 条,翻第 1001 页每分片取 10010 条。而用户只拿到 10 条。翻得越深,浪费越多,协调节点的堆内存先遭殃。max_result_window 默认 10000 就是给这条链路设的闸。MySQL 深分页 limit 100000, 10 也是同款问题,读者可以回忆 MySQL 系列里的对应篇目,病根一模一样:归并取回一大截,只用一小截。
search_after:翻页不回头
思路反转:既然每次从头数到第 N 条太贵,那就记住上一页最后一条的排序值,下一页直接从它后面接着捞:
GET /product/_search
{
"size": 10,
"sort": [ { "sales": "desc" }, { "_id": "asc" } ],
"search_after": [ 8620, "p1024" ]
}search_after 里的两个值,就是上一页第 10 条文档的销量和 ID。每个分片只需取到「比这个值更靠后」的少量数据,成本与页码无关,翻到多深都一样快。两个细节决定成败:排序必须带唯一 tie-breaker(销量 8620 的商品可能有一百件,补上 _id 才能唯一定位断点);只支持连续翻页,不能跳页——所以适合「下一页」按钮和无限滚动流,不适合页码条。
scroll 与 PIT:全量导出的两代方案
如果是导出全量数据这种「一口气走完」的场景,用 scroll:第一次查询拍一个快照,后续带着 scroll_id 翻页,数据始终来自同一份快照,不受写入影响。但 scroll 维护快照有成本,且视图会过期,官方已经不推荐它做实时翻页。
现在的推荐组合是 PIT 加 search_after:先开一个 point in time 拿到 pit_id,相当于轻量级快照,再在这个视图里连续 search_after,兼顾一致性与性能。
| 方案 | 适用 | 限制 |
|---|---|---|
| from + size | 浅翻页,前几页 | 成本随页码放大,默认封顶 10000 |
| search_after | 连续翻页、无限滚动 | 不能跳页,排序要带唯一键 |
| scroll | 离线全量导出 | 快照有维护成本,不推荐实时用 |
| PIT + search_after | 实时深翻页的官方推荐 | 要先开 PIT,多一次交互 |
老王最后的产品方案很干脆:用户侧保留页码条,但限制最多翻 100 页——反正数据显示九成用户翻不过三页;运营的全量核查改用 PIT 加 search_after 的导出任务。深分页需求消失,问题就地终结。
小结
深分页一句话:from 加 size 是给浅翻页设计的,深了就用游标:连续翻 search_after,全量走 PIT 或 scroll,跳页需求先问产品要不要真做。报错不是限制你,是提醒你换工具。
翻页搞定了,运营的新需求又来了:按品牌、价格段、销量区间做统计报表。查文档是 ES 的老本行,做统计它也是把好手。下一篇讲聚合:分桶、嵌套与指标,把搜索页变成数据分析页。
评论 (0)