[ElasticSearch] 亿级数据分页查询优化过程

Posted 一杯糖不加咖啡

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[ElasticSearch] 亿级数据分页查询优化过程相关的知识,希望对你有一定的参考价值。

一、需求场景

公司业务需求,有约10亿条数据,根据一些条件进行查询,字段数量比较庞大(27个),查询条件不太多(约8个),例如比较常见的时间范围、地区范围。
程序要求响应速度在200ms以内。

二、硬件环境

3台搭载ES6.8.5的服务器组成ES集群,配置均为1.4T硬盘+256G内存

三、整体设计

  1. 采用分页查询展示数据
  2. 避免ES中的复杂查询操作
  3. 字段均设置为keyword保证占用最少的空间

四、分页方法选择(参考:博文地址

  1. From+Size
    因为最初考虑不周,使用的是RestHighLevelClient进行查询,查询语句:

    // esEntityRepository是继承了extends ElasticsearchRepository<EsEntity, Long> 的封装工具类
    org.springframework.data.domain.Page<EsEntity> result = esEntityRepository.search(booleanQueryBuilder,PageRequest.of(pageNum, pageSize)
            );
    

    例如查出第 6~8 笔数据

    GET /my_index/_search
    
      "from": 5,
      "size": 3
    
    

    from:表示记录开始的顺序,默认0
    size:表示取回几笔数据,默认10
    from+size 默认不能超过10000,可以通过修改索引参数index.max_result_window调整

    1.2 基本原理
    协调节点将查询请求发送给所有分片
    各分片收到请求后,查出 from + size 的数据,并返回给协调节点。例如:当前请求是查询第5~10笔数据,from + size = 10,因此每个分片都要返回排序后的前10笔数据
    协调节点将收到的数据进行合并,重新排序,然后返回指定的size分页数据给客户端

    1.3 存在的问题
    深分页的情况下,也就是from + size 值特别大,可能会造成慢查询。因为每个节点都需要返回 from + size 的排序数据,然后最终还需要合并以及排序,就算没有内存溢出,对CPU和IO也有巨大影响。

    根据官网介绍

    index.max_result_window
    The maximum value of from + size for searches to this index. Defaults to 10000. Search requests take heap memory and time proportional to from + size and this limits that memory. See Scroll or Search After for a more efficient alternative to raising this.

    官网已经指出了from + size搜索到该索引 的最大值默认为 10000,搜索请求占用的堆内存和时间与之成正比 from + size,因此会限制该内存。有关提高此效果的更有效的选择,请参见 Scroll 或者 Search After。

    下面我们使用官网推荐的两种办法进行分页。

  2. Scroll(根据官网介绍该方法已经不推荐使用了)
    2.1 基本用法
    第一步,初始化 scroll ,并获取第一批数据:

    POST /my_index/_search?scroll=5m
    
      "size":2,
      "query": 
        "match_all": 
      , 
      "sort": [
        
          "num": 
            "order": "asc"
          
        
      ]
    
    

    scroll=5m:指定查询上下文的有效期,这边设置为5分钟
    size:指定初始化分页大小,这边设置2
    sort:排序
    返回值如下:

    
      "_scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFDFaQ3hQblVCNEZuWkdyWGFCcDFnAAAAAADGUE0WclVEQkh0VUlSWnFFM05QUEJaMU5Sdw==",
      "took" : 0,
      "timed_out" : false,
      "_shards" : 
        "total" : 1,
        "successful" : 1,
        "skipped" : 0,
        "failed" : 0
      ,
      "hits" : 
        "total" : 
          "value" : 10,
          "relation" : "eq"
        ,
        "max_score" : null,
        "hits" : [
          
            "_index" : "my_index",
            "_type" : "_doc",
            "_id" : "pJBvPnUB4FnZGrXaoStR",
            "_score" : null,
            "_source" : 
              "num" : 1
            ,
            "sort" : [
              1
            ]
          ,
          
            "_index" : "my_index",
            "_type" : "_doc",
            "_id" : "pZBvPnUB4FnZGrXaoStR",
            "_score" : null,
            "_source" : 
              "num" : 2
            ,
            "sort" : [
              2
            ]
          
        ]
      
    
    

    注意:返回值的第一项是_scroll_id,后面的每一步查询都需要传递该参数
    第二步,获取下一页数据:

    POST /_search/scroll
    
      "scroll": "5m",
      "scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFDFaQ3hQblVCNEZuWkdyWGFCcDFnAAAAAADGUE0WclVEQkh0VUlSWnFFM05QUEJaMU5Sdw=="
    
    

    scroll:指定查询上下文的有效期,这边仍然设置为5分钟。注意:这步执行完毕后,查询上下文的有效期将重新计算。可以理解为scroll有效期的设置指的是2次查询的时间间隔,如果超过该有效期没有任务查询发生,那将会失效。
    scroll_id:传递最近一次查询返回的_scroll_id

    2.2 基本原理
    scroll 初始化会返回所有符合条件的数据,并形成一份快照数据
    每次获取分页数据,都会以类似游标的方式读取该快照
    快照的有效期由scroll参数指定,每次查询重新传递scroll参数,会刷新有效期
    快照形成后,ES索引数据的更新都不会被 scroll 读取到,这边读取的仍然是快照生成时的数据。因此,不适用于实时数据要求非常高的场景。

    2.3 存在的问题
    不建议同时打开大量的 scroll 上下文,因为在 scroll 有效期到达之前,都会占用一定的内存空间。官方默认同时打开的 scroll 数量是500,可以通过 search.max_open_scroll_context参数调整
    scroll 上下文指的是第一次初始化形成的快照,后续基于该上下文的查询都不会查到最新数据。因此不适用于实时性高的场景。
    为了避免这些问题,官方推出另一个查询方式:Search After

  3. Search After
    3.1 基本用法
    第一步:构造数据:

    PUT /my_index/_bulk?refresh
    "index":
    "num":1, "name":"zhangsan"
    "index":
    "num":2, "name":"lisi", "age": 20
    "index":
    "num":2, "name":"lisi", "age": 23
    "index":
    "num":3, "name":"wangwu"
    "index":
    "num":4, "name":"zhaoliu"
    

    第二步:查出第1页数据,按num+name进行组合排序

    GET /my_index/_search
    
      "size": 2,
      "sort": [
        
          "num": 
            "order": "asc"
          ,
          "name.keyword": 
            "order": "asc"
          
        
      ]
    
    

    结果如下:

    "hits" : [
      
        "_index" : "my_index",
        "_type" : "_doc",
        "_id" : "BpHsPnUB4FnZGrXapQfN",
        "_score" : null,
        "_source" : 
          "num" : 1,
          "name" : "zhangsan"
        ,
        "sort" : [
          1,
          "zhangsan"
        ]
      ,
      
        "_index" : "my_index",
        "_type" : "_doc",
        "_id" : "B5HsPnUB4FnZGrXapQfN",
        "_score" : null,
        "_source" : 
          "num" : 2,
          "name" : "lisi",
          "age" : 20
        ,
        "sort" : [
          2,
          "lisi"
        ]
      
    ]
    

    结果如下:

    "hits" : [
      
        "_index" : "my_index",
        "_type" : "_doc",
        "_id" : "CZHsPnUB4FnZGrXapQfN",
        "_score" : null,
        "_source" : 
          "num" : 3,
          "name" : "wangwu"
        ,
        "sort" : [
          3,
          "wangwu"
        ]
      ,
      
        "_index" : "my_index",
        "_type" : "_doc",
        "_id" : "CpHsPnUB4FnZGrXapQfN",
        "_score" : null,
        "_source" : 
          "num" : 4,
          "name" : "zhaoliu"
        ,
        "sort" : [
          4,
          "zhaoliu"
        ]
      
    ]
    

    3.2 基本原理
    search after 在排序的基础上,基于上一笔的sort值,查询排在它之后的数据,以此来实现分页
    search after 是无状态的,它总是查询ES索引最新的数据,这一点跟 scroll 查询完全相反

    3.3 存在的问题
    sort 排序组合必须是唯一的,否则会出现数据丢失的情况。例如上面的例子,[2,“lisi”] 这个组合有2笔数据,但是第二页直接从[3,“wangwu”]开始了。建议是加上id字段作为排序字段。
    该方法在分页中不能调页而且只使用该方法只能跳转下一页(因为获取不到上一页的最后一条数据)

  4. 总结
    4.1 from / size :
    该查询的实现原理类似于mysql中的limit,比如查询第10001条数据,那么需要将前面的10000条都拿出来,进行过滤,最终才得到数据。性能较差,实现简单,适用于少量数据,数据量不超过10w,适合浅分页。

    4.2 scroll:该查询实现类似于消息消费的机制,首次查询的时候会在内存中保存一个历史快照以及游标(scroll_id),记录当前消息查询的终止位置,下次查询的时候将基于游标进行消费。性能良好,维护成本高,在游标失效前,不会更新数据,不够灵活,一旦游标创建size就不可改变,适用于大量数据导出或者索引重建、深分页,并且数据实时要求不高,最适合离线场景。

    4.3 search_after: 性能优秀,类似于优化后的分页查询,历史条件过滤掉数据,适合深分页。

  5. SearchAfter实现可跳转前后N页
    5.1 基本思路
    SearchAfter之所以只能选择下一页是因为只能获取到当前页最后一条数据作为search_after的参数,那么如果我能把当前页前后N页最后一条都给前端返回是不是就能实现了呢?
    假设目标为跳转前后5页,则当选择第1页时给前端返回页码以及数据情况:

    1-数据 2-该页最后一条 3-该页最后一条 4-该页最后一条 5-该页最后一条 6-该页最后一条

    当选择第6页时返回页码以及数据情况:

    1-该页最后一条 2-该页最后一条 3-该页最后一条 4-该页最后一条 5-该页最后一条 6-数据 7-该页最后一条 8-该页最后一条 9-该页最后一条 10-该页最后一条 11-该页最后一条

    5.2 基本用法
    使用SearchAfter的语句进行查询,但是size是原来的2N+1倍,在程序中处理返回数据还是该页码的最后一条数据
    前端传值:目标页码、后端SearchAfter参数(当目标页码>N+1 时为目标页码-(N+1)页码的最后一条,否则为空)

    5.3 存在问题
    a. 如此设计存在大量返回但没有使用的数据,产生一定的资源浪费和时间消耗
    b. 同样只能实现跳转前后N页,不可实现任意跳转

  6. 产品设计优化
    使用第5种方案进行设计,最终结果仍然达不到查询全部数据耗时为200ms以内,分析原因可能是数据量大而服务器内存不足(优秀博文、强烈推荐:参考链接)。于是只好改变产品设计缩短ES默认查询时间间隔,此方法为最终采用,但探索过程学到了很多有趣的知识。

以上是关于[ElasticSearch] 亿级数据分页查询优化过程的主要内容,如果未能解决你的问题,请参考以下文章

财务平台亿级数据量毫秒级查询优化之elasticsearch原理解析(转)

ElasticSearch实战(五十)-让Elasticsearch飞起来!百亿级数据存储与查询优化实战!!!

ElasticSearch实战(五十)-让Elasticsearch飞起来!百亿级数据存储与查询优化实战!!!

亿级数据量场景下,如何优化数据库分页查询方法?

查询亿级数据毫秒级返回!Elasticsearch 是如何做到的?丨极客时间

百亿级数据 分库分表 后面怎么分页查询?