在日常工作中,我们的方法提供两种接口用来分页批量的获取数据,第一种是普通的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
那么依照这样的需求我们请求发送到集群会怎么处理呢?
处理流程如下【请求会被随机转发主分片或副本分片,采取随机轮询的方式,我们这里假定都是主分片处理】:
- client发送分页查询请求到ES1(coordinating node)上,ES1上的【S2、S3】各建立一个大小为from+size(30)的优先级队列来存放查询结果;
- 协调节点将请求转发到ES2【S1】和ES3【S0】上,它们各建立一个大小为from+size(30)的优先级队列来存放查询结果;
- 每个shards在内部执行查询(搜索户籍地为乌拉特前旗,且按照年龄进行排序),把from+size(30)条记录存到内部的优先级队列(top N表)中;
- 每个shards把缓存的from+size(30)条记录返回给ES1;
- query phase:ES1获取到各个shards数据后,进行合并排序,选择30*4共120条记录里的前 from + size 30条数据以及用于排序的 _score 存到优先级队列即可,以便 fetch 阶段使用。
- fetch phase:协调节点ES1获取到整体的top30后,取其中的第20-30条也就是第三页的数据中的10个doc id,根据doc id去各个节点上拉取实际的document数据,最终返回给客户端
这样一个数据量在这种场景下还是可以hold的,但是如果查询量比较大呢?假设我们每个分片上存储了10万条数据,共计40万条数据,我们要取第1万页的数据,也就是from为10000,size为10,那么我们再看一遍流程如下:
- client发送分页查询请求到ES1(coordinating node)上,ES1上的【S2、S3】各建立一个大小为from+size(10010)的优先级队列来存放查询结果;内存、IO损耗
- 协调节点将请求转发到ES2【S1】和ES3【S0】上,它们各建立一个大小为from+size(10010)的优先级队列来存放查询结果;内存、IO损耗
- 每个shards在内部执行查询(按照年龄进行排序),把from+size(10010)条记录存到内部的优先级队列(top N表)中;CPU损耗
- 每个shards把缓存的from+size(10010)条记录返回给ES1;网络带宽损耗
- query phase:ES1获取到各个shards数据后,进行合并排序,选择10010*4共40040条记录里的前 from + size 10010条数据以及用于排序的 _score 存到优先级队列即可,以便 fetch 阶段使用。CPU损耗
- 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,刷新搜索结果的缓存时间,相对于流程我们再来看一下请求过程:
- client发送分页查询请求到ES1(coordinating node)上,ES1上的【S2、S3】各建立一个大小为from+size(10)的优先级队列来存放查询结果;内存无损耗、IO无损耗
- 协调节点将请求转发到ES2【S1】和ES3【S0】上,它们各建立一个大小为from+size(10)的优先级队列来存放查询结果;内存无损耗、IO无损耗
- 每个shards在内部执行查询(按照年龄进行排序),把from+size(10)条记录存到内部的优先级队列(top N表)中;CPU无损耗
- 每个shards把缓存的from+size(10)条记录返回给ES1;网络带宽无损耗
- query phase:ES1获取到各个shards数据后,发起下一次请求,直到轮询发起10001次请求,共获得40040条数据后执行合并排序,进行合并排序,选择10010*4共40040条记录里的前 from + size 10010条数据以及用于排序的 _score 存到优先级队列即可,以便 fetch 阶段使用。CPU无损耗、快照堆积
- 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 | 动态的数据导出和穷举 |