)
Elasticsearch 搜索數(shù)量不能超過10000的解決方案但凡用 Elasticsearch 做過搜索或者導出功能的同學大概率都撞上過這面墻明明索引里有幾十萬條數(shù)據(jù)一個match_all查下去hits.total卻只給你 10000再往后翻就報Result window is too large的錯。第一次遇到的時候我也挺懵查了一下文檔才發(fā)現(xiàn)是index.max_result_window的默認值在作怪。這其實不是 ES 在故意給你使絆子而是它在用這個限制保護集群的內存和響應速度。但業(yè)務可不管這些產(chǎn)品經(jīng)理一句“全量導出”你就得老老實實想辦法繞過去。這篇文章我就把這幾年來用過的、踩過的坑整理成一套完整的方案。不是單純告訴你“調參數(shù)就行”而是把為什么會有 10000 這個數(shù)、各種方案分別適合什么場景、參數(shù)怎么算、代碼怎么寫一次講清楚。不管你是剛接觸 ES 的新手還是已經(jīng)被深分頁折磨過的老手這篇應該都能幫你省下不少試錯的時間。1. 為什么搜索數(shù)量被鎖死在100001.1 從深分頁的代價說起ES 的搜索本質上是一個“分散收集”的過程。一個索引通常有多個分片你發(fā)一個查詢請求協(xié)調節(jié)點會把請求廣播到所有分片每個分片各自完成排序然后只返回自己排名靠前的from size條結果給協(xié)調節(jié)點協(xié)調節(jié)點再對這些結果做全局排序。問題就出在這里。如果from是 100000協(xié)調節(jié)點就必須先從每個分片取回 100000 條文檔哪怕最終只需要 10 條。分片數(shù)量越多這個中間結果集就越龐大內存開銷呈線性甚至超線性增長。在深分頁的情況下整個集群的資源會被這種無意義的計算白白吃掉??梢源炙阋还P賬假設索引有 5 個分片查詢from100000, size10那么協(xié)調節(jié)點需要接收 5 × 100000 500000 條文檔每條文檔就算只保留_id、_score和排序字段也有幾百字節(jié)這一輪下來就是幾百 MB 的內存暫用。如果業(yè)務 QPS 再高一點協(xié)調節(jié)點的堆內存直接被撐爆不是玩笑。所以 ES 提供了max_result_window這個保護機制。默認 10000也就是說from size的最大值不允許超過 10000。它的本質是默認場景下ES 只支持你在前 10000 條結果里翻頁再深就不玩了。1.2 實際場景中誰會被這個限制卡住很多人以為“搜索”最多就看前幾頁10000 條完全夠用。但在真實業(yè)務中撞墻的場景遠比想象中多后臺管理系統(tǒng)的數(shù)據(jù)導出運營要導出一份半年內的訂單明細總量可能 20 萬條但導出接口基本都是一個scroll或者search_after就能解決的事。定時任務的全量同步把 ES 里的數(shù)據(jù)同步到數(shù)倉、或者重灌另一個索引動不動幾十萬上百萬條。業(yè)務上的“下一頁”遍歷某些檢索產(chǎn)品的翻頁深度確實能超過幾百頁。聚合分析的前置查詢先查出一批文檔 ID 再做后續(xù)處理為了避免 deep pagination很多團隊直接把size設成 Integer.MAX_VALUE結果就是直接報錯。不管是哪種情況解決思路本質上只有兩類第一類是把窗口調大讓 ES 允許你翻得更深第二類是換一種遍歷方式壓根不走深分頁。下面挨個說。2. 方案橫向對比每個思路的優(yōu)缺點和適用場景2.1 直接調大 index.max_result_window最直觀的辦法。一條命令就能搞定PUT /your_index/_settings { index.max_result_window: 1000000 }也可以用通配符批量設置PUT /my_logs_*/_settings { index.max_result_window: 1000000 }這個方案的優(yōu)點是簡單、立即生效、不需要改代碼適合某些緊急情況下的快速止血。但缺點也非常明顯它只是把“墻”挪遠了并沒有消除深度分頁的內存開銷。如果你把窗口調到 100 萬一旦有查詢真的翻到 50 萬頁協(xié)調節(jié)點的內存照樣會被瞬間打滿整個集群可能因此進入 GC 長暫停甚至宕機。我的建議是這個方案只適合“臨時應急”或者“明確知道數(shù)據(jù)量很小”的場景。比如一個索引上限就是兩萬條那調到 50000 完全沒毛病。如果是用來解決全量導出的問題千萬不要靠它硬撐——你這是在拿集群的穩(wěn)定性做賭注。2.2 使用 Scroll 游標Scroll 是 ES 專門為“遍歷全部數(shù)據(jù)”設計的 API核心思路是第一次查詢時ES 為這個查詢生成一個快照游標之后通過游標 ID 分批拉取數(shù)據(jù)不再重復排序計算。# 第一次請求返回 _scroll_id POST /your_index/_search?scroll1m { size: 1000, query: { match_all: {} } } # 后續(xù)每次通過 scroll id 拉下一批 POST /_search/scroll { scroll: 1m, scroll_id: DXF1ZXJ5QW5kRmV0Y2gB... }Scroll 的優(yōu)點很明顯它是官方設計的遍歷方案性能穩(wěn)定不會因為數(shù)據(jù)總量大而內存失控。但它也有幾個讓人頭疼的副作用生成快照是有成本的如果索引在 scroll 期間發(fā)生寫入、刪除、更新scroll 查詢到的是快照建立那一刻的數(shù)據(jù)不是“實時”的。游標會話需要占用服務端資源超過scroll參數(shù)指定的時間后游標會被自動清理。用完還必須顯式刪除游標否則資源泄漏。因為不支持跳頁你只能從頭拉到尾所以它根本不適合用在“用戶翻頁瀏覽”的交互場景里。結論是Scroll 最適合“一次性全量導出”“數(shù)據(jù)同步”“重索引”這類離線任務。當年我做某系統(tǒng)數(shù)據(jù)遷移用 scroll 配合多線程并發(fā)消費一次性搬了接近 200 萬條數(shù)據(jù)整個過程非常穩(wěn)。但如果你是要做“支持跳到任意頁”的后臺列表scroll 幫不了你。2.3 使用 Search After 方案Search after 是目前社區(qū)公認的“分頁理想之選”。它的設計思路是記錄上一頁最后一條數(shù)據(jù)的排序值下一頁查詢時以這個排序值為起點向后查找。典型的用法如下GET /your_index/_search { size: 10, query: { match_all: {} }, sort: [ { id: asc }, { _id: asc } ] }拿到最后一條結果后把 sort 數(shù)組里的值填進下一次請求GET /your_index/_search { size: 10, query: { match_all: {} }, search_after: [100024, doc_id_value], sort: [ { id: asc }, { _id: asc } ] }Search after 的原理是不通過from來定位偏移量而是拿取上一頁末尾的排序值作為條件直接讓 ES 從對應位置開始掃描。這樣每次查詢只需獲取下一頁的數(shù)據(jù)協(xié)調節(jié)點不需要保留前面所有頁的中間結果內存開銷和查詢深度沒有直接關系。它的優(yōu)勢非常契合交互式分頁穩(wěn)定、高效、不占額外資源、能看到實時數(shù)據(jù)變化。缺點同樣是無法自由跳頁——你想從第 1 頁直接跳到第 100 頁是做不到的必須一頁頁順著走。那什么時候用 search_after 最合適數(shù)據(jù)量幾十萬到上千萬的后臺列表、帶“加載更多”的 C 端信息流、以及所有需要并行遍歷數(shù)據(jù)的場景。我自己現(xiàn)在寫后臺列表基本直接默認 search_after不再用 from size。2.4 順帶說一句 Track Total Hits很多同學還遇到過另一個現(xiàn)象hits.total顯示為 10000但數(shù)據(jù)其實遠不止這些。這跟max_result_window是兩碼事它是track_total_hits參數(shù)控制的。當它為默認的false時ES 只精確統(tǒng)計前 10000 條命中數(shù)超過就顯示 10000。如果業(yè)務上需要知道全量命中數(shù)可以在查詢時設置GET /your_index/_search { track_total_hits: true, query: { match_all: {} } }注意開啟后會產(chǎn)生額外的統(tǒng)計開銷如果索引特別大、命中數(shù)量特別多這個計數(shù)過程本身會消耗一定性能。建議只在“必須展示精確總數(shù)”的頁面上開并且配合size限制使用不要和深分頁同時開。3. 深入剖析從分頁原理到 Search After 的完整實操3.1 為什么排序字段決定了 Search After 的成敗Search after 依賴排序值的穩(wěn)定性。如果排序字段存在重復值那基于這個重復值的“起點定位”就會出問題同一頁數(shù)據(jù)可能出現(xiàn)重復或丟失。規(guī)避的方法是保證排序組合“全局唯一”。最穩(wěn)的組合是一個業(yè)務主鍵如自增 id 或時間戳_id兜底。_id是文檔的唯一標識天然不可能重復。只要業(yè)務主鍵相同就靠_id來打破平局保證每一頁結果的順序是嚴格確定的。這里有個細節(jié)如果你用_id做排序字段ES 內部對_id的排序走的是keyword字典序不是你以為的“數(shù)字序”。如果你的主鍵是純數(shù)字的字符串排序結果可能是 “1002”“1003”“999” 這種和你預期的數(shù)字順序不一致需要留意。解決辦法有兩種要么在 mapping 里新增一個long類型的字段存自增數(shù)字主鍵要么設計排序時用日期 自增數(shù)字兩個字段組合。3.2 實操用 Python 封裝一個通用 Scroll 導出工具數(shù)據(jù)導出的場景我推薦直接用 scroll 來寫。下面是一個封裝好的通用導出函數(shù)實測在數(shù)據(jù)量 50 萬級別的索引上跑得很穩(wěn)from elasticsearch import Elasticsearch, helpers import time class ESExporter: def __init__(self, hosts, index, queryNone, batch_size5000): self.es Elasticsearch(hosts) self.index index self.query query or {query: {match_all: {}}} self.batch_size batch_size def export_all(self): total 0 start time.time() resp self.es.search( indexself.index, bodyself.query, scroll5m, sizeself.batch_size, _sourceTrue ) scroll_id resp.get(_scroll_id) try: while resp[hits][hits]: batch_docs resp[hits][hits] total len(batch_docs) # 在這里把 batch_docs 寫進文件、數(shù)據(jù)庫或消息隊列 self._process_batch(batch_docs) resp self.es.scroll(scroll_idscroll_id, scroll5m) finally: self.es.clear_scroll(scroll_idscroll_id) print(f導出完成共 {total} 條耗時 {time.time() - start:.2f}s) def _process_batch(self, docs): # 示例打印前 3 條數(shù)據(jù)的 id for doc in docs[:3]: print(doc[_id])幾個容易踩的注意點scroll 參數(shù)的時長設置要合理。官方推薦“能覆蓋兩個批次處理間隔”即可比如 5 分鐘處理 5000 條那設置5m就夠。設太長會占著服務端資源不釋放。每一批處理完一定要記得用clear_scroll清理游標。雖然游標超時會自動失效但在高并發(fā)場景下不主動清理會積累大量空閑游標導致內存碎片增加。如果是要并發(fā)消費不要在一個線程里串行 scroll可以開多個 worker 線程每個線程獨立使用一個 scroll_id這樣能顯著提升吞吐。我在搬數(shù)據(jù)時開了 8 個線程速度大概提升了 4 倍左右。3.3 實操用 Java 實現(xiàn) Search After 的深分頁列表查詢國內用 Java 寫 ES 查詢的團隊還是最多的我直接給一套完整的 Java 實現(xiàn)public ListMapString, Object searchAfterQuery( RestHighLevelClient client, String index, long lastId, String lastDocId, int size) throws IOException { SearchRequest request new SearchRequest(index); SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); sourceBuilder.query(QueryBuilders.matchAllQuery()); sourceBuilder.size(size); sourceBuilder.fetchSource(true); // 排序組合用數(shù)字 id 做主排序_id 做唯一性兜底 sourceBuilder.sort(new FieldSortBuilder(data_id).order(SortOrder.ASC)); sourceBuilder.sort(_doc); if (lastId 0) { sourceBuilder.searchAfter(new Object[]{lastId, lastDocId}); } request.source(sourceBuilder); SearchResponse response client.search(request, RequestOptions.DEFAULT); ListMapString, Object list new ArrayList(); for (SearchHit hit : response.getHits().getHits()) { MapString, Object map hit.getSourceAsMap(); map.put(_doc_id, hit.getId()); list.add(map); } return list; }注意我用的是sort(_doc)而不是sort(_id)。_doc是 ES 內置的“輸入順序”排序它不額外計算字段值性能極高而_id排序走字段反轉索引需要額外內存。兩者選誰取決于你的場景如果數(shù)據(jù)幾乎只追加不更新_doc完全夠用如果會有排序波動風險就回到_id兜底方案。調用示例ListMapString, Object page searchAfterQuery(client, order_index, 0, , 20); String lastDocId (String) page.get(page.size() - 1).get(_doc_id); Long lastDataId (Long) page.get(page.size() - 1).get(data_id); // 下一次查詢 page searchAfterQuery(client, order_index, lastDataId, lastDocId, 20);3.4 核心參數(shù)的計算與選擇邏輯很多同學會問size到底設多少合適scroll 的batch_size呢這個沒有什么銀彈但可以給一套參考邏輯search_after 場景size 設 10~50 都行關鍵是排序字段穩(wěn)定。如果信息流類產(chǎn)品一次加載 20 條用戶體驗最好。scroll 全量導出場景批量大小推薦在 1000~5000 之間。批量太小網(wǎng)絡往返次數(shù)多吞吐上不去批量太大單批次響應體過大遇到臟數(shù)據(jù)可能導致 JSON 解析異常重試代價高。我在生產(chǎn)環(huán)境壓測過一個索引批量 5000 條時吞吐最高、垃圾回收最平緩批量調到 20000 時反而觸發(fā)了好幾次老年代 GC整體耗時反而上升。內存預估一個粗略但實用的公式是size × 單條文檔平均大小 × 分片數(shù)。如果單條文檔 2KB、分片 5 個、size 5000那么一個批次協(xié)調節(jié)點大約要暫用 50MB 內存。多線程并發(fā)時要按照線程數(shù)翻倍估算。3.5 生產(chǎn)級搜索封裝思路說實話把從 fromsize 切換到 search_after 的過程做成“透明無感”才是工程上最優(yōu)雅的做法。我在一個通用查詢服務里封裝了一個分頁工具對外暴露PageRequest對象public class PageRequest { private int pageSize; private Object[] searchAfter; private boolean useSearchAfter; // 第一頁useSearchAfterfalsepageSize20 // 下一頁useSearchAftertruesearchAfter上一頁最后一個排序值 }代碼層面從第一頁翻到第一百頁的時候前端傳回來的是一個“游標”字符串后端把它反序列化成排序數(shù)組塞進 search_after 里。這樣對前端而言和普通分頁沒區(qū)別但后端從底層規(guī)避了深分頁的問題。具體做法是把上一頁的sort數(shù)組用 Base64 編碼后作為下一頁的請求參數(shù)這樣既不需要用戶記住頁碼也天然支持無限深度翻頁。4. 常見問題與排查技巧實錄4.1 調大 max_result_window 后查詢仍然報錯這種情況大概率是目標索引的設置沒生效。一個容易忽略的點是ES 7.x 之后索引名支持通配符但通配符只能匹配完整的索引名不能匹配別名。如果你通過別名查詢設置卻打在物理索引上那查詢依然會報錯。排查步驟# 查看當前索引的實際設置 GET /your_index/_settings/index.max_result_window # 確認別名指向哪些物理索引 GET /_alias/your_alias還有一種可能是你改了模板但只對新索引生效。老索引的 setting 是創(chuàng)索引時寫死的必須單獨更新。4.2 Scroll 明明設置了超時時間連接卻一直報錯最常見的原因是 scroll 游標在服務端是獨立于 HTTP 連接的你設置scroll1m后如果兩個批次之間處理耗時超過了 60 秒比如批處理器里調用了外部接口很慢游標已經(jīng)過期你再拿著舊的 scroll_id 去請求就報No search context found。排查思路把 scroll 時間放寬到一個有余量的值比如10m或者你可以在批處理邏輯里做多線程并行處理縮短單批耗時。還有一個低級錯誤同一個 scroll_id 被兩個線程并發(fā)消費。ES 的 scroll 游標不是線程安全的并發(fā)消費同一個游標會導致結果錯亂或者拋異常。正確做法是每個線程初始化獨立的 scroll 查詢。4.3 為什么 search_after 有時會漏數(shù)據(jù)或者重復這個坑我?guī)屯屡挪檫^最后定位到原因業(yè)務方用了一個時間戳字段做排序但這個字段在寫入后會被更新導致排序值發(fā)生“跨頁”移動。第一條記錄本來排在第 5 頁末尾下一秒被更新到第 1 頁前部search_after 基于舊排序值往后查就會漏掉這種“排序值變小”的記錄。解決方案很明確排序字段選擇寫入后不變的字段比如create_time、自增主鍵。如果業(yè)務無法保證不可變就要接受 search_after 的“弱一致性”同時結合業(yè)務規(guī)則去規(guī)避比如只查三天前的數(shù)據(jù)三天內不允許變動。兜底方案是加一個_id參與排序至少保證文檔不會因為平局而重復/丟失。4.4 深度分頁時內存飆升要不要換方案如果你還在用 from size并且翻到了四五萬頁發(fā)現(xiàn)協(xié)調節(jié)點內存異常飆升你第一步要做的不是加內存而是立刻把查詢改成 search_after。同時通過慢日志查看是不是有高成本查詢PUT /your_index/_settings { index.search.slowlog.threshold.query.warn: 5s, index.search.slowlog.threshold.fetch.warn: 3s }開啟慢日志后觀察一段時間往往能發(fā)現(xiàn)不只是分頁方式的問題還有可能是 sort 字段沒建 doc_values、或者查詢里帶了腳本排序——這些都會放大內存開銷。4.5 常見問題速查表問題現(xiàn)象可能原因推薦處理方式翻頁超過 10000 直接報錯max_result_window 默認限制交互式分頁用 search_after全量導出用 scrolltotal 一直顯示 10000track_total_hits 未開啟需要精確總數(shù)時開啟但要評估性能開銷scroll 中途報 context not found游標超時調大 scroll 時間或減少單批處理耗時search_after 結果有重復/遺漏排序字段不唯一或會變動增加“業(yè)務主鍵 _id”雙字段排序fromsize 深分頁內存暴漲協(xié)調節(jié)點需要合并全量中間結果立即切換到 search_after并評估查詢成本調大 setting 后仍報錯別名/模板未覆蓋到優(yōu)先檢查索引真實 setting 和別名指向5. 我的最終建議與選型路徑如果是新項目我的默認路徑是這樣的普通業(yè)務列表、后臺管理系統(tǒng)、運營平臺 → 直接用search_after不解釋。全量數(shù)據(jù)導出、索引重建、數(shù)倉同步 → 直接用scroll并且每批處理完清游標。不能改代碼的臨時救急 → 調max_result_window同時評估風險并盡快補方案。沒有深翻頁需求、確認總量小 → 繼續(xù)用 fromsize但建議 size 不超過 100。我個人在實際操作中最深的體會是不要在分頁技術上糾結“哪種寫法最?!倍认朊靼啄愕膱鼍笆恰坝脩艨磾?shù)據(jù)”還是“系統(tǒng)搬數(shù)據(jù)”。這兩個場景對分頁的要求完全不同選型錯了再優(yōu)化也都別扭。還有一個小技巧無論用 scroll 還是 search_after都要在索引上精心設計排序字段最好單獨加一個data_id作為穩(wěn)定排序字段別省這一步。后面你會感謝自己當時加了它。