ES深度分页浅析及解决思路
业务场景及问题
在一些业务场景中,为了提升查询效率,我们一般会把数据往ES写入一份,然后查询就直接从ES查询。
在从ES进行分页查询时,例如:查询第10001-10010条的订单数据,就会遇到“深度分页”的问题。
什么是深度分页?为什么会有这个问题?
深度分页其实就是搜索的深浅度,比如第1页,第2页,第10页,第20页,是比较浅的;第10000页,第20000页就是很深了。
那么为什么会遇到这个问题呢?这得从ES的存储结构开始说起
ES存储结构的初步认知
ES一般是以集群(Cluster)的方式部署的
整朵云,就是ES集群。如下图所示,云里面一个个的黑色边框的盒子,就是一个节点(Node)
每个节点是一个Elasticsearch实例,负责存储和处理数据。节点可以运行在不同的服务器上,以分散负载并提高系统的容错性和性能。
在一个或多个节点之间,多个绿色小方块组合在一起,就构成了Elasticsearch的索引(Index)。可以类比成MySQL的一张表。
在一个索引下,分布在不同节点里的,绿色的小方块,称之为分片(Shard)。数据就存储在分片里面。
执行一次分页查询时,大概会发生什么?
以一开始的例子为例,从搜索结果中第 1000 条数据位置开始,取之后的 10 条数据作为结果返回,这种分页方式在 ES 集群内部是如何执行的呢?
在 ES 中,搜索一般包括 2 个阶段,Query 阶段和 Fetch 阶段,Query 阶段主要确定要获取哪些 doc,也就是返回所要获取 doc 的 id 集合(可以理解成MySQL中的 SELECT id),Fetch 阶段主要通过 id 获取具体的 doc。
Query 阶段
- 第一步:客户端 发送查询请求到 服务端。当客户端发送查询请求时,它可以通过集群的任意一个节点发送请求。通常来说,客户端会通过一个负载均衡器或者按照配置的节点列表选择一个节点发送请求。假设Node1接收到了请求,那么,Node1节点会创建一个大小为 from + size 的优先级队列,用来存放结果。此时 Node1 被称为 coordinating node(协调节点)
- 第二步:Node1 将请求广播到涉及的 Shard 上,每个 Shard 内部执行搜索请求,然后将执行结果存到自己内部的大小同样为 from+size 的优先级队列里
- 第三步:每个 Shard 将暂存的自身优先级队列里的结果返给 Node1,Node1 拿到所有 Shard 返回的结果后,对结果进行一次合并,产生一个全局的优先级队列,存在 Node1 的优先级队列中。(假设一个Index有2个 Shard,那么 Node1 会拿到 (from + size) * 2 条数据,这些数据只包含 doc 的唯一标识_id 和用于排序的_score,然后 Node1 会对这些数据合并排序,选择前 from + size 条数据存到优先级队列)
Fetch 阶段
- 第一步:Node1 根据刚才合并后保存在优先级队列中的 from+size 条数据的 id 集合,发送请求到对应的 Shard 上查询 doc 数据详情
- 第二步:各 Shard 接收到查询请求后,查询到对应的数据详情并返回为 Node1。(Node1 中的优先级队列中保存了 from + size 条数据的_id,但是在 Fetch 阶段并不需要取回所有数据,只需要取回从 from 到 from + size 之间的 size 条数据详情即可,这 size 条数据可能在同一个 Shard 也可能在不同的 Shard)
- 第三步:Node1 获取到对应的分页数据后,返回给 客户端
在了解了ES的基本结构以及查询过程之后,结合一开始的例子,在Query阶段,每个Shard都会有大量的查询,返回给协调节点时,还涉及到大量数据的排序(发生在堆当中,堆内存中汇总的数据也就越多,对内存的压力也就越大),并且保存到优先级队列的数据量也很大,占用大量节点机器内存资源。
ES为了避免用户在不了解其内部原理的情况下而做出错误的操作,设置了一个阈值,即max_result_window,其默认值为10000,其作用是为了保护堆内存不被错误操作导致溢出。因此也就出现了文章一开始所演示的问题。
深度分页的场景解决方案
修改max_result_window
既然默认值为10000,那改大一点不就简单粗暴的解决了这个问题。但随之而来的会带来潜在的性能问题。不推荐。
尽量避免深度分页
在某个查询条件保持不变的情况下,真的有必要查看10000条之后的数据吗?例如真的有人会看10001条的订单记录吗?是不是通过改变查询条件,就能够解决?
像谷歌、百度等公司,其实都是这么做的,会限制分页范围,防止“跳页”。
对于电商的场景,例如淘宝、京东,虽然保留了“跳页”,但只允许查询“前100页”
虽然加上了限制,但是并没有影响到用户的实际体验。首先,如果是直接输入很大的页码,进行“跳页”的用户,这些用户本身就是恶意的行为,因此删除“跳页”并无不妥。其次,对于搜索引擎来说,用户其实只关心前几页的数据,即便他通过分页条跳了几页,但这种搜索并不涉及深度分页,即便它不停的点下去,也有其它方案解决此问题。像类似淘宝这种直接截断前100页数据的做法,看似暴力,其实是在不牺牲用户体验的前提下,极大的提升了搜索的性能,这也变相的为那些“正常用户”,提升了搜索体验。
所以,在产品、业务的角度,应该要避免深度分页。但很可惜的是,博主目前所任职的公司,并没有遵循这一点。
Scroll 分页
scroll 分页方式类似关系型数据库中的 cursor(游标),首次查询时会生成并缓存快照,返回给客户端快照读取的位置参数(scroll_id),后续每次请求都会通过 scroll_id 访问快照实现快速查询需要的数据,有效降低查询和存储的性能损耗。
scroll 分页方式在 Query 阶段同样也是由 协调节点 广播查询请求,获取、合并、排序其他 Shard 返回的数据_id 集合,不同的是 scroll 分页方式会将返回数据 _id 的集合生成快照保存到 协调节点。
Fetch 阶段以游标的方式从生成的快照中获取 size 条数据的_id,并去其他 Shard 获取数据详情返回给客户端,同时将下一次游标开始的位置标识_scroll_id 也返回。
这样下次客户端发送获取下一页请求时带上 scroll_id 标识,协调节点 会从 scroll_id 标记的位置获取接下来 size 条数据,同时再次返回新的游标位置标识 scroll_id,这样依次类推直到取完所有数据。
这么做的好处是减少了查询和排序的次数,避免性能损耗,但缺点就是只能实现上一页、下一页的翻页功能,不兼容通过页码查询数据的跳页。同时由于其在搜索初始化阶段会生成快照,后续数据的变化无法及时体现在查询结果,因此更加适合一次性批量查询或非实时数据的分页查询。
Search After 分页
Search After 分页方式是 ES 5 新增的一种分页查询方式,其实现的思路同 Scroll 分页方式基本一致,通过记录上一次分页的位置标识,来进行下一次分页数据的查询。相比于 Scroll 分页方式,它的优点是可以实时体现数据的变化,解决了查询快照导致的查询结果延迟问题。
Search After 方式也不支持跳页功能,每次查询一页数据。第一次每个 Shard 返回一页数据(size 条),协调节点 一共获取到 Shard 数 * size 条数据 , 接下来 协调节点 在内存中进行排序,取出前 size 条数据作为第一页搜索结果返回。
当拉取第二页时,不同于 Scroll 分页方式,Search After 方式会找到第一页数据被拉取的最大值(查询条件中需要带上排序),作为第二页数据拉取的查询条件。
这样每个 Shard 还是返回一页数据(size 条),协调节点 获取到 Shard 数 * size 条数据进行内存排序,取得前 size 条数据作为全局的第二页搜索结果。
参考:
空空如也!