当前位置: 首页 > 科技观察

ElasticSearch深度分页解决方案

时间:2023-03-12 07:44:59 科技观察

前言Elasticsearch是一个实时的分布式搜索和分析引擎。在使用过程中,有一些典型的使用场景,比如分页、遍历等。在关系型数据库的使用中,我们被告知要注意甚至明确禁止使用深度分页。同样,在Elasticsearch中,我们应该尽量避免使用深度分页。本文主要介绍Elasticsearch中分页相关的内容!from/Size参数在ES中,分页查询默认返回匹配的前10条。如果需要分页,则需要使用from和size参数。from参数定义了需要跳过的命中数,默认为0;size参数定义需要返回的最大命中数。一个基本的ES查询语句是这样的:POST/my_index/my_type/_search{"query":{"match_all":{}},"from":100,"size":10}上面的query表示从搜索结果从第100条开始取10条数据。“那么,这条查询语句在ES集群内部是如何执行的呢?”在ES中,搜索一般包括两个阶段,query和fetch阶段,这个很容易理解。查询阶段确定要获取哪些文档,获取阶段获取特定文档。医生。Query阶段如上图所示,它描述了一个搜索请求的查询阶段:Client发送一个搜索请求,node1接收到请求,然后node1创建一个大小为from+size的优先级队列来存储结果,我们管理的node1称为协调节点。协调节点将请求广播到涉及的分片,每个分片在内部执行搜索请求,然后将结果存储在内部优先级队列中,大小与from+size相同。优先级队列可以理解为一个列表,其中包含top的N个结果的列表。每个分片将自己优先级队列中暂存的数据返回给协调节点,协调节点在得到各个分片返回的结果后进行合并,生成一个全局优先级队列,并存储在自己的优先级队列中。在上面的例子中,协调节点得到(from+size)*6条数据,然后合并排序,选择最先的from+size条数据存入优先级队列,供fetch阶段使用。另外,每个分片返回给协调节点的数据用于从+size条数据中选择第一个,所以只需要返回唯一标识doc的_id和用于排序的_score,这样也可以保证返回的数据量足够小。协调节点计算出自己的优先级队列后,查询阶段结束,进入获取阶段。fetchphase的queryphase知道要取哪些数据,但不会取具体的数据,这就是fetchphase做的事情。上图为fetch流程:协调节点向相关分片发送GET请求。分片根据doc的_id获取数据详情,然后返回给协调节点。协调节点向客户端返回数据。协调节点的优先级队列中有from+size_doc_ids。然而,在获取阶段,没有必要检索所有数据。上面的例子中,前100条数据不需要取,只需要优先级队列中的第101条到第110条数据就可以了。需要取的数据可能在不同的分片中,也可能在同一个分片中。协调节点使用“multi-get”避免多次从同一个分片中获取数据,从而提高性能。“以这种方式请求深度分页是有问题的:”我们可以假设在具有5个主分片的索引中进行搜索。当我们请求第一页结果时(从1到10的结果),每个分片产生前10个结果返回给协调节点,协调节点对50个结果进行排序,得到所有结果中的前10个。现在假设我们请求第1000页——从10001到10010的结果。除了每个分片必须产生前10010个结果之外,所有的工作方式都相同。协调器节点然后对所有50050个结果进行排序,最后丢弃其中的50040个结果。“排序结果的成本随着分页的深度呈指数增长。""注1:"size的大小不能超过index.max_result_window参数设置,默认为10000,如果搜索size大于10000,需要设置index.max_result_window参数PUT_settings{"index":{"max_result_window":"10000000"}}"注2:"_doc会在以后的版本中去掉,参见:https://www.elastic.co/cn/blog/moving-from-types-to-typeless-apis-in-elasticsearch-7-0https://elasticsearch.cn/article/158深度分页问题Elasticsearch的From/Size方法提供了分页特性,同时也有其局限性,比如一个索引有10亿条数据,分成10个分片,然后一个搜索请求,from=1000000,size=100,这个时候,会带来严重的性能问题:CPU,内存,IO,网络带宽。在查询阶段,每个分片需要返回1,000,100个数据发送给协调节点,协调节点需要接收10*1000,100条数据。即使每条数据只有_doc_id和_score,数据量太大了?」另一方面,我们意识到这种深度分页请求是不合理的,因为我们很少人为地看后面的请求。在很多业务场景中,分页是直接被限制的,比如我们只能看前面100页。”比如一个1000万粉丝的微信大V,要给所有粉丝发群信息,或者给某个省份的粉丝发群信息,那么他需要获取所有符合条件的粉丝,最简单的思考方式ofit是使用from+size,但这是不现实的。这时候可以使用Elasticsearch提供的其他方法来实现遍历。深度分页问题大致可以分为两类:《随机深度分页:随机跳页》《滚动深度分页:一次只能查询一页》《下面介绍几种官方的深度分页方式》ScrollScroll遍历数据我们可以将滚动理解为关系数据库中的游标。所以scroll不适合实时搜索,更适合后台批量处理任务,比如群发。这种分页的用途“不是为了实时查询数据”,而是为了“一次性查询大量数据(甚至所有数据”)。因为这个滚动相当于维护了一个当前索引段的快照信息,这个快照信息就是你执行这个滚动查询时的快照。在这个查询之后任何新索引的数据都不会在这个快照中被查询。但是相对于from和size,它不是查询所有的数据,然后剔除不需要的部分,而是记录一个读取的位置,保证下次继续快速读取。不考虑排序时,可与SearchType.SCAN结合使用。滚动可以分为初始化和遍历。初始化时,“缓存所有满足搜索条件的搜索结果(注意这里只是缓存了doc_id,并不是所有的文档数据都真正缓存了,数据在fetch阶段就已经完成了)”,可以这么认为作为快照。遍历时,从这个快照中取数据,即初始化后,向索引插入、删除、更新数据不会影响遍历结果。《基本使用》POST/twitter/tweet/_search?scroll=1m{"size":100,"query":{"match":{"title":"elasticsearch"}}}初始化并指定索引和类型,然后,加入参数scroll表示暂存搜索结果的时间,其他就跟普通的搜索请求一样。会返回一个_scroll_id,_scroll_id用于下次取数据。"Traverse"POST/_search?scroll=1m{"scroll_id":"XXXXXXXXXXXXXXXXXXXXXXIamscrollidXXXXXXXXXXXXXXXX"}这里的scroll_id是上次遍历得到的_scroll_id或者初始化返回的_scroll_id。同样,需要包含scroll参数。重复此步骤,直到返回数据为空,即遍历完成。“注意每次都要传参数scroll,用于刷新搜索结果的缓存时间。”此外,“无需指定索引和类型”。设置滚动时,需要缓存搜索结果,直到下一次遍历完成。“同时,也不宜太长,毕竟空间有限。”【优缺点】缺点:“scroll_id会占用大量资源(尤其是排序请求)”同样,scroll也是有超时时间的,频繁的scroll请求会导致一系列的问题。“它是生成的历史快照,数据的变化不会反映在快照上。”》优点:》适用于大数据量的非实时处理,如数据迁移或索引变更等。ScrollScanES提供了滚动扫描的方式来进一步提升遍历性能,但是滚动扫描不支持排序,所以滚动扫描适用于不需要排序的场景。【基本使用】ScrollScan的遍历与普通Scroll的遍历是一样的,在初始化上略有不同。POST/my_index/my_type/_search?search_type=scan&scroll=1m&size=50{"query":{"match_all":{}}}需要指定参数:search_type:赋值扫描,即使用ScrollScan来遍历并同时告诉Elasticsearch搜索结果不需要排序。scroll:同上,上传时间。size:不同于普通size,这个size表示每个分片返回的size个数,最终结果最多为number_of_shards*size。《ScrollScan与Scroll的区别》Scroll-Scan的结果是“未排序”的,不排序,按索引顺序返回,可以提高数据检索性能。初始化时只返回_scroll_id,没有具体命中。结果大小控制的是每个片段返回的数据量,而不是整个请求返回的数据量。SlicedScroll如果你的数据量很大,用Scroll来遍历数据确实是不能接受的。现在可以使用Scroll接口并发进行数据遍历。每个Scroll请求可以分成多个Slice请求,可以理解为切片。每个Slice独立并行,比使用Scroll遍历快很多倍。POST/index/type/_search?scroll=1m{"query":{"match_all":{}},"slice":{"id":0,"max":5}}POSTip:port/index/type/_search?scroll=1m{"query":{"match_all":{}},"slice":{"id":1,"max":5}}上面的例子可以分别请求两条数据,并且最后五个块数据合并的结果和直接滚动扫描一样。其中,max为块数,id为块数。官方文档建议max的值不要超过分片个数,否则可能会导致内存爆炸。SearchAfterSearch_after是ES5引入的一种新的分页查询机制,它的原理和scroll差不多,所以代码也差不多。"基本使用:"第一步:POSTtwitter/_search{"size":10,"query":{"match":{"title":"es"}},"sort":[{"date":"asc"},{"_id":"desc"}]}返回结果信息:{"took":29,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":5,"relation":"eq"},"max_score":null,"hits":[{...},"sort":[...]},{...},"sort":[124648691,"624812"]}]}}上面的请求会返回一个列表,其中包含对每个文档进行排序的排序数组值。这些排序值可以在search_after参数中使用,以获取下一页数据。例如,我们可以使用最后一个文档的排序值并将其传递给search_after参数:GETtwitter/_search{"size":10,"query":{"match":{"title":"es"}},"search_after":[124648691,"624812"],"sort":[{"date":"asc"},{"_id":"desc"}]}如果我们要按照上次读取的结果为了读取下一页数据,第二个查询在第一个查询语句的基础上增加了search_after,并指明从哪条数据之后开始读取。《基本原理》es维护了一个实时游标,以上一次查询的最后一条记录作为游标,方便下一页的查询。是无状态查询,所以每次查询都是最新的数据。由于它使用记录作为游标,因此“SearchAfter至少需要一个全局唯一变量在doc(每个文档具有唯一值的字段应该用作排序规范)”“优点和缺点”“优点:”无状态查询可以防止在查询过程中,数据的变化不能及时反映到查询中。不需要维护scroll_id,不需要维护snapshots,可以避免大量资源消耗。【缺点:】由于查询是无状态的,查询过程中的变化可能会导致跨页面的值不一致。排序顺序可能会在执行期间发生变化,具体取决于索引更新和删除。至少需要指定一个唯一的非重复字段进行排序。不适合大规模的页面跳转查询,或者全量导出。跳转到N页查询相当于在ones之后重复执行N次搜索,而fullexport则是在短时间内执行大量的重复查询。SEARCH_AFTER不是自由跳转到任意页面的解决方案,而是并行滚动多个查询的解决方案。总结分页方式的性能优缺点。+尺寸的场景很低。灵活性好。简单的深度分页问题比较小。深度分页的问题是可以容忍的。scroll解决了深分页的问题,需要维护一个scroll_id。海量数据的导出需要查询海量结果集的数据。search_after没有深度分页问题的高性能是最好的。它可以反映数据的实时变化。实现复杂,需要全局唯一字段。连续分页的实现会比较复杂。因为每次查询都需要上次查询的结果,所以不适合分页ES7版本变化大幅跳页查询海量数据:https://www.elastic.co/guide/en/elasticsearch/reference/master/paginate-search-results.html#scroll-search-results在7.*版本中,ES官方不再推荐使用Scroll方式进行深度分页,而是推荐使用search_after配合PIT进行查询;从7.*版本开始,您可以使用SEARCH_AFTER参数通过上一页的一组排序值检索下一页命中。使用SEARCH_AFTER需要具有相同查询和排序值的多个搜索请求。如果在这些请求之间发生刷新,则结果的顺序可能会发生变化,从而导致页面之间的结果不一致。为防止这种情况,您可以创建一个时间点(PIT)以在搜索期间保留当前索引状态。POST/my-index-000001/_pit?keep_alive=1m返回一个PITID:{"id":"46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA=="}在搜索请求中指定PIT:GET/_search{"size":10000,"query":{"match":{"user.id":"elkbee"}},"pit":{"id":"46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA==","keep_alive":"1m"},"sort":[{"@timestamp":{"order":"asc","format":"strict_date_optional_time_nanos","numeric_type":"date_nanos"}}]}性能比较:1-10、49000-49010、99000-99010,各10个数据(前提10w条),性能大致是这样的:pageforwardforpictures对于pageforward,ES中没有对应的API,但是按照官方的说法(https://github.com/elastic/elasticsearch/issues/29449),ES中的正向翻页问题可以通过翻转排序的方式来实现,即:对于某个页面,正序search_after该页面的最后一个数据id为下一页,然后倒序search_after该页的第一个数据id就是上一页。国内论坛有人用缓存解决上一页的问题:https://elasticsearch.cn/question/7711图片总结如果数据量小(from+size10000以内),还是只关注结果集的TopN数据,可以使用from/size分页,简单粗暴的数据量,深度翻页,后台批处理任务(数据迁移)等任务,使用scroll方式数据量大,深度翻页用户的转,实时和高并发查询需求,使用searchafter方式个人认为Scroll和search_after的原理基本相同,都是使用游标方式进行深度分页。虽然这种方法可以在一定程度上解决深度分页的问题。然而,它们并不是深度分页问题的最终解决方案,“必须避免!!”。对于Scroll来说,维护scroll_id和历史快照是不可避免的,还必须保证scroll_id的存活时间,这对服务器来说是一个巨大的负载。对于Search_After,如果允许用户大幅度跳转页面,会导致短时间内频繁的搜索动作,效率很低,而且会增加服务器的负载。数据不一致或排序改变,导致结果不准确。Search_After本身就是一种业务折中方案,不允许指定跳转页面,只提供下一页的功能。scroll默认你会在后面fetch所有符合条件的数据,所以它只是搜索所有符合条件的doc_id(这也是为什么官方推荐使用doc_id进行排序的原因,因为doc_id本身是有缓存的,如果使用其他字段排序会增加查询),并将它们排序并保存在坐标节点(coordinatenode)中,但是不是每次scroll读取所有数据,而是每次滚动读取size个文档,并返回最后读取的一个文档和上下文状态,用于告知哪个文档是哪个下次需要读取碎片。这也是为什么官方不推荐scroll用于用户实时分页查询,而是适合大批量拉取数据的原因,因为它不是为实时读取数据而设计的。