ElasticSearch 深度分页
深度分页问题
我们使用mysql的时候经常遇到分页查询的场景,在mysql中使用
limit
关键字来实现分页,比如下面的示例
1 | select * from orders_history where type=8 limit 100,100; |
在ElasticsSearch(以下简称ES)同样也有很多分页查询的场景,比如在数据量比较大的情况下,并且查询条件比较复杂,在mysql中无法命中索引,我们往往会选择使用ES的分页查询。
分页方式
在Elasticsearch中支持的三种分页查询方式
- From + Size 查询
- Scroll 查询
- Search After 查询
from/size
from+size的分页查询称为”浅”分页,它的原理很简单,就是查询前20条数据,然后截断前10条,只返回10-20的数据,这样其实白白浪费了前10条的查询
在深度分页的情况下,这种使用方式效率是非常低的,比如from = 50000, size=10, es 需要在各个分片上匹配排序并得到50010条数据,协调节点拿到这些数据再进行全局排序处理,然后结果集中取最后10条数据返回。
使用方式
这是ES分页最常用的一种方案,跟mysql类似
- from:未指定,默认值是 0,注意不是1,代表当前页返回数据的起始值。
- size:未指定,默认值是 10,代表当前页返回数据的条数
分页查询
查询小区数据中
0-10
页的数据
1 | GET logstash-village-2022.08.22/_search |
优缺点
优点
- 支持随机翻页
缺点
- 受制于 max_result_window 设置,不能无限制翻页。
- 存在深度翻页问题,越往后翻页越慢。
适用场景
非常适合小型数据集或者大数据集返回 Top N(N <= 10000)结果集的业务场景
类似主流 PC 搜索引擎(谷歌、bing、百度、360、sogou等)支持随机跳转分页的业务场景
深度分页问题
Elasticsearch 在深度分页的情况下会限制最大分页数,避免大数据量的查询导致性能低下
深度分页限制
ES的
max_result_window
默认值是10000,也就意味着:如果每页有 10 条数据,会最大翻页至1000页
实际主流搜索引擎都翻不了那么多页,举例:百度搜索“上海”,翻到第 76 页,就无法再往下翻页了,提示信息如下截图所示:
尝试深度分页
我们将上面的查询改造下,查询
9999
页之后的十条数据
1 | GET logstash-village-2022.08.22/_search |
我们发现查询后直接报错了,Result window is too large
为什么限制深度分页
为了性能,es限制了我们分页的深度,es目前支持的最大的 max_result_window = 10000;
也就是说我们不能获取10000个以上的文档 , 当ES 分页查询超过一定的值(10000)后,会报错,如果数据量非常大的情况下进行查询可能会产生OOM
不推荐使用 from + size 做深度分页查询的核心原因:
- 搜索请求通常跨越多个分片,每个分片必须将其请求的命中内容以及任何先前页面的命中内容加载到内存中。
- 对于翻页较深的页面或大量结果,这些操作会显著增加内存和 CPU 使用率,从而导致性能下降或节点故障
分页查询步骤
- 协调节点或者客户端节点,需要将请求发送到所有的分片
- 每个分片把from + size个结果,返回给协调节点或者客户端节点
- 协调节点或者客户端节点进行结果合并,如果有n个分片,则查询数据是 n * (from+size) , 如果from很大的话,会造成oom或者网络资源的浪费。
问题分析
我么有三个shard(分片),每个分片有10w条数据如果要查询9999-10009的数据
查询的时候协调节点就会分别从每个分片中获取10009条数据,一共30027条数据,然后进行排序获取出10条数据,所以深度分页会给系统带来很大的压力
解决方案
限制分页数
我们可以限制分页的数量,而规避深度分页带来的性能影响,例如天猫会限制在80页
修改max_result_window初始值
怎么解决这个问题,首先能想到的就是调大这个window
1 | PUT logstash-village/_settings |
但这种方法只是暂时解决问题,当数据量越来越大,分页也越来越深,而且越会出OOM问题的,所以当索引非常非常大(千万或亿),是无法使用from + size 做深分页的,分页越深则越容易OOM,即便不OOM,也很消耗CPU和内存资源
Scroll 遍历查询
ES为了避免深分页,不允许使用分页(from&size)查询10000条以后的数据,因此如果要查询第10000条以后的数据,要使用ES提供的 scroll(游标) 来查询
scroll
查询可以用来对 Elasticsearch 有效地执行大批量的文档查询,而又不用付出深度分页那种代价,游标查询允许我们先做查询初始化,然后再批量地拉取结果,这有点儿像传统数据库中的 cursor
。
如果把 From + size 和 search_after 两种请求看做近实时的请求处理方式,那么 scroll 滚动遍历查询显然是非实时的,数据量大的时候,响应时间可能会比较长。
使用方式
scroll 核心执行步骤如下
生成scroll_id
指定检索语句同时设置 scroll 上下文保留时间,如果文档不需要特定排序,可以指定按照文档创建的时间返回会使迭代更高效。
1 | GET logstash-village-2022.08.22/_search?scroll=1m # 保持游标查询窗口一分钟 |
从 Scroll 请求返回的结果反映了发出初始搜索请求时索引的状态,类似在那一个时刻做了快照,随后对文档的更改(写入、更新或删除)只会影响以后的搜索请求。
scroll就是把一次的查询结果缓存一定的时间,如scroll=1m则把查询结果在下一次请求上来时暂存1分钟,response比传统的返回多了一个scroll_id,下次带上这个scroll_id即可找回这个缓存的结果。
分页查询
后续翻页, 通过上一次查询返回的scroll_id 来不断的取下一页,请求指定的
scroll_id
时就不需要索引条件等信息了,直到没有要返回的结果为止
1 | GET /_search/scroll |
如果srcoll_id 的生存期很长,那么每次返回的 scroll_id 都是一样的,直到该 scroll_id 过期,才会返回一个新的 scroll_id
每读取一页都会重新设置 scroll_id 的生存时间,所以这个时间只需要满足读取当前页就可以,不需要满足读取所有的数据的时间,1 分钟足以。
注意事项
使用初始化返回的
_scroll_id
来进行请求,每一次请求都会继续返回初始化中未读完数据,并且会返回一个_scroll_id
,这个_scroll_id
可能会改变,因此每一次请求应该带上上一次请求返回的_scroll_id
每次发送scroll请求时,都要再重新刷新这个scroll的开启时间,以防不小心超时导致数据取得不完整,如果没有数据了,就会回传空的hits,可以用这个判断是否遍历完成了数据
优缺点
优点
- 支持全量遍历(单次遍历的 size 值也不能超过 max_result_window 大小)
缺点
- 响应时间非实时。
- 保留上下文需要足够的堆内存空间。
适用场景
- 全量或数据量很大时遍历结果数据,而非分页查询。
- 官方文档强调:不再建议使用scroll API进行深度分页,如果要分页检索超过 Top 10,000+ 结果时,推荐使用:PIT + search_after。
scroll 分页原理
游标查询会取某个时间点的快照数据,查询初始化之后索引上的任何变化会被它忽略,它通过保存旧的数据文件来实现这个特性,结果就像保留初始化时的索引视图一样。
深度分页的代价根源是结果集全局排序,如果去掉全局排序的特性的话查询结果的成本就会很低, 游标查询用字段 _doc
来排序,这个指令让 Elasticsearch 仅仅从还有结果的分片返回下一批结果。
启用游标查询可以通过在查询的时候设置参数 scroll
的值为我们期望的游标查询的过期时间, 游标查询的过期时间会在每次做查询的时候刷新,所以这个时间只需要足够处理当前批的结果就可以了,而不是处理查询结果的所有文档的所需时间。
这个过期时间的参数很重要,因为保持这个游标查询窗口需要消耗资源,所以我们期望如果不再需要维护这种资源就该早点儿释放掉,设置这个超时能够让 Elasticsearch 在稍后空闲的时候自动释放这部分资源。
滚动游标原理
对一次查询生成一个游标 scroll_id , 后续的查询只需要根据这个游标scroll_id 去取数据,直到结果集中返回的 hits 字段为空,就表示遍历结束。
scroll_id 的生成可以理解为建立了一个临时的历史快照,或者可以理解为一个保存doc快照的临时的结果文件,快照文件形成之后,原doc的增删改查等操作不会影响到这个快照的结果。
Scroll 的理解
使用scroll就是一次把要用的数据都排完了,缓存起来
在遍历时,从这个快照里取数据,分批取出
因此,游标可以增加性能的原因,Scroll 使用from+size还好
是因为如果做深分页,from+size 每次搜索都必须重新排序,非常浪费资源,而且容易OOM
scroll的清理
srcoll_id 的存在会耗费大量的资源来保存一份当前查询结果集映像,并且会占用文件描述符
为了防止因打开太多scroll而导致的问题,不允许用户打开滚动超过某个限制,默认情况下,打开的滚动的最大数量为500,可以使用search.max_open_scroll_context
群集设置更新此限制,虽然es 会有自动清理机制,但是,尽量保障所有文档获取完毕之后,手动清理掉 scroll_id
根据scroll_id清理
使用 es 提供的 CLEAR_API 来删除指定的 scroll_id
1 | DELETE /_search/scroll/FGluY2x1ZGVfY29udGV4dF91dWlkDnF1ZXJ5VGhlbkZldGNoAxZvdlVSblJQdFFLaWx5RjJDaGpSakxnAAAAAAAALkAWMGY3M3liWXFRVE9jOTZPbU5fUFNFdxZOc0dSOFl5WVRPZTExdUNrZnJrRmp3AAAAAAAALyEWSk9zQ2VQR0xTR09TZ2RmcWdEa05NdxZIaFBUWWVlTFRpeS1IajI5cjBGWEh3AAAAAAAAABkWRHhTSEQ3R2ZRVC1rX2JURHdnU2d3Zw== |
清理所有的scroll
根据ID清理会比较麻烦,我们完全可以全部清除掉
1 | DELETE /_search/scroll/_all |
search_after 查询
scroll 的方式,官方的建议不用于实时的请求(一般用于数据导出),因为每一个
scroll_id
不仅会占用大量的资源,而且会生成历史快照,对于数据的变更不会反映到快照上。
search_after 查询本质:使用前一页中的一组排序值来检索匹配的下一页,search_after 分页的方式是根据上一页的最后一条数据来确定下一页的位置,同时在分页请求的过程中,如果有索引数据的增删改查,这些变更也会实时的反映到游标上。
但是需要注意,因为每一页的数据依赖于上一页最后一条数据,所以无法跳页请求,无法指定页数,只能实现“下一页”这种需求。
为了找到每一页最后一条数据,每个文档必须有一个全局唯一值,官方推荐使用 _uid 作为全局唯一值,其实使用业务层的 id 也可以。
前置条件:使用 search_after 要求后续的多个请求返回与第一次查询相同的排序结果序列,也就是说,即便在后续翻页的过程中,可能会有新数据写入等操作,但这些操作不会对原有结果集构成影响。
使用方式
search_after 分页查询可以简单概括为如下几个步骤
创建 PIT 视图
创建 PIT 视图,这是前置条件不能省
1 | POST logstash-village-2022.08.22/_pit?keep_alive=5m |
keep_alive=5m
,类似scroll的参数,代表视图保留时间是 5 分钟,超过 5 分钟执行会报错
创建基础查询
创建基础查询语句,这里要设置翻页的条件
- 设置了PIT,检索时候就不需要再指定索引。
- id 是基于步骤1 返回的 id 值。
- 排序 sort 指的是:按照哪个关键字排序。
1 | GET /_search |
在每个返回文档的最后,会有两个结果值,如下所示
其中,
60
就是我们指定的排序方式:基于{"greening": {"order": "desc" }}
降序排列
官方文档把这种隐含的字段叫做:tiebreaker (决胜字段),tiebreaker 等价于_shard_doc。
tiebreaker 本质含义:每个文档的唯一值,确保分页不会丢失或者分页结果数据出现重复(相同页重复或跨页重复)。
后续翻页
后续翻页都需要借助 search_after 指定前一页的最后一个文档的 sort 字段值,search_after 查询仅支持向后翻页
1 | GET /_search |
优缺点
优点
- 不严格受制于 max_result_window,可以无限制往后翻页(不严格含义:单次请求值不能超过 max_result_window;但总翻页结果集可以超过)
缺点
- 只支持向后翻页,不支持随机翻页。
适用场景
不支持随机翻页,更适合手机端应用的场景
分页原理
search_after 查询本质:使用前一页中的一组排序值来检索匹配的下一页
可以创建一个时间点 Point In Time(PIT)保障搜索过程中保留特定事件点的索引状态,Point In Time(PIT)是 Elasticsearch 7.10 版本之后才有的新特性,PIT的本质是存储索引数据状态的轻量级视图。
PIT视图
如下示例能很好的解读 PIT 视图的内涵。
1 | 创建 PIT |
有了 PIT,search_after 的后续查询都是基于 PIT 视图进行,能有效保障数据的一致性
三种分页方式对比
性能对比
页方式 | 1~10 | 49000~49010 | 99000~99010 |
---|---|---|---|
form+size | 8ms | 30ms | 117ms |
scroll | 7ms | 66ms | 36ms |
search_after | 5ms | 8ms | 7ms |
优缺点对比
分页方式 | 性能 | 优点 | 缺点 | 场景 |
---|---|---|---|---|
from + size | 低 | 灵活性好,实现简单 | 深度分页问题 | 数据量比较小,能容忍深度分页问题 |
scroll | 中 | 解决了深度分页问题 | 无法反应数据的实时性(快照版本) | 维护成本高,需要维护一个 scroll_id |
search_after | 高 | 性能最好不存在深度分页问题能够反映数据的实时变更 | 实现复杂,需要有一个全局唯一的字段连续分页的实现会比较复杂,因为每一次查询都需要上次查询的结果 | 海量数据的分页 |