ES 分页方案

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 页前 5 条内容。对应的 from = 1000,size = 5.

ES 的查询过程为:

  1. 每个 shard 将所在数据加载到内存并排序,然后取前 1005 个,返回给 coordinator.
  2. 每个 shard 都执行上面的操作。
  3. 最后 coordinator 将 1005 * 4 = 4020 条数据排序,然后取 5 条数据返回。

可以发现,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 的请求方式分为两步:

  1. 第一次请求,ES 会返回生成生成的 scroll_id
  2. 之后的请求,不断使用 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 是比较好的一种方式。

参考

深度分页

scroll

阿里-分页方式

posted @ 2021-04-08 15:57  来份锅包肉  阅读(825)  评论(0编辑  收藏  举报