ElasticSearch分页查询

Elasticsearch 是一个分布式的搜索与分析引擎,它的部分使用经典场景与关系型数据库(在本篇文章中以 MySQL 举例)非常相似,比如分页、遍历等。

在分页场景下,数据库需要遍历全表读取,然后根据参数跳过舍弃指定条数的记录,最终返回数据,在页码较大的深度分页场景时,数据库的响应时间较长,且会占用较多的服务器资源。Elasticsearch 中也同样如此,Elasticsearch 会聚合、遍历整个索引读取文档,然后舍弃指定条数的文档,最终返回数据,此过程也将占用较多的服务器资源。因此不管是在关系型数据库中还是 ElasticSearch ,在分页场景下应该尽量避免使用普通的分页查询进行深度分页读取较后的数据。

普通分页查询

from + size 分页方式是 ES 最基本的分页方式,类似于关系型数据库中的 limit 分页查询方式。from 参数表示分页起始位置;size 参数表示每页获取数据条数。

1
2
3
4
5
6
7
8
# GET http://localhost:9200/index_name/_search
{
"query": {
"match_all": {}
},
"from": 10,
"size": 20
}

from + size 参数分页的最大值受 index.max_result_window 配置影响,默认情况下最大值为10000,如果尝试一次性查询大于最大值的数据量,或者查询分页所在位置超过最大值,ElasticSearch 会抛出异常提示查询结果窗口过大,可以使用 Scroll API 进行替代查询。

当分页查询时索引中匹配到的文档数量大于等于10000时,ElasticSearch 在 hit 中统计的文档总数只会返回10000,relation 也从精确等于的 eq 变为 gte

对大于10000条记录的索引进行普通分页查询

# GET http://localhost:9200/over_ten_thousand_index/_search

1
2
3
4
5
6
7
{
"query": {
"match_all": {}
},
"from": 9999,
"size": 1
}

返回结果如下,可以看到 hits.total.relation 为大概值 gte,匹配中大于等于10000条文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"took": 22,
"timed_out": false,
"_shards": {
"total": 3,
"successful": 3,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 10000,
"relation": "gte"
},
"max_score": 1,
"hits": [
// data...
]
}
}

对小于10000条记录的索引进行普通分页查询,返回结果如下,可以看到 hits.total.relation 为精确值 eq

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 57,
"relation": "eq"
},
"max_score": 1,
"hits": [
// data...
]
}
}

如果需要更大的查询结果,也可以修改索引的 index.max_result_window 配置,将可返回的结果上限调大。一般情况下不建议调整此参数的值,因为更深度的分页查询会消耗大量的服务器资源。

1
2
3
4
5
6
# PUT http://localhost:9200/index_name/_settings
{
"index":{
"max_result_window":10001
}
}

Scroll查询

Scroll 分页方式类似关系型数据库中的 cursor(游标),首次查询时会生成并缓存快照,返回给客户端快照读取的位置参数ID(scroll_id),后续每次请求都会通过 scroll_id 访问快照实现快速查询需要的数据,有效降低查询和存储的性能损耗但后续数据的变化无法及时体现在查询结果,而且此种分页方式不支持跳页查询。

使用 Scroll 分页方式需要维护位置参数ID,位置参数ID具有存活有效期,如果用户长时间停留在页面未操作,再次返回操作时位置参数ID已经过期,那么就无法继续查询后续数据,只能重新从头开始查询。另外,同时维护大量的位置参数ID对 ElasticSearch 集群同样也会造成较大的压力,所以 Scroll 分页方式并不适合用于给用户进行实时的分页查询,而是适合于内部使用的大批量拉取数据的场景。

1
2
3
4
5
6
7
# POST http://localhost:9200/index_name/_search?scroll=1m
{
"query": {
"match_all": {}
},
"size": 10000
}

返回结果如下,可以看到返回结果中多了一个 _scroll_id 的字段,下次查询时需要将这个ID继续传入,重复这一步骤进行获取后续数据,直到返回的数据集为空时,此时已经遍历完索引中的所有数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"_scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDnF1ZXJ5VGhlbkZldGNoAxZmMExld1prb1R5V3FBRGRMZVJvbTJnAAAAAAAV79AWWmNpOW1tb0FSSWlUcFA1N0o1aS1HZxZmMExld1prb1R5V3FBRGRMZVJvbTJnAAAAAAAV79EWWmNpOW1tb0FSSWlUcFA1N0o1aS1HZxZmMExld1prb1R5V3FBRGRMZVJvbTJnAAAAAAAV79IWWmNpOW1tb0FSSWlUcFA1N0o1aS1HZw==",
"took": 106,
"timed_out": false,
"_shards": {
"total": 3,
"successful": 3,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 41586277,
"relation": "eq"
},
"max_score": 1,
"hits": [
// data...
]
}
}

后续遍历只需传入返回的 scroll_id 即可,不过需要注意 scroll_id 的有效期。

1
2
3
4
# GET http://localhost:9200/_search/scroll
{
"scroll_id":"FGluY2x1ZGVfY29udGV4dF91dWlkDnF1ZXJ5VGhlbkZldGNoAxZmMExld1prb1R5V3FBRGRMZVJvbTJnAAAAAAAV79AWWmNpOW1tb0FSSWlUcFA1N0o1aS1HZxZmMExld1prb1R5V3FBRGRMZVJvbTJnAAAAAAAV79EWWmNpOW1tb0FSSWlUcFA1N0o1aS1HZxZmMExld1prb1R5V3FBRGRMZVJvbTJnAAAAAAAV79IWWmNpOW1tb0FSSWlUcFA1N0o1aS1HZw=="
}

Scroll 查询默认存在以 _doc 进行排序生成的历史快照,对于数据的实时变更并不会反映到快照上。如果不需要排序,可以在初始化参数时指定类型加入 search_type 参数,传入此参数后 ElasticSearch 不会对返回的结果进行排序。

1
# POST http://localhost:9200/index_name/_search?search_type=scan&scroll=1m

Search After查询

Search After 分页查询方式是 ElasticSearch 5.x 新增的一种分页查询方式,其实现的思路同 Scroll 分页方式基本一致,通过记录上一次分页的位置标识,来进行下一次分页数据的查询。相比于 Scroll 分页方式,它的优点是可以实时体现数据的变化,解决了查询快照导致的无法实时查询的延迟问题,不过这种分页查询方式同样也不支持跳页查询。

1
2
3
4
5
6
7
8
9
10
11
# POST http://localhost:9200/index_name/_search
{
"query": {
"match_all": {}
},
"size": 1000,
"sort": [
{"_id": {"order": "desc"}}
],
"search_after": ["params"] // 与sort中指定的字段对应,传入最后一条记录的值即可。如果sort中用多个字段排序,则传入多个参数值。
}

如果临时需要向前翻页,也可将 order 排序方式从 desc 改为 asc

分页方式总结

分页方式 性能 优点 缺点
from+size 实现简单 可查询的数据范围较小
Scroll 可大批量查询数据,解决深度分页的问题 无法跳页,需要维护scroll_id,不适合用于用户实时查询的场景
Search After 解决海量数据深度分页的问题,不需要维护scroll_id 无法大幅度的跳页,需要有一个全局唯一的字段