ES 分页方案
ES 中,存在三种常见的分页方案:
- FROM, SIZE
- Search-After
- Scroll
下面将依次比较三种方案之间的 trede-off,并给出相应建议的应用场景。
常见分页,FROM, SIZE
ES 提供了常见的分页功能,通过在 search API 中,指定 from 和 size 来实现分页的效果:
{
"from": 10,
"size": 20,
"sort": [{"timestamp": "desc"}],
"query": {
"match_all": {} # 返回所有 doc
}
}
from: 表示起点位置,默认是 0.
size:表示返回的数量,默认是 10.
这种分页方式到没什么好说的,但需要注意的是由于 ES 为了支持海量数据的查询,本身采用了分布式的架构。
而对于分布式架构来说,存在一个典型的深度分页的问题。
ES 在存储数据时,会将其分配到不同的 shard 中。在查询时,如果 from 值过大,就会导致分页起点太深。
每个 shard 查询时,都会将 from 之前位置的所有数据和请求 size 的总数返回给 coordinator. 简单来说,就是想取第 n 页的内容,但是却返回了前 n 的内容。
而对于 coordinator 来说,会显著导致内存和CPU使用率升高,特别是在高并发的场景下,导致性能下降或者节点故障。
举例来说,当前 ES 共有 4 个 shard,并且每个 shard 没有副本。假如分页的大小为 10. 然后想取第101 页内容。对应的 from = 1000,size = 10.
ES 的查询过程为:
- 每个 shard 将所在数据加载到内存并排序,然后取前 1010 个,返回给 coordinator.
- 每个 shard 都执行上面的操作。
- 最后 coordinator 将 1010 * 4 = 4040 条数据排序,然后取 10 条数据返回。
可以发现,from 的位置太深,造成了如下的问题:
- 返回给 coordinator 数值太大,明明就需要 5 条数据,但却给 coordinator 1005 条数据
- coordinator 需要处理每个 shard 返回前 101 页的结果。但需要的仅是第 101 页的内容,却对前 101 页的内容进行了排序,浪费了内存和 cpu 的资源。
ES 为了规避这个问题,通过设置 max_result_window 来限制 from 和 size 的大小,默认大小仅支持 10000 条。当超过 10000 的大小,则会报出异常。
在页数不深或者考虑内存,低并发等情况,可以通过临时调整 max_result_window 来解决该问题,但如果页数太深则建议使用的 Search-After 的方式。
SearchAfter 分页
为了应对深度分页的情况,ES 推荐使用 SearchAfter 的方式,来实现数据的深度翻页检索。
在具体实现上,通过动态指针的技术。在第一次使用 search api 查询时,附带一个 sort 参数,其中 sort 的值必须唯一,可以用 _id
作为排序参数。
{
"from": 0,
"size": 1,
"sort": [
{"timestamp": "desc"},
{"_id": "asc"}
],
"query": {
"match_all": {}
}
}
每个 shard 在排序后会记录当前查询的最后位置,然后将其返回。
{
"took": 0,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 10,
"relation": "eq"
},
"max_score": null,
"hits": [
{
"_index": "cmi_alarm_info",
"_type": "_doc",
"_id": "1,3,d_to_s_JitterAvg",
"_score": null,
"_source": {
"src_device_id": 1,
"dst_device_id": 3,
"type": "d_to_s_JitterAvg",
"status": "normal",
"create_time": 1617085800
},
"sort": [
1617085800,
"1,3,d_to_s_JitterAvg"
]
}
]
}
}
下次查询时,在 search_after
携带 Response 中返回的 sort 参数,实现分页的查询。
{
"from": 0,
"size": 1,
"sort": [
{"timestamp": "desc"},
{"_id": "asc"}
],
"query": {
"match_all": {}
},
"search_after": [
1617084900,
"13,3,d_to_s_JitterAvg"
]
}
和 from size 的查询方式相比, 每个 shard 每次返回给 coordinator 的结果仅为 size 数量,将空间复杂度由 O(n) 降为 O(1).
但 Search-after 也有一些问题:
首先就是不支持跳页的情况。
如果需求上一定需要跳页时,只能通过 from 或者 size 的方式。同时为了避免深度分页的问题,一般可以采用限制页面数量的方式。在确定 size 后,设置一个最大的分页值。在查询时,分页数不允许超过该值。
其次,随着翻页深度的增加,查询的效率也会有所降低,但不会导致 OOM,算是可以完成深度查询的任务。原因在于,虽然说通过排序字段,可以很好的定位出下一次翻页的开始位置。但在每次请求时,从头扫描该字段,找到该字段的位置。页数越深,找到该位置的时间也就越长。
Scroll 分页
虽然说 search-after 可以在一定程度上避免深度分页的问题,但在处理大数据量,效率并不高。在一些对实时性要求不高的场景,如利用 Spark 进行大规模计算时。就可以利用 scroll 分页的方式,检索所有数据。
scroll 的请求方式分为两步:
- 第一次请求,ES 会返回生成生成的 scroll_id
- 之后的请求,不断使用 scroll_id 进行查询,直到所有数据被检索完成。
第一次请求,添加 scroll 标识,并拿到 scroll_id 作为下次请求的参数:
POST /my-index-000001/_search?scroll=1m
{
"size": 100,
"query": {
"match": {
"message": "foo"
}
}
}
Response:
{
"_scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAADlx8Wb0VzanNRSENRbUtBQVEzbHltcF9WQQ==",
"took": 0,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
}
}
第二次请求,使用 scroll_id 直到遍历完所有数据:
POST /_search/scroll
{
"scroll" : "1m",
"scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}
对于 Scroll 来说,会返回第一次请求时刻的所有文档,之后文档的改变并不会被查询到,保留的时间通过 scroll 参数指定。在查询性能上,时间和空间复杂度都为 O(1),能以恒定的速度查询完所有数据。
在原理上,相当于第一次查询阶段, 保留所有的 doc id 信息。在随后的查询中,根据的需要的 doc id,在不同的 shard 中拉取不同的文档。和 search-after 相比,省去了每次都要全局排序的过程。
总结
from, size 适用于常见的查询,例如需要支持跳页并实时查询的场景。但查询深度过深时,会有深度分页的问题,造成 OOM.
如果在业务上,可以不选择跳页的方式,可以使用的 search-after 的方式避免深度分页的问题。但如果一定要跳页的话,只能采用限制最大分页数的方式。
但对于超大数据量,以及需要高并发获取等离线场景,scroll 是比较好的一种方式。
参考