ElasticSearch 深度分頁詳解
1 前言
ElasticSearch 是一個實時的分散式搜尋與分析引擎,常用於大量非結構化資料的儲存和快速檢索場景,具有很強的擴充套件性。縱使其有諸多優點,在搜尋領域遠超關係型資料庫,但依然存在與關係型資料庫同樣的深度分頁問題,本文就此問題做一個實踐性分析探討
2 from + size 分頁方式
from + size 分頁方式是 ES 最基本的分頁方式,類似於關係型資料庫中的 limit 方式。from 參數列示:分頁起始位置;size 參數列示:每頁獲取資料條數。例如:
GET /wms_order_sku/_search{ "query": { "match_all": {} }, "from": 10, "size": 20}
該條 DSL 語句表示從搜尋結果中第 10 條資料位置開始,取之後的 20 條資料作為結果返回。這種分頁方式在 ES 叢集內部是如何執行的呢?
在 ES 中,搜尋一般包括 2 個階段,Query 階段和 Fetch 階段,Query 階段主要確定要獲取哪些 doc,也就是返回所要獲取 doc 的 id 集合,Fetch 階段主要透過 id 獲取具體的 doc。
2.1 Query 階段
如上圖所示,Query 階段大致分為 3 步:
- 第一步:Client 傳送查詢請求到 Server 端,Node1 接收到請求然後建立一個大小為 from + size 的優先順序佇列用來存放結果,此時 Node1 被稱為 coordinating node(協調節點);
- 第二步:Node1 將請求廣播到涉及的 shard 上,每個 shard 內部執行搜尋請求,然後將執行結果存到自己內部的大小同樣為 from+size 的優先順序佇列裡;
- 第三步:每個 shard 將暫存的自身優先順序佇列裡的結果返給 Node1,Node1 拿到所有 shard 返回的結果後,對結果進行一次合併,產生一個全域性的優先順序佇列,存在 Node1 的優先順序佇列中。(如上圖中,Node1 會拿到 (from + size) * 6 條資料,這些資料只包含 doc 的唯一標識_id 和用於排序的_score,然後 Node1 會對這些資料合併排序,選擇前 from + size 條資料存到優先順序佇列);
2.2 Fetch 階段
如上圖所示,當 Query 階段結束後立馬進入 Fetch 階段,Fetch 階段也分為 3 步:
- 第一步:Node1 根據剛才合併後儲存在優先順序佇列中的 from+size 條資料的 id 集合,傳送請求到對應的 shard 上查詢 doc 資料詳情;
- 第二步:各 shard 接收到查詢請求後,查詢到對應的資料詳情並返回為 Node1;(Node1 中的優先順序佇列中儲存了 from + size 條資料的_id,但是在 Fetch 階段並不需要取回所有資料,只需要取回從 from 到 from + size 之間的 size 條資料詳情即可,這 size 條資料可能在同一個 shard 也可能在不同的 shard,因此 Node1 使用 multi-get 來提高效能)
- 第三步:Node1 獲取到對應的分頁資料後,返回給 Client;
2.3 ES 示例
依據上述我們對 from + size 分頁方式兩階段的分析會發現,假如起始位置 from 或者頁條數 size 特別大時,對於資料查詢和 coordinating node 結果合併都是巨大的效能損耗。
例如:索引 wms_order_sku 有 1 億資料,分 10 個 shard 儲存,當一個請求的 from = 1000000, size = 10。在 Query 階段,每個 shard 就需要返回 1000010 條資料的_id 和_score 資訊,而 coordinating node 就需要接收 10 * 1000010 條資料,拿到這些資料後需要進行全域性排序取到前 1000010 條資料的_id 集合儲存到 coordinating node 的優先順序佇列中,後續在 Fetch 階段再去獲取那 10 條資料的詳情返回給客戶端。
分析:這個例子的執行過程中,在 Query 階段會在每個 shard 上均有巨大的查詢量,返回給 coordinating node 時需要執行大量資料的排序操作,並且儲存到優先順序佇列的資料量也很大,佔用大量節點機器記憶體資源。
2.4 實現示例
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 小結
其實 ES 對結果視窗的返回資料有預設 10000 條的限制(引數:index.max_result_window = 10000),當 from + size 的條數大於 10000 條時 ES 提示可以透過 scroll 方式進行分頁,非常不建議調大結果視窗引數值。
3 Scroll 分頁方式
scroll 分頁方式類似關係型資料庫中的 cursor(遊標),首次查詢時會生成並快取快照,返回給客戶端快照讀取的位置引數(scroll_id),後續每次請求都會透過 scroll_id 訪問快照實現快速查詢需要的資料,有效降低查詢和儲存的效能損耗。
3.1 執行過程
scroll 分頁方式在 Query 階段同樣也是 coordinating node 廣播查詢請求,獲取、合併、排序其他 shard 返回的資料_id 集合,不同的是 scroll 分頁方式會將返回資料_id 的集合生成快照儲存到 coordinating node 上。Fetch 階段以遊標的方式從生成的快照中獲取 size 條資料的_id,並去其他 shard 獲取資料詳情返回給客戶端,同時將下一次遊標開始的位置標識_scroll_id 也返回。這樣下次客戶端傳送獲取下一頁請求時帶上 scroll_id 標識,coordinating node 會從 scroll_id 標記的位置獲取接下來 size 條資料,同時再次返回新的遊標位置標識 scroll_id,這樣依次類推直到取完所有資料。
3.2 ES 示例
第一次查詢時不需要傳入_scroll_id,只要帶上 scroll 的過期時間引數(scroll=1m)、每頁大小(size)以及需要查詢資料的自定義條件即可,查詢後不僅會返回結果資料,還會返回_scroll_id。
GET /wms_order_sku2021_10/_search?scroll=1m { "query": { "bool": { "must": [ { "range": { "shipmentOrderCreateTime": { "gte": "2021-10-04 00:00:00", "lt": "2021-10-15 00:00:00" } } } ] } }, "size": 20}
第二次查詢時不需要指定索引,在 JSON 請求體中帶上前一個查詢返回的 scroll_id,同時傳入 scroll 引數,指定重新整理搜尋結果的快取時間(上一次查詢快取 1 分鐘,本次查詢會再次重置快取時間為 1 分鐘)
GET /_search/scroll { "scroll":"1m", "scroll_id" : "DnF1ZXJ5VGhlbkZldGNoIAAAAAJFQdUKFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74YxZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAiY--F4WZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJMQKhIFmw2c1hwVFk1UXppbDhZcW1za2ZzdlEAAAACRUHVCxZZRnNhOGNrRFI0eVZKSm5DbXQxTDRRAAAAAkxAqEcWbDZzWHBUWTVRemlsOFlxbXNrZnN2UQAAAAImPvhdFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACJ-MhBhZOMmYzWVVMbFIzNkdnN1FwVXVHaEd3AAAAAifjIQgWTjJmM1lVTGxSMzZHZzdRcFV1R2hHdwAAAAIn4yEHFk4yZjNZVUxsUjM2R2c3UXBVdUdoR3cAAAACJ5db8xZxeW5NRXpHOFR0eVNBOHlOcXBGbWdRAAAAAifjIQkWTjJmM1lVTGxSMzZHZzdRcFV1R2hHdwAAAAJFQdUMFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74YhZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAieXW_YWcXluTUV6RzhUdHlTQTh5TnFwRm1nUQAAAAInl1v0FnF5bk1Fekc4VHR5U0E4eU5xcEZtZ1EAAAACJ5db9RZxeW5NRXpHOFR0eVNBOHlOcXBGbWdRAAAAAkVB1Q0WWUZzYThja0RSNHlWSkpuQ210MUw0UQAAAAImPvhfFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACJ-MhChZOMmYzWVVMbFIzNkdnN1FwVXVHaEd3AAAAAkVB1REWWUZzYThja0RSNHlWSkpuQ210MUw0UQAAAAImPvhgFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACTECoShZsNnNYcFRZNVF6aWw4WXFtc2tmc3ZRAAAAAiY--GEWZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJFQdUOFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACRUHVEBZZRnNhOGNrRFI0eVZKSm5DbXQxTDRRAAAAAiY--GQWZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJFQdUPFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74ZRZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAkxAqEkWbDZzWHBUWTVRemlsOFlxbXNrZnN2UQAAAAInl1v3FnF5bk1Fekc4VHR5U0E4eU5xcEZtZ1EAAAACTECoRhZsNnNYcFRZNVF6aWw4WXFtc2tmc3ZR"}
3.3 實現示例
protected <T> Page<T> searchPageByConditionWithScrollId(BoolQueryBuilder queryParam, Class<T> targetClass, Page<T> 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<T> list = new ArrayList<T>(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 小結
scroll 分頁方式的優點就是減少了查詢和排序的次數,避免效能損耗。缺點就是隻能實現上一頁、下一頁的翻頁功能,不相容透過頁碼查詢資料的跳頁,同時由於其在搜尋初始化階段會生成快照,後續資料的變化無法及時體現在查詢結果,因此更加適合一次性批次查詢或非實時資料的分頁查詢。
啟用遊標查詢時,需要注意設定期望的過期時間(scroll = 1m),以降低維持遊標查詢視窗所需消耗的資源。注意這個過期時間每次查詢都會重置重新整理為 1 分鐘,表示遊標的閒置失效時間(第二次以後的查詢必須帶 scroll = 1m 引數才能實現)
4 Search After 分頁方式
Search After 分頁方式是 ES 5 新增的一種分頁查詢方式,其實現的思路同 Scroll 分頁方式基本一致,透過記錄上一次分頁的位置標識,來進行下一次分頁資料的查詢。相比於 Scroll 分頁方式,它的優點是可以實時體現資料的變化,解決了查詢快照導致的查詢結果延遲問題。
4.1 執行過程
Search After 方式也不支援跳頁功能,每次查詢一頁資料。第一次每個 shard 返回一頁資料(size 條),coordinating node 一共獲取到 shard 數 * size 條資料 , 接下來 coordinating node 在記憶體中進行排序,取出前 size 條資料作為第一頁搜尋結果返回。當拉取第二頁時,不同於 Scroll 分頁方式,Search After 方式會找到第一頁資料被拉取的最大值,作為第二頁資料拉取的查詢條件。
這樣每個 shard 還是返回一頁資料(size 條),coordinating node 獲取到 shard 數 * size 條資料進行記憶體排序,取得前 size 條資料作為全域性的第二頁搜尋結果。
後續分頁查詢以此類推…
4.2 ES 示例
第一次查詢只傳入排序欄位和每頁大小 size
GET /wms_order_sku2021_10/_search { "query": { "bool": { "must": [ { "range": { "shipmentOrderCreateTime": { "gte": "2021-10-12 00:00:00", "lt": "2021-10-15 00:00:00" } } } ] } }, "size": 20, "sort": [ { "_id": { "order": "desc" } },{ "shipmentOrderCreateTime":{ "order": "desc" } } ] }
接下來每次查詢時都帶上本次查詢的最後一條資料的 _id 和 shipmentOrderCreateTime 欄位,迴圈往復就能夠實現不斷下一頁的功能
GET /wms_order_sku2021_10/_search { "query": { "bool": { "must": [ { "range": { "shipmentOrderCreateTime": { "gte": "2021-10-12 00:00:00", "lt": "2021-10-15 00:00:00" } } } ] } }, "size": 20, "sort": [ { "_id": { "order": "desc" } },{ "shipmentOrderCreateTime":{ "order": "desc" } } ], "search_after": ["SO-460_152-1447931043809128448-100017918838",1634077436000] }
4.3 實現示例
public <T> ScrollDto<T> queryScrollDtoByParamWithSearchAfter( BoolQueryBuilder queryParam, Class<T> targetClass, int pageSize, String afterId, List<FieldSortBuilder> 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<T> 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資料轉換bean,totalRow={}, size={}, use time:{}", scrollResp.getHits().getTotalHits(), hits.length, System.currentTimeMillis() - now); return ScrollDto.<T>builder().scrollId(afterId).result(list).totalRow((int) scrollResp.getHits().getTotalHits()).build(); }
4.4 小結
Search After 分頁方式採用記錄作為遊標,因此 Search After 要求 doc 中至少有一條全域性唯一變數(示例中使用_id 和時間戳,實際上_id 已經是全域性唯一)。Search After 方式是無狀態的分頁查詢,因此資料的變更能夠及時的反映在查詢結果中,避免了 Scroll 分頁方式無法獲取最新資料變更的缺點。同時 Search After 不用維護 scroll_id 和快照,因此也節約大量資源。
5 總結思考
5.1 ES 三種分頁方式對比總結
- 如果資料量小(from+size 在 10000 條內),或者只關注結果集的 TopN 資料,可以使用 from/size 分頁,簡單粗暴
- 資料量大,深度翻頁,後臺批處理任務(資料遷移)之類的任務,使用 scroll 方式
- 資料量大,深度翻頁,使用者實時、高併發查詢需求,使用 search after 方式
5.2 個人思考
- 在一般業務查詢頁面中,大多情況都是 10-20 條資料為一頁,10000 條資料也就是 500-1000 頁。正常情況下,對於使用者來說,有極少需求翻到比較靠後的頁碼來檢視資料,更多的是透過查詢條件框定一部分資料檢視其詳情。因此在業務需求敲定初期,可以同業務人員商定 1w 條資料的限定,超過 1w 條的情況可以藉助匯出資料到 Excel 表,在 Excel 表中做具體的操作。
- 如果給匯出中心返回大量資料的場景可以使用 Scroll 或 Search After 分頁方式,相比之下最好使用 Search After 方式,既可以保證資料的實時性,也具有很高的搜尋效能。
- 總之,在使用 ES 時一定要避免深度分頁問題,要在跳頁功能實現和 ES 效能、資源之間做一個取捨。必要時也可以調大 max_result_window 引數,原則上不建議這麼做,因為 1w 條以內 ES 基本能保持很不錯的效能,超過這個範圍深度分頁相當耗時、耗資源,因此謹慎選擇此方式。
作者:何守優
相關文章
- MySQL、Elasticsearch 深度分頁2023-04-16MySqlElasticsearch
- [Mark]解決ElasticSearch深度分頁機制中Result window is too large問題2018-05-24Elasticsearch
- Elasticsearch 分頁查詢2021-04-05Elasticsearch
- JavaScript數字分頁效果詳解2018-09-07JavaScript
- 分頁機制圖文詳解2018-03-11
- 自定義分頁標籤詳解2017-01-06
- 掌握ASP分頁技術詳解2008-09-19
- 讀寫分離 & 分庫分表 & 深度分頁2024-03-09
- Elasticsearch SQL用法詳解2018-12-13ElasticsearchSQL
- 24-PHP+MySQL分頁技術詳解2021-09-09PHPMySql
- 【elasticsearch】搜尋過程詳解2022-03-19Elasticsearch
- mysql中limit的用法詳解[資料分頁常用]2016-05-06MySqlMIT
- Java中Elasticsearch 實現分頁方式(三種方式)2023-01-08JavaElasticsearch
- elasticsearch查詢之大資料集分頁查詢2022-02-08Elasticsearch大資料
- elasticsearch查詢之大資料集分頁效能分析2022-02-09Elasticsearch大資料
- 從原理到應用,Elasticsearch詳解2019-08-10Elasticsearch
- Elasticsearch 索引的對映配置詳解2018-08-12Elasticsearch索引
- elasticsearch Request Body 與 Query DSL詳解2020-11-02Elasticsearch
- elasticsearch的java程式碼操作詳解2018-03-16ElasticsearchJava
- Oracle中的SQL分頁查詢原理和方法詳解2017-05-23OracleSQL
- Elasticsearch深度應用(上)2022-07-08Elasticsearch
- Elasticsearch深度應用(下)2022-07-09Elasticsearch
- Oracle分頁查詢中排序與效率問題解決方法詳解2014-05-30Oracle排序
- ElasticSearch 文件(document)內部機制詳解2022-03-13Elasticsearch
- Elasticsearch使用實戰以及程式碼詳解2024-02-29Elasticsearch
- 得物面試:MySQL 深度分頁如何最佳化?2024-01-18面試MySql
- Elasticsearch 分詞器2021-02-08Elasticsearch分詞
- 網頁效能管理詳解2015-09-17網頁
- js 陣列深度拷貝詳解2020-09-30JS陣列
- 中介軟體:ElasticSearch元件RestHighLevelClient用法詳解2021-01-25Elasticsearch元件RESTclient
- Web網頁效能管理詳解2015-09-17Web網頁
- 原生js頁面事件詳解2024-06-25JS事件
- Elasticsearch IK分詞器2021-08-18Elasticsearch分詞
- Elasticsearch 指令碼分組2019-01-15Elasticsearch指令碼
- 半夜被慢查詢告警吵醒,limit深度分頁的坑2024-06-27MIT
- Mysql系列第八講 詳解排序和分頁(order by & limit)及存在的坑2020-09-29MySql排序MIT
- 深度詳解GaussDB bufferpool快取策略2020-09-15快取
- 【詳解】FPGA:深度學習的未來?2016-02-27FPGA深度學習