摘自:https://cloud.tencent.com/developer/article/2110964

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

  • From + Size 查询

  • Search After 查询

  • Scroll 遍历查询

「说明:」

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

官方文档地址:https://www.elastic.co/guide/en/elasticsearch/reference/7.14/paginate-search-results.html

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

「为什么分布式存储系统中对深度分页支持都不怎么友好呢?」

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


假设在一个有 4 个主分片的索引中搜索,每页返回10条记录。

当我们请求结果的第1页(结果从 1 到 10 ),每一个分片产生前 10 的结果,并且返回给 协调节点 ,协调节点对 40 个结果排序得到全部结果的前 10 个。

当我们请求第 99 页(结果从 990 到 1000),需要从每个分片中获取满足查询条件的前1000个结果,返回给协调节点, 然后协调节点对全部 4000 个结果排序,获取前10个记录。

当请求第10000页,每页10条记录,则需要先从每个分片中获取满足查询条件的前100010个结果,返回给协调节点。然后协调节点需要对全部(100010 * 分片数4)的结果进行排序,然后返回前10个记录。

可以看到,在分布式系统中,对结果排序的成本随分页的深度成指数上升。

这就是 web 搜索引擎对任何查询都不要返回超过 10000 个结果的原因。

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

From + Size 查询

准备数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
PUT user_index
{
"mappings": {
"properties": {
"id": {"type": "integer"},
"name": {"type": "keyword"}
}
}
}

POST user_index/_bulk
{ "create": { "_id": "1" }}
{ "id":1,"name":"老万"}
{ "create": { "_id": "2" }}
{ "id":2,"name":"老王"}
{ "create": { "_id": "3" }}
{ "id":3,"name":"老刘"}
{ "create": { "_id": "4" }}
{ "id":4,"name":"小明"}
{ "create": { "_id": "5" }}
{ "id":5,"name":"小红"}

查询演示

「无条件查询」

1
POST user_index/_search

默认返回前10个匹配的匹配项。其中:

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

「指定from+size查询」

1
2
3
4
5
6
7
8
9
10
11
POST user_index/_search
{
"from": ,
"size": ,
"query": {
"match_all": {}
},
"sort": [
{"id": "asc"}
]
}

max_result_window

es 默认采用的分页方式是 from+ size 的形式,在深度分页的情况下,这种使用方式效率是非常低的。

比如 from = 5000,size=10, es 需要在各个分片上匹配排序并得到5000*10条有效数据,然后在结果集中取最后 10条数据返回,这种方式类似于 mongo 的 skip + size。

除了效率上的问题,还有一个无法解决的问题是,es 目前支持最大的 skip 值是 「max_result_window ,默认为 10000」。也就是当 from + size > max_result_window 时,es 将返回错误。

1
2
3
4
5
6
7
8
9
10
11
POST user_index/_search
{
"from": 10000,
"size": 10,
"query": {
"match_all": {}
},
"sort": [
{"id": "asc"}
]
}

这是ElasticSearch最简单的分页查询,但以上命令是会报错的。

报错信息,指window默认是10000。

1
2
3
4
5
6
7
"root_cause": [
{
"type": "illegal_argument_exception",
"reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10001]. 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。

1
2
3
4
5
6
PUT user_index/_settings
{
"index" : {
"max_result_window" : 20000
}
}

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

「官方建议:」

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

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

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

Search After 查询

search_after 参数使用上一页中的一组排序值来检索下一页的数据。

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

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

「注意⚠️:」

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#1、给索引user_index创建pit
POST /user_index/_pit?keep_alive=5m

#2、统计当前记录数 5
POST /user_index/_count

#3、根据pit统计当前记录数 5
GET /_search
{
"query": {
"match_all": {}
},
"pit": {
"id": "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIODBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==",
"keep_alive": "5m"
},
"sort": [
{"id": "asc"}
]
}
#4、插入一条数据
POST user_index/_bulk
{ "create": { "_id": "6" }}
{ "id":6,"name":"老李"}

#5、数据总量 6
POST /user_index/_count

#6、根据pit统计数据总量还是 5 ,说明是根据时间点的视图进行统计。
GET /_search
{
"query": {
"match_all": {}
},
"pit": {
"id": "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIODBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==",
"keep_alive": "5m"
},
"sort": [
{"id": "asc"}
]
}

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

search_after 分页查询可以简单概括为如下几个步骤。

获取索引的pit

1
POST /user_index/_pit?keep_alive=m

根据pit首次查询

说明:根据pit查询的时候,不用指定索引名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /_search
{
"query": {
"match_all": {}
},
"pit": {
"id": "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIODBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==",
"keep_alive": "1m"
},
"sort": [
{"id": "asc"}
]
}

查询结果:返回的sort值为2.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
hits" : [
{
"_index" : "user_index",
"_type" : "_doc",
"_id" : "",
"_score" : null,
"_source" : {
"id" : 2,
"name" : "老王"
},
"sort" : [
2
]
}
]

根据search_after和pit进行翻页查询

说明:

search_after指定为上一次查询返回的sort值。

要获得下一页结果,请使用最后一次命中的排序值(包括 tiebreaker)作为 search_after 参数重新运行先前的搜索。如果使用 PIT,请在 pit.id 参数中使用最新的 PIT ID。搜索的查询和排序参数必须保持不变。如果提供,则 from 参数必须为 0(默认值)或 -1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /_search
{
"size": 1,
"query": {
"match_all": {}
},
"pit": {
"id": "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIOJ7FmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==",
"keep_alive": "5m"
},
"sort": [
{"id": "asc"}
],
"search_after": [
2
]
}

search_after 优缺点分析

search_after 查询仅支持向后翻页。

不严格受制于max_result_window,可以无限制往后翻页。

单次请求值不能超过 max_result_window;但总翻页结果集可以超过。

思考

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

Scroll 遍历查询

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

相比于 From + size 和 search_after 返回一页数据,Scroll API 可用于从单个搜索请求中检索大量结果(甚至所有结果),其方式与传统数据库中游标(cursor)类似。

Scroll API 原理上是对某次查询生成一个游标 scroll_id, 后续的查询只需要根据这个游标去取数据,直到结果集中返回的 hits 字段为空,就表示遍历结束。scroll_id 的生成可以理解为建立了一个临时的历史快照,在此之后的增删改查等操作不会影响到这个快照的结果。

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

首次查询,并获取_scroll_id

1
2
3
4
5
6
7
POST /user_index/_search?scroll=1m
{
"size": 1,
"query": {
"match_all": {}
}
}

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
"_scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EAAAAAACDlQBZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3",
"took" : ,
"timed_out" : false,
"_shards" : {
"total" : ,
"successful" : ,
"skipped" : ,
"failed" :
},
"hits" : {
"total" : {
"value" : ,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "user_index",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"id" : ,
"name" : "老万"
}
}
]
}
}

根据scroll_id遍历数据

1
2
3
4
5
POST /_search/scroll                                                               
{
"scroll" : "1m",
"scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EAAAAAACDlKxZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3"
}

删除游标scroll

1
2
3
4
DELETE /_search/scroll
{
"scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EAAAAAACDlKxZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3"
}

优缺点

scroll查询的相应数据是非实时的,这点和 PIT 视图比较类似,如果遍历过程中插入新的数据,是查询不到的。

并且保留上下文需要足够的堆内存空间。

适用场景

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

「官方文档强调:」

不再建议使用scroll API进行深度分页。如果要分页检索超过 Top 10000+ 结果时,推荐使用:PIT + search_after。

业务层面优化

很多时候,技术解决不了的问题,可以通过业务层面变通下来解决!

比如,针对分页场景,我们可以采用如下优化方案。

增加默认的筛选条件

通过尽可能的增加默认的筛选条件,如:时间周期和最低评分,减少满足条件的数据量,避免出现深度分页的情况。

采用滚动增量显示

典型场景比如手机上面浏览微博,可以一直往下滚动加载。

示例:

如下列表展示中,取消了分页按钮,通过滚动条增量加载数据。

滚动分页

小范围跳页

通过对分页组件的设计,禁止用户直接跳转到非常大的页码中。比如直接跳转到最后一页这种操作。

示例:google搜索的小范围跳页。

谷歌搜索小范围跳页


总结

分布式存储引擎的深度分页目前没有完美的解决方案。

比如针对百度、google这种全文检索的查询,通过From+ size返回Top 10000 条数据完全能满足使用需求,末尾查询评分非常低的结果一般参考意义都不大。

  • From+ size:需要随机跳转不同分页(类似主流搜索引擎)、Top 10000 条数据之内分页显示场景。
  • search_after:仅需要向后翻页的场景及超过Top 10000 数据需要分页场景。
  • Scroll:需要遍历全量数据场景 。
  • max_result_window:调大治标不治本,不建议调过大。
  • PIT:本质是视图。

分布式存储引擎的深度分页目前没有完美的解决方案

百度搜索分页

百度搜索的分页最多只能到 76 页,不管你搜索的结果匹配了多少内容,只能翻到第 76 页,而且也只能小范围跳页。

搜索引擎都不能无限的翻页下去

es深度分页问题

淘宝搜索只有100页

分布式存储引擎的搜索,有天然的缺陷存在,没有完美的方案。当存在技术解决不了的问题,那就从产品层面解决它。