Administrator
发布于 2020-08-12 / 3674 阅读
96

Elasticsearch 深分页与 search_after 方案

报错: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耗时协调节点堆内存峰值
018ms12MB
100052ms48MB
5000340ms210MB
99001250ms480MB

耗时和内存基本随 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/size18ms1250ms(from=9900)不支持
search_after19ms21ms24ms
scroll25ms26ms27ms

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/sizescrollsearch_after
最大深度10000无限无限
数据是否实时实时快照实时(配 PIT 可快照)
能否跳页可以不行不行
服务端开销随深度线性增长维护上下文极低
典型场景前 100 页全量导出、数据迁移用户深分页、信息流

我们最后的改造方案:运营后台的商品列表(用户手动翻页),前 100 页用 from/size,超过就提示"请用筛选条件缩小范围";导出功能改成 scroll 异步任务,跑完生成 Excel 发邮件;C 端的信息流(下拉加载更多)用 search_after,游标放在客户端。

下篇预告

这篇先把《Elasticsearch 深分页与 search_after 方案》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考