一、from + size

ElasticSearch默认采用的分页方式是from + size的形式,在深度分页的情况下,这种使用方式的效率是非常低的,比如我们执行如下查询:

GET /student/student/_search
{
"query":{
"match_all": {}
},
"from":5000,
"size":10
}

以上DSL语句执行后,ElasticSearch需要在各个分片上匹配排序并得到5010条数据,协调节点拿到这些数据再进行排序处理,然后结果集中取最后10条数据返回。

这样的深度分页的效率非常低,因为我们只需要查询10条数据,而ElasticSearch则需要对from + size条数据进行排序处理后返回。

其次:ElasticSearch为了性能,限制了分页的深度,目前ElasticSearch支持的最大的查询长度是max_result_window = 10000;也就是说我们不能分页到10000条数据以上。

例如:9990+10 = 10000,此时可以执行分页查询。

java es 深度分页查询 es深度分页原理_elasticsearch

但是,当9991+10 > 10000,此时执行查询的返回为:

java es 深度分页查询 es深度分页原理_elasticsearch_02

二、scroll(游标)

        相对于from和size的分页来说,使用scroll可以模拟一个传统数据的游标,记录当前读取的文档信息位置。这个分页的用法,不是为了实时查询数据,而是为了一次性查询大量的数据(甚至是全部的数据)。

传统数据库游标:

      游标(cursor)是系统为用户开设的一个数据缓冲区,存放SQL语句的执行结果。每个游标区都有一个名字,用户可以用SQL语句逐一从游标中获取记录,并赋给主变量,交由主语言进一步处理。就本质而言,游标实际上是一种能从包括多条数据记录的结果集中每次提取一条记录的机制。游标是一段私有的SQL工作区,也就是一段内存区域,用于暂时存放受SQL语句影响到的数据。通俗理解就是将受影响的数据暂时放到了一个内存区域的虚表中,而这个虚表就是游标。

ElasticSearch scroll:

      使用scroll滚动搜索,可以先搜索一批数据,然后下次再搜索一批数据,以此类推,直到搜索出全部的数据。scroll搜索会在第一次搜索的时候,保存一个当时的视图快照,之后只会基于该旧的视图快照提供数据搜索,如果这个期间数据变更,是不会让用户看到的。每次发送scroll请求,我们还需要指定一个scroll参数,指定一个时间窗口,每次搜索请求只要在这个时间窗口内能完成就可以了。

      一个滚屏搜索允许我们做一个初始阶段搜索并且持续批量从Elasticsearch里拉取结果直到没有结果剩下。

      滚屏搜索会及时制作快照。这个快照不会包含任何在初始阶段搜索请求后对index做的修改。它通过将旧的数据文件保存在手边,所以可以保护index的样子看起来像搜索开始时的样子。这样将使得我们无法得到用户最近的更新行为

scroll的使用:

执行如下curl,每次请求两条。可以定制 scroll = 1m意味着该窗口过期时间为1分钟。

GET /student/student/_search?scroll=1m
{
"query":{
"match_all": {}
},
"size":2
}
{
"_scroll_id" : "DnF1ZXJ5VGhlbkZldGNoAwAAAAAAAR-lFlBWekUzVGlUUjhXQ09KQ2dwLTA1eFEAAAAAAAEfphZQVnpFM1RpVFI4V0NPSkNncC0wNXhRAAAAAAABH6cWUFZ6RTNUaVRSOFdDT0pDZ3AtMDV4UQ==",
"took" : 15,
"timed_out" : false,
"_shards" : {
"total" : 3,
"successful" : 3,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 4,
"max_score" : 1.0,
"hits" : [
{
"_index" : "student",
"_type" : "student",
"_id" : "2",
"_score" : 1.0,
"_source" : {
"id" : 2,
"name" : "仙道彰",
"age" : 17,
"gender" : "男",
"address" : "神奈川县陵南高中"
}
},
{
"_index" : "student",
"_type" : "student",
"_id" : "4",
"_score" : 1.0,
"_source" : {
"id" : 4,
"name" : "赤木刚宪",
"age" : 20,
"address" : "东京大学",
"gender" : "男"
}
}
]
}
}

可以看到在返回结果中,存在一个很重要的_scroll_id,在后面的请求中,都需要在带着这个_scroll_id去请求。如下:

GET /_search/scroll
{
"scroll":"1m",
"scroll_id":"DnF1ZXJ5VGhlbkZldGNoAwAAAAAAAR-lFlBWekUzVGlUUjhXQ09KQ2dwLTA1eFEAAAAAAAEfphZQVnpFM1RpVFI4V0NPSkNncC0wNXhRAAAAAAABH6cWUFZ6RTNUaVRSOFdDT0pDZ3AtMDV4UQ=="
}

执行一次,得到结果:

{
"_scroll_id" : "DnF1ZXJ5VGhlbkZldGNoAwAAAAAAASI_FlBWekUzVGlUUjhXQ09KQ2dwLTA1eFEAAAAAAAEiQRZQVnpFM1RpVFI4V0NPSkNncC0wNXhRAAAAAAABIkAWUFZ6RTNUaVRSOFdDT0pDZ3AtMDV4UQ==",
"took" : 3,
"timed_out" : false,
"_shards" : {
"total" : 3,
"successful" : 3,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 4,
"max_score" : 1.0,
"hits" : [
{
"_index" : "student",
"_type" : "student",
"_id" : "3",
"_score" : 1.0,
"_source" : {
"id" : 3,
"name" : "赤木晴子",
"age" : 17,
"gender" : "女",
"address" : "神奈川县湘北高中"
}
},
{
"_index" : "student",
"_type" : "student",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"id" : 1,
"name" : "樱木花道",
"age" : 18,
"address" : "神奈川县湘北高中",
"gender" : "男"
}
}
]
}
}

再执行一次,得到结果:

{
"_scroll_id" : "DnF1ZXJ5VGhlbkZldGNoAwAAAAAAASI_FlBWekUzVGlUUjhXQ09KQ2dwLTA1eFEAAAAAAAEiQRZQVnpFM1RpVFI4V0NPSkNncC0wNXhRAAAAAAABIkAWUFZ6RTNUaVRSOFdDT0pDZ3AtMDV4UQ==",
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 3,
"successful" : 3,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 4,
"max_score" : 1.0,
"hits" : [ ]
}
}

现在student这个索引中共有4条数据,id分别为 1, 2, 3, 4。当我们使用 scroll 查询第3次的时候,返回结果为kong。这时我们就知道已经结果集已经匹配完了。

三、search_after

      from + size的分页方式虽然是最灵活的分页方式,但是当分页深度达到一定程度将会产生深度分页的问题。scroll能够解决深度分页的问题,但是其无法实现实时查询,即当scroll_id生成后无法查询到之后数据的变更,因为其底层原理是生成数据的快照。这时 search_after应运而生。其是在es-5.X之后才提供的。

      search_after 是一种假分页方式,根据上一页的最后一条数据来确定下一页的位置,同时在分页请求的过程中,如果有索引数据的增删改查,这些变更也会实时的反映到游标上。为了找到每一页最后一条数据,每个文档必须有一个全局唯一值,官方推荐使用 _id 作为全局唯一值,但是只要能表示其唯一性就可以。

执行如下查询:

GET /student/student/_search
{
"query":{
"match_all": {}
},
"size":2,
"sort": [
{
"id": {
"order": "desc"
}
}
]
}

结果为:

{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 3,
"successful" : 3,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 4,
"max_score" : null,
"hits" : [
{
"_index" : "student",
"_type" : "student",
"_id" : "4",
"_score" : null,
"_source" : {
"id" : 4,
"name" : "赤木刚宪",
"age" : 20,
"address" : "东京大学",
"gender" : "男"
},
"sort" : [
4
]
},
{
"_index" : "student",
"_type" : "student",
"_id" : "3",
"_score" : null,
"_source" : {
"id" : 3,
"name" : "赤木晴子",
"age" : 17,
"gender" : "女",
"address" : "神奈川县湘北高中"
},
"sort" : [
3
]
}
]
}
}

可以看到结果集中的  "sort" : [3] ,将当前结果集中sort的值,作为下一次查询search_after的参数:

GET /student/student/_search
{
"query":{
"match_all": {}
},
"size":2,
"sort": [
{
"_id": {
"order": "desc"
}
}
],
"search_after":[3]
}

得到结果为:

{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 3,
"successful" : 3,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 4,
"max_score" : null,
"hits" : [
{
"_index" : "student",
"_type" : "student",
"_id" : "4",
"_score" : null,
"_source" : {
"id" : 4,
"name" : "赤木刚宪",
"age" : 20,
"address" : "东京大学",
"gender" : "男"
},
"sort" : [
4
]
},
{
"_index" : "student",
"_type" : "student",
"_id" : "3",
"_score" : null,
"_source" : {
"id" : 3,
"name" : "赤木晴子",
"age" : 17,
"gender" : "女",
"address" : "神奈川县湘北高中"
},
"sort" : [
3
]
}
]
}
}

这样我们就使用search_after方式实现了分页查询。

四、三种分页方式的比较

分页方式

性能

优点

缺点

场景

from + size


灵活性好,实现简单

深度分页问题

数据量比较小,能容忍深度分页问题

scroll


解决了深度分页问题

无法反应数据的实时性(快照版本)

维护成本高,需要维护一个 scroll_id

海量数据的导出,需要查询海量结果集的数据

search_after


性能最好

不存在深度分页问题

能够反映数据的实时变更

实现复杂,需要有一个全局唯一的字段

连续分页的实现会比较复杂,因为每一次查询都需要上次查询的结果

海量数据的分页