报错:Result window is too large
运营后台有个"导出全部商品"的功能,前端做成了分页拉取,一页 100 条,用循环一直翻到最后一页。上线当天就炸了,日志里全是这个:
org.elasticsearch.ElasticsearchStatusException: Elasticsearch exception [type=search_phase_execution_exception,
reason=all shards failed]
Caused by: org.elasticsearch.ElasticsearchException$1:
Result window is too large, from + size must be less than or equal to: [10000] but was [23500].
See the scroll api for a more efficient way to request large data sets.
This limit can be set by changing the [index.max_result_window] index level setting.
第 236 页,from=23500,超过默认上限 10000。这个报错信息本身已经把答案说了一半了。
为什么 from/size 深分页这么慢
先说清楚一件反直觉的事:ES 的 from=10000&size=10,不是"跳过前 10000 条取 10 条",而是"每个分片各取前 10010 条,汇总排序后取第 10001~10010 条"。
因为 ES 是分布式的,索引有 3 个主分片,每个分片只持有 1/3 的数据。协调节点不知道全局第 10001 名在哪个分片上,只能让每个分片都交出自己的前 10010 名,然后在内存里归并排序。
实际请求量:from=23500, size=100,3 分片 → 每个分片要排序并返回 23600 条,协调节点要归并 70800 条文档,最后扔掉 70700 条,只留 100 条。
实测数据(我们的 item 索引,3 分片 1 副本,380 万文档):
| from | 耗时 | 协调节点堆内存峰值 |
|---|---|---|
| 0 | 18ms | 12MB |
| 1000 | 52ms | 48MB |
| 5000 | 340ms | 210MB |
| 9900 | 1250ms | 480MB |
耗时和内存基本随 from 线性增长。所以 10000 这个限制不是随便定的,是 ES 团队认为超过这个深度的做法本身就是错的,用一个硬限制逼你去用正确的 API。
我见过有人直接调大 index.max_result_window 了事:
PUT item/_settings
{ "index.max_result_window": 100000 }
这就相当于把烟雾报警器的电池拆了。from=99000 的时候一次查询要归并 30 万条文档,几个并发就把节点堆打满,触发 OOM。我们测试环境这么干过一次,8GB 堆的节点直接被 ES 的 circuit breaker 熔断,整个索引查询全部失败。
scroll:适合导出,不适合实时分页
scroll 的思路是:第一次查询时在协调节点上建一个快照上下文,返回一个 scroll_id,后续用这个 id 一页一页往下取。快照是创建时刻的数据视图,之后的写入不会反映到结果里。
# 1. 初始化,注意加 sort 和 scroll 参数
GET item/_search?scroll=5m
{
"size": 1000,
"query": { "term": { "status": 1 } },
"sort": ["_doc"] /* _doc 排序不打分,最快 */
}
# 返回 _scroll_id 和第一批 1000 条
# 2. 用 scroll_id 翻页,不需要再带 from/query
POST _search/scroll
{
"scroll": "5m",
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAD..."
}
# 3. 用完一定记得清掉,否则上下文一直占堆
DELETE _search/scroll
{ "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAD..." }
Java 客户端(我们用 High Level Rest Client 7.8)的写法:
public List<ItemDoc> exportAll(QueryBuilder query) throws IOException {
SearchRequest request = new SearchRequest("item");
request.scroll(TimeValue.timeValueMinutes(5));
SearchSourceBuilder source = new SearchSourceBuilder()
.query(query)
.size(1000)
.sort("_doc"); // 不关心顺序时用 _doc,跳过打分
request.source(source);
SearchResponse resp = client.search(request, RequestOptions.DEFAULT);
String scrollId = resp.getScrollId();
List<ItemDoc> all = new ArrayList<>();
try {
while (true) {
SearchHit[] hits = resp.getHits().getHits();
if (hits == null || hits.length == 0) break;
for (SearchHit hit : hits) {
all.add(JSON.parseObject(hit.getSourceAsString(), ItemDoc.class));
}
SearchScrollRequest scrollReq = new SearchScrollRequest(scrollId)
.scroll(TimeValue.timeValueMinutes(5));
resp = client.scroll(scrollReq, RequestOptions.DEFAULT);
scrollId = resp.getScrollId();
}
} finally {
ClearScrollRequest clear = new ClearScrollRequest();
clear.addScrollId(scrollId);
client.clearScroll(clear, RequestOptions.DEFAULT); // 必须清
}
return all;
}
导出 380 万条,用时 6 分 20 秒,平均 1 万条/秒。同样的量用 from/size 根本跑不完。
但 scroll 有几个硬伤,决定了它只能用于离线导出:
- 数据是快照,查询期间新写入的商品不会出现,用户看到的是 6 分钟前的状态。
- scroll_id 维护上下文要占堆。
scroll=5m表示 5 分钟不翻页就过期。多个并发的 scroll 会累积上下文,ES 默认上限 500 个(search.max_open_scroll_context)。忘了clearScroll会一直占着,直到超时。 - 不支持跳页。用户不能直接从第 1 页跳到第 500 页,只能一页页往后滚。所以做不了普通的分页 UI。
search_after:实时分页的正解
如果既要深分页、又要实时数据、又要能跳(配合一点设计),用 search_after。
原理:上一页最后一条文档的排序值,作为下一页的游标。ES 直接用这个值在分片上定位,跳过前面所有文档,不需要归并。
GET item/_search
{
"size": 100,
"query": { "term": { "status": 1 } },
"sort": [
{ "price": "asc" },
{ "itemId": "asc" } /* 必须有个唯一字段做 tiebreaker */
],
"search_after": [ 1299.00, "SKU0000001234" ]
}
有两个使用前提,我踩过:
第一,sort 里必须包含一个唯一字段。如果只按 price 排序,同一个价格的商品有几百个,ES 无法确定"下一页从哪里开始",会出现重复或丢失。我一开始就是只排了 price,翻页时明显看到有商品重复出现,加上 itemId 作第二排序字段才正常。
第二,第一次查询不带 search_after,后续每页用上一页最后一条的 sort 值:
public PageResult<ItemDoc> search(ItemQuery query, List<Object> lastSortValues) {
SearchSourceBuilder source = new SearchSourceBuilder()
.query(buildQuery(query))
.size(query.getSize())
.sort("price", SortOrder.ASC)
.sort("itemId", SortOrder.ASC);
if (lastSortValues != null && !lastSortValues.isEmpty()) {
source.searchAfter(lastSortValues.toArray());
}
SearchResponse resp = client.search(
new SearchRequest("item").source(source), RequestOptions.DEFAULT);
SearchHit[] hits = resp.getHits().getHits();
List<ItemDoc> list = Arrays.stream(hits)
.map(h -> JSON.parseObject(h.getSourceAsString(), ItemDoc.class))
.collect(Collectors.toList());
// 把最后一条的排序值返回给前端,下一页带回来
List<Object> nextCursor = hits.length == 0 ? null
: Arrays.asList(hits[hits.length - 1].getSortValues());
return new PageResult<>(list, nextCursor);
}
前端要把这个游标原样传回来。我们把它做成了 base64 编码的字符串塞在请求参数里,对前端来说跟"页码"长得差不多。
性能实测:
| 方案 | 第 1 页 | 第 100 页 | 第 500 页 |
|---|---|---|---|
| from/size | 18ms | 1250ms(from=9900) | 不支持 |
| search_after | 19ms | 21ms | 24ms |
| scroll | 25ms | 26ms | 27ms |
search_after 的耗时几乎不随深度变化,因为它做的是"定位"而不是"跳过"。
PIT:让 search_after 看到一致的数据
search_after 有个跟 scroll 相反的问题:它每次都是实时查询,分页过程中数据变了会导致结果不一致。用户翻到第 5 页时,如果第 1 页的商品被下架了,后面所有页的内容整体前移,就会出现跳过的文档。
ES 7.10 引入的 PIT(Point In Time,时间点) 就是解决这个的。它比 scroll 轻量得多——只是一个轻量的上下文,不缓存数据,也不支持翻页,纯粹是"让多次查询看到同一份数据视图"。
# 1. 创建 PIT,返回 id
POST item/_pit?keep_alive=2m
# 2. 查询时带上 pit id,注意不能带 index 参数
GET _search
{
"size": 100,
"pit": { "id": "46ToAwMDaWR5BXV1aWQy...", "keep_alive": "2m" },
"sort": [ { "price": "asc" }, { "itemId": "asc" } ],
"search_after": [ 1299.00, "SKU0000001234" ]
}
需要说明的是:PIT 是 7.10 才有的,我们线上的 7.8 用不了。我是在本地的 7.10 测试环境验证的。7.10 之前要解决一致性,只能用 scroll,或者接受最终一致(我们运营后台目前就是这个策略,翻页时偶尔少一条,运营同学刷新一下就好)。
另外 ES 7.x 里 keep_alive 的上下文有上限,用完记得删:
DELETE _pit
{ "id" : "46ToAwMDaWR5BXV1aWQy..." }
三个方案怎么选
| 维度 | from/size | scroll | search_after |
|---|---|---|---|
| 最大深度 | 10000 | 无限 | 无限 |
| 数据是否实时 | 实时 | 快照 | 实时(配 PIT 可快照) |
| 能否跳页 | 可以 | 不行 | 不行 |
| 服务端开销 | 随深度线性增长 | 维护上下文 | 极低 |
| 典型场景 | 前 100 页 | 全量导出、数据迁移 | 用户深分页、信息流 |
我们最后的改造方案:运营后台的商品列表(用户手动翻页),前 100 页用 from/size,超过就提示"请用筛选条件缩小范围";导出功能改成 scroll 异步任务,跑完生成 Excel 发邮件;C 端的信息流(下拉加载更多)用 search_after,游标放在客户端。
下篇预告
这篇先把《Elasticsearch 深分页与 search_after 方案》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。