Elasticsearch 5.x 源碼分析(3)from size, scroll 和 search after

前兩天突然被業務部的同事問了一句:“我現在要做搜索結果全量導,該用哪個接口,性能要好的?”之前雖然是知道這三種方法都是可以做分頁的深度查詢,但是由于具體的代碼實現細節沒看過,因此心里一下子就沒有了底氣,只好回答說先看看。


from size

from size是最家喻戶曉的,也是最暴力的,需要查詢from + size 的條數時,coordinate node就向該index的其余的shards 發送同樣的請求,等匯總到(shards * (from + size))條數時在coordinate node再做一次排序,最終抽取出真正的 from 后的 size 條結果,所以from size 的源碼也懶得過了,這里只是順帶提一下。實在沒弄明白Elasticsearch的from size機制的必須先做功課,下面的文章帶圖,通俗易懂:

http://lxwei.github.io/posts/%E4%BD%BF%E7%94%A8scroll%E5%AE%9E%E7%8E%B0Elasticsearch%E6%95%B0%E6%8D%AE%E9%81%8D%E5%8E%86%E5%92%8C%E6%B7%B1%E5%BA%A6%E5%88%86%E9%A1%B5.html

所以說當索引非常大時(千萬級或億級)時是無法用這個方法做深度分頁的(啟用routing機制可以減少這種中間態的條數,降低 OOO的風險,看是始終不是長遠之計,而且性能風險擺在那里。


search after

這是Elasticsearch 5 新引入的一種分頁查詢機制,其實原理幾乎就是和scroll一樣,因此代碼也是幾乎一樣的, 簡單三句話介紹search after怎么用就是:

  • 它必須先要指定排序(因為一定要按排序記住坐標)
  • 必須從第一頁開始搜起(你可以隨便指定一個坐標讓它返回結果,只是你不知道會在全量結果的何處)
  • 從第一頁開始以后每次都帶上search_after=lastEmittedDocFieldValue 從而為無狀態實現一個狀態,說白了就是把每次固定的from size偏移變成一個確定值lastEmittedDocFieldValue,而查詢則從這個偏移量開始獲取size個doc(每個shard 獲取size個,coordinate node最后匯總
    shards*size 個。

最后一點非常重要,也就是說,無論去到多少頁,coordinate node向其它node發送的請求始終就是請求size個docs,是個常量,而不再是from size那樣,越往后,你要請求的docs就越多,而要丟棄的垃圾結果也就越多
也就是,如果我要做非常多頁的查詢時,最起碼search after是一個常量查詢延遲和開銷,并無什么副作用。
有人就會問,為啥每次提供一個search_after值就可以找到確定的那一頁的內容呢,Elasticsearch 不是分布式的么,每個shard只維護一部分的離散的文檔,其實這個我之前也沒搞懂,自從群上一小伙扔我一干貨后就秒懂了,這里也推薦大家先做做功課,看看目前一些分庫分表的數據查詢的方式方法:
業界難題-“跨庫分頁”的四種方案
如果你實在懶得看完,我就貼出search_after 的實現原理吧,如下:

三、業務折衷法
“全局視野法”雖然性能較差,但其業務無損,數據精準,不失為一種方案,有沒有性能更優的方案呢?
任何脫離業務的架構設計都是耍流氓”,技術方案需要折衷,在技術難度較大的情況下,業務需求的折衷能夠極大的簡化技術方案。
業務折衷一:禁止跳頁查詢
在數據量很大,翻頁數很多的時候,很多產品并不提供“直接跳到指定頁面”的功能,而只提供“下一頁”的功能,這一個小小的業務折衷,就能極大的降低技術方案的復雜度。


如上圖,不夠跳頁,那么第一次只能夠查第一頁:
(1)將查詢order by time offset 0 limit 100,改寫成order by time where time>0 limit 100
(2)上述改寫和offset 0 limit 100的效果相同,都是每個分庫返回了一頁數據(上圖中粉色部分);

(3)服務層得到2頁數據,內存排序,取出前100條數據,作為最終的第一頁數據,這個全局的第一頁數據,一般來說每個分庫都包含一部分數據(如上圖粉色部分);
咦,這個方案也需要服務器內存排序,豈不是和“全局視野法”一樣么?第一頁數據的拉取確實一樣,但每一次“下一頁”拉取的方案就不一樣了。
點擊“下一頁”時,需要拉取第二頁數據,在第一頁數據的基礎之上,能夠找到第一頁數據time的最大值:

這個上一頁記錄的time_max,會作為第二頁數據拉取的查詢條件:
(1)將查詢order by time offset 100 limit 100,改寫成order by time where time>$time_max limit 100

(2)這下不是返回2頁數據了(“全局視野法,會改寫成offset 0 limit 200”),每個分庫還是返回一頁數據(如上圖中粉色部分);

(3)服務層得到2頁數據,內存排序,取出前100條數據,作為最終的第2頁數據,這個全局的第2頁數據,一般來說也是每個分庫都包含一部分數據(如上圖粉色部分);
如此往復,查詢全局視野第100頁數據時,不是將查詢條件改寫為offset 0 limit 9900+100(返回100頁數據),而是改寫為time>$time_max99 limit 100(仍返回一頁數據),以保證數據的傳輸量和排序的數據量不會隨著不斷翻頁而導致性能下降。


Scroll

上面有說到search after的總結就是如果我要做非常多頁的查詢時,最起碼search after是一個常量查詢延遲和開銷,并無什么副作用,可是,就像要查詢結果全量導出那樣,要在短時間內不斷重復同一查詢成百甚至上千次,效率就顯得非常低了。scroll就是把一次的查詢結果緩存一定的時間,如scroll=1m則把查詢結果在下一次請求上來時暫存1分鐘,response比傳統的返回多了一個scroll_id,下次帶上這個scroll_id即可找回這個緩存的結果。這里就scroll完成的邏輯去看看源代碼。
scroll的查詢可以簡單分成下面幾步:

  • client端向coordinate node發起類似 /{index}/_search?scroll=1m的請求
  • coordinate node會根據參數初始化一個QueryThenFetch請求或者QueryAndFetch請求,這個步驟和其它請求無異,這里有個概念就是coordinate node會在自己的節點查一遍數據(取決于它自身是否一個data節點)再往其他節點發送一遍請求,收到結果時再提煉出最終結果,再發起一個fetch 請求取最終數據
  • client往后會向coordinate node發起類似 _search/scroll 請求,在這個請求里會帶上上次查詢返回的scroll_id參數,循環這個階段知道無結果返回
  • client端會發起一個DELETE 操作向服務器請求查詢已結束,清楚掉相關緩存

Elasticsearch 中處理REST相關的客戶端請求的類都放在org.elasticsearch.action.rest

scroll會涉及到的兩個RestAction類

這兩個類的邏輯基本都是一樣的,只是構造出的request對象類型不同而已,最后RestSearchAction調用的是return channel -> client.search(searchRequest, new RestStatusToXContentListener<>(channel));而RESTSearchScrollAction調用的是return channel -> client.searchScroll(searchScrollRequest, new RestStatusToXContentListener<>(channel));

所有的Rest**Action是通過注冊自己感興趣的url來提供服務的

public RestSearchAction(Settings settings, RestController controller) {
        super(settings);
        controller.registerHandler(GET, "/_search", this);
        controller.registerHandler(POST, "/_search", this);
        controller.registerHandler(GET, "/{index}/_search", this);
        controller.registerHandler(POST, "/{index}/_search", this);
        controller.registerHandler(GET, "/{index}/{type}/_search", this);
        controller.registerHandler(POST, "/{index}/{type}/_search", this);
    }
public RestSearchScrollAction(Settings settings, RestController controller) {
        super(settings);

        controller.registerHandler(GET, "/_search/scroll", this);
        controller.registerHandler(POST, "/_search/scroll", this);
        controller.registerHandler(GET, "/_search/scroll/{scroll_id}", this);
        controller.registerHandler(POST, "/_search/scroll/{scroll_id}", this);
    }

這一層主要處理用戶的請求參數,構造出統一的SearchRequest類型,就會繼續傳遞到下一層Action,下一層Action都是以Transport***Action,可以在ActionModule中找到相關的映射,可以把Transport***Action看作是***Service的的一層包裝。
繼續看TransportSearchAction,它主要做了兩件事

  1. 根據查詢的index[ ]找到涉及的所有nodes
  2. 構造出異步請求job發送到這些nodes完成查詢
    在第2步里,需要確定searchRequest的type, 因為每種type它的fetch邏輯可能不一樣,其中重要的代碼是
            case QUERY_AND_FETCH:
            case QUERY_THEN_FETCH:
                searchAsyncAction = new SearchQueryThenFetchAsyncAction(logger, searchTransportService, connectionLookup,
                    aliasFilter, concreteIndexBoosts, searchPhaseController, executor, searchRequest, listener, shardIterators,
                    timeProvider, clusterStateVersion, task);
                break;
           

然后在SearchQueryThenFetchAsyncAction里就管理著具體的請求階段了,這里有三個階段:

  1. query
  2. fetch
  3. merge

這些邏輯和一般請求無異,就不再仔細分析了,后面在敘述我遇到的一個問題時再回來看

而SearchService 處理scroll 的請求其實也很容易理解,如果是scroll屬性,則把查詢結果緩存,如果帶著scrollContext上來,則從上次游標開始,再抓取size的結果集,我們看相關的部分代碼

直接跳到QueryPhase 的相關處理代碼:

           else {
                // Perhaps have a dedicated scroll phase?
                final ScrollContext scrollContext = searchContext.scrollContext();
                assert (scrollContext != null) == (searchContext.request().scroll() != null);
                final Collector topDocsCollector;
                ScoreDoc after = null;
                if (searchContext.request().scroll() != null) {
                    numDocs = Math.min(searchContext.size(), totalNumDocs);
                    after = scrollContext.lastEmittedDoc;

                    if (returnsDocsInOrder(query, searchContext.sort())) {
                        if (scrollContext.totalHits == -1) {
                            // first round
                            assert scrollContext.lastEmittedDoc == null;
                            // there is not much that we can optimize here since we want to collect all
                            // documents in order to get the total number of hits
                        } else {
                            // now this gets interesting: since we sort in index-order, we can directly
                            // skip to the desired doc and stop collecting after ${size} matches
                            if (scrollContext.lastEmittedDoc != null) {
                                BooleanQuery bq = new BooleanQuery.Builder()
                                    .add(query, BooleanClause.Occur.MUST)
                                    .add(new MinDocQuery(after.doc + 1), BooleanClause.Occur.FILTER)
                                    .build();
                                query = bq;
                            }
                            searchContext.terminateAfter(numDocs);
                        }
                    }
                } else {
                    after = searchContext.searchAfter();
                }

這里就看到了,如果啟用的是scroll的話那么 from參數是會ignore的 ,也就是每次只會請求size的數量文檔,而在這里也看到,它是通過lastEmittedDoc 這個游標來保持狀態的,如果該參數不為空的話,就會在SearchRequestquery外面再包一層MUSTBooleanQuery來指定邊界。
這里也看到了search after的身影了,所以我說search after和scroll的代碼幾乎都是一樣的,如果指定after的話那么在構造Lucene調用時也把這個邊界的fieldDoc傳進去。
當然這個方法有300多行,只是剩下的就是一些Lucene的API了,我也還沒有去研究Lucene的源碼,最后得到的結果是

            topDocsCallable = () -> {
                    final TopDocs topDocs;
                    if (topDocsCollector instanceof TopDocsCollector) {
                        topDocs = ((TopDocsCollector<?>) topDocsCollector).topDocs();
                    } else if (topDocsCollector instanceof CollapsingTopDocsCollector) {
                        topDocs = ((CollapsingTopDocsCollector) topDocsCollector).getTopDocs();
                    } else {
                        throw new IllegalStateException("Unknown top docs collector " + topDocsCollector.getClass().getName());
                    }
                    if (scrollContext != null) {
                        if (scrollContext.totalHits == -1) {
                            // first round
                            scrollContext.totalHits = topDocs.totalHits;
                            scrollContext.maxScore = topDocs.getMaxScore();
                        } else {
                            // subsequent round: the total number of hits and
                            // the maximum score were computed on the first round
                            topDocs.totalHits = scrollContext.totalHits;
                            topDocs.setMaxScore(scrollContext.maxScore);
                        }
                        if (searchContext.request().numberOfShards() == 1) {
                            // if we fetch the document in the same roundtrip, we already know the last emitted doc
                            if (topDocs.scoreDocs.length > 0) {
                                // set the last emitted doc
                                scrollContext.lastEmittedDoc = topDocs.scoreDocs[topDocs.scoreDocs.length - 1];
                            }
                        }
                    }
                    return topDocs;
                };

這個也只是中間狀態,結果還要在coordinate node再做一次匯總。


其實這里基本上就把scroll的代碼看完了,不過在最后我還有兩個疑問,第一: 關于search after,我每次傳的只是sort中一個field 或幾個field的一個具體值,后臺根據這個值找到的一個doc來作為“游標”,但是萬一我有大量的相同的值的話它如何找到對應的doc?例如我一個索引一半是“男”一半是“女”那我猜測如果用性別來排序那么search after = “男” 應該是工作異常的,不過我還沒試,有答案的朋友告知一聲。

第二就是一個插曲,也是我為啥看了2天scroll源碼的原因,在最后這段代碼里,我們看到每個shard 它是通 通過lastEmittedDoc來確定游標位置的,而我們也已經知道,所有結果還需要再在coordinate node上做匯總,也就是說,這次這個shard的偏移量并不是最終的偏移量,這個shard的結果集有可能最后會全用上,又或者全用不上,因此這個lastEmittedDoc 肯定是動態set的。
一開始我自然而然就覺得應該是這個scroll_id來每次指定每個shard應該從哪里開始查,因為群里的兄弟也告訴我,ES2 中這個scroll_id是每次返回都是不一樣的,一定要每次傳這個最后的id過去才可以繼續,但ES5里面就杯具了,這個值是固定的...

static String buildScrollId(AtomicArray<? extends SearchPhaseResult> searchPhaseResults) throws IOException {
        try (RAMOutputStream out = new RAMOutputStream()) {
            out.writeString(searchPhaseResults.length() == 1 ? ParsedScrollId.QUERY_AND_FETCH_TYPE : ParsedScrollId.QUERY_THEN_FETCH_TYPE);
            out.writeVInt(searchPhaseResults.asList().size());
            for (SearchPhaseResult searchPhaseResult : searchPhaseResults.asList()) {
                out.writeLong(searchPhaseResult.getRequestId());
                out.writeString(searchPhaseResult.getSearchShardTarget().getNodeId());
            }
            byte[] bytes = new byte[(int) out.getFilePointer()];
            out.writeTo(bytes, 0);
            return Base64.getUrlEncoder().encodeToString(bytes);
        }
    }

在scroll 查詢里,RequestID也是不變的,那么scroll_id確實就是不變的,然后我又跑去問了一兄弟,他又告訴我,ES2 中scroll是無排序的,size是指每個shard的size,而不是總size,所以shard獲取的結果是一定會返回的,可是這邏輯ES5 又不一樣了,size就是總結果集的size而不是shard的,而且scroll是支持sort的...
繼續郁悶了好久,我始終堅信ES5 中這個lastEmittedDoc值肯定是每次查詢動態調的,最終終于發現了端倪...

還記得scroll章一開始說的,SearchQueryThenFetchAsyncAction 會做 query,fetch,和merge三件事情,我們只看了query,然后我們繼續看 fetch階段:

private void innerRun() throws IOException {
        final int numShards = context.getNumShards();
        final boolean isScrollSearch = context.getRequest().scroll() != null;
        List<SearchPhaseResult> phaseResults = queryResults.asList();
        String scrollId = isScrollSearch ? TransportSearchHelper.buildScrollId(queryResults) : null;
        final SearchPhaseController.ReducedQueryPhase reducedQueryPhase = resultConsumer.reduce();
        final boolean queryAndFetchOptimization = queryResults.length() == 1;
        final Runnable finishPhase = ()
            -> moveToNextPhase(searchPhaseController, scrollId, reducedQueryPhase, queryAndFetchOptimization ?
            queryResults : fetchResults);
        if (queryAndFetchOptimization) {
            assert phaseResults.isEmpty() || phaseResults.get(0).fetchResult() != null;
            // query AND fetch optimization
            finishPhase.run();
        } else {
            final IntArrayList[] docIdsToLoad = searchPhaseController.fillDocIdsToLoad(numShards, reducedQueryPhase.scoreDocs);
            if (reducedQueryPhase.scoreDocs.length == 0) { // no docs to fetch -- sidestep everything and return
                phaseResults.stream()
                    .map(SearchPhaseResult::queryResult)
                    .forEach(this::releaseIrrelevantSearchContext); // we have to release contexts here to free up resources
                finishPhase.run();
            } else {
                final ScoreDoc[] lastEmittedDocPerShard = isScrollSearch ?
                    searchPhaseController.getLastEmittedDocPerShard(reducedQueryPhase, numShards)
                    : null;
                final CountedCollector<FetchSearchResult> counter = new CountedCollector<>(r -> fetchResults.set(r.getShardIndex(), r),
                    docIdsToLoad.length, // we count down every shard in the result no matter if we got any results or not
                    finishPhase, context);
                for (int i = 0; i < docIdsToLoad.length; i++) {
                    IntArrayList entry = docIdsToLoad[i];
                    SearchPhaseResult queryResult = queryResults.get(i);
                    if (entry == null) { // no results for this shard ID
                        if (queryResult != null) {
                            // if we got some hits from this shard we have to release the context there
                            // we do this as we go since it will free up resources and passing on the request on the
                            // transport layer is cheap.
                            releaseIrrelevantSearchContext(queryResult.queryResult());
                        }
                        // in any case we count down this result since we don't talk to this shard anymore
                        counter.countDown();
                    } else {
                        SearchShardTarget searchShardTarget = queryResult.getSearchShardTarget();
                        Transport.Connection connection = context.getConnection(searchShardTarget.getClusterAlias(),
                            searchShardTarget.getNodeId());
                        ShardFetchSearchRequest fetchSearchRequest = createFetchRequest(queryResult.queryResult().getRequestId(), i, entry,
                            lastEmittedDocPerShard, searchShardTarget.getOriginalIndices());
                        executeFetch(i, searchShardTarget, counter, fetchSearchRequest, queryResult.queryResult(),
                            connection);
                    }
                }
            }
        }
    }

final ScoreDoc[] lastEmittedDocPerShard = isScrollSearch ? searchPhaseController.getLastEmittedDocPerShard(reducedQueryPhase, numShards) : null;終于在fetch階段發現了這個lastEmittedDoc數組,原來在coordinate node上進入了fetch階段時會一并發送這個值給每個shard的,把這個變量一并寫入構造的fetchRequest里了,而searchService里在做fetch階段時做了一把記錄:

public FetchSearchResult executeFetchPhase(ShardFetchRequest request, SearchTask task) {
        final SearchContext context = findContext(request.id());
        final SearchOperationListener operationListener = context.indexShard().getSearchOperationListener();
        context.incRef();
        try {
            context.setTask(task);
            contextProcessing(context);
            if (request.lastEmittedDoc() != null) {
                context.scrollContext().lastEmittedDoc = request.lastEmittedDoc();
            }
            context.docIdsToLoad(request.docIds(), 0, request.docIdsSize());

謎底終于解開,安心睡覺去了...

全篇完

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內容