ElasticSearch查詢流程詳解

一、前言

前面已經介紹了ElasticSearch的寫入流程,了解了ElasticSearch寫入時的分布式特性的相關原理。ElasticSearch作為一款具有強大搜索功能的存儲引擎,它的讀取是什么樣的呢?讀取相比寫入簡單的多,但是在使用過程中有哪些需要我們注意的呢?本篇文章會進行詳細的分析。

在前面的文章我們已經知道ElasticSearch的讀取分為兩種GET和SEARCH。這兩種操作是有一定的差異的,下面我們先對這兩種核心的數據讀取方式進行一一分析。

二、GET的流程

2.1 整體流程

image.png

(圖片來自官網)

以下是從主分片或者副本分片檢索文檔的步驟順序:

  • 客戶端向 Node 1 發送獲取請求

  • 節點使用文檔的 _id 來確定文檔屬于分片 0 。分片 0 的副本分片存在于所有的三個節點上。在這種情況下,它將請求轉發到 Node 2

  • Node 2 將文檔返回給 Node 1,然后將文檔返回給客戶端。

注意:

  • 在處理讀取請求時,協調節點在每次請求的時候都會通過輪詢所有的副本分片來達到負載均衡。

  • 在文檔被檢索時,已經被索引的文檔可能已經存在于主分片上但是還沒有復制到副本分片。在這種情況下,副本分片可能會報告文檔不存在,但是主分片可能成功返回文檔。一旦索引請求成功返回給用戶,文檔在主分片和副本分片都是可用的

2.2 GET詳細流程

2.2.1 協調節點處理過程

在協調節點有個http_server_worker線程池。收到讀請求后它的具體過程為:

  • 收到請求,先獲取集群的狀態信息

  • 根據路由信息計算id是在哪一個分片上

  • 因為一個分片可能有多個副本分片,所以上述的計算結果是一個列表

  • 調用transportServer的sendRequest方法向目標發送請求

  • 上一步的方法內部會檢查是否為本地node,如果是的話就不會發送到網絡,否則會異步發送

  • 等待數據節點回復,如果成功則返回數據給客戶端,否則會重試

  • 重試會發送上述列表的下一個。

2.2.2 數據節點處理過程

數據節點上有一個get線程池。收到了請求后,處理過程為:

  • 在數據節點有個shardTransporthander的messageReceived的入口專門接收協調節點發送的請求
private class ShardTransportHandler implements TransportRequestHandler<Request> {
  @Override
  public void messageReceived(final Request request, final TransportChannel channel, Task task) {
      asyncShardOperation(request, request.internalShardId, new ChannelActionListener<>(channel, transportShardAction, request));
  }
}
  • shardOperation方法會先檢查是否需要refresh,然后調用indexShard.getService().get()讀取數據并存儲到GetResult中。
if (request.refresh() && !request.realtime()) {
  indexShard.refresh("refresh_flag_get");
}
GetResult result = indexShard.getService().get(
                    request.type(), request.id(), 
                    request.storedFields(), request.realtime(),
                    request.version(), request.versionType(), 
                    request.fetchSourceContext());

  • indexShard.getService().get()最終會調用GetResult getResult = innerGet(……)用來獲取結果。即ShardGetService#innerGet
private GetResult innerGet(String type, String id, String[] gFields, boolean realtime, long version, VersionType versionType, long ifSeqNo, long ifPrimaryTerm, FetchSourceContext fetchSourceContext) {
      ................
      Engine.GetResult get = null;
          ............
      get = indexShard.get(new Engine.Get(realtime, realtime, type, id, uidTerm).version(version).versionType(versionType).setIfSeqNo(ifSeqNo).setIfPrimaryTerm(ifPrimaryTerm));
          ..........
      if (get == null || get.exists() == false) {
          return new GetResult(shardId.getIndexName(), type, id, UNASSIGNED_SEQ_NO, UNASSIGNED_PRIMARY_TERM, -1, false, null, null, null);
      }
  try {
      return innerGetLoadFromStoredFields(type, id, gFields, fetchSourceContext, get, mapperService);
  } finally {
      get.close();
  }
  • 上面代碼的indexShard.get讀取真正的數據時會最終調用:

    org.elasticsearch.index.engine.InternalEngine#gett

public GetResult get(Get get, BiFunction<String, SearcherScope, Engine.Searcher> searcherFactory) throws EngineException {
    try (ReleasableLock ignored = readLock.acquire()) {
        ensureOpen();
        SearcherScope scope;
        if (get.realtime()) {
            VersionValue versionValue = null;
            try (Releasable ignore = versionMap.acquireLock(get.uid().bytes())) {
                // we need to lock here to access the version map to do this truly in RT
                versionValue = getVersionFromMap(get.uid().bytes());
            }
            if (versionValue != null) {
                if (versionValue.isDelete()) {
                    return GetResult.NOT_EXISTS;
                }
    。。。。。。
    //刷盤操作
     refreshIfNeeded("realtime_get", versionValue.seqNo);

注意:

get過程會加讀鎖。處理realtime選項,如果為true,則先判斷是否有數據可以刷盤,然后調用Searcher進行讀取。Searcher是對IndexSearcher的封裝在早期realtime為true則會從tranlog中讀取,后面只會從index的lucene讀取了。即實時的數據只在lucene之中。

  • innerGetLoadFromStoredFields根據type,id,filed,source等信息過濾,并將結果放到getresult之中返回

2.3 小結

  • GET是根據doc id 哈希找到對應的shard的

  • get請求默認是實時的,但是不同版本有差異,在5.x以前,讀不到寫的doc會從translog中去讀取,之后改為讀取不到會進行refresh到lucene中,因此現在的實時讀取需要復制一定的性能損耗的代價。如果對實時性要求不高,可以請求是手動帶上realtime為false

三、search流程

3.1 search整體流程

對于Search類請求,ElasticSearch請求是查詢lucene的Segment,前面的寫入詳情流程也分析了,新增的文檔會定時的refresh到磁盤中,所以搜索是屬于近實時的。而且因為沒有文檔id,你不知道你要檢索的文檔在哪個分配上,需要將索引的所有的分片都去搜索下,然后匯總。ElasticSearch的search一般有兩個搜索類型

  • dfs_query_and_fetch,流程復雜一些,但是算分的時候使用了全局的一些指標,這樣獲取的結果可能更加精確一些。

  • query_then_fetch,默認的搜索類型。

所有的搜索系統一般都是兩階段查詢:

第一階段查詢到匹配的docID,第二階段再查詢DocID對應的完整文檔。這種在ElasticSearch中稱為query_then_fetch,另一種就是一階段查詢的時候就返回完整Doc,在ElasticSearch中叫query_and_fetch,一般第二種適用于只需要查詢一個Shard的請求。因為這種一次請求就能將數據請求到,減少交互次數,二階段的原因是需要多個分片聚合匯總,如果數據量太大那么會影響網絡傳輸效率,所以第一階段會先返回id。

除了上述的這兩種查詢外,還有一種三階段查詢的情況。

搜索里面有一種算分邏輯是根據TF和DF來計算score的,而在普通的查詢中,第一階段去每個Shard中獨立查詢時攜帶條件算分都是獨立的,即Shard中的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模型。

在ElasticSearch7.x,用戶沒法指定以下兩種方式:DFS_query_and_fetchquery_and_fetch

注:這兩種算分的算法模型在《ElasticSearch實戰篇》有介紹:

這里query_then_fetch具體的搜索的流程圖如下:

image.png

(圖片來自官網)

查詢階段包含以下四個步驟:

  • 客戶端發送一個 search 請求到 Node 3 , Node 3 會創建一個大小為 from + size 的空優先隊列。

  • Node 3 將查詢請求轉發到索引的每個主分片或副本分片中。每個分片在本地執行查詢并添加結果到大小為 from + size 的本地有序優先隊列中。

  • 每個分片返回各自優先隊列中所有文檔的 ID 和排序值給協調節點,也就是 Node 3 ,它合并這些值到自己的優先隊列中來產生一個全局排序后的結果列表。

  • 當一個搜索請求被發送到某個節點時,這個節點就變成了協調節點。這個節點的任務是廣播查詢請求到所有相關分片并將它們的響應整合成全局排序后的結果集合,這個結果集合會返回給客戶端。

3.2 search詳細流程

image.png

以上就是ElasticSearch的search的詳細流程,下面會對每一步進行進一步的說明。

3.2.1 協調節點

3.2.1.1 query階段

協調節點處理query請求的線程池為:

http_server_work

  • 負責解析請求

負責該解析功能的類為:

org.elasticsearch.rest.action.search.RestSearchAction

@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 ->
        parseSearchRequest(searchRequest, request, parser, client.getNamedWriteableRegistry(), setSize));
        。。。。。。。。。。。。
    };
}

主要將restquest的參數封裝成SearchRequest

這樣SearchRequest請求發送給TransportSearchAction處理

  • 生成目的分片列表

將索引涉及到的shard列表或者有跨集群訪問相關的shard列表合并

private void executeSearch(...........) {
 ........
     //本集群的列表分片列表
   localShardIterators = StreamSupport.stream(localShardRoutings.spliterator(), false)
              .map(it -> new SearchShardIterator(
                  searchRequest.getLocalClusterAlias(), it.shardId(), it.getShardRoutings(), localIndices))
              .collect(Collectors.toList());
  .......
  //遠程集群的分片列表
 final GroupShardsIterator<SearchShardIterator> shardIterators = mergeShardsIterators(localShardIterators, remoteShardIterators);
  .......
}
  • 遍歷分片發送請求

如果有多個分片位于同一個節點,仍然會發送多次請求

public final void run() {
      ......
      for (final SearchShardIterator iterator : toSkipShardsIts) {
          assert iterator.skip();
          skipShard(iterator);
      }
      ......
      ......
      if (shardsIts.size() > 0) {
          //遍歷分片發送請求
          for (int i = 0; i < shardsIts.size(); i++) {
              final SearchShardIterator shardRoutings = shardsIts.get(i);
              assert shardRoutings.skip() == false;
              assert shardItIndexMap.containsKey(shardRoutings);
              int shardIndex = shardItIndexMap.get(shardRoutings);
              //執行shard請求
              performPhaseOnShard(shardIndex, shardRoutings, shardRoutings.nextOrNull());
          }
      ......

shardsIts為搜索涉及的所有分片,而shardRoutings.nextOrNull()會從分片的所有副本分片選出一個分片來請求。

  • 收集和合并請求

onShardSuccess對收集到的結果進行合并,這里需要檢查所有的請求是否都已經有了回復。

然后才會判斷要不要進行executeNextPhase

private void onShardResultConsumed(Result result, SearchShardIterator shardIt) {
      successfulOps.incrementAndGet();
      AtomicArray<ShardSearchFailure> shardFailures = this.shardFailures.get();
      if (shardFailures != null) {
          shardFailures.set(result.getShardIndex(), null);
      }
      successfulShardExecution(shardIt);
  }
  private void successfulShardExecution(SearchShardIterator shardsIt) {
      ......
      //計數器累加
      final int xTotalOps = totalOps.addAndGet(remainingOpsOnIterator);
      //是不是所有分都已經回復,然后調用onPhaseDone();
      if (xTotalOps == expectedTotalOps) {
          onPhaseDone();
      } else if (xTotalOps > expectedTotalOps) {
          throw new AssertionError("unexpected higher total ops [" + xTotalOps + "] compared to expected [" + expectedTotalOps + "]",
              new SearchPhaseExecutionException(getName(), "Shard failures", null, buildShardFailures()));
      }
  }

當返回結果的分片數等于預期的總分片數時,協調節點會進入當前Phase的結束處理,啟動下一個階段Fetch Phase的執行。onPhaseDone()會executeNextPhase來執行下一個階段。

3.2.1.2 fetch階段

當觸發了executeNextPhase方法將觸發fetch階段

  • 發送fetch請求

上一步的executeNextPhase方法觸發Fetch階段,Fetch階段的起點為FetchSearchPhase#innerRun函數,從查詢階段的shard列表中遍歷,跳過查詢結果為空的 shard。其中也會封裝一些分頁信息的數據。

private void executeFetch(....){
      //發送請求
     context.getSearchTransport().sendExecuteFetch(connection, fetchSearchRequest, context.getTask(),
          new SearchActionListener<FetchSearchResult>(shardTarget, shardIndex) {
              //處理成功的消息
              @Override
              public void innerOnResponse(FetchSearchResult result) {
                  try {
                      progressListener.notifyFetchResult(shardIndex);
                      counter.onResult(result);
                  } catch (Exception e) {
                      context.onPhaseFailure(FetchSearchPhase.this, "", e);
                  }
              }
              //處理失敗的消息
              @Override
              public void onFailure(Exception e) {
                  ........
              }
          });
}
  • 收集結果

使用了countDown多線程工具,fetchResults存儲某個分片的結果,每收到一個shard的數據就countDoun一下,當都完畢后,觸發finishPhase。接著會進行下一步:

CountedCollector:

final CountedCollector<FetchSearchResult> counter = new CountedCollector<>(fetchResults, docIdsToLoad.length, finishPhase, context);

finishPhase:

final Runnable finishPhase = ()
  -> moveToNextPhase(searchPhaseController, queryResults, reducedQueryPhase, queryAndFetchOptimization ?
  queryResults : fetchResults.getAtomicArray());

  • 執行字段聚合

執行字段折疊功能,有興趣可以研究下。即ExpandSearchPhase模塊。ES 5.3版本以后支持的Field Collapsing查詢。通過該類查詢可以輕松實現按Field值進行分類,每個分類獲取排名前N的文檔。如在菜單行為日志中按菜單名稱(用戶管理、角色管理等)分類,獲取每個菜單排名點擊數前十的員工。用戶也可以按Field進行Aggregation實現類似功能,但Field Collapsing會更易用、高效。

  • 回復客戶端

ExpandSearchPhase執行完了,就返回給客戶端結果了。

context.sendSearchResponse(searchResponse, queryResults);

3.2.2 數據節點

處理數據節點請求的線程池為:search

根據前面的兩個階段,數據節點主要處理協調節點的兩類請求:query和fetch

  • 響應query請求

這里響應的請求就是第一階段的query請求

transportService.registerRequestHandler(QUERY_ACTION_NAME, ThreadPool.Names.SAME, ShardSearchRequest::new,
    (request, channel, task) -> {
        //執行查詢
        searchService.executeQueryPhase(request, keepStatesInContext(channel.getVersion()), (SearchShardTask) task,
        //注冊結果監聽器
            new ChannelActionListener<>(channel, QUERY_ACTION_NAME, request));
    });

executeQueryPhase:

public void executeQueryPhase(ShardSearchRequest request, boolean keepStatesInContext,
                                SearchShardTask task, ActionListener<SearchPhaseResult> listener) {
   ...........
      final IndexShard shard = getShard(request);
      rewriteAndFetchShardRequest(shard, request, new ActionListener<ShardSearchRequest>() {
          @Override
          public void onResponse(ShardSearchRequest orig) {
                .......
              //執行真正的請求
              runAsync(getExecutor(shard), () -> executeQueryPhase(orig, task, keepStatesInContext), listener);
          }
      @Override
      public void onFailure(Exception exc) {
          listener.onFailure(exc);
      }
  });
  }

executeQueryPhase會執行loadOrExecuteQueryPhase方法


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);
      }
  }

這里判斷是否從緩存查詢,默認啟用緩存,緩存的算法默認為LRU,即刪除最近最少使用的數據。如果不啟用緩存則會執行queryPhase.execute(context);底層調用lucene進行檢索,并且進行聚合。

public void execute(SearchContext searchContext) throws QueryPhaseExecutionException {
      .......
      //聚合預處理
      aggregationPhase.preProcess(searchContext);
      .......
         //全文檢索并打分
      rescorePhase.execute(searchContext);
      .......
       //自動補全和糾錯
      suggestPhase.execute(searchContext);
      //實現聚合
      aggregationPhase.execute(searchContext);
      .......

  }

關鍵點:

  • 慢查詢日志中的query日志統計時間就是該步驟的時間;

  • 聚合lucene的操作也是在本階段完成;

  • 查詢的時候會使用lRU緩存,緩存為節點級別的;

  • 響應fetch請求;

transportService.registerRequestHandler(FETCH_ID_ACTION_NAME, ThreadPool.Names.SAME, true, true, ShardFetchSearchRequest::new,
    (request, channel, task) -> {
        searchService.executeFetchPhase(request, (SearchShardTask) task,
            new ChannelActionListener<>(channel, FETCH_ID_ACTION_NAME, request));
    });

  • 執行fetch;

  • 調用fetchPhase的execute方法獲取doc;

  • 將結果封裝到FetchSearchResult,調用網絡組件發送到response。

3.3 小結

  • search是比較耗費資源的,它需要遍歷相關的所有分片,每個分片可能有多個lucene段,那么每個段都會遍歷一下,因此ElasticSearch的常見優化策略就是將段進行合并;

  • 分頁查詢的時候,即使是查后面幾頁,也會將前幾頁的數據聚合進行分頁,因此非常耗費內存,對于這種有深度分頁的需求可能要尋找其它的解決方式。

四、總結

ElasticSearch查詢分為兩類,一類為GET,另一類為SEARCH。它們使用場景不同。

  • 如果對是實時性要求不高,可以GET查詢時不要刷新來提升性能。

  • GET讀取一個分片失敗后,會嘗試從其它分片讀取。

  • 慢query日志是統計數據節點接收到了query請求后的耗時日志。

  • 每次分頁的請求都是一次重新搜索的過程,而不是從第一次搜索的結果中獲取,這樣深度分頁會比較耗費內存。這樣也符合常見使用場景,因為基本只看前幾頁,很少深度分頁;如果確實有需要,可以采用scoll根據_scroll_id查詢的方式。

  • 搜索需要遍歷分片所有的Lucene分段,段的合并會對查詢性能有好處。

  • 聚會操作在lucene檢索完畢后ElasticSearch實現的。

本文主要分析了ElasticSearch分布式查詢主體流程,并未對lucene部分進行分析,有興趣的可以自行查找相關資料。


程序員的核心競爭力其實還是技術,因此對技術還是要不斷的學習,關注 “IT巔峰技術” 公眾號 ,該公眾號內容定位:中高級開發、架構師、中層管理人員等中高端崗位服務的,除了技術交流外還有很多架構思想和實戰案例。

作者是 《 消息中間件 RocketMQ 技術內幕》 一書作者,同時也是 “RocketMQ 上海社區”聯合創始人,曾就職于拼多多、德邦等公司,現任上市快遞公司架構負責人,主要負責開發框架的搭建、中間件相關技術的二次開發和運維管理、混合云及基礎服務平臺的建設。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容