前兩天突然被業務部的同事問了一句:“我現在要做搜索結果全量導,該用哪個接口,性能要好的?”之前雖然是知道這三種方法都是可以做分頁的深度查詢,但是由于具體的代碼實現細節沒看過,因此心里一下子就沒有了底氣,只好回答說先看看。
from size
from size是最家喻戶曉的,也是最暴力的,需要查詢from + size 的條數時,coordinate node就向該index的其余的shards 發送同樣的請求,等匯總到(shards * (from + size))
條數時在coordinate node再做一次排序,最終抽取出真正的 from 后的 size 條結果,所以from size 的源碼也懶得過了,這里只是順帶提一下。實在沒弄明白Elasticsearch的from size機制的必須先做功課,下面的文章帶圖,通俗易懂:
所以說當索引非常大時(千萬級或億級)時是無法用這個方法做深度分頁的(啟用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
下
這兩個類的邏輯基本都是一樣的,只是構造出的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
,它主要做了兩件事
- 根據查詢的index[ ]找到涉及的所有nodes
- 構造出異步請求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
里就管理著具體的請求階段了,這里有三個階段:
- query
- fetch
- 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
這個游標來保持狀態的,如果該參數不為空的話,就會在SearchRequest
的query
外面再包一層MUST
的BooleanQuery
來指定邊界。
這里也看到了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());
謎底終于解開,安心睡覺去了...
全篇完