这篇主要简单介绍SearchScroll的流程和SearchScroll的并发原理。
流程剖析
使用SearchScroll功能,用户的请求主要分为两个阶段,我们将第一阶段称之为Search阶段,第二阶段称之为Scroll阶段。如下图所示。
其中第一阶段和传统的Search请求流程几乎一致,在Search流程的基础上进行了一些额外的特殊处理,比如Slice并发处理、Context上下文保留、Response中返回scroll_id、记录本次的游标地址方便下一次scroll请求继续获取数据等等。
第二阶段Scroll请求则大大简化,Search中的许多流程都不要再次进行,仅需要执行query、fetch、response三个阶段。而完整的search请求包含rewrite、can_match、dfs、query、fetch、dfs_query、expand、response等复杂的流程,因此其在es的代码实现中也没有严格遵循上述的流程流转的框架,也没有SearchPhaseContext等context实现。
Search阶段第一个阶段是Search的流程,其中在 Elasticsearch内核解析 - 查询篇 有详细的介绍。这里按照查询流程,仅介绍一些不同的地方。
CreateContext创建SearchContex后,如果是scroll请求,则在searchContext中设置ScrollContext。ScrollContext中主要包含context的有效时间、上一次访问了哪个文档lastEmittedDoc(即游标位置)等信息。具体如下:
private Map context = null;
public long totalHits = -1;
public float maxScore;
public ScoreDoc lastEmittedDoc;
public Scroll scroll;
queryPhase.preProcess中会处理sliceFilter,判断该slice请求到达哪个shard。这里是进行slice并发请求核心处理逻辑,简单来说根据slice的id和shard_id是否匹配来判断是否在本shard上进行请求。然后将query进行重写,将用户原有的query放入到boolQuery的must中,slice构建出的filter放入boolQuery的filter中。 SearchScroll通过SearchContext保留上下文。每个context都有一个id,它是单机原子自增的,后续如果还需要使用则可以根据id拿到该context。context会自动清理,默认5分钟的keepAlive,新来的请求会刷新keepAlive,或者通过clearScroll来主动清除该context。
LoadOrExecuteQueryPhaseSearchScroll请求结果永远不会被cache,判断条件很简单,如果请求中携带了scroll参数,这一步会直接跳过。
QueryPhase.execute该步骤为search查询的核心逻辑,search请求携带scroll和不携带scroll在这里几乎是一模一样的,具体参考上述链接的文章介绍。
FetchSearchPhasefetch阶段,需要将query阶段返回的doc_id进行fetch其doc内容。 如果是scroll类型的search请求,则需要buildScrollId,scrollid中保存了一个数组,每个元素包含2个值:
- nodeid,下次请求知道上一次请求在哪个shard上进行的。
- RequestId(ContextId),找到上一次请求对应的searchContext,方便进行下一次请求。
fetch结束的时候,需要将本次请求发给用户的最后一个元素的排序字段的值的大小保留下来,这个值是哪个字段取决于search请求中的sort设置了什么值。elasticsearch推荐使用_doc进行排序,这样性能最好。当获取到最后一个文档后,需要更新到searchContext中的ScrollContext的lastEmittedDoc值,这样下次请求就知道从哪里开始进行搜索了。
小结总结一下Search和Scroll的核心区别,主要是在query阶段需要处理并发的scroll请求(slice),fetch阶段需要得到本次返回给用户的最后一个文档lastEmittedDoc,然后告知data节点的context,这样下次请求就可以继续从上一个记录点进行搜索。
该阶段是在elasticsearch中是通过调用SearchScrollRequest发起请求,其参数主要有两个:
- scroll_id,方便在data节点上找到对应的context,继续上一次的请求。
- scroll失效时间,即刷新context的aliveTime,aliveTime过后该context失效。这个参数一般使用不多,使用默认值即可。
该阶段从api层面来看已经区别很大,一个是SearchRequest,另一个是SearchScrollRequest。search的流程上面主要是分析了一些不同的地方,接下来讲一下scroll的流程,只有query、fetch、response三个phase,其中response仅仅是拼装和返回数据,这里略过。
query- 在协调节点上,将scroll_id进行parse,得到本次请求的目标shard和对应shard上的searchContext的id,将这两个参数通过InternalScrollSearchRequest请求转发到data节点上。
- 在data节点上,从内存中获取到对应的searchContext,即获取到了用户原来的query和上次游标信息lastEmittedDoc。然后再执行QueryPhase.execute时,会将query进行改写,如下代码所示。改写后将lastEmittedDoc放入boolQuery的filter中,这就是为什么scroll请求可以知道下次请求的数据应该从哪里开始。并且这个MinDocQuery的性能是比传统的rangeQuery要快很多的,它仅仅匹配 >=after.doc + 1的文档,可以直接跳过很多无效的扫描。
final ScoreDoc after = scrollContext.lastEmittedDoc;
if (after != null) {
BooleanQuery bq = new BooleanQuery.Builder()
.add(query, BooleanClause.Occur.MUST)
.add(new MinDocQuery(after.doc + 1), BooleanClause.Occur.FILTER)
.build();
query = bq;
}
// ... and stop collecting after ${size} matches
searchContext.terminateAfter(searchContext.size());
searchContext.trackTotalHits(false);
fetch
- 在协调节点上,将各个shard返回的数据进行排序,然后将用户想要的size个数据进行fetch,这些数据同样需要得到lastEmittedDoc, 与Search阶段一致,都是通过ShardFetchRequest告知data节点上searchContext本次的lastEmittedDoc,并更新在context中供下次查询使用。
- 在data节点上,如果传入的request.lastEmittedDoc不为空,则更新searchContext中的lastEmittedDoc。
SearchScroll天然支持基于shard的并发查询,而Search接口想要支持并发查询,需要将query进行拆分,虽然也能进行并发查询,但是其背后浪费的集群资源相对较多。 首先从API使用方式上介绍SearchScroll的并发,我们用一个简单的例子做说明。Slice参数是SearchScroll控制并发切分的参数,id、max是其最主要的两个参数,id取值为[0,max),max取值没有特别的限制,一般不超过1024,但是推荐max取值为小于等于索引shard的个数。id、max两个参数决定了后续在data节点如何检索数据。
GET /bar/_search?scroll=1m
{
"slice": {
"id": 0,
"max": 128
},
"query": {
"match" : {
"title" : "foo"
}
}
}
SearchScroll并发获取数据只需要我们多个线程调用Elasticsearch的接口即可,然后请求到达data节点后,开始处理slice,如果该slice不应该查询本shard,则直接返回一个MatchNoDocsQuery这样的filter,然后本shard上的查询会迅速得到执行。如果并发数等于shard数,就相当于一个并发真实的查询了一个shard。而用Search接口拆query后进行并发查询,每个并发还是会访问所有的shard在所有数据上进行查询,浪费集群的资源。 SearchScroll如何判定一个slice是否应该查询一个节点上的shard,只需要进行简单的hash值判断即可。有4个参数id、max、shardID、numShards(索引shard个数)决定了是否会进行MatchNoDocsQuery,具体规则如下:
- 当max>=numShards,如果 id%numShards!=shardID,则返回MatchNoDocsQuery
- 当maxslice0、2、4
- shard1->slice1、3
- shard0->slice0、5
- shard1->slice1、6
- shard2->slice2、7
- shard3->slice3
- shard4->slice4
单shard内slice是根据slice.field参数来切分的,推荐使用_id或者_uid来进行切分,_uid也是该参数的默认值。其它支持DocValue的number类型的field都可以进行切分。
- 根据_uid字段进行切分,则使用TermsSliceQuery进行切分
- 这个filter是O(N*M),其中N是term的枚举数量,M是每个term出现的平均次数。
- 每个segment会生成一个DocIdSet
- 首轮Search请求由于score没有cache,需要真正的去遍历拿docid,因此执行较慢。
- 针对每个segment,遍历term dictionary,计算每个term的hashCode, Math.floorMod(hashCode, slice_max) == slice_id 来决定是否放入到DocIdSet。
- 计算hash值的函数:StringHelper.murmurhash3_x86_32
- 其它DocValue数值类型字段进行切分,则使用DocValuesSliceQuery进行切分
- DocValuesSliceQuery和TermsSliceQuery类似,只是没有使用_uid作为切分,它使用了指定field的排序好的SortedNumericDocValues
- 它构造出的DocIdSet是一个全量的DocIdSet(DocIdSetIterator.all),但是在scorer时候有一个两阶段的过程,TwoPhaseIterator中如果match才会取出,不然就指向下一个。match中定义的逻辑和上面_uid切分是一致的,都是根据hash值是否和slice_id对应。如果Math.floorMod(hashCode, slice_max) == slice_id就拿出来,不然就跳过。
- 计算hash值:BitMixer.mix。该计算hash值的速度估计会比string的要快,因为实现要比murmurhash3_x86_32简单很多。
- 基于DocValue数值的注意点:
- 该字段不能更新,只能设置一次
- 该字段的分布要均匀,不然每个slice获取到的docId不均匀。
单shard内切分slice的两种方式总结:
- TermsSliceQuery耗内存,可能会造成jvm内存紧张;DocValuesSliceQuery不占用内存,但是依赖读DocValue,因此速度没有TermsSliceQuery快。
- TermsSliceQuery真实的遍历了_uid的值,而DocValuesSliceQuery遍历了doc_id序号,根据这个doc_id去取DocValue。
当前Elasticsearch在SearchScroll接口上有很多地方存在性能问题或者稳定性问题,我们对他们进行了一些优化和改进,让该接口性能更好和使用更佳。本节主要介绍的是我们在SearchScroll接口上做的一些优化的工作。
queryAndFetch这个优化是Elasticsearch目前就有的,但是还有改进的空间。 当索引只有一个shard的时候,Elasticsearch能够启用该优化,这时候SearchScroll查询能够启用queryAndFetch查询策略,这样在协调节点上只需要一步queryAndFetch操作就可以从data节点上拿到数据,而默认的查询策略queryThenFetch需要经历一个两阶段操作。如图所示,queryAndFetch这种查询方式可以节省一次网络开销,查询时间缩短。
当用户的shard数不等于1时候,Elasticsearch没有任何优化。但是,当用户的SearchScroll的max和shard数一致的时候,也是可以开启queryAndFetch优化的,因为一个并发仅仅在一个shard上真正的执行。我们将这些case也进行了优化,在多并发时候也能进行queryAndFetch优化,节省CPU、网络、内存等资源消耗,提高整体吞吐率。
原文地址:Elasticsearch之SearchScroll原理剖析和性能及稳定性优化 - 知乎Elasticsearch是一款优秀的开源企业级搜索引擎,其查询接口主要为Search接口,提供了丰富的各类查询、排序、统计聚合等功能。本文将要介绍的是另一个查询接口SearchScroll,同时介绍一下我们在这方面做的一些性能…https://zhuanlan.zhihu.com/p/231790621