面试官:介绍一下如何处理elasticSearch深度分页

324 阅读4分钟

深度分页介绍

平时使用es分页查询数据时的写法:

POST test_index/_search
{
  "from":1000, -- 要跳过的条数
  "size":2000  -- 返回hits最大条数
}

java代码实现:

SearchQuery search = new NativeSearchQueryBuilder()
        .withQuery(QueryBuilders.matchAllQuery())
        .withPageable(PageRequest.of(page, size))
        .build();
Page<XXX> page = elasticsearchTemplate.continueScroll(searchQuery, XXX.class);

这条语句在es中的执行过程可以分为两大步骤:
第一步:query查询到具体的doc编号;

  • 接收客户端请求,协调者节点自身创建from+size大小的优先队列,队列中数据只保留分数以及doc编号;
  • 将命令广播到其他分片中;
  • 其他分片自身也创建from+size大小的优先队列,队列中的数据也根据条件只保留分数以及doc编号
  • 协调者节点获取到全部分片中文档的分数以及编号,根据优先队列取到要查询的doc编号。

第二步:fetcht根据doc编号查询doc文档。

  • 根据doc编号,到各个分片中查询doc文档;
  • 查询到结果返回给client。

image.png

根据上述步骤可以知道采用from+size方式的优缺点。

  1. 缺点:
    如果我们想查询的数据越深(from越大),哪怕只查询一条数据(size大小),在es内部执行过程中也要查询大量的数据,这就带来了深度分页的问题。同时es的配置中还限制了每次查询返回的最大结果(max_result_window),它的默认值是10000
  2. 优点:
    支持随机翻页,当数据量较小时性能也还好。

解决方案

scroll

search_after的原理是以前一页的结果当作参照点,进行查询这个参照点的下一份匹配数据内容,es命令:

-- scroll=1m为有效期为1分
POST test_index/_search?scroll=1m  
{
  "from":0,
  "size":1
}

POST _search/scroll?scroll=1m
{ 
-- 上述命令返回的scroll_id,直到数据为空则遍历完成。
"scroll_id":"FGluY2x1ZGVfY29udGV4dF91d******W5kRmV0Y2gBFkdHTWhUYUF4U2E2T0FhR2tlWDg2NlEAAAAAAHb57hY4RHk4bnRDMFRGNmhNbkJSOERDejhR" 
}

java代码实现:

ScrolledPage<LiveInfoEntity> scrolledPage = elasticsearchTemplate.startScroll(SCROLL_TIME, searchQuery, XXX.class);
while (CollectionUtils.isNotEmpty(scrolledPage.getContent())) {
    result.addAll(scrolledPage.getContent());
    scrolledPage = elasticsearchTemplate.continueScroll(scrolledPage.getScrollId(), SCROLL_TIME, XXX.class);
}

它的内部执行过程:
1、将所有符合条件的doc编号缓存起来,形成当前时间的一个快照;
2、每次查询根据doc编号fetch取到doc文档。
这里会有一个误区觉得,doc编号被缓存了,文档没缓存,在查询到一个doc文档之前,把该文档更改了,为什么查询结果还是之前的文档内容。这是因为在es中有一个pit(视图)的概念,创建一个视图时,根据该视图查询,只能查看到创建此视图时间之前的数据;实现的原理类似mysql的视图。

优点:

  1. 当需要检索全部数据时,可以采用scroll,例如遍历数据进行相关操作。

缺点:

  1. 响应非实时
  2. 单次size不能超过max_result_window
  3. 需要大量的空间来缓存数据
  4. 仅支持向后翻页

search_after

es的写法:

命令1:
POST test_index/_search
{
  "from":0,
  "size":2,
  "sort": [
    {
      "brith": { -- 根据生日倒序排序
        "order": "desc"
      }
    }
  ]
}
结果1:
{
  .....
        "sort" : [
          1724148000000
        ]
  .....
}

命令2:
POST test_index/_search
{
  "from": 0,
  "size": 2,
  "search_after": [
    1724148000000 -- 结果最后一条数据的sort中的值。
  ],
  "sort": [
    {
      "brith": {
        "order": "desc"
      }
    }
  ]
}

java代码实现:

Query query = new NativeSearchQueryBuilder()
        .withPageable(PageRequest.of(page, size))
        .withSearchType(SearchType.DEFAULT)
        .withSorts(SortBuilders.fieldSort("field"))
        .build();
List<Object> value = new ArrayList<>();
// 存储上一次查询条件的值
value.add("value");
query.setSearchAfter(value);
SearchHits hits = elasticsearchTemplate.search(query, XXX.class);

它的原理实际是维护了一个实时游标,它以上一次查询的最后一条记录为游标,方便对下一页的查询,它是一个无状态的查询,因此每次查询的都是最新的数据。

优点:

  1. 查询数据是实时的,每次更新可以实时反映到结果中;
  2. 不需要维护快照,可以避免消耗大量的存储空间。

缺点:

  1. 由于无状态查询,因此在查询期间的变更可能会导致数据跨页面的不一致;
  2. 排序顺序可能会在执行期间发生变化,具体取决于索引的更新和删除;
  3. 需要有一个唯一的不重复字段来排序;
  4. 它不适用于大幅度跳页查询,或者全量导出。

总结

方式性能优点缺点场景|
from+size随机跳转不同页深度分页性能很差适用于少量数据的分页
scroll解决了深度分页问题维护成本高,无法反应数据的实时性需要遍历全部数据的场景
search_after性能好,维护成本低,解决深度分页问题实现复杂,需要有一个全局唯一的字段,每次都需要上一次查询结果,不适合大幅度跳页的场景海量数据分页