1 前言
ElasticSearch 是一個實時的分布式搜索與分析引擎,常用于大量非結(jié)構(gòu)化數(shù)據(jù)的存儲和快速檢索場景,具有很強的擴展性。縱使其有諸多優(yōu)點,在搜索領(lǐng)域遠超關(guān)系型數(shù)據(jù)庫,但依然存在與關(guān)系型數(shù)據(jù)庫同樣的深度分頁問題,本文就此問題做一個實踐性分析探討
2 from + size 分頁方式
from + size 分頁方式是 ES 最基本的分頁方式,類似于關(guān)系型數(shù)據(jù)庫中的 limit 方式。from 參數(shù)表示:分頁起始位置;size 參數(shù)表示:每頁獲取數(shù)據(jù)條數(shù)。例如:
GET?/wms_order_sku/_search { ??"query":?{ ????"match_all":?{} ??}, ??"from":?10, ??"size":?20 }? 該條 DSL 語句表示從搜索結(jié)果中第 10 條數(shù)據(jù)位置開始,取之后的 20 條數(shù)據(jù)作為結(jié)果返回。這種分頁方式在 ES 集群內(nèi)部是如何執(zhí)行的呢? 在 ES 中,搜索一般包括 2 個階段,Query 階段和 Fetch 階段,Query 階段主要確定要獲取哪些 doc,也就是返回所要獲取 doc 的 id 集合,F(xiàn)etch 階段主要通過 id 獲取具體的 doc。
2.1 Query 階段
如上圖所示,Query 階段大致分為 3 步:
第一步:Client 發(fā)送查詢請求到 Server 端,Node1 接收到請求然后創(chuàng)建一個大小為 from + size 的優(yōu)先級隊列用來存放結(jié)果,此時 Node1 被稱為 coordinating node(協(xié)調(diào)節(jié)點);
第二步:Node1 將請求廣播到涉及的 shard 上,每個 shard 內(nèi)部執(zhí)行搜索請求,然后將執(zhí)行結(jié)果存到自己內(nèi)部的大小同樣為 from+size 的優(yōu)先級隊列里;
第三步:每個 shard 將暫存的自身優(yōu)先級隊列里的結(jié)果返給 Node1,Node1 拿到所有 shard 返回的結(jié)果后,對結(jié)果進行一次合并,產(chǎn)生一個全局的優(yōu)先級隊列,存在 Node1 的優(yōu)先級隊列中。(如上圖中,Node1 會拿到 (from + size) * 6 條數(shù)據(jù),這些數(shù)據(jù)只包含 doc 的唯一標(biāo)識_id 和用于排序的_score,然后 Node1 會對這些數(shù)據(jù)合并排序,選擇前 from + size 條數(shù)據(jù)存到優(yōu)先級隊列);
2.2 Fetch 階段
如上圖所示,當(dāng) Query 階段結(jié)束后立馬進入 Fetch 階段,F(xiàn)etch 階段也分為 3 步:
第一步:Node1 根據(jù)剛才合并后保存在優(yōu)先級隊列中的 from+size 條數(shù)據(jù)的 id 集合,發(fā)送請求到對應(yīng)的 shard 上查詢 doc 數(shù)據(jù)詳情;
第二步:各 shard 接收到查詢請求后,查詢到對應(yīng)的數(shù)據(jù)詳情并返回為 Node1;(Node1 中的優(yōu)先級隊列中保存了 from + size 條數(shù)據(jù)的_id,但是在 Fetch 階段并不需要取回所有數(shù)據(jù),只需要取回從 from 到 from + size 之間的 size 條數(shù)據(jù)詳情即可,這 size 條數(shù)據(jù)可能在同一個 shard 也可能在不同的 shard,因此 Node1 使用 multi-get 來提高性能)
第三步:Node1 獲取到對應(yīng)的分頁數(shù)據(jù)后,返回給 Client;
2.3 ES 示例
依據(jù)上述我們對 from + size 分頁方式兩階段的分析會發(fā)現(xiàn),假如起始位置 from 或者頁條數(shù) size 特別大時,對于數(shù)據(jù)查詢和 coordinating node 結(jié)果合并都是巨大的性能損耗。 例如:索引 wms_order_sku 有 1 億數(shù)據(jù),分 10 個 shard 存儲,當(dāng)一個請求的 from = 1000000, size = 10。在 Query 階段,每個 shard 就需要返回 1000010 條數(shù)據(jù)的_id 和_score 信息,而 coordinating node 就需要接收 10 * 1000010 條數(shù)據(jù),拿到這些數(shù)據(jù)后需要進行全局排序取到前 1000010 條數(shù)據(jù)的_id 集合保存到 coordinating node 的優(yōu)先級隊列中,后續(xù)在 Fetch 階段再去獲取那 10 條數(shù)據(jù)的詳情返回給客戶端。 分析:這個例子的執(zhí)行過程中,在 Query 階段會在每個 shard 上均有巨大的查詢量,返回給 coordinating node 時需要執(zhí)行大量數(shù)據(jù)的排序操作,并且保存到優(yōu)先級隊列的數(shù)據(jù)量也很大,占用大量節(jié)點機器內(nèi)存資源。
2.4 實現(xiàn)示例
private?SearchHits?getSearchHits(BoolQueryBuilder?queryParam,?int?from,?int?size,?String?orderField)?{
????????SearchRequestBuilder?searchRequestBuilder?=?this.prepareSearch(); ????????searchRequestBuilder.setQuery(queryParam).setFrom(from).setSize(size).setExplain(false); ????????if?(StringUtils.isNotBlank(orderField))?{ ????????????searchRequestBuilder.addSort(orderField,?SortOrder.DESC); ????????} ????????log.info("getSearchHits?searchBuilder:{}",?searchRequestBuilder.toString()); ????????SearchResponse?searchResponse?=?searchRequestBuilder.execute().actionGet(); ????????log.info("getSearchHits?searchResponse:{}",?searchResponse.toString()); ????????return?searchResponse.getHits(); ????}
2.5 小結(jié)
其實 ES 對結(jié)果窗口的返回數(shù)據(jù)有默認(rèn) 10000 條的限制(參數(shù):index.max_result_window = 10000),當(dāng) from + size 的條數(shù)大于 10000 條時 ES 提示可以通過 scroll 方式進行分頁,非常不建議調(diào)大結(jié)果窗口參數(shù)值。
3 Scroll 分頁方式
scroll 分頁方式類似關(guān)系型數(shù)據(jù)庫中的 cursor(游標(biāo)),首次查詢時會生成并緩存快照,返回給客戶端快照讀取的位置參數(shù)(scroll_id),后續(xù)每次請求都會通過 scroll_id 訪問快照實現(xiàn)快速查詢需要的數(shù)據(jù),有效降低查詢和存儲的性能損耗。
3.1 執(zhí)行過程
scroll 分頁方式在 Query 階段同樣也是 coordinating node 廣播查詢請求,獲取、合并、排序其他 shard 返回的數(shù)據(jù)_id 集合,不同的是 scroll 分頁方式會將返回數(shù)據(jù)_id 的集合生成快照保存到 coordinating node 上。Fetch 階段以游標(biāo)的方式從生成的快照中獲取 size 條數(shù)據(jù)的_id,并去其他 shard 獲取數(shù)據(jù)詳情返回給客戶端,同時將下一次游標(biāo)開始的位置標(biāo)識_scroll_id 也返回。這樣下次客戶端發(fā)送獲取下一頁請求時帶上 scroll_id 標(biāo)識,coordinating node 會從 scroll_id 標(biāo)記的位置獲取接下來 size 條數(shù)據(jù),同時再次返回新的游標(biāo)位置標(biāo)識 scroll_id,這樣依次類推直到取完所有數(shù)據(jù)。
3.2 ES 示例
第一次查詢時不需要傳入_scroll_id,只要帶上 scroll 的過期時間參數(shù)(scroll=1m)、每頁大小(size)以及需要查詢數(shù)據(jù)的自定義條件即可,查詢后不僅會返回結(jié)果數(shù)據(jù),還會返回_scroll_id。
?
private?SearchHits?getSearchHits(BoolQueryBuilder?queryParam,?int?from,?int?size,?String?orderField)?{ ????????SearchRequestBuilder?searchRequestBuilder?=?this.prepareSearch(); ????????searchRequestBuilder.setQuery(queryParam).setFrom(from).setSize(size).setExplain(false); ????????if?(StringUtils.isNotBlank(orderField))?{ ????????????searchRequestBuilder.addSort(orderField,?SortOrder.DESC); ????????} ????????log.info("getSearchHits?searchBuilder:{}",?searchRequestBuilder.toString()); ????????SearchResponse?searchResponse?=?searchRequestBuilder.execute().actionGet(); ????????log.info("getSearchHits?searchResponse:{}",?searchResponse.toString()); ????????return?searchResponse.getHits(); ????}
第二次查詢時不需要指定索引,在 JSON 請求體中帶上前一個查詢返回的 scroll_id,同時傳入 scroll 參數(shù),指定刷新搜索結(jié)果的緩存時間(上一次查詢緩存 1 分鐘,本次查詢會再次重置緩存時間為 1 分鐘)
GET?/_search/scroll { ??"scroll":"1m", ??"scroll_id"?:?"DnF1ZXJ5VGhlbkZldGNoIAAAAAJFQdUKFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74YxZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAiY--F4WZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJMQKhIFmw2c1hwVFk1UXppbDhZcW1za2ZzdlEAAAACRUHVCxZZRnNhOGNrRFI0eVZKSm5DbXQxTDRRAAAAAkxAqEcWbDZzWHBUWTVRemlsOFlxbXNrZnN2UQAAAAImPvhdFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACJ-MhBhZOMmYzWVVMbFIzNkdnN1FwVXVHaEd3AAAAAifjIQgWTjJmM1lVTGxSMzZHZzdRcFV1R2hHdwAAAAIn4yEHFk4yZjNZVUxsUjM2R2c3UXBVdUdoR3cAAAACJ5db8xZxeW5NRXpHOFR0eVNBOHlOcXBGbWdRAAAAAifjIQkWTjJmM1lVTGxSMzZHZzdRcFV1R2hHdwAAAAJFQdUMFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74YhZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAieXW_YWcXluTUV6RzhUdHlTQTh5TnFwRm1nUQAAAAInl1v0FnF5bk1Fekc4VHR5U0E4eU5xcEZtZ1EAAAACJ5db9RZxeW5NRXpHOFR0eVNBOHlOcXBGbWdRAAAAAkVB1Q0WWUZzYThja0RSNHlWSkpuQ210MUw0UQAAAAImPvhfFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACJ-MhChZOMmYzWVVMbFIzNkdnN1FwVXVHaEd3AAAAAkVB1REWWUZzYThja0RSNHlWSkpuQ210MUw0UQAAAAImPvhgFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACTECoShZsNnNYcFRZNVF6aWw4WXFtc2tmc3ZRAAAAAiY--GEWZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJFQdUOFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACRUHVEBZZRnNhOGNrRFI0eVZKSm5DbXQxTDRRAAAAAiY--GQWZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJFQdUPFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74ZRZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAkxAqEkWbDZzWHBUWTVRemlsOFlxbXNrZnN2UQAAAAInl1v3FnF5bk1Fekc4VHR5U0E4eU5xcEZtZ1EAAAACTECoRhZsNnNYcFRZNVF6aWw4WXFtc2tmc3ZR" }
?
?
3.3 實現(xiàn)示例
?
protected??Page ?searchPageByConditionWithScrollId(BoolQueryBuilder?queryParam,?Class ?targetClass,?Page ?page)?throws?IllegalAccessException,?InstantiationException,?InvocationTargetException?{ ????????SearchResponse?scrollResp?=?null; ????????String?scrollId?=?ContextParameterHolder.get("scrollId"); ????????if?(scrollId?!=?null)?{ ????????????scrollResp?=?getTransportClient().prepareSearchScroll(scrollId).setScroll(new?TimeValue(60000)).execute() ????????????????????.actionGet(); ????????}?else?{ ????????????logger.info("基于scroll的分頁查詢,scrollId為空"); ????????????scrollResp?=?this.prepareSearch() ????????????????????.setSearchType(SearchType.QUERY_AND_FETCH) ????????????????????.setScroll(new?TimeValue(60000)) ????????????????????.setQuery(queryParam) ????????????????????.setSize(page.getPageSize()).execute().actionGet(); ????????????ContextParameterHolder.set("scrollId",?scrollResp.getScrollId()); ????????} ????????SearchHit[]?hits?=?scrollResp.getHits().getHits(); ????????List ?list?=?new?ArrayList (hits.length); ????????for?(SearchHit?hit?:?hits)?{ ????????????T?instance?=?targetClass.newInstance(); ????????????this.convertToBean(instance,?hit); ????????????list.add(instance); ????????} ????????page.setTotalRow((int)?scrollResp.getHits().getTotalHits()); ????????page.setResult(list); ????????return?page; ????}
?
?
3.4 小結(jié)
scroll 分頁方式的優(yōu)點就是減少了查詢和排序的次數(shù),避免性能損耗。缺點就是只能實現(xiàn)上一頁、下一頁的翻頁功能,不兼容通過頁碼查詢數(shù)據(jù)的跳頁,同時由于其在搜索初始化階段會生成快照,后續(xù)數(shù)據(jù)的變化無法及時體現(xiàn)在查詢結(jié)果,因此更加適合一次性批量查詢或非實時數(shù)據(jù)的分頁查詢。 啟用游標(biāo)查詢時,需要注意設(shè)定期望的過期時間(scroll = 1m),以降低維持游標(biāo)查詢窗口所需消耗的資源。注意這個過期時間每次查詢都會重置刷新為 1 分鐘,表示游標(biāo)的閑置失效時間(第二次以后的查詢必須帶 scroll = 1m 參數(shù)才能實現(xiàn))
4 Search After 分頁方式
Search After 分頁方式是 ES 5 新增的一種分頁查詢方式,其實現(xiàn)的思路同 Scroll 分頁方式基本一致,通過記錄上一次分頁的位置標(biāo)識,來進行下一次分頁數(shù)據(jù)的查詢。相比于 Scroll 分頁方式,它的優(yōu)點是可以實時體現(xiàn)數(shù)據(jù)的變化,解決了查詢快照導(dǎo)致的查詢結(jié)果延遲問題。
4.1 執(zhí)行過程
Search After 方式也不支持跳頁功能,每次查詢一頁數(shù)據(jù)。第一次每個 shard 返回一頁數(shù)據(jù)(size 條),coordinating node 一共獲取到 shard 數(shù) * size 條數(shù)據(jù) , 接下來 coordinating node 在內(nèi)存中進行排序,取出前 size 條數(shù)據(jù)作為第一頁搜索結(jié)果返回。當(dāng)拉取第二頁時,不同于 Scroll 分頁方式,Search After 方式會找到第一頁數(shù)據(jù)被拉取的最大值,作為第二頁數(shù)據(jù)拉取的查詢條件。 這樣每個 shard 還是返回一頁數(shù)據(jù)(size 條),coordinating node 獲取到 shard 數(shù) * size 條數(shù)據(jù)進行內(nèi)存排序,取得前 size 條數(shù)據(jù)作為全局的第二頁搜索結(jié)果。
后續(xù)分頁查詢以此類推…
4.2 ES 示例
第一次查詢只傳入排序字段和每頁大小 size
?
GET?/wms_order_sku2021_10/_search { ??"query":?{ ????"bool":?{ ??????"must":?[ ????????{ ??????????"range":?{ ????????????"shipmentOrderCreateTime":?{ ??????????????"gte":?"2021-10-12?0000", ??????????????"lt":?"2021-10-15?0000" ????????????} ??????????} ????????} ??????] ????} ??}, ??"size":?20, ??"sort":?[ ????{ ??????"_id":?{ ????????"order":?"desc" ??????} ????},{ ??????"shipmentOrderCreateTime":{ ????????"order":?"desc" ??????} ????} ??] }
接下來每次查詢時都帶上本次查詢的最后一條數(shù)據(jù)的 _id 和 shipmentOrderCreateTime 字段,循環(huán)往復(fù)就能夠?qū)崿F(xiàn)不斷下一頁的功能
GET?/wms_order_sku2021_10/_search { ??"query":?{ ????"bool":?{ ??????"must":?[ ????????{ ??????????"range":?{ ????????????"shipmentOrderCreateTime":?{ ??????????????"gte":?"2021-10-12?0000", ??????????????"lt":?"2021-10-15?0000" ????????????} ??????????} ????????} ??????] ????} ??}, ??"size":?20, ??"sort":?[ ????{ ??????"_id":?{ ????????"order":?"desc" ??????} ????},{ ??????"shipmentOrderCreateTime":{ ????????"order":?"desc" ??????} ????} ??], ??"search_after":?["SO-460_152-1447931043809128448-100017918838",1634077436000] }
?
?
4.3 實現(xiàn)示例
?
?
public??ScrollDto ?queryScrollDtoByParamWithSearchAfter( ????????????BoolQueryBuilder?queryParam,?Class ?targetClass,?int?pageSize,?String?afterId, ????????????List ?fieldSortBuilders)?{ ????????SearchResponse?scrollResp; ????????long?now?=?System.currentTimeMillis(); ????????SearchRequestBuilder?builder?=?this.prepareSearch(); ????????if?(CollectionUtils.isNotEmpty(fieldSortBuilders))?{ ????????????fieldSortBuilders.forEach(builder::addSort); ????????} ????????builder.addSort("_id",?SortOrder.DESC); ????????if?(StringUtils.isBlank(afterId))?{ ????????????log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分頁查詢,afterId為空"); ????????????SearchRequestBuilder?searchRequestBuilder?=?builder.setSearchType(SearchType.DFS_QUERY_THEN_FETCH) ????????????????????.setQuery(queryParam).setSize(pageSize); ????????????scrollResp?=?searchRequestBuilder.execute() ????????????????????.actionGet(); ????????????log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分頁查詢,afterId?為空,searchRequestBuilder:{}",?searchRequestBuilder); ????????}?else?{ ????????????log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分頁查詢,afterId="?+?afterId); ????????????Object[]?afterIds?=?JSON.parseObject(afterId,?Object[].class); ????????????SearchRequestBuilder?searchRequestBuilder?=?builder.setSearchType(SearchType.DFS_QUERY_THEN_FETCH) ????????????????????.setQuery(queryParam).searchAfter(afterIds).setSize(pageSize); ????????????log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分頁查詢,searchRequestBuilder:{}",?searchRequestBuilder); ????????????scrollResp?=?searchRequestBuilder.execute() ????????????????????.actionGet(); ????????} ????????SearchHit[]?hits?=?scrollResp.getHits().getHits(); ????????log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分頁查詢,totalRow={},?size={},?use?time:{}",?scrollResp.getHits().getTotalHits(),?hits.length,?System.currentTimeMillis()?-?now); ????????now?=?System.currentTimeMillis(); ????????List ?list?=?new?ArrayList<>(); ????????if?(ArrayUtils.getLength(hits)?>?0)?{ ????????????list?=?Arrays.stream(hits) ????????????????????.filter(Objects::nonNull) ????????????????????.map(SearchHit::getSourceAsMap) ????????????????????.filter(Objects::nonNull) ????????????????????.map(JSON::toJSONString) ????????????????????.map(e?->?JSON.parseObject(e,?targetClass)) ????????????????????.collect(Collectors.toList()); ????????????afterId?=?JSON.toJSONString(hits[hits.length?-?1].getSortValues()); ????????} ????????log.info("es數(shù)據(jù)轉(zhuǎn)換bean,totalRow={},?size={},?use?time:{}",?scrollResp.getHits().getTotalHits(),?hits.length,?System.currentTimeMillis()?-?now); ????????return?ScrollDto. builder().scrollId(afterId).result(list).totalRow((int)?scrollResp.getHits().getTotalHits()).build(); ????}
?
?
4.4 小結(jié)
Search After 分頁方式采用記錄作為游標(biāo),因此 Search After 要求 doc 中至少有一條全局唯一變量(示例中使用_id 和時間戳,實際上_id 已經(jīng)是全局唯一)。Search After 方式是無狀態(tài)的分頁查詢,因此數(shù)據(jù)的變更能夠及時的反映在查詢結(jié)果中,避免了 Scroll 分頁方式無法獲取最新數(shù)據(jù)變更的缺點。同時 Search After 不用維護 scroll_id 和快照,因此也節(jié)約大量資源。
5 總結(jié)思考
5.1 ES 三種分頁方式對比總結(jié)
如果數(shù)據(jù)量小(from+size 在 10000 條內(nèi)),或者只關(guān)注結(jié)果集的 TopN 數(shù)據(jù),可以使用 from/size 分頁,簡單粗暴
數(shù)據(jù)量大,深度翻頁,后臺批處理任務(wù)(數(shù)據(jù)遷移)之類的任務(wù),使用 scroll 方式
數(shù)據(jù)量大,深度翻頁,用戶實時、高并發(fā)查詢需求,使用 search after 方式
5.2 個人思考
在一般業(yè)務(wù)查詢頁面中,大多情況都是 10-20 條數(shù)據(jù)為一頁,10000 條數(shù)據(jù)也就是 500-1000 頁。正常情況下,對于用戶來說,有極少需求翻到比較靠后的頁碼來查看數(shù)據(jù),更多的是通過查詢條件框定一部分?jǐn)?shù)據(jù)查看其詳情。因此在業(yè)務(wù)需求敲定初期,可以同業(yè)務(wù)人員商定 1w 條數(shù)據(jù)的限定,超過 1w 條的情況可以借助導(dǎo)出數(shù)據(jù)到 Excel 表,在 Excel 表中做具體的操作。
如果給導(dǎo)出中心返回大量數(shù)據(jù)的場景可以使用 Scroll 或 Search After 分頁方式,相比之下最好使用 Search After 方式,既可以保證數(shù)據(jù)的實時性,也具有很高的搜索性能。
總之,在使用 ES 時一定要避免深度分頁問題,要在跳頁功能實現(xiàn)和 ES 性能、資源之間做一個取舍。必要時也可以調(diào)大 max_result_window 參數(shù),原則上不建議這么做,因為 1w 條以內(nèi) ES 基本能保持很不錯的性能,超過這個范圍深度分頁相當(dāng)耗時、耗資源,因此謹(jǐn)慎選擇此方式。
編輯:黃飛
?
評論
查看更多