在日常工作中,我们的方法提供两种接口用来分页批量的获取数据,第一种是普通的GetEntityList,另外一种是GetEntityIdListByScrollV2,第二种就是我们所说的Scroll方式查询数据。批量获取数据的时候为了性能总是推荐Scroll的方式,但是一直不明白这种方式是什么意思,今天来学习下几种不同的分页查询方式的适用场景。

浅度分页适用场景

一个搜索请求到来的时候,正如我在上篇blog【ElasticSearch从入门到放弃系列 九】Elasticsearch原理机制探索里谈到的,有一个请求的流程,我们举个例子来回顾下,如果我在ES集群的3个节点全部4个分片上共存储了400条人员信息数据,每个分片100条,接下来我要分页获取所有人员中【按年龄排序户籍地为乌拉特前旗】的每页10条的第3页的全部人员数据。也就是依据年龄去排序所有数据,然后取from为20,size为10的10条数据。

  • size:显示应该返回的结果数量,默认是10。
  • from:显示查询数据的偏移量,即应该跳过的初始结果数量,默认是0,我们这里取第三页的数据,则from应该设置为20

那么依照这样的需求我们请求发送到集群会怎么处理呢?

es深翻页 es深分页原理_es深翻页


处理流程如下【请求会被随机转发主分片或副本分片,采取随机轮询的方式,我们这里假定都是主分片处理】:

  1. client发送分页查询请求到ES1(coordinating node)上,ES1上的【S2、S3】各建立一个大小为from+size(30)的优先级队列来存放查询结果;
  2. 协调节点将请求转发到ES2【S1】和ES3【S0】上,它们各建立一个大小为from+size(30)的优先级队列来存放查询结果;
  3. 每个shards在内部执行查询(搜索户籍地为乌拉特前旗,且按照年龄进行排序),把from+size(30)条记录存到内部的优先级队列(top N表)中;
  4. 每个shards把缓存的from+size(30)条记录返回给ES1;
  5. query phase:ES1获取到各个shards数据后,进行合并排序,选择30*4共120条记录里的前 from + size 30条数据以及用于排序的 _score 存到优先级队列即可,以便 fetch 阶段使用。
  6. fetch phase:协调节点ES1获取到整体的top30后,取其中的第20-30条也就是第三页的数据中的10个doc id,根据doc id去各个节点上拉取实际的document数据,最终返回给客户端

这样一个数据量在这种场景下还是可以hold的,但是如果查询量比较大呢?假设我们每个分片上存储了10万条数据,共计40万条数据,我们要取第1万页的数据,也就是from为10000,size为10,那么我们再看一遍流程如下:

  1. client发送分页查询请求到ES1(coordinating node)上,ES1上的【S2、S3】各建立一个大小为from+size(10010)的优先级队列来存放查询结果;内存、IO损耗
  2. 协调节点将请求转发到ES2【S1】和ES3【S0】上,它们各建立一个大小为from+size(10010)的优先级队列来存放查询结果;内存、IO损耗
  3. 每个shards在内部执行查询(按照年龄进行排序),把from+size(10010)条记录存到内部的优先级队列(top N表)中;CPU损耗
  4. 每个shards把缓存的from+size(10010)条记录返回给ES1;网络带宽损耗
  5. query phase:ES1获取到各个shards数据后,进行合并排序,选择10010*4共40040条记录里的前 from + size 10010条数据以及用于排序的 _score 存到优先级队列即可,以便 fetch 阶段使用。CPU损耗
  6. fetch phase:协调节点ES1获取到整体的top10010后,取其中的第10001-10010条也就是第10000页的数据中的10个doc id,根据doc id去各个节点上拉取实际的document数据,最终返回给客户端

以上的各个阶段可以看到,当页码很深的时候,我们拿10条数据是多么的不容易,性能损耗是多么严重,所以ES对这种获取方式的数据条数做了限制:

[root@localhost elasticsearch-5.7.4]# curl -XGET 'http://11.12.84.126:9200/_audit_0102/_log_0102/_search?size=2&from=10000&pretty=true'
{
  "error" : {
    "root_cause" : [ {
      "type" : "query_phase_execution_exception",
      "reason" : "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. 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 parameter."
    } ],
    "type" : "search_phase_execution_exception",
    "reason" : "all shards failed",
    "phase" : "query",
    "grouped" : true,
    "failed_shards" : [ {
      "shard" : 0,
      "index" : "_audit_0102",
      "node" : "f_CQitYESZedx8ZbyZ6bHA",
      "reason" : {
        "type" : "query_phase_execution_exception",
        "reason" : "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. 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 parameter."
      }
    } ]
  },
  "status" : 500
}

from+size最多限制10000,超过限制即报错,当然这个参数可以通过如下的方式调整,例如调整到50000

curl -XPUT "http://11.12.84.126:9200/_audit_0102/_settings" -d '{
        "index": {
            "max_result_window": 50000
        }
    }'

但就算调整了也只是一种临时方案,硬件极限承载能力并不是通过调整配置能解决的,需要更换策略。从报错信息也可以看出,ES推荐使用Scroll的方式:

See the scroll api for a more efficient way to request large data sets

深度分页适用场景

在进行深度分页时我们不推荐再使用from+size的方式,而是使用Scroll,当然Scroll细分也有几种,分别适用于不同的场景。

Scroll

scroll 类似于sql中的cursor,使用scroll,每次只能获取一页的内容(按照上边的场景,每次获取10条),然后会返回一个scroll_id。根据返回的这个scroll_id可以不断地获取下一页的内容,所以scroll并不适用于有跳页的情景。Scroll的流程分为两个步骤

  • 第一次搜索完成之后,将所有复合条件的搜索结果缓存起来,类似于对结果集做了一个快照;
  • 在需要返回数据时,从该快照中按照scroll返回数据;在scroll快照生成之后,在快照有效期范围内,对于该索引的增删改都不会影响快照的结果。

以下是具体操作:

初始化

初始化的时候请求接口还需要index和type【6版本后没有了】信息,初始化的作用时将所有复合条件的搜索结果缓存起来,类似于对结果集做了一个快照,之后的搜索就是游标在快照上的滚动了。

GET UserInfo/_search?scroll=5m
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "home": "乌拉特前旗"
          }
        }
      ]
    }
  },
  "size": 10,
  "from": 0,
  "sort": [
    {
      "age": {
        "order": "desc"
      },
      "_id": {
        "order": "desc"
      }
    }
  ]
}

其中:scroll=5m表示设置scroll_id保留5分钟可用;使用scroll必须要将from设置为0【不允许跳页】;size决定后面每次调用_search搜索返回的数量【这里为10条】。需要注意:实际返回给协调节点的数量为:分片的数量*size,也就是每次返回40条,共进行10001次请求和返回行为

搜索

然后我们可以通过数据返回的_scroll_id读取下一页内容,每次请求将会读取下10条数据,直到数据读取完毕或者scroll_id保留时间截止,请求的接口不再使用索引名了,而是 _search/scroll,其中GET和POST方法都可以使用

GET _search/scroll
{
  "scroll_id": "DnF1ZXJ5VGhlbk【全网唯一Scrollid】",
  "scroll": "5m"
}

需要注意:每次都要传参数 scroll,刷新搜索结果的缓存时间,相对于流程我们再来看一下请求过程:

  1. client发送分页查询请求到ES1(coordinating node)上,ES1上的【S2、S3】各建立一个大小为from+size(10)的优先级队列来存放查询结果;内存无损耗、IO无损耗
  2. 协调节点将请求转发到ES2【S1】和ES3【S0】上,它们各建立一个大小为from+size(10)的优先级队列来存放查询结果;内存无损耗、IO无损耗
  3. 每个shards在内部执行查询(按照年龄进行排序),把from+size(10)条记录存到内部的优先级队列(top N表)中;CPU无损耗
  4. 每个shards把缓存的from+size(10)条记录返回给ES1;网络带宽无损耗
  5. query phase:ES1获取到各个shards数据后,发起下一次请求,直到轮询发起10001次请求,共获得40040条数据后执行合并排序,进行合并排序,选择10010*4共40040条记录里的前 from + size 10010条数据以及用于排序的 _score 存到优先级队列即可,以便 fetch 阶段使用。CPU无损耗、快照堆积
  6. fetch phase:协调节点ES1获取到整体的top100010后,取其中的第10001-10010条也就是第10000页的数据中的10个doc id,根据doc id去各个节点上拉取实际的document数据,最终返回给客户端
删除Scroll上下文快照

scroll的搜索上下文会在scroll的保留时间截止后自动清除,但是我们知道scroll是非常消耗资源的,所以一个建议就是当不需要scroll数据的时候,尽可能快的把scroll_id显式删除掉,因为每一个 scroll_id 不仅会占用大量的资源,而且会生成历史快照【在初始化请求时初始化数据快照,后续游标在快照上移动】,资源占用是很大的【5版本前scroll_id每次返回是变化的,5版本后就不变了】。又是一个经典的以空间换时间的例子

Scroll-Scan

一般来说,仅仅想要找到结果,不关心顺序。可以通过组合 scroll 和 scan 来关闭任何打分或者排序,以最高效的方式返回结果。需要做的就是将 search_type=scan 加入到查询的字符串中

GET UserInfo/_search?scroll=5m&search_type=scan
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "home": "乌拉特前旗"
          }
        }
      ]
    }
  },
  "size": 10,
  "from": 0,
  "sort": [
    {
      "age": {
        "order": "desc"
      },
      "_id": {
        "order": "desc"
      }
    }
  ]
}

不算分,关闭排序,结果会按照在索引中出现的顺序返回

Scroll-after

scroll 的方式,官方的建议不用于实时的请求(一般用于数据导出),因为每一个 scroll_id 不仅会占用大量的资源,而且会生成历史快照,对于数据的变更不会反映到快照上。

search_after 分页的方式是根据上一页的最后一条数据来确定下一页的位置同时在分页请求的过程中,如果有索引数据的增删改查,这些变更也会实时的反映到游标上。但是需要注意,因为每一页的数据依赖于上一页最后一条数据,所以无法跳页请求。为了找到每一页最后一条数据,每个文档必须有一个全局唯一值,官方推荐使用 _uid 作为全局唯一值

GET UserInfo/_search?scroll=5m
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "home": "乌拉特前旗"
          }
        }
      ]
    }
  },
  "size": 10,
  "from": 0,
  "sort": [
    {
      "age": {
        "order": "desc"
      },
      "_id": {
        "order": "desc"   //全局唯一值
      }
    }
  ]
}

接下来使用sort返回值搜索下一页

GET test_dev/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "home": "乌拉特前旗"
          }
        }
      ]
    }
  },
  "size": 10,
  "from": 0,
  "search_after": [
    "d0xH6GYBBtbwbQSP0j1A"
  ],
  "sort": [
    {
     "age": {
        "order": "desc"
      },
      "_id": {
        "order": "desc"
      }
    }
  ]
}

search_after及多个排序字段多个参数用逗号隔开,作为下一个检索search_after的参数。

总结

对比以上的几种分页查询方式我们来总结一下:

分页查询方式

优点

缺点

适用场景

from+size

查询方式简单,只需一次请求

深度分页情况下,内存、IO、CPU、网络带宽损耗严重

10000条数据以内的分页查询和获取

Scroll

深度查询下不受影响,可以持续遍历进行深度分页查询

请求次数多,快照堆积多,查询过程中以快照模式存储,历史快照对于数据的变更不会反映到快照上

数据导出,穷举

Scroll-Scan

深度查询下不受影响,可以持续遍历进行深度分页查询,且由于无需排序,速度较Scroll快

较Scroll而言,无法排序

对排序没有要求的数据导出、穷举

Scroll-after

深度查询下不受影响,可以持续遍历进行深度分页查询,且因为不依赖Scroll_id,不依赖快照,所以数据变更可以体现在查询结果中

较Scroll而言,由于不生成快照,每条数据都必须有一个全局唯一id

动态的数据导出和穷举