learn-tech/专栏/ElasticSearch知识体系详解/16原理:ES原理之读取文档流程详解.md
2024-10-16 00:01:16 +08:00

20 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        16 原理ES原理之读取文档流程详解
                        文档查询步骤顺序

先看下整体的查询流程

单个文档

以下是从主分片或者副本分片检索文档的步骤顺序:

客户端向 Node 1 发送获取请求。 节点使用文档的 _id 来确定文档属于分片 0 。分片 0 的副本分片存在于所有的三个节点上。 在这种情况下,它将请求转发到 Node 2 。 Node 2 将文档返回给 Node 1 ,然后将文档返回给客户端。

在处理读取请求时,协调结点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。

在文档被检索时,已经被索引的文档可能已经存在于主分片上但是还没有复制到副本分片。 在这种情况下,副本分片可能会报告文档不存在,但是主分片可能成功返回文档。 一旦索引请求成功返回给用户,文档在主分片和副本分片都是可用的。

多个文档

使用 mget 取回多个文档的步骤顺序:

以下是使用单个 mget 请求取回多个文档所需的步骤顺序:

客户端向 Node 1 发送 mget 请求。 Node 1 为每个分片构建多文档获取请求,然后并行转发这些请求到托管在每个所需的主分片或者副本分片的节点上。一旦收到所有答复, Node 1 构建响应并将其返回给客户端。

文档读取过程详解

所有的搜索系统一般都是两阶段查询第一阶段查询到匹配的DocID第二阶段再查询DocID对应的完整文档这种在Elasticsearch中称为query_then_fetch。这里主要介绍最常用的2阶段查询其它方式可以参考这里 )。

在初始查询阶段时,查询会广播到索引中每一个分片拷贝(主分片或者副本分片)。 每个分片在本地执行搜索并构建一个匹配文档的大小为 from + size 的优先队列。PS在2. 搜索的时候是会查询Filesystem Cache的但是有部分数据还在Memory Buffer所以搜索是近实时的。 每个分片返回各自优先队列中 所有文档的 ID 和排序值 给协调节点,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。 接下来就是 取回阶段,协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。每个分片加载并丰富文档,如果有需要的话,接着返回文档给协调节点。一旦所有的文档都被取回了,协调节点返回结果给客户端。

深入ElasticSearch读取文档的实现机制

TIP

作为选读内容。

读操作

一致性指的是写入成功后下次读操作一定要能读取到最新的数据。对于搜索这个要求会低一些可以有一些延迟。但是对于NoSQL数据库则一般要求最好是强一致性的。

结果匹配上NoSQL作为数据库查询过程中只有符合不符合两种情况而搜索里面还有是否相关类似于NoSQL的结果只能是0或1而搜索里面可能会有0.10.50.9等部分匹配或者更相关的情况。

结果召回上搜索一般只需要召回最满足条件的Top N结果即可而NoSQL一般都需要返回满足条件的所有结果。

搜索系统一般都是两阶段查询第一个阶段查询到对应的Doc ID也就是PK第二阶段再通过Doc ID去查询完整文档而NoSQL数据库一般是一阶段就返回结果。在Elasticsearch中两种都支持。

目前NoSQL的查询聚合、分析和统计等功能上都是要比搜索弱的。

Lucene的读

Elasticsearch使用了Lucene作为搜索引擎库通过Lucene完成特定字段的搜索等功能在Lucene中这个功能是通过IndexSearcher的下列接口实现的

public TopDocs search(Query query, int n); public Document doc(int docID); public int count(Query query); ......(其他)

第一个search接口实现搜索功能返回最满足Query的N个结果第二个doc接口通过doc id查询Doc内容第三个count接口通过Query获取到命中数。

这三个功能是搜索中的最基本的三个功能点对于大部分Elasticsearch中的查询都是比较复杂的直接用这个接口是无法满足需求的比如分布式问题。这些问题都留给了Elasticsearch解决我们接下来看Elasticsearch中相关读功能的剖析。

Elasticsearch的读

Elasticsearch中每个Shard都会有多个Replica主要是为了保证数据可靠性除此之外还可以增加读能力因为写的时候虽然要写大部分Replica Shard但是查询的时候只需要查询Primary和Replica中的任何一个就可以了。

在上图中该Shard有1个Primary和2个Replica Node当查询的时候从三个节点中根据Request中的preference参数选择一个节点查询。preference可以设置_local_primary_replica以及其他选项。如果选择了primary则每次查询都是直接查询Primary可以保证每次查询都是最新的。如果设置了其他参数那么可能会查询到R1或者R2这时候就有可能查询不到最新的数据。

PS: 上述代码逻辑在OperationRouting.Java的searchShards方法中。

接下来看一下Elasticsearch中的查询是如何支持分布式的。

Elasticsearch中通过分区实现分布式数据写入的时候根据_routing规则将数据写入某一个Shard中这样就能将海量数据分布在多个Shard以及多台机器上已达到分布式的目标。这样就导致了查询的时候潜在数据会在当前index的所有的Shard中所以Elasticsearch查询的时候需要查询所有Shard同一个Shard的Primary和Replica选择一个即可查询请求会分发给所有Shard每个Shard中都是一个独立的查询引擎比如需要返回Top 10的结果那么每个Shard都会查询并且返回Top 10的结果然后在Client Node里面会接收所有Shard的结果然后通过优先级队列二次排序选择出Top 10的结果返回给用户。

这里有一个问题就是请求膨胀用户的一个搜索请求在Elasticsearch内部会变成Shard个请求这里有个优化点虽然是Shard个请求但是这个Shard个数不一定要是当前Index中的Shard个数只要是当前查询相关的Shard即可这个需要基于业务和请求内容优化通过这种方式可以优化请求膨胀数。

Elasticsearch中的查询主要分为两类Get请求通过ID查询特定DocSearch请求通过Query查询匹配Doc。

PS:上图中内存中的Segment是指刚Refresh Segment但是还没持久化到磁盘的新Segment而非从磁盘加载到内存中的Segment。

对于Search类请求查询的时候是一起查询内存和磁盘上的Segment最后将结果合并后返回。这种查询是近实时Near Real Time主要是由于内存中的Index数据需要一段时间后才会刷新为Segment。

对于Get类请求查询的时候是先查询内存中的TransLog如果找到就立即返回如果没找到再查询磁盘上的TransLog如果还没有则再去查询磁盘上的Segment。这种查询是实时Real Time的。这种查询顺序可以保证查询到的Doc是最新版本的Doc这个功能也是为了保证NoSQL场景下的实时性要求。

所有的搜索系统一般都是两阶段查询第一阶段查询到匹配的DocID第二阶段再查询DocID对应的完整文档这种在Elasticsearch中称为query_then_fetch还有一种是一阶段查询的时候就返回完整Doc在Elasticsearch中称作query_and_fetch一般第二种适用于只需要查询一个Shard的请求。

除了一阶段两阶段外还有一种三阶段查询的情况。搜索里面有一种算分逻辑是根据TFTerm Frequency和DFDocument Frequency计算基础分但是Elasticsearch中查询的时候是在每个Shard中独立查询的每个Shard中的TF和DF也是独立的虽然在写入的时候通过_routing保证Doc分布均匀但是没法保证TF和DF均匀那么就有会导致局部的TF和DF不准的情况出现这个时候基于TF、DF的算分就不准。为了解决这个问题Elasticsearch中引入了DFS查询比如DFS_query_then_fetch会先收集所有Shard中的TF和DF值然后将这些值带入请求中再次执行query_then_fetch这样算分的时候TF和DF就是准确的类似的有DFS_query_and_fetch。这种查询的优势是算分更加精准但是效率会变差。另一种选择是用BM25代替TF/DF模型。

在新版本Elasticsearch中用户没法指定DFS_query_and_fetch和query_and_fetch这两种只能被Elasticsearch系统改写。

Elasticsearch查询流程

Elasticsearch中的大部分查询以及核心功能都是Search类型查询上面我们了解到查询分为一阶段二阶段和三阶段这里我们就以最常见的的二阶段查询为例来介绍查询流程。

Client Node

Client Node 也包括了前面说过的Parse Request这里就不再赘述了接下来看一下其他的部分。

Get Remove Cluster Shard

判断是否需要跨集群访问如果需要则获取到要访问的Shard列表。

Get Search Shard Iterator

获取当前Cluster中要访问的Shard和上一步中的Remove Cluster Shard合并构建出最终要访问的完整Shard列表。

这一步中会根据Request请求中的参数从Primary Node和多个Replica Node中选择出一个要访问的Shard。

For Every Shard:Perform

遍历每个Shard对每个Shard执行后面逻辑。

Send Request To Query Shard

将查询阶段请求发送给相应的Shard。

Merge Docs

上一步将请求发送给多个Shard后这一步就是异步等待返回结果然后对结果合并。这里的合并策略是维护一个Top N大小的优先级队列每当收到一个shard的返回就把结果放入优先级队列做一次排序直到所有的Shard都返回。

翻页逻辑也是在这里如果需要取Top 30~ Top 40的结果这个的意思是所有Shard查询结果中的第30到40的结果那么在每个Shard中无法确定最终的结果每个Shard需要返回Top 40的结果给Client Node然后Client Node中在merge docs的时候计算出Top 40的结果最后再去除掉Top 30剩余的10个结果就是需要的Top 30~ Top 40的结果。

上述翻页逻辑有一个明显的缺点就是每次Shard返回的数据中包括了已经翻过的历史结果如果翻页很深则在这里需要排序的Docs会很多比如Shard有1000取第9990到10000的结果那么这次查询Shard总共需要返回1000 * 10000也就是一千万Doc这种情况很容易导致OOM。

另一种翻页方式是使用search_after这种方式会更轻量级如果每次只需要返回10条结构则每个Shard只需要返回search_after之后的10个结果即可返回的总数据量只是和Shard个数以及本次需要的个数有关和历史已读取的个数无关。这种方式更安全一些推荐使用这种。

如果有aggregate也会在这里做聚合但是不同的aggregate类型的merge策略不一样具体的可以在后面的aggregate文章中再介绍。

Send Request To Fetch Shard

选出Top N个Doc ID后发送给这些Doc ID所在的Shard执行Fetch Phase最后会返回Top N的Doc的内容。

Query Phase

接下来我们看第一阶段查询的步骤:

Create Search Context

创建Search Context之后Search过程中的所有中间状态都会存在Context中这些状态总共有50多个具体可以查看DefaultSearchContext或者其他SearchContext的子类。

Parse Query

解析Query的Source将结果存入Search Context。这里会根据请求中Query类型的不同创建不同的Query对象比如TermQuery、FuzzyQuery等最终真正执行TermQuery、FuzzyQuery等语义的地方是在Lucene中。

这里包括了dfsPhase、queryPhase和fetchPhase三个阶段的preProcess部分只有queryPhase的preProcess中有执行逻辑其他两个都是空逻辑执行完preProcess后所有需要的参数都会设置完成。

由于Elasticsearch中有些请求之间是相互关联的并非独立的比如scroll请求所以这里同时会设置Context的生命周期。

同时会设置lowLevelCancellation是否打开这个参数是集群级别配置同时也能动态开关打开后会在后面执行时做更多的检测检测是否需要停止后续逻辑直接返回。

Get From Cache

判断请求是否允许被Cache如果允许则检查Cache中是否已经有结果如果有则直接读取Cache如果没有则继续执行后续步骤执行完后再将结果加入Cache。

Add Collectors

Collector主要目标是收集查询结果实现排序对自定义结果集过滤和收集等。这一步会增加多个Collectors多个Collector组成一个List。

FilteredCollector先判断请求中是否有Post FilterPost Filter用于SearchAgg等结束后再次对结果做Filter希望Filter不影响Agg结果。如果有Post Filter则创建一个FilteredCollector加入Collector List中。 PluginInMultiCollector判断请求中是否制定了自定义的一些Collector如果有则创建后加入Collector List。 MinimumScoreCollector判断请求中是否制定了最小分数阈值如果指定了则创建MinimumScoreCollector加入Collector List中在后续收集结果时会过滤掉得分小于最小分数的Doc。 EarlyTerminatingCollector判断请求中是否提前结束Doc的Seek如果是则创建EarlyTerminatingCollector加入Collector List中。在后续Seek和收集Doc的过程中当Seek的Doc数达到Early Terminating后会停止Seek后续倒排链。 CancellableCollector判断当前操作是否可以被中断结束比如是否已经超时等如果是会抛出一个TaskCancelledException异常。该功能一般用来提前结束较长的查询请求可以用来保护系统。 EarlyTerminatingSortingCollector如果Index是排序的那么可以提前结束对倒排链的Seek相当于在一个排序递减链表上返回最大的N个值只需要直接返回前N个值就可以了。这个Collector会加到Collector List的头部。EarlyTerminatingSorting和EarlyTerminating的区别是EarlyTerminatingSorting是一种对结果无损伤的优化而EarlyTerminating是有损的人为掐断执行的优化。 TopDocsCollector这个是最核心的Top N结果选择器会加入到Collector List的头部。TopScoreDocCollector和TopFieldCollector都是TopDocsCollector的子类TopScoreDocCollector会按照固定的方式算分排序会按照分数+doc id的方式排列如果多个doc的分数一样先选择doc id小的文档。而TopFieldCollector则是根据用户指定的Field的值排序。

lucene::search

这一步会调用Lucene中IndexSearch的search接口执行真正的搜索逻辑。每个Shard中会有多个Segment每个Segment对应一个LeafReaderContext这里会遍历每个Segment到每个Segment中去Search结果然后计算分数。

搜索里面一般有两阶段算分第一阶段是在这里算的会对每个Seek到的Doc都计算分数为了减少CPU消耗一般是算一个基本分数。这一阶段完成后会有个排序。然后在第二阶段再对Top 的结果做一次二阶段算分,在二阶段算分的时候会考虑更多的因子。二阶段算分在后续操作中。

具体请求比如TermQuery、WildcardQuery的查询逻辑都在Lucene中后面会有专门文章介绍。

rescore

根据Request中是否包含rescore配置决定是否进行二阶段排序如果有则执行二阶段算分逻辑会考虑更多的算分因子。二阶段算分也是一种计算机中常见的多层设计是一种资源消耗和效率的折中。

Elasticsearch中支持配置多个Rescore这些rescore逻辑会顺序遍历执行。每个rescore内部会先按照请求参数window选择出Top window的doc然后对这些doc排序排完后再合并回原有的Top 结果顺序中。

suggest::execute()

如果有推荐请求,则在这里执行推荐请求。如果请求中只包含了推荐的部分,则很多地方可以优化。推荐不是今天的重点,这里就不介绍了,后面有机会再介绍。

aggregation::execute()

如果含有聚合统计请求则在这里执行。Elasticsearch中的aggregate的处理逻辑也类似于Search通过多个Collector来实现。在Client Node中也需要对aggregation做合并。aggregate逻辑更复杂一些就不在这里赘述了后面有需要就再单独开文章介绍。

上述逻辑都执行完成后如果当前查询请求只需要查询一个Shard那么会直接在当前Node执行Fetch Phase。

Fetch Phase

Elasticsearch作为搜索系统时或者任何搜索系统中除了Query阶段外还会有一个Fetch阶段这个Fetch阶段在数据库类系统中是没有的是搜索系统中额外增加的阶段。搜索系统中额外增加Fetch阶段的原因是搜索系统中数据分布导致的在搜索中数据通过routing分Shard的时候只能根据一个主字段值来决定但是查询的时候可能会根据其他非主字段查询那么这个时候所有Shard中都可能会存在相同非主字段值的Doc所以需要查询所有Shard才能不会出现结果遗漏。同时如果查询主字段那么这个时候就能直接定位到Shard就只需要查询特定Shard即可这个时候就类似于数据库系统了。另外数据库中的二级索引又是另外一种情况但类似于查主字段的情况这里就不多说了。

基于上述原因第一阶段查询的时候并不知道最终结果会在哪个Shard上所以每个Shard中管都需要查询完整结果比如需要Top 10那么每个Shard都需要查询当前Shard的所有数据找出当前Shard的Top 10然后返回给Client Node。如果有100个Shard那么就需要返回100 * 10 = 1000个结果而Fetch Doc内容的操作比较耗费IO和CPU如果在第一阶段就Fetch Doc那么这个资源开销就会非常大。所以一般是当Client Node选择出最终Top N的结果后再对最终的Top N读取Doc内容。通过增加一点网络开销而避免大量IO和CPU操作这个折中是非常划算的。

Fetch阶段的目的是通过DocID获取到用户需要的完整Doc内容。这些内容包括了DocValuesStoreSourceScript和Highlight等具体的功能点是在SearchModule中注册的系统默认注册的有

ExplainFetchSubPhase DocValueFieldsFetchSubPhase ScriptFieldsFetchSubPhase FetchSourceSubPhase VersionFetchSubPhase MatchedQueriesFetchSubPhase HighlightPhase ParentFieldSubFetchPhase

除了系统默认的8种外还有通过插件的形式注册自定义的功能这些SubPhase中最重要的是Source和HighlightSource是加载原文Highlight是计算高亮显示的内容片断。

上述多个SubPhase会针对每个Doc顺序执行可能会产生多次的随机IO这里会有一些优化方案但是都是针对特定场景的不具有通用性。

Fetch Phase执行完后整个查询流程就结束了。

参考文档

https://www.elastic.co/guide/cn/elasticsearch/guide/current/distrib-read.html

https://www.elastic.co/guide/cn/elasticsearch/guide/current/distrib-multi-doc.html

https://www.elastic.co/guide/cn/elasticsearch/guide/current/inside-a-shard.html

https://zhuanlan.zhihu.com/p/34674517

https://zhuanlan.zhihu.com/p/34669354

https://www.cnblogs.com/yangwenbo214/p/9831479.html