ElasticSearch 分页查询及深度分页原理与实现

查询流程

查询阶段

在初始化查询阶段(query phase),查询被向索引中的每个分片副本(原本或副本)广播。每个分片在本地执行搜索并且建 立了匹配 document 的优先队列(priority queue)。

优先队列:一个优先队列(priority queue)只是一个存有前n个(top-n)匹配document的有序列表。这个优先队列的大小由分页参数 from + size 决定。

在这里插入图片描述

查询阶段

  1. 客户端发送一个 search(搜索) 请求给 Node 3 , Node 3 创建了一个长度为 from+size 的空优先级队列。
  2. Node 3 转发(根据ID选择路由) 这个搜索请求到索引中每个分片的原本或副本。每个分片在本地执行这个查询并且将结果到一个大小为 from+size 的有序本地优先队列里去。
  3. 每个分片返回 document 的ID和它优先队列里的所有 document 的排序值给协调节点 Node 3 。Node 3 把这些值合并到自己的优先队列里产生全局排序结果。

当一个搜索请求被发送到一个节点Node,这个节点就变成了协调节点。这个节点的工作是向所有相关的分片广播搜索请求并且把它们的响应整合成一个全局的有序结果集。对于后续请求,协调节点会轮询所有 的分片副本以分摊负载。每一个分片在本地执行查询和建立一个长度为 from+size 的有序优先队列——这个长度意味着它自己的结果数量就足够满 全局的请求要求。分片返回一个轻量级的结果列表给协调节点。只包含documentID值和排序需要用到的值,例如 _score 。

取回阶段

查询阶段辨别出那些满足搜索请求的document,但我们仍然需要取回那些document本身。这就是取回阶段的工作,如图分布式搜索的取回阶段所示。
在这里插入图片描述

取回阶段

  1. 协调节点辨别出哪个document需要取回,并且向相关分片发出 GET 请求。
  2. 每个分片加载document并且根据需要丰富(enrich)它们,然后再将document
  3. 一旦所有的document都被取回,协调节点会将结果返回给客户端。 协调节点先决定哪些document是实际(actually)需要取回的。例如,我们指定查询 { “from”: 90, “size”: 10} ,那么前90 条将会被丢弃,只有之后的10条会需要取回。这些document可能来自与原始查询请求相关的某个、某些或者全部分片。

协调节点为每个持有相关document的分片建立多点get请求然后发送请求到处理查询阶段的分片副本。 一旦协调节点收到所有结果,会将它们汇集到单一的回答响应里,这个响应将会返回给客户端。

分布式系统中深度分页问题

我们看一下分布式存储系统中分页查询的过程:

  1. 假设在一个有 4 个主分片的索引中搜索,每页返回10条记录。
  2. 当我们请求结果的第1页(结果从 1 到 10 ),每一个分片产生前 10 的结果,并且返回给 协调节点 ,协调节点对 40 个结果排序得到全部结果的前 10 个。
  3. 当我们请求第 99 页(结果从 990 到 1000),需要从每个分片中获取满足查询条件的前1000个结果,返回给协调节点, 然后协调节点对全部 4000 个结果排序,获取前10个记录。
  4. 当请求第10000页,每页10条记录,则需要先从每个分片中获取满足查询条件的前100010个结果,返回给协调节点。然后协调节点需要对全部(100010 * 分片数4)的结果进行排序,然后返回前10个记录。

可以看到,在分布式系统中,对结果排序的成本随分页的深度成指数上升。这就是 web 搜索引擎对任何查询都不要返回超过 10000 个结果的原因。

分页窗口限制

为了限制 from + size 分页的深度,ElasticSearch 的分页窗口默认最多允许 10000 条数据,即 在每页 20 条数据的情况,最多可以分 500 页,超过后报错

三种分页查询方式

ES 支持的三种分页查询方式

  • From + Size 查询
  • Scroll 遍历查询
  • Search After 查询

说明: 官方已经不再推荐采用Scroll API进行深度分页。如果遇到超过10000的深度分页,推荐用search_after + PIT

创建测试数

index:shopping

 @Test
 public void saveAll(){
   List<Product> productList = new ArrayList<>();
   for (int i = 0; i < 10000; i++) {
     Product product = new Product();
     product.setId(Long.valueOf(i));
     product.setTitle("["+i+"]小米手机");
     product.setCategory("手机");
     product.setPrice(1999.0 + i);
     product.setImages("http://www.test/xm.jpg");
     productList.add(product);
   }
   productDao.saveAll(productList);
 }

From + Size 查询

通过 fromsize是 ElasticSearch 最常用的分页方式,可以类比 MySQL 的 LIMIT start,limit

from:未指定,默认值是 0,注意不是1,代表当前页返回数据的起始值。

size: 未指定,默认值是 10,代表当前页返回数据的条数。

 POST shopping/_search
 {
     "from": 0, 
     "size": 10,
     "query": {
         "match_all": {}
     },
     "sort": [
         {"id": "asc"}    
     ]
 }

Java Client

 /**
  * 分页查询 FROM + SIZE 查询
  * @param currentPage 当前页,第一页从 0 开始, 1 表示第二页
  * @param pageSize 每页显示多少条
  */
 public void findByPageable(int currentPage,int pageSize){
   //设置排序(排序方式,正序还是倒序,排序的 id)
   Sort sort = Sort.by(Sort.Direction.ASC,"id");
   //设置查询分页
   PageRequest pageRequest = PageRequest.of(currentPage, pageSize,sort);
   //分页查询
   Page<Product> productPage = productDao.findAll(pageRequest);
   for (Product Product : productPage.getContent()) {
     System.out.println(Product);
   }
 }

测试分页窗口限制

 // 这是ElasticSearch最简单的分页查询,但以上命令是会报错的。
 POST shopping/_search
 {
     "from": 10000, 
     "size": 10,
     "query": {
         "match_all": {}
     },
     "sort": [
         {"id": "asc"}    
     ]
 }
 ​
 ​
 // 因为size + from > 10000 所有导致报错
 "error" : {
     "root_cause" : [
       {
         "type" : "illegal_argument_exception",
         "reason" : "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. 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",
 }

怎么解决这个问题,首先能想到的就是调大这个window。

 PUT user/_settings
 { 
     "index" : { 
         "max_result_window" : 20000
     }
 }

然后这种方式只能暂时解决问题,当es 的使用越来越多,数据量越来越大,深度分页的场景越来越复杂时,如何解决这种问题呢?

官方建议:

  • 避免过度使用 from 和 size 来分页或一次请求太多结果。

  • 不推荐使用 from + size 做深度分页查询的核心原因:

    • 搜索请求通常跨越多个分片,每个分片必须将其请求的命中内容以及任何先前页面的命中内容加载到内存中。
    • 对于翻页较深的页面或大量结果,这些操作会显著增加内存和 CPU 使用率,从而导致性能下降或节点故障。

Search After + PIT 查询

使用search_after 进行分页 相比 from & size 的方式要更加高效,而且在不断有新数据入库的时候仅仅使用 from 和 size 分页会有重复的情况,相比使用 scroll 分页,search_after 可以进行实时的查询,不过 search_after 不适合跳跃式的分页。

使用 search_after 类比 SQL,相当于

 # 查询shopping num > 0 的前5条数据 并且不会走索引
 SELECT * FROM shopping WHERE ORDER BY num > 0 ASC LIMIT 10# 优化:获取返回列表中的最后一个 num,即 最大的 num,定为 {before_max_num}
 SELECT * FROM shopping WHERE num > {before_max_num} ORDER BY num ASC LIMIT 5

但是 search_after 参数使用上一页中的一组排序值来检索下一页的数据。(增加一个条件查询 排序值 > 上一页排序值 )使用 search_after 需要具有相同查询和排序值的多个搜索请求。 如果在这些请求之间发生刷新,结果的顺序可能会发生变化,从而导致跨页面的结果不一致。 为防止出现这种情况,您可以创建一个时间点 (PIT) 以保留搜索中的当前索引状态。

时间点 Point In Time(PIT)保障搜索过程中保留特定事件点的索引状态。

注意⚠️:

es 给出了 search_after 的方式,这是在 >= 5.0 版本才提供的功能。

Point In Time(PIT)是 Elasticsearch 7.10 版本之后才有的新特性。

PIT (时间点)

PIT的本质:存储索引数据状态的轻量级视图。

如下示例能很好的解读 PIT 视图的内涵。

 // 1.给索引user_index创建 pit
 POST /shopping/_pit?keep_alive=5m
 // 2. 统计当前记录数 5
 POST /shopping/_count
 ​
 // 3. 根据pit统计当前记录数 5
 GET /_search
 {
   "query": {
         "match_all": {}
     },
   "pit": {
       "id":  "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIODBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==", 
       "keep_alive": "5m"
   },
   "sort": [
         {"id": "asc"}    
   ]
 }
 // 4. 插入一条数据
 POST shopping/_bulk
 { "create":  {  "_id": "6" }}
 { "id":6,"name":"奶瓶"}// 5. 数据总量 6
 POST /shopping/_count
 ​
 // 6. 根据pit统计数据总量还是 5 ,说明是根据时间点的视图进行统计。
 GET /_search
 {
   "query": {
         "match_all": {}
    },
   "pit": {
       "id":  "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIODBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==", 
       "keep_alive": "5m"
   },
   "sort": [
         {"id": "asc"}    
     ]
 }

有了 PIT,search_after 的后续查询都是基于 PIT 视图进行,能有效保障数据的一致性。

 //1. 获取索引的pit 
 POST /shopping/_pit?keep_alive=5m
 ​
 //2. 根据 pit 首次查询 根据 pit 查询的时候,不用指定索引名称。
 GET /_search
 {
   "size": 1,
   "from": 0,//注意from要从0开始
   "query": {
        "match_all": {}
    },
   "pit": {
       "id":  "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIODBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==", 
       "keep_alive": "1m"
   },
   "sort": [
        {"id": "asc"}    
     ]
 }
 //查询结果
 "hits" : [
   {
     "_index" : "shopping",
     "_type" : "_doc",
     "_id" : "0",
     "_score" : null,
     "_source" : {
       "_class" : "com.caffee.es.model.Product",
       "id" : 0,
       "title" : "[0]小米手机",
       "category" : "手机",
       "price" : 1999.0,
       "images" : "http://www.test/xm.jpg"
     },
     "sort" : [
       0,
       4294967296
     ]
   }
  ]//3. 根据search_after和pit进行翻页查询: search_after指定为上一次查询返回的sort值。
 GET /_search
 {
   "size": 1,
   "query": {
     "match_all": {}
   },
   "pit": {
       "id":  "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIOJ7FmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==", 
       "keep_alive": "5m"
   },
   "sort": [
         {"id": "asc"}    
    ],
   "search_after": [                                
     0
   ]
 }
 //查询结果
 "hits" : [
   {
     "_index" : "shopping",
     "_type" : "_doc",
     "_id" : "1",
     "_score" : null,
     "_source" : {
       "_class" : "com.caffee.es.model.Product",
       "id" : 1,
       "title" : "[1]小米手机",
       "category" : "手机",
       "price" : 2000.0,
       "images" : "http://www.test/xm.jpg"
     },
     "sort" : [
       1
     ]
   }
 ]
  1. 带有 pit 参数的搜索请求不得指定 index、routing 和 preference,因为这些参数是从时间点复制的。
  2. id 参数告诉 Elasticsearch 从这个时间点使用上下文执行请求。
  3. keep_alive 参数告诉 Elasticsearch 应该将时间点的生存时间延长多长时间。

Java Client

 /**
  * 分页查询 search_after + SIZE + PIT 查询
  * @param indices 索引名用于创建PIT
  * @param sortNum 排序值
  * @param pageSize 页数
  * @return
  * @throws Exception
  */
 public Integer findByPageableBySearchAfterPIT(String indices,int sortNum,int pageSize) throws Exception {// 1. 创建时间点,过期时间5分钟
   String pitId = createPit(indices,5);
   // 2.结合 search after 和 PIT ID 进行深度分页
   final PointInTimeBuilder pitBuilder = new PointInTimeBuilder(pitId);// 3.创建搜索条件
   final SearchSourceBuilder searchSourceBuilder = SearchSourceBuilder.searchSource()
     .pointInTimeBuilder(pitBuilder) // 指定 pit
     .from(0)
     .size(pageSize)
     .searchAfter(new Object[]{sortNum})
     .sort("id", SortOrder.ASC);
   SearchRequest searchRequest = new SearchRequest();//indices 无需指定索引名
   searchRequest.source(searchSourceBuilder);
   //4. 获取结果
   SearchResponse search = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
   Object[] arrays = new Object[1];
   System.out.println(search.getHits().getHits());
   for(SearchHit hit : search.getHits().getHits()){
     Map<String, Object> map = hit.getSourceAsMap();
     System.out.println(JSONObject.toJSONString(map));
     System.out.println(hit.getSortValues()[0]);
     arrays = hit.getSortValues();
   }
   System.out.println("sort值" + arrays[0]);// 最后关闭 Point In Time
   final ClosePointInTimeRequest closePointInTimeRequest = new ClosePointInTimeRequest(pitId);
   restHighLevelClient.closePointInTime(closePointInTimeRequest,RequestOptions.DEFAULT);if(arrays[0] == null) return null;
   else return Integer.parseInt(arrays[0].toString());
 }/**
  * 创建 PIT OpenPointInTimeRequest支持版本:highlevelclient7.16以上
  * @param indices 索引名
  * @param keep_alive 存活时间 单位:分钟
  * @throws Exception
  */
 private String createPit(String indices,int keep_alive) throws Exception{
   // 构造 pit open Request
   //1. 根据索引创建时间点
   final OpenPointInTimeRequest pitRequest = new OpenPointInTimeRequest(indices);
   //2. 设置存活时间
   pitRequest.keepAlive(TimeValue.timeValueMinutes(keep_alive));//打开 pit 获取 pitId
   final OpenPointInTimeResponse pitResponse = restHighLevelClient.openPointInTime(pitRequest, RequestOptions.DEFAULT);
   //3. 读取返回的时间点 id
   final String pitId = pitResponse.getPointInTimeId();return pitId;
 }
优缺点分析
  • 仅支持向后分页,不支持 跳页,上一页比较适合无限下拉的场景,比如:移动端的下拉列表
  • 不严格受制于 max_result_window,可以无限制往后翻页,单次请求值不能超过 max_result_window;但总翻页结果集可以超过。

思考 🤔

1、为什么采用 search_after 查询能解决深度分页的问题? 2、search_after + pit 分页查询过程中,PIT视图过期怎么办? 3、search_after 查询,如果需要回到前几页怎么办?

Scroll 遍历查询

scroll api 的方式会创建一个快照,每次查询后,输入上一次的 scroll_id, 来实现 下一页 的功能

所有文档获取完毕之后,需要手动清理掉 scroll_id 。虽然es 会有自动清理机制,但是 srcoll_id 的存在会耗费大量的资源来保存一份当前查询结果集映像,并且会占用文件描述符。所以用完之后要及时清理。使用 es 提供的 CLEAR_API 来删除指定的 scroll_id。

  1. 因为是在最开始分页的时候生成了一个快照,翻页的时候用的是快照数据,有新的数据写入以后,无法被查到,不会出现 跳过部分数据重复返回 的问题
  2. 因为快照的生成有一定的成本,这种方式比较适合离线的数据导出场景不适合实时的在线查询
  3. 在关闭 scroll 之前,segments 会一直被占用不会被 merge,这会导致 segments 和 文件句柄 的增加而不被释放

注意:ES官方不再推荐使用Scroll API 进行深度分页。 如果您需要在分页超过 10,000 个点击时保留索引状态,请使用带有时间点 (PIT) 的 search_after 参数。

 // 1. 首次查询,并获取_scroll_id
 GET  /shopping/_search?scroll=1m
 {
   "from" : 0,
   "size" : 5,
   "sort" : [
     {
       "id" : {
         "order" : "asc"
       }
     }
   ]
 }
 // 查询结果
 {
   "_scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDnF1ZXJ5VGhlbkZldGNoAxZBeDZHc2tUTFFMS0tlbnRDTjhXcjVBAAAAAAAAD5wWWXpCaUVWSVNRZkNma2VLY1VEMUprQRZBeDZHc2tUTFFMS0tlbnRDTjhXcjVBAAAAAAAAD54WWXpCaUVWSVNRZkNma2VLY1VEMUprQRZBeDZHc2tUTFFMS0tlbnRDTjhXcjVBAAAAAAAAD50WWXpCaUVWSVNRZkNma2VLY1VEMUprQQ==",
   "hits" : {
     "total" : {
       "value" : 10000,
       "relation" : "eq"
     },
     "max_score" : null,
     "hits" : [
       {
         "_index" : "shopping",
         "_type" : "_doc",
         "_id" : "0",
         "_score" : null,
         "_source" : {
           "_class" : "com.caffee.es.model.Product",
           "id" : 0,
           "title" : "[0]小米手机",
           "category" : "手机",
           "price" : 1999.0,
           "images" : "http://www.test/xm.jpg"
         },
         "sort" : [
           0
         ]
       }
     ]
   }
 }
 //2. 根据scroll_id遍历数据
 POST /_search/scroll                                                               
 {
   "scroll" : "1m",                                                                 
   "scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EAAAAAACDlKxZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3" 
 }// 3.删除游标scroll
 DELETE /_search/scroll
 {
     "scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EAAAAAACDlKxZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3"
 }

Java Client

 // 基本的查询条件不变
 final SearchSourceBuilder sourceBuilder = SearchSourceBuilder.searchSource()
   .from(0).size(5)
   .sort(SortBuilders.fieldSort("num").order(SortOrder.ASC));
 // 
 final SearchRequest searchRequest = new SearchRequest();
 searchRequest.indices("test-paginate-index");
 searchRequest.source(sourceBuilder);// 指定 Scroll 方式 和 失效时间
 final Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1));
 searchRequest.scroll(scroll);
 ​
 ​
 SearchResponse response = highLevelClient.search(searchRequest);// 从响应结果中获取 scrollId
 final String scrollId = response.getScrollId();// 接下来通过 滚动的方式 查询
 final SearchScrollRequest searchScrollRequest = Requests.searchScrollRequest(scrollId);
 searchScrollRequest.scroll(scroll);// 这里从第2页开始再翻 10 页
 for (int i = 0; i < 10; i++) {
   response = highLevelClient.searchScroll(searchScrollRequest);
 }// 使用完别忘的清除资源
 final ClearScrollRequest clearScrollRequest = new ClearScrollRequest();
 clearScrollRequest.addScrollId(scrollId);
 highLevelClient.clearScroll(clearScrollRequest);
优缺点

scroll 查询的相应数据是非实时的,这点和PIT视图比较类似,如果遍历过程中插入新的数据,是查询不到的。 并且保留上下文需要足够的堆内存空间。

适用场景

全量或数据量很大时遍历结果数据,而非分页查询。

官方文档强调: 不再建议使用scroll API进行深度分页。如果要分页检索超过 Top 10,000+ 结果时,推荐使用:PIT + search_after。

折叠去重分页

如果我们需要根据查询结果去重,可以使用 ElasticSearch 的 Collapse 折叠功能,Collapse 同样也支持 from + size 分页,比如 我们这个实例中, id 是唯一,但是 price 都会有大量的重复,如果我们 根据 price 去重分页就可以通过 Collapse 实现

 GET  /shopping/_search
 {
   "from" : 0,
   "size" : 5,
   "collapse": {
     "field": "price"
   },
   "sort" : [
     {
       "price" : {
         "order" : "asc"
       }
     }
   ]
 }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值