问题背景
- 这是一条线上的方法性能监控报警信息,收到报警第一时间查看报警接口的线上监控;
- 看监控发现接口几乎不可用了,继续查看接口内部方法的监控,包括 HBASE、ES 等相关内部调用方法的监控,定位造成接口性能异常的具体方法;
- 经排查发现除 ES 方法性能异常之外,其他方法的监控性能都正常,继续查看 ES 监控;
- ES 监控(QPS、CPU、内存等指数异常升高几倍);
- 初步定位是 ES 出现问题,ES 相关方法代码主要是在做分页检索,猜测存在深分页问题,继续排查日志;
- 日志情况(被刷到 1000 多页),通过日志最终定位到异常原因是有人在不停的发起翻页请求,但是接口未做保护限制,导致线上接口性能超时严重。
ES 深分页解决方案梳理
一、根据业务情况限制分页
限制翻页数(page)为 100 页,后续不再提供检索
,这是业内最常用的方法,简单有效,之所以这么做,一方面是因为通常认为 100 页以后的检索内容对于检索者的参考意义并不大,另一方面考虑到除了恶意请求外,应该不会有翻到 100 页没有找到检索内容还依然要检索的;
二、通过 ES 自身方案解决
既然第一种方式简单有效就可以解决线上问题,那为什么还要深究 ES 自身的一些技术方案呢?作为一个技术人,对于技术,还是要做到 知其然并知其所以然
,对于一些技术方案,你可以不用,但你不能不懂
。
关于 ES 查询的三种方式如下:
1、from + size 方式
- 一个最基本的 ES 查询语句是这样的:
1 | bash复制代码POST /my_index/my_type/_search |
- 上面的查询表示从搜索结果中取第 100 条开始的 10 条数据,那么,这个查询语句在 ES 集群内部是怎么执行的呢;
- 在 ES 中,搜索一般包括两个阶段,
query
和fetch
阶段,可以简单的理解,query 阶段确定要取哪些 doc(ids),fetch 阶段取出具体的 doc; - Query 阶段:
+ 
+ 如上图所示,描述了一次搜索请求的 query 阶段;
+ Client 发送一次搜索请求,node1 接收到请求,然后,node1 创建一个大小为 from + size 的优先级队列用来存结果,我们管 node1 叫 **`coordinating node`**;
+ coordinating node 将请求广播到涉及到的 shards,每个 shard 在内部执行搜索请求,然后,将结果存到内部的大小同样为 from + size 的优先级队列里,可以把优先级队列理解为一个包含 top N 结果的列表;
+ 每个 shard 把暂存在自身优先级队列里的数据返回给 coordinating node,coordinating node 拿到各个 shards 返回的结果后对结果进行一次合并,产生一个全局的优先级队列,存到自身的优先级队列里;
+ 在上面的例子中,coordinating node 拿到 (from + size) \* 6 条数据,然后合并并排序后选择前面的 from + size 条数据存到优先级队列,以便 fetch 阶段使用。另外,各个分片返回给 coordinating node 的数据用于选出前 from + size 条数据,所以,只需要返回 **`唯一标记 doc 的 _id`** 以及 **`用于排序的 _score`** 即可,这样也可以保证返回的数据量足够小;
+ coordinating node 计算好自己的优先级队列后,query 阶段结束,进入 fetch 阶段。
- Fetch 阶段:
+ 
+ 上图展示了 fetch 过程;
+ coordinating node 发送 GET 请求到相关 shards;
+ shard 根据 doc 的 \_id 取到数据详情,然后返回给 coordinating node;
+ coordinating node 返回数据给 Client;
+ coordinating node 的优先级队列里有 from + size 个 \_doc \_id,但是,在 fetch 阶段,**`并不需要取回所有数据`**,在上面的例子中,前100条数据是不需要取的,只需要取优先级队列里的第101到110条数据即可;
+ 需要取的数据可能在不同分片,也可能在同一分片,coordinating node 使用 **`multi-get`** 来避免多次去同一分片取数据,从而提高性能。
- ES 的这种方式提供了分页的功能,同时,也有相应的限制。
+ 举个例子,一个索引,有 1 亿数据,分 10 个 shards,然后,一个搜索请求,from=1,000,000,size=100,这时候,会带来严重的性能问题;
+ 在 query 阶段,每个 shard 需要返回 1,000,100 条数据给 coordinating node,而 coordinating node 需要接收 10 \* 1,000,100 条数据,即使每条数据只有 \_id 和 \_score,这数据量也很大了,而且,这才一个查询请求而已;
+ 在另一方面,这种深分页的请求并不合理,因为我们是很少人为的看很后面的请求的,在很多的业务场景中,都直接限制分页,比如只能看前100页;
+ 不过,这种深度分页确实存在,比如,被爬虫了,这个时候,直接干掉深度分页就好;又或者,业务上有遍历数据的需要,这时候就需要取得所有符合条件的数据,而最容易想到的就是利用 from + size 来实现,不过,这个是不现实的,这时,可以采用 ES 提供的 **`scroll`** 方式来实现遍历。
2、scroll 方式
- 官网描述:
- scroll 查询可以用来对 Elasticsearch
有效地执行大批量的文档查询
,而又不用付出深度分页那种代价; - 游标查询允许我们
先做查询初始化
,然后再批量地拉取结果。这有点儿像传统数据库中的cursor
; - 游标查询会
取某个时间点的快照数据
。查询初始化之后索引上的任何变化会被它忽略。它通过保存旧的数据文件来实现这个特性,结果就像保留初始化时的索引视图一样; - 深度分页的代价根源是
结果集全局排序
,如果去掉全局排序的特性的话查询结果的成本就会很低。游标查询用字段 _doc 来排序。这个指令让 Elasticsearch 仅仅从还有结果的分片返回下一批结果。
- scroll 查询可以用来对 Elasticsearch
- 怎么理解官网的描述呢?
- Scroll 是
为检索大量的结果而设计
的。例如,我们需要查询 1~100 页的数据,每页 100 条数据; - 如果使用 from + size(Search)查询:每次都需要在每个分片上查询得分最高的 from + 100 条数据,然后协同节点把收集到的 n × (from + 100) 条数据聚合起来再进行一次排序(Query 阶段);
- 每次返回 from + 1 开始的 100 条数据(Fetch 阶段),并且要重复执行 100 次 Search 查询过程(Query + Fetch),才能获取全部 100 页数据;
- Scroll 查询:
- 如果使用 Scroll 查询,只需要在各个分片上查询 10000 条数据,协同节点聚合 n × 10000 条数据进行合并、排序(Query 阶段),并取出排名前 10000 的结果(Fetch 阶段)快照起来,后续滚动查询时,只需根据设置的 size,直接
在生成的快照里面以游标的形式
,取回这个 size 数量的文档即可。这样做的好处是减少了查询和排序的次数
。
- 如果使用 Scroll 查询,只需要在各个分片上查询 10000 条数据,协同节点聚合 n × 10000 条数据进行合并、排序(Query 阶段),并取出排名前 10000 的结果(Fetch 阶段)快照起来,后续滚动查询时,只需根据设置的 size,直接
- Scroll 是
3、search_after 方式
- search_after 是 ES 5 新引入的一种分页查询机制,其实
原理几乎就是和 scroll 一样
,简单总结如下:
+ 必须先要 **`指定排序`**;
+ 必须 **`从第一页开始`**;
+ 从第一页开始以后每次都带上 **`search_after=lastEmittedDocFieldValue`** 从而为无状态实现一个状态,其实就是把每次固定的 from + size 偏移变成一个确定值 lastEmittedDocFieldValue,而查询则从这个偏移量开始获取 size 个 \_doc(每个 shard 获取 size 个,coordinate node 最后汇总 shards \* size 个)。
- 最后一点非常重要,也就是说,无论去到多少页,coordinate node 向其它 node 发送的请求始终就是请求 size 个 docs,是个
常量
,而不再是 from + size 那样,越往后,你要请求的 docs 就越多,而要丢弃的垃圾结果也就越多; - 也就是说,如果要做非常多页的查询时,最起码 search_after 是一个
常量查询延迟和开销
; - 有人就会问,为啥每次提供一个 search_after 值就可以找到确定的那一页的内容呢,Elasticsearch 不是分布式的么,每个 shard 只维护一部分的离散的文档,那 Elasticsearch 是怎么做的呢?
- search_after 的实现原理:
+ 第一次只能够查第一页,每个 shard 返回了一页数据;
+ 服务层得到 2 页数据,内存排序,取出前 100 条数据,作为最终的第一页数据,这个全局的第一页数据,一般来说 **`每个 shard 都包含一部分数据`**(比如 shard1 包含了 20 条,shard2 包含了 80 条);
+ 这个方案也需要服务器内存排序,岂不是和 scroll 一样么?第一页数据的拉取确实一样,但每一次“下一页”拉取的方案就不一样了;
+ 点击“下一页”时,需要拉取第二页数据,在第一页数据的基础之上,能够 **`找到第一页数据被拉取的最大值`**,这个上一页记录的 max,会作为第二页数据拉取的查询条件;
+ 这样就可以不需要像 scroll 那样返回 2 页数据了,每个 shard 还是返回一页数据,只不过是 **`从 max 开始的一页长度的数据`**;
+ 服务层得到 2 页数据,内存排序,取出前 100 条数据,作为最终的第 2 页数据,这个全局的第 2 页数据,一般来说也是每个 shard 都包含一部分数据;
+ 如此往复,查询第 100 页数据时,不是返回 100 页数据,而是仍返回一页数据,以保证数据的传输量和排序的数据量 **`不会随着不断翻页而导致性能下降`**。
- search_after 实验结果(随机部分日志截取):
+ 
本文转载自: 掘金