Elasticsearch原理分析——Search流程
GET操作只能对单个文档进行处理,由 _index、 _type、 _id三元组来确定唯一文档。但搜索需要一种更复杂的模型,因为不知道查询会命中哪些文档。
找到匹配文档仅仅完成了搜索流程的一半,因为分片中的结果必须组合成单个排序列表。集群的任意节点都可以接收搜索请求,接收客户端请求的节点称为协调节点。在协调节点,搜索任务被执行需要两个阶段,即 query then fetch。真正执行搜索任务的节点称为数据节点。
需要两个阶段才能完成搜索的原因是,在查询的时候不知道文档位于哪个分片,因此索引的所有分片都要参与搜索,然后协调节点将结果合并,在根据文档ID获取文档内容。例如,有5个分片,查询返回前10个匹配度最高的文档,那么每个分片都查询出当前分片的 TOP 10,协调节点将 5 x 10 = 50的结果再次排序,返回最终 TOP 10的结果给客户端。
curl -XGET "http://127.0.0.1/myindex/mytype/_search?q=first&pretty"
{
"took": 4,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 32871,
"max_score": 20.668762,
"hits": [
{
"_index": "myindex",
"_type": "mytype",
"_id": "12",
"_score": 20.668762,
"_source": {
"title": "first",
}
}
]
}
}
在上面的例子中,我们从所有字段搜索“first”关键词,返回信息中几个基本字段的含义如下:
- took代表执行搜索的时间(单位毫秒);
- total代表本次搜索命中的文档数量;
- max_score为最大得分,代表文档匹配度;
- hits为搜索命中的结果列表,默认为10条;
1. 索引和搜索
ES中的数据可分为两类:精确值和全文。
- 精确值,比如日期和用户id、IP地址等。
- 全文,指文本内容,比如一条日志,或邮件内容。
这两种类型的数据在查询时是不同的:对精确值的比较是二进制的,查询要么匹配,要么不匹配;全文内容的查询无法给出“有”还是“没有”的结果,它只能找到结果是“看起来像”你要查询的东西,因此把查询结果按相似度排序,评分越高,相似度越大。
对数据建立索引和执行搜索的原理如下图所示:
1.1 建立索引
如果是全文数据,则对文本进行分析,这项工作在ES中由分析器实现。分析器实现如下功能:
- 字符过滤器。主要对字符串进行预处理,例如,去掉HTML,将&转换成and等。
- 分词器(Tokenizer)。将字符串分割为单个词条,例如,根据空格和标点符号分割,输出的词条称为词元(Token)。
- Token过滤器。根据停用词(Stop word)删除词元,例如,and、the等无用词元,或者更加同义词表添加词条,例如,jump和leap。
- 语言处理。对上一步得到的Token做一些和语言相关的处理,例如,转为小写,以及将单词转换为词根的形式。语言处理组件输出的结果称为词(Term)。
分析完毕后,将分析器输出的词(Term)传递给索引组件,生成倒排和正排索引,在存储到文件系统中。
1.2 执行搜索
搜索调用Lucene完成,如果是全文搜索,则:
- 对检索字段使用建立索引时相同的分析器进行分析,产生Token列表;
- 根据查询语句的语法规则转换成一颗语法树;
- 查找符合语法树的文档;
- 对匹配到的文档列表进行相关性评分,评分策略一般使用TF/IDF;
- 根据评分结果进行排序
2. search type
ES目前有两种搜索类型:
- DFS_QUERY_THEN_FETCH;
- QUERY_THREN_FETCH(默认)。
两种不同的搜索类型的区别在于查询阶段,DFS查询阶段的流程要多一些,它使用全局信息来获取更准确的评分。
本章的流程分析默认搜索类型。下面我们仍旧按照请求涉及的节点来分析流程,搜索流程涉及两个节点:协调节点和数据节点。
3. 分布式搜索过程
一个搜索请求必须询问请求的索引中所有分片的某个副本来进行匹配。假设一个索引有5个主分片,每个主分片有一个副本分片,共10分片,一次搜索请求会由5个分片来共同完成,他们可能是主分片,也可能是副分片。也就是说,一次搜索请求只会命中所有分片副本中的一个。
当搜索任务执行在分布式系统上时,整体流程如下图所示:
3.1 协调节点流程
两阶段响应的实现位置:
- 查询(Query)阶段——search.InitialSearchPhase;
- 取回(Fetch)阶段——search.FetchSearchPhase。
它们都继承自SearchPhase
,如下图所示:
3.1.1 Query阶段
在初始查询阶段,查询会广播到索引中每一个分片副本(主分片或副分片)。每个分片在本地执行搜索并构建一个匹配文档的优先队列。
优先队列是一个存有topN匹配文档的有序列表。有序队列大小为分页参数from+size。
分布式搜索的Query节点,如下图所示:
QUERY_THEN_FEATCH搜索类型的查询步骤如下:
- 客户端发送search请求到NODE3。
- NODE3将查询请求转发到索引的每个主分片或副分片中。
- 每个分片在本地执行查询,并使用本地的Term/Docuemnt Frequency信息进行打分,添加结果到大小为from+size的本地优先队列中。
- 每个分片返回各自优先队列中所有文档的ID和排序值给协调节点,协调节点合并这些值到自己的优先队列中,产生一个全局排序后的列表。
协调节点广播查询请求到所有有关分片时,可以是主分片或副分片,协调节点将在之后的请求中轮询所有的分片副本来分摊负载。
查询阶段并不会对搜索请求的内容进行解析,无论搜索什么内容,只看本次搜索需要命中哪些shard,然后针对每个特定shard选择一个副本,转发搜索请求。
3.1.1.1 Query阶段源码分析
执行本流程的线程池:http_server_work
。
3.1.1.1.1 解析请求
在RestSearchAction#prepareRequest
方法中将请求体解析为SearchRequest
数据结构:
@Override
public RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException {
SearchRequest searchRequest = new SearchRequest();
IntConsumer setSize = size -> searchRequest.source().size(size);
request.withContentOrSourceParamParserOrNull(parser ->
//解析请求为SearchRequest
parseSearchRequest(searchRequest, request, parser, setSize));
return channel -> {
RestStatusToXContentListener<SearchResponse> listener = new RestStatusToXContentListener<>(channel);
HttpChannelTaskHandler.INSTANCE.execute(client, request.getHttpChannel(), searchRequest, SearchAction.INSTANCE, listener);
};
}
3.1.1.1.2 构造目的shard列表
在TransportSearchAction#executeSearch
,将请求涉及的本集群shard列表和远程集群的shard列表(远程集群用于跨集群访问)合并:
private void executeSearch(SearchTask task, SearchTimeProvider timeProvider, SearchRequest searchRequest,
OriginalIndices localIndices, String[] concreteIndices, Map<String, Set<String>> routingMap,
Map<String, AliasFilter> aliasFilter, Map<String, Float> concreteIndexBoosts,
List<SearchShardIterator> remoteShardIterators, BiFunction<String, String, DiscoveryNode> remoteConnections,
ClusterState clusterState, ActionListener<SearchResponse> listener, SearchResponse.Clusters clusters) {
//得到本地集群shard
GroupShardsIterator<ShardIterator> localShardsIterator = clusterService.operationRouting().searchShards(clusterState,
concreteIndices, routingMap, searchRequest.preference(), searchService.getResponseCollectorService(), nodeSearchCounts);
//合并集群shard remoteShardIterators远程集群shard
GroupShardsIterator<SearchShardIterator> shardIterators = mergeShardsIterators(localShardsIterator, localIndices,
searchRequest.getLocalClusterAlias(), remoteShardIterators);
}
3.1.1.1.3 遍历所有shard发送请求
请求是基于shard遍历的,如果列表中的N个shard位于同一个节点,则向其发送N次请求,并不会把请求合并为一个。在AbstractSearchAsyncAction#run
中实现:
@Override
public final void run() {
for (final SearchShardIterator iterator : toSkipShardsIts) {
assert iterator.skip();
skipShard(iterator);
}
if (shardsIts.size() > 0) {
assert request.allowPartialSearchResults() != null : "SearchRequest missing setting for allowPartialSearchResults";
for (int index = 0; index < shardsIts.size(); index++) {
final SearchShardIterator shardRoutings = shardsIts.get(index);
assert shardRoutings.skip() == false;
//执行shard级请求
performPhaseOnShard(index, shardRoutings, shardRoutings.nextOrNull());
}
}
}
shardsIts
为本次搜索涉及的所有分片,shardRouting.nextOrNull()
从某个分片的所有副本中选择一个。例如,从myindex中选择主分片。
转发请求同时定义一个Listener
,用于处理Response
:
private void performPhaseOnShard(final int shardIndex, final SearchShardIterator shardIt, final ShardRouting shard) {
executePhaseOnShard(shardIt, shard,
new SearchActionListener<Result>(shardIt.newSearchShardTarget(shard.currentNodeId()), shardIndex) {
//收到执行成功的回复
@Override
public void innerOnResponse(Result result) {
try {
onShardResult(result, shardIt);
} finally {
executeNext(pendingExecutions, thread);
}
}
//收到执行失败的回复
@Override
public void onFailure(Exception t) {
try {
onShardFailure(shardIndex, shard, shard.currentNodeId(), shardIt, t);
} finally {
executeNext(pendingExecutions, thread);
}
}
});
}
发送过程依然调用transport
模块实现。
3.1.1.1.4 收集返回结果
本过程在search线程池中执行:
private void onShardResult(Result result, SearchShardIterator shardIt) {
results.consumeResult(result);
successfulShardExecution(shardIt);
}
consumeResult
对收集的结果进行合并。
successfulShardExecution
方法检查是否所有请求收到回复,是否进入下一阶段:
private void successfulShardExecution(SearchShardIterator shardsIt) {
final int remainingOpsOnIterator;
if (shardsIt.skip()) {
remainingOpsOnIterator = shardsIt.remaining();
} else {
remainingOpsOnIterator = shardsIt.remaining() + 1;
}
//计算累加器
final int xTotalOps = totalOps.addAndGet(remainingOpsOnIterator);
//检查是否收到全部回复
if (xTotalOps == expectedTotalOps) {
onPhaseDone();
} else if (xTotalOps > expectedTotalOps) {
throw new AssertionError("unexpected higher total ops [" + xTotalOps + "] compared to expected ["
+ expectedTotalOps + "]");
}
}
onPhaseDone
会调用executeNextPhase
,从而开始执行取回阶段。
3.1.2 Fetch阶段
Query阶段知道了取哪些数据,但是没有取具体的数据,这就是Fetch阶段要做的。
分布式搜索的Fetch阶段,如下图所示:
Fetch阶段由以下步骤构成:
- 协调节点向相关NODE发送GET请求。
- 分片所在节点向协调节点返回数据。
- 协调节点等待所有文档被取得,然后返回给客户端。
分片所在节点在返回文档数据时,处理有可能出现的 _source字段和高亮参数。
协调节点首先决定哪些文档“确实”需要被取回,例如,如果查询指定了
{
"from":90,
"size":10
}
则总有从第91个开始的10个结果需要被取回。
为了避免在协调节点中创建的 number_of_shards * (from + size)
优先队列过大,应尽量控制分页深度。
3.1.2.1 Fetch阶段源码解析
Fetch阶段的目的是通过文档ID获取完整的文档内容。
执行本流程的线程池:search
。
3.1.2.1.1 发送Fetch请求
Query阶段的executeNextPhase
方法触发Fetch,Fetch阶段的起点为FetchSearchPhase#innerRun
函数,从查询阶段的shard列表中遍历,跳过查询结果为空的shard,对特定目标shard执行executeFetch
来获取数据。其中包括分页信息。对scroll请求的处理也在FetchSearchPhase#innerRun
函数中。
private void executeFetch(final int shardIndex, final SearchShardTarget shardTarget,
final CountedCollector<FetchSearchResult> counter,
final ShardFetchSearchRequest fetchSearchRequest, final QuerySearchResult querySearchResult,
final Transport.Connection connection) {
//发送请求
context.getSearchTransport().sendExecuteFetch(connection, fetchSearchRequest, context.getTask(),
new SearchActionListener<FetchSearchResult>(shardTarget, shardIndex) {
//处理返回成功的消息
@Override
public void innerOnResponse(FetchSearchResult result) {
try {
counter.onResult(result);
} catch (Exception e) {
context.onPhaseFailure(FetchSearchPhase.this, "", e);
}
}
//处理返回失败的消息
@Override
public void onFailure(Exception e) {
try {
logger.debug(() -> new ParameterizedMessage("[{}] Failed to execute fetch phase", fetchSearchRequest.id()), e);
counter.onFailure(shardIndex, shardTarget, e);
} finally {
releaseIrrelevantSearchContext(querySearchResult);
}
}
});
}
executeFetch
的参数FetchSearchResult
中包含分页信息,最后订阅一个Listener
,每个成功获取一个shard数据后执行counter.onResult
,其中调用对结果处理回调,把result
保存到数组中,然后执行countDown
:
void onResult(R result) {
try {
resultConsumer.accept(result);
} finally {
countDown();
}
}
3.1.2.1.2 收集结果
收集器的定义在innerRun
中,包括收到的shard数据存放在哪里,收集完成后谁来处理:
final CountedCollector<FetchSearchResult> counter = new CountedCollector<>(r -> fetchResults.set(r.getShardIndex(), r),
docIdsToLoad.length,
finishPhase, context);
fetchResults
用于存储从某个shard收集到的结果,每收到一个shard的数据就执行一次counter.countDown()
。当所有shard数据收集完毕后,countDown
会触发执行finishPhase
:
final Runnable finishPhase = ()
-> moveToNextPhase(searchPhaseController, scrollId, reducedQueryPhase, queryAndFetchOptimization ?
queryResults : fetchResults);
moveToNextPhase
方法执行下一阶段,下一阶段要执行的任务定义在FetchSearchPhase
构造函数中,主要是触发ExpandSearchPhase
:
FetchSearchPhase(SearchPhaseResults<SearchPhaseResult> resultConsumer,
SearchPhaseController searchPhaseController,
SearchPhaseContext context) {
this(resultConsumer, searchPhaseController, context,
(response, scrollId) -> new ExpandSearchPhase(context, response, // collapse only happens if the request has inner hits
(finalResponse) -> sendResponsePhase(finalResponse, scrollId, context)));
}
3.1.2.1.3 ExpandSearchPhase
取回阶段完成之后执行ExpandSearchPhase#run
,主要判断是否用字段折叠,根据需要实现字段折叠功能,如果没有实现字段折叠,则直接返回给客户端。
3.1.2.1.4 回复客户端
ExpandSearchPhase
执行完之后回复客户端,在sendResponsePhase
方法中实现:
private static SearchPhase sendResponsePhase(InternalSearchResponse response, String scrollId, SearchPhaseContext context) {
return new SearchPhase("response") {
@Override
public void run() {
context.sendSearchResponse(response, scrollId);
}
};
}
3.2 执行搜索的数据节点流程
执行本流程的线程池:search
。
对各种Query、Fetch请求的处理入口注册于SearchTransportService#registerRequestHandler
。
3.2.1 响应Query请求
以常见的Query请求为例,其action为:indices:data/read/search[phase/query]
主要过程就是执行查询,然后发送Response
:
注册indices:data/read/search[phase/query]
事件
transportService.registerRequestHandler(QUERY_ACTION_NAME, ThreadPool.Names.SAME, ShardSearchRequest::new,
(request, channel, task) -> {
//执行查询
searchService.executeQueryPhase(request, (SearchTask) task, new ChannelActionListener<>(
channel, QUERY_ACTION_NAME, request));
});
执行查询
/**
* Try to load the query results from the cache or execute the query phase directly if the cache cannot be used.
* 尝试从缓存加载查询结果,如果无法使用缓存,则直接执行查询阶段。
*/
private void loadOrExecuteQueryPhase(final ShardSearchRequest request, final SearchContext context) throws Exception {
//是否查询缓存
final boolean canCache = indicesService.canCache(request, context);
context.getQueryShardContext().freezeContext();
if (canCache) {
indicesService.loadIntoContext(request, context, queryPhase);
} else {
queryPhase.execute(context);
}
}
处理查询结果:
public final class ChannelActionListener<
Response extends TransportResponse, Request extends TransportRequest> implements ActionListener<Response> {
private static final Logger logger = LogManager.getLogger(ChannelActionListener.class);
private final TransportChannel channel;
private final Request request;
private final String actionName;
public ChannelActionListener(TransportChannel channel, String actionName, Request request) {
this.channel = channel;
this.request = request;
this.actionName = actionName;
}
/**
* 处理查询成功的情况
* @param response
*/
@Override
public void onResponse(Response response) {
try {
channel.sendResponse(response);
} catch (Exception e) {
onFailure(e);
}
}
/**
* 处理查询失败的情况
* @param e
*/
@Override
public void onFailure(Exception e) {
try {
channel.sendResponse(e);
} catch (Exception e1) {
e1.addSuppressed(e);
logger.warn(() -> new ParameterizedMessage(
"Failed to send error response for action [{}] and request [{}]", actionName, request), e1);
}
}
}
查询实现入口在searchService.executeQueryPhase
中。查询时,先查看是否允许cache中取。这个cache由节点的所有分片共享,基于LRU算法实现:空间满的时候删除最近最少使用的数据。cache并不缓存全部检索结果。
核心的查询封装在queryPhase.execute(context);
中,其中调用Lucene实现检索,同时实现聚合:
@Override
public void execute(SearchContext searchContext) throws QueryPhaseExecutionException {
if (searchContext.hasOnlySuggest()) {
//自动补全级纠错
suggestPhase.execute(searchContext);
searchContext.queryResult().topDocs(new TopDocsAndMaxScore(
new TopDocs(new TotalHits(0, TotalHits.Relation.EQUAL_TO), Lucene.EMPTY_SCORE_DOCS), Float.NaN),
new DocValueFormat[0]);
return;
}
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("{}", new SearchContextSourcePrinter(searchContext));
}
// Pre-process aggregations as late as possible. In the case of a DFS_Q_T_F
// request, preProcess is called on the DFS phase phase, this is why we pre-process them
// here to make sure it happens during the QUERY phase
aggregationPhase.preProcess(searchContext);
final ContextIndexSearcher searcher = searchContext.searcher();
boolean rescore = execute(searchContext, searchContext.searcher(), searcher::setCheckCancelled);
//全文检索,并且打分
if (rescore) { // only if we do a regular search
rescorePhase.execute(searchContext);
}
suggestPhase.execute(searchContext);
//聚合查询
aggregationPhase.execute(searchContext);
if (searchContext.getProfilers() != null) {
ProfileShardResult shardResults = SearchProfileShardResults
.buildShardResults(searchContext.getProfilers());
searchContext.queryResult().profileResults(shardResults);
}
}
其中包含几个核心功能:
execute()
:调用Lucene的searcher.search()
实现搜索;rescorePhase
:全文检索,并且打分suggestPhase
:自动补全和纠错aggregationPhase
:实现聚合。
总结:
- 慢查询Query日志的统计时间在于本阶段的处理时间。
- 聚合操作在本阶段实现,在Lucene检索后完成。
3.2.2 响应Fetch请求
以常见的基于ID进行Fetch请求为例,其action为:indices:data/read/search[phase/fetch/id]
。
主要过程是执行Fetch,然后发送Response
:
注册indices:data/read/search[phase/fetch/id]
事件
transportService.registerRequestHandler(FETCH_ID_ACTION_NAME, ThreadPool.Names.SAME, true, true, ShardFetchSearchRequest::new,
(request, channel, task) -> {
//执行Fetch
searchService.executeFetchPhase(request, (SearchTask)task, new ChannelActionListener<>(channel, FETCH_ID_ACTION_NAME,
request));
});
执行Fetch查询
public void executeFetchPhase(ShardFetchRequest request, SearchTask task, ActionListener<FetchSearchResult> listener) {
runAsync(request.id(), () -> {
final SearchContext context = findContext(request.id(), request);
context.incRef();
try {
context.setTask(task);
contextProcessing(context);
if (request.lastEmittedDoc() != null) {
context.scrollContext().lastEmittedDoc = request.lastEmittedDoc();
}
context.docIdsToLoad(request.docIds(), 0, request.docIdsSize());
try (SearchOperationListenerExecutor executor = new SearchOperationListenerExecutor(context, true, System.nanoTime())) {
//执行fetch
fetchPhase.execute(context);
if (fetchPhaseShouldFreeContext(context)) {
freeContext(request.id());
} else {
contextProcessedSuccessfully(context);
}
executor.success();
}
return context.fetchResult();
} catch (Exception e) {
logger.trace("Fetch phase failed", e);
processFailure(context, e);
throw e;
} finally {
cleanContext(context);
}
}, listener);
}
对Fetch响应的实现封装在searchService.executeFetchPhase
中,其核心是调用fetchPhase.execute(context)
。按照命中的doc取得相关数据,填充到SearchHits
中,最终封装到FetchSearchResult
中。
总结:
慢查询Fetch日志的统计时间在于本阶段的处理时间
4. 小结
- 聚合是在ES中实现的,而非Lucene。
- Query和Fetch请求之间是无状态的,除非是scroll方式。
- 分页搜索不会单独cache,cache和分页没有关系。
- 每次分页的请求都是一次重新搜索的过程,而不是从第一次搜索的结果中去取。看上去不太符合常规的做法,事实上互联网的搜索引擎都是重新执行了搜索过程:人们基本只看前几页,很少深度分页;重新执行一次搜索很快;如果缓存第一次搜索结果等待翻页命中,则这种缓存的代价太大,意义不大,因此不如重新执行一次搜索。
- 搜索需要遍历分片所有的Lucene分段,因此合并Lucene分段对搜索性能有好处。
5. 关注我
搜索微信公众号:java架构强者之路