• Elasticsearch 搜索数量不能超过10000的解决方案


    一. 问题描述

    开发环境: JDK1.8、Elasticsearch7.3.1、RestHighLevelClient

    问题: 最近在通过Java客户端操作ES进行分页查询(from+size)时,需要返回满足条件的数据总数。我发现满足条件的数据总数一旦超过10000条,使用SearchResponse的getHits().getTotalHits().value返回的结果永远是10000。为什么会被限制只能搜索10000条数据呢?如何查询精确的数据总数呢?

    Tips: 本文侧重点在如何精确的获取数据总数,如果想知道如何深度搜索,请参考我的另一篇博客 Elasticsearch from+size与scroll混合使用实现深度分页搜索

    二. 问题分析

    查看官方文档: Elasticsearch 7.3

    Elasicsearch通过index.max_result_window参数控制了能够获取的数据总数from+size的最大值,默认是10000条。但是,由于数据需要从其它节点分别上报到协调节点,因此搜索请求的数据越多,会导致在协调节点占用分配给Elasticsearch的堆内存和搜索、排序时间越大。针对这种满足条件数量较多的深度搜索,官方建议我们使用Scroll。

    三. 解决方案

    3.1 调大index.max_result_window(不推荐)

    既然知道了是index.max_result_window参数限制了搜索数量,我们可以通过适当调高index.max_result_window的值,以此来满足需求。设置方法如下:

    • kibana上执行
    新建索引: 
    PUT your_index
    {
      "settings": {
        "max_result_window": "100000"
      }
    }
    
    在原有索引的基础上,调大index.max_result_window的默认值:
    PUT your_index/_settings?preserve_existing=true
    {
      "max_result_window": "100000"
    }
    
    • 服务器上执行
    curl -H "Content-Type: application/json" -X PUT 'http://127.0.0.1:9200/your_index/_settings?preserve_existing=true' -d '{"max_result_window" : "100000"}'

    这个方案我个人不太推荐,除非能预估出生产环境中索引内数据总量可能达到的上限,否则在未来实际数据量可能会超过设置的值,仍然会再次引发搜索数量受限的问题。

    3.2 cardinality(不推荐)

    cardinality字面意思是基数,作为聚合函数,它的作用与Mysql中的distinct类似,用于统计给定字段的不同值的数量。值得注意的是,cardinality获取的仅仅是估计值。使用方式如下:

    SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
    
    // 设置聚合函数
    AggregationBuilder aggregationBuilder = AggregationBuilders.cardinality("distinct_id").field("_id");
    sourceBuilder.aggregation(aggregationBuilder);
    
    // 调用ES客户端,发起请求,得到响应结果
    response = search("INDEX_NAME索引名称", sourceBuilder);
    
    // 获取总记录数
    total = ((ParsedCardinality)response.getAggregations().getAsMap().get("distinct_id")).getValue();

    其中,“distinct_id"是我为聚合函数随便起的名称,可以任意指定,”_id"是希望进行分组统计的字段名称。上方这一段代码实际上可以翻译成以下执行语句:

    GET index_name/_search
    {
      "aggs": {
        "distinct_id": {
          "cardinality": {
            "field": "_id"
          }
        }
      }
    }

    3.3 track_total_hits(推荐)

    文档: track_total_hits
    使用方式:

    SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
    sourceBuilder.trackTotalHits(true);
    // 省略查询方法...
    SearchResponse sumResponse = search(sourceBuilder);
    if(sumResponse != null) {
        // 满足条件的总记录数
        long total = sumResponse.getHits().getTotalHits().value;
    }



    Elasticsearch from+size与scroll混合使用实现深度分页搜索

     

    一. 需求

    环境准备: JDK1.8 Elasticsearch7.3.1 RestHighLevelClient客户端
    对Elasticsearch做深度分页,比如第1500页,每页20条记录,且需要支持前后翻页。

    二. 思考

    由于index.max_result_window的限制,直接使用from+size无法搜索满足条件10000条以上的记录。如果贸然增大index.max_result_window值,那么你怎么知道系统未来会在索引内存多少条数据?

    就算这一次设置值暂时解决了问题,那么未来又陷入瓶颈了怎么办?重新设值吗?调大后会增大内存压力的问题难道就不需要考虑吗?

    这时就需要使用scroll了,但scroll不能盲目的使用,它虽然支持深度分页,纯粹的使用scroll只能不断地向后翻页,我们还需要考虑如何向前翻页。

    三. 实现方案

    不改变index.max_result_window的默认值,但搜索手段根据搜索数量划分为以下两种:

    1. 搜索数量<=10000
      使用from+size的方式分页和搜索数据。
    2. 搜索数量>10000
      使用scroll的方式搜索数据。针对对每次分页查询请求,我都会创建游标,接着手动滚动到包含请求数据的那一屏,最后取出请求页面中的目标数据。

    比如现在准备查询第1413页,页面容量为10条数据,游标每次移动1000条记录,总记录数为1000000(这个值不重要了)。如果以1作为第一条数据的下标,则有以下规律:

    滚屏次数数据的下标范围
    1 1~1000
    2 1001~2000
    15 14001 ~ 15000
    n (n-1) * 1000 + 1 ~ n*1000

    第1413页的第一条数据的下标=(1413-1)*10+1=14121
    第1413页的最后一条数据的下标=14121+10-1=14130
    只需要移动15次游标,则在第15次游标查询返回的1000条数据中,一定包含了第1413页的所有数据。

    但我们还需要考虑另一种情况,比如现在准备查询第934页,页面容量为15条数据,游标仍然保持每次移动1000条记录。
    第934页的第一条数据的下标=(934-1)*15+1=13996
    第934页的最后一条数据的下标=13996+15-1=14010
    注意,我们的游标只能获取13001~14000和14001~15000范围内的数据,第934页会横跨两次游标执行结果,针对这种情况,我在代码中做了特殊处理。

    接下来是代码:

    • 定义搜索条件
    // 自定义搜索条件
    BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
    boolQueryBuilder.must(QueryBuilders.matchQuery("name", "麦当劳"));
    
    SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
    sourceBuilder.query(boolQueryBuilder);
    // 设置请求超时时间
    sourceBuilder.timeout(new TimeValue(20, TimeUnit.SECONDS));
    // 排序
    sourceBuilder.sort("salary", SortOrder.ASC);
    • 与ES客户端交互的底层逻辑
      esClient就是RestHighLevelClient的对象
    protected SearchResponse search(String requestIndexName, SearchSourceBuilder sourceBuilder) throws Exception {
        SearchRequest searchRequest = new SearchRequest(requestIndexName);
        searchRequest.source(sourceBuilder);
        return esClient.search(searchRequest, RequestOptions.DEFAULT);
    }
    
    protected SearchResponse search(String requestIndexName,SearchSourceBuilder searchSourceBuilder,
                                            TimeValue timeValue) throws IOException {
        SearchRequest searchRequest = new SearchRequest(requestIndexName);
        searchSourceBuilder.size(ElasticsearchConstant.MAX_SCROLL_NUM);
        searchRequest.source(searchSourceBuilder);
        searchRequest.scroll(timeValue);
        return esClient.search(searchRequest, RequestOptions.DEFAULT);
    }
    
    protected SearchResponse searchScroll(String scrollId, TimeValue timeValue) throws IOException {
        SearchScrollRequest searchScrollRequest = new SearchScrollRequest(scrollId);
        searchScrollRequest.scroll(timeValue);
        return esClient.scroll(searchScrollRequest, RequestOptions.DEFAULT);
    }
    • 搜索逻辑(核心代码)
    // 本次搜索满足条件的数据总数
    long total = 0;
    // 精度
    int accuracy = 1;
    // 希望被忽略的记录条数
    int ignoreLogNum = (pageNum - 1) * pageSize;
    // 待查询页面内第一条记录的下标
    int firstSelectLogNum = 1;
    // 待查询页面内最后一条记录的下标
    int lastSelectLogNum = -1;
    // 当前游标查询返回结果中最后一条记录的下标
    int lastAllowLogNum = -1;
    // 游标Id
    String scrollId = null;
    // Elasticsearch 搜索返回结果对象
    SearchResponse response = null;
    
    try {
        firstSelectLogNum = ignoreLogNum + 1;
        lastSelectLogNum = firstSelectLogNum + pageSize - 1;
        String indexName = ElasticsearchConstant.SUB_INDEX_NAME_PREFIX + bizSubLogQuery.getProductNum().toLowerCase();
        if(firstSelectLogNum > ElasticsearchConstant.MAX_RESULT_WINDOW) {
            // 构建游标查询 此时游标已经移动了1次
            response = search(indexName, sourceBuilder, TimeValue.timeValueMinutes(1));
            if(response != null && response.getHits().getHits().length > 0) {
                // 游标总共需要移动的次数
                int scrollNum = firstSelectLogNum / ElasticsearchConstant.MAX_SCROLL_NUM + 1;
                lastAllowLogNum = scrollNum * ElasticsearchConstant.MAX_SCROLL_NUM;
                accuracy = firstSelectLogNum - (firstSelectLogNum / ElasticsearchConstant.MAX_SCROLL_NUM) * ElasticsearchConstant.MAX_SCROLL_NUM;
                // 游标Id
                scrollId = response.getScrollId();
                // 游标还需移动scrollNum-1次
                while(--scrollNum > 0 && scrollId != null) {
                    response = searchScroll(scrollId, TimeValue.timeValueMinutes(1));
                    scrollId = response.getScrollId();
                }
            }
        } else {
            // 分页参数
            sourceBuilder.from((pageNum - 1) * pageSize);
            sourceBuilder.size(pageSize);
    
            // 获取满足记录的总条数
            response = search(indexName, sourceBuilder);
        }
    
        // 查询总数
        sourceBuilder.size(0);
        sourceBuilder.trackTotalHits(true);
        SearchResponse sumResponse = search(indexName, sourceBuilder);
        if(sumResponse != null) {
            total = sumResponse.getHits().getTotalHits().value;
        }
    } catch (ElasticsearchStatusException ese) {
        if (RestStatus.NOT_FOUND == ese.status()) {
            log.error("待搜索的产品不存在");
        } else {
            log.error(ese.getMessage());
        }
    } catch (IOException ioe) {
        log.error("搜索失败,网络连接出现异常", ioe);
    } catch (Exception e) {
        log.error("搜索失败,未知异常", e);
    }
    
    if (response == null) {
        return new PageInfo<>();
    }
    
    // 搜索结果,使用集合来存放
    List<Map<String, String>> list = new ArrayList<>();
    
    // 游标一次性最高可能返回1000条数据,需要通过页面容量来约束
    int maxPageSize = pageSize;
    
    for (int i = 0; i < response.getHits().getHits().length; i++) {
        if(i+1 >= accuracy) {
            SearchHit hit = response.getHits().getAt(i);
            if(--maxPageSize < 0) {
                break;
            }
            try {
                list.add(JacksonUtils.jsonStrToMap(hit.getSourceAsString(), String.class, String.class));
            } catch (JsonProcessingException e) {
                log.error("jackson转换异常", e);
            }
        }
    }
    
    if(scrollId != null && maxPageSize>0 && lastAllowLogNum!=-1 && lastSelectLogNum>lastAllowLogNum) {
        // 存在目标数据不在本次游标查询的结果范围内
        // 需要再次移动游标 (务必保证游标移动的步长大于页面容量)
        try {
            response = searchScroll(scrollId, TimeValue.timeValueMinutes(1));
            for(int i = 0; i < maxPageSize && i < response.getHits().getHits().length; i++) {
                SearchHit hit = response.getHits().getAt(i);
                try {
                    list.add(JacksonUtils.jsonStrToMap(hit.getSourceAsString(), String.class, String.class));
                } catch (JsonProcessingException e) {
                    log.error("jackson转换异常", e);
                }
            }
        } catch (IOException ioe) {
            log.error("搜索失败,网络连接出现异常", ioe);
        }
    }
     
  • 相关阅读:
    【HTML5 绘图与动画】使用canvas
    【H5新增元素和文档结构】新的全局属性 1. contentEditable 可编辑内容 2. contextmenu 快捷菜单 3. data 自定义属性 4. draggable 可拖动 5. dropzone 拖动数据 6. hidden 隐藏 7. spellcheck 语法检查 8. translate 可翻译
    【H5新增元素和文档结构】完善旧元素 1. a 超链接 2. ol 有序列表 3. dl 定义列表 4. cite 引用文本 5. small 小号字体 6. iframe 浮动框架 7. script 脚本
    【H5新增元素和文档结构】新的语义信息 1. address 2. time 3. figure 跟 figcaption 4. details 和 summary 5. mark 6. progress 7. meter 8. dialog 9.bdi 10. wbr 11. ruby、rt、rp 12. command
    【H5新增元素跟文档结构】新的文档结构 1. article 文章块 2. section 区块 3. nav 导航条 4. aside 辅助栏 5. main 主要区域 6. header 标题栏 7. hgroup 标题组 8. footer 页脚栏
    5_PHP数组_3_数组处理函数及其应用_9_数组集合运算函数
    【华为云技术分享】鲲鹏弹性云服务器GCC交叉编译环境搭建指南
    【华为云技术分享】7 分钟全面了解位运算
    【华为云技术分享】Linux内核编程环境 (1)
    【华为云技术分享】华为云MySQL 8.0正式商用,全新增强版开源利器强势来袭
  • 原文地址:https://www.cnblogs.com/gqzdev/p/14034962.html
Copyright © 2020-2023  润新知