问题背景
- 这是一条线上的方法性能监控报警信息,收到报警第一时间查看报警接口的线上监控;
- 看监控发现接口几乎不可用了,继续查看接口内部方法的监控,包括 HBASE、ES 等相关内部调用方法的监控,定位造成接口性能异常的具体方法;
- 经排查发现除 ES 方法性能异常之外,其他方法的监控性能都正常,继续查看 ES 监控;
- ES 监控(QPS、CPU、内存等指数异常升高几倍);
- 初步定位是 ES 出现问题,ES 相关方法代码主要是在做分页检索,猜测存在深分页问题,继续排查日志;
- 日志情况(被刷到 1000 多页),通过日志最终定位到异常原因是有人在不停的发起翻页请求,但是接口未做保护限制,导致线上接口性能超时严重。
ES 深分页解决方案梳理
一、根据业务情况限制分页
限制翻页数(page)为 100 页,后续不再提供检索
,这是业内最常用的方法,简单有效,之所以这么做,一方面是因为通常认为 100 页以后的检索内容对于检索者的参考意义并不大,另一方面考虑到除了恶意请求外,应该不会有翻到 100 页没有找到检索内容还依然要检索的;
二、通过 ES 自身方案解决
既然第一种方式简单有效就可以解决线上问题,那为什么还要深究 ES 自身的一些技术方案呢?作为一个技术人,对于技术,还是要做到 知其然并知其所以然
,对于一些技术方案,你可以不用,但你不能不懂
。
关于 ES 查询的三种方式如下:
1、from + size 方式
- 一个最基本的 ES 查询语句是这样的:
POST /my_index/my_type/_search
{
"query": { "match_all": {}},
"from": 100,
"size": 10
}
复制代码
-
上面的查询表示从搜索结果中取第 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 是
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 实验结果(随机部分日志截取):
近期评论