ES深分页问题解决方案问题背景ES深分页解决方案梳理

问题背景

image.png

  • 这是一条线上的方法性能监控报警信息,收到报警第一时间查看报警接口的线上监控;

image.png

  • 看监控发现接口几乎不可用了,继续查看接口内部方法的监控,包括 HBASE、ES 等相关内部调用方法的监控,定位造成接口性能异常的具体方法;
  • 经排查发现除 ES 方法性能异常之外,其他方法的监控性能都正常,继续查看 ES 监控;

image.png

  • 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 中,搜索一般包括两个阶段,queryfetch 阶段,可以简单的理解,query 阶段确定要取哪些 doc(ids),fetch 阶段取出具体的 doc;

  • Query 阶段:

    • image.png
    • 如上图所示,描述了一次搜索请求的 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 阶段:

    • image.png
    • 上图展示了 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 是 为检索大量的结果而设计 的。例如,我们需要查询 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 数量的文档即可。这样做的好处是 减少了查询和排序的次数

      • image.png

      • image.png

      • image.png

      • image.png

3、search_after 方式

image.png

  • 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 实验结果(随机部分日志截取):

    • image.png