ElasticSearch的深分页deep paging解决方案

ElasticSearch在做搜索应用时,难免会遇到深分页的问题,上一篇文章介绍了ElasticSearch分页的客户端基本用法,本篇文章将结合具体代码示例来说明ElasticSearch分页的具体用法以及深分页的用法。

温馨提示:本博客已经发布小程序,可在微信小程序中搜索”百变码农”,手机上也能看!

1、ElasticSearch分页客户端代码及Java代码示例

现有索引:假设现在需要在student_index索引下的studentInfo类型中搜索学生列表,每页查询2条,分别去查询前3页

(1)客户端命令行实现

a、查询第一页:

GET /student_index/studentInfo/_search?from=0&size=2
或者:
GET /student_index/studentInfo/_search
{
  "from":0,
  "size":2
}

查询结果:

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 6,
    "max_score": 1,
    "hits": [
      {
        "_index": "student_index",
        "_type": "studentInfo",
        "_id": "5",
        "_score": 1,
        "_source": {
          "name": "xiaozhang",
          "age": 25,
          "gender": "male"
        }
      },
      {
        "_index": "student_index",
        "_type": "studentInfo",
        "_id": "2",
        "_score": 1,
        "_source": {
          "name": "wb3",
          "age": 24,
          "gender": "male"
        }
      }
    ]
  }
}

b、查询第二页:

GET /student_index/studentInfo/_search?from=2&size=2
或者:
GET /student_index/studentInfo/_search
{
  "from":2,
  "size":2
}

搜索结果:和上述第一页结构类似,此处略。

c、查询第三页:

GET /student_index/studentInfo/_search?from=4&size=2
或者:
GET /student_index/studentInfo/_search
{
   "from":4,
   "size":2
}

搜索结果:和上述结构类似,此处略。

注意:在分页搜索中,结果集中通常需要的数据是total和hits中的列表。返回给客户端的是一个总数量及当前页中的数据列表。

(2)使用JavaAPI实现分页
// 获取查询对象SearchRequestBuilder,使用不同的客户端框架此处不同
SearchRequestBuilder srq = SearchUtils.getSearchRequestBuilder();

// 获取分页的当前页码,通过入参的query对象中传入
int pageNo = query.getPageNo() <= 0 ? 0 : query.getPageNo() - 1;

// 设置其他查询条件,例如:名称,年龄,性别等
srq.setQuery(buildCommonQuery(query));

// 设置分页参数中的起始位置from
srq.setFrom(pageNo * query.getPageSize());

// 设置分页参数中的size,即:每页数据条数
srq.setSize(activityQuery.getPageSize());

// 设置查询的排序规则,没有排序需求可以忽略,此处表示按照年龄倒叙
srq.addSort("age", SortOrder.DESC);

SearchResponse response = srq.execute().actionGet();

// 创建返回结果集
List<ResultInfo> list = new ArrayList<>();

// 从查询的返回结果中获取命中的结果列表,即:上述结果中的hits数组中的内容
SearchHits hits = response.getHits();

// 获取总条数,即分页时需要返回的总数。MySQL中可能需要单独查询该数量
long total = hits.getTotalHits();

// 用来封装查询结果。此处示例使用Map,真正使用时,建议使用一个Info对象,
//  通过对象中的total字段和list字段来封装总数量及数据列表,否则map的key
//  写错之后,将会无法获取到数据。
Map<String,Object> resultMap = new HashMap<>();

// 如果未查到数据,直接返回空列表
if (null == hits || total <= 0) {
    resultMap.put("total", total);
    resultMap.put("list", list);
    return resultMap;
}
// ES返回的hits为一个字符串数组,数组中的每个值为一个文档对应的json字符串
for (SearchHit hit : hits) {
    String source = hit.getSourceAsString();
    // 通过JSON序列化工具类将json传反序列化为对应的对象,ResultInfo中的字段
    //  可以根据需要来确定
    ResultInfo resultInfo = 
                     JsonUtils.fromJson(source, ResultInfo.class);
    list.add(resultInfo);
}
// 封装结果总数
resultMap.put("total", total);
// 封装结果列表
resultMap.put("list", list);
// 返回结果
return resultMap;

2、ElasticSearch的深分页解决方案代码实现

ES默认查询时,当from+size的值大于10000时,将会直接抛出查询异常,如下:

GET student_index/studentInfo/_search
{
  "from":9999,
  "size":10
}

返回结果:

{
  "error": {
    "root_cause": [
      {
        "type": "query_phase_execution_exception",
        "reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10009]. 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."
      }
    ],
    "type": "search_phase_execution_exception",
    "reason": "all shards failed",
    "phase": "query",
    "grouped": true,
    "failed_shards": [
      {
        "shard": 0,
        "index": "student_index",
        "node": "WkuQMAI5T0uCY-EaJr2jEg",
        "reason": {
          "type": "query_phase_execution_exception",
          "reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10009]. 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."
        }
      }
    ]
  },
  "status": 500
}

此时,如果业务上无法做限制。则解决方案有如下的三种:

(1)临时针对某个索引修改index.max_result_window参数的值

修改方式如下:

PUT student_index/_settings
{
  "max_result_window":20000
}
// 表示将student_index索引的查询结果集改到20000
//  即:from+size可以大于10000,但是不能大于20000

// 设置完成之后,可以通过如下方式查询是否设置成功
GET student_index/_settings

// 返回结果如下
{
  "student_index": {
    "settings": {
      "index": {
        "number_of_shards": "5",
        "provided_name": "student_index",
        "max_result_window": "20000", // from+size最大值已经被修改
        "creation_date": "1546011977987",
        "number_of_replicas": "1",
        "uuid": "Qsp-Pd7HS_y7-BbR2hgr5g",
        "version": {
          "created": "6030299"
        }
      }
    }
  }
}

注意:该方式可以在紧急情况下解决查询问题,但是不建议使用,因为修改了最大大小之后,按照传统的分页方式向后查询的时候,会越来越慢,调用量如果太大,可能会导致ES查询线程被大量阻塞,最终无法响应客户端的查询请求。

(2)使用scroll实现深分页查询

a、ES客户端查询代码:

每次查询时指定scroll的过期时间,然后在返回结果中会有一个_scroll_id的字符串,在下次查询的时候带着该_scroll_id即可,示例如下:

查询第一页时,指定scroll的过期时间及分页参数:

GET student_index/studentInfo/_search?from=0&size=2&scroll=3m

返回结果:

{
  // 返回的scroll_id
  "_scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAKE4FldrdVFNQUk1VDB1Q1ktRWFKcjJqRWcAAAAAAAChOhZXa3VRTUFJNVQwdUNZLUVhSnIyakVnAAAAAAAAoTwWV2t1UU1BSTVUMHVDWS1FYUpyMmpFZwAAAAAAAKE5FldrdVFNQUk1VDB1Q1ktRWFKcjJqRWcAAAAAAAChOxZXa3VRTUFJNVQwdUNZLUVhSnIyakVn",
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 6,
    "max_score": 1,
    "hits": [
      {
        "_index": "student_index",
        "_type": "studentInfo",
        "_id": "5",
        "_score": 1,
        "_source": {
          "name": "xiaozhang",
          "age": 25,
          "gender": "male"
        }
      },
      {
        "_index": "student_index",
        "_type": "studentInfo",
        "_id": "2",
        "_score": 1,
        "_source": {
          "name": "wb3",
          "age": 24,
          "gender": "male"
        }
      }
    ]
  }
}

查询第二页:

GET _search/scroll?scroll=10m&scroll_id=DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAKE4FldrdVFNQUk1VDB1Q1ktRWFKcjJqRWcAAAAAAAChOhZXa3VRTUFJNVQwdUNZLUVhSnIyakVnAAAAAAAAoTwWV2t1UU1BSTVUMHVDWS1FYUpyMmpFZwAAAAAAAKE5FldrdVFNQUk1VDB1Q1ktRWFKcjJqRWcAAAAAAAChOxZXa3VRTUFJNVQwdUNZLUVhSnIyakVn

查询结果:和上述查询第一页时返回的结果类似,此处略。

注意:

① 第一次生成了scroll_id之后,后续的查询都会基于这个scroll_id进行查询,只要是在这个scroll_id的有效期内。过期之后,才会返回一个新的scroll_id,所以在有效期内,使用scroll_id去查询数据时,不需要待index和type,直接根据scroll_id查询即可;

② 没读取一页,都会重新设置scroll_id的有效时间,所以这个scroll_id只需要保证读能够读取完当前页内的数据即可,不需要设置太长时间,大多数情况下1m就够用;

③ 切记,最好不要在大的循环内去重复开启游标,这样会造成内存大量浪费,如果循环次数过大,将会导致ES节点内存占用过大,发生Full GC,进而导致ES节点CPU短时间内迅速飙升;

b、Java代码中使用scroll_id代码示例:

// 获取查询对象SearchRequestBuilder,使用不同的客户端框架此处不同 
SearchRequestBuilder srq = SearchUtils.getSearchRequestBuilder();
// 设置普通查询条件
BoolQueryBuilder qb = boolQuery();
// 设置查询条件
qb.must(QueryBuilders.termQuery("age", query.getAge()));
SearchResponse response = srq
        .setQuery(qb)
        .setSize(size)
        .setScroll(new TimeValue(SCROLL_TIMEOUT)) // 设置游标有效期
        .execute()
        .actionGet();
ResultInfo resultInfo = new ResultInfo();
// 获取查询结果
SearchHits hits = response.getHits();
// 查询到的总数
resultInfo.setTotal(hits.getTotalHits());
// 将查询结果取出来,封装为结果集,放入到resultInfo中
resultInfo.setResult(getResultFromHits(hits));
// 将scrollId放入到返回结果中,在下次调用时传入方法即可.
resultInfo.setScrollId(response.getScrollId());
return resultInfo;
(3)使用search_after实现深分页查询

分页查询商品列表,每页查询2条商品数据:

// 查询第一页,按照ID排序
GET product_index/productInfo/_search
// 返回结果
{
  "size": 2,
  "sort": [
    {
      "id": {
        "order": "asc"
      }
    }
  ]
}


// 查询第二页,需要传入第一页最后一个数据的唯一标识,此处使用的是主键ID
//  这个唯一标识需要确保全局唯一
GET product_index/productInfo/_search
// 返回结果
{
  "size": 2,
  "sort": [
    {
      "id": {
        "order": "asc"
      }
    }
  ],
  "search_after":[2]
}

注意要点:

① search_after的原理就是通过上一页的位置直接查询下一页,所以需要使用一个唯一标识来保证这个位置;

② 在使用searc_after查询时,如果手动传入from参数,from参数的值必须为0或者-1,否则会抛出如下的异常:

{
  "error": {
    "root_cause": [
      {
        "type": "search_context_exception",
        "reason": "`from` parameter must be set to 0 when `search_after` is used."
      }
    ],
    "type": "search_phase_execution_exception",
    "reason": "all shards failed",
    "phase": "query",
    "grouped": true,
    "failed_shards": [
      {
        "shard": 0,
        "index": "product_index",
        "node": "WkuQMAI5T0uCY-EaJr2jEg",
        "reason": {
          "type": "search_context_exception",
          "reason": "`from` parameter must be set to 0 when `search_after` is used."
        }
      }
    ]
  },
  "status": 500
}

至此,ElasticSearch的深分页问题及对应的解决方案代码示例介绍完毕,欢迎转发!

温馨提示:如果小程序端代码显示混乱,是因为移动端兼容性导致,可移步至PC端站点查看!

文章属于原创,如果转发请标注文章来源:个人小站【www.jinnianshizhunian.vip