為什麼 ES 的搜索結果只到 10,000?強制 “數清楚” 的代價有多大

新聞
HongKong
5
03:54 PM · Jan 27 ,2026

“為什麼搜出來的結果總數永遠是 10,000?是不是數據丟了?”

這是每一個 Elasticsearch 新手在 7.x 版本之後都會遇到的“靈魂發問”。

而在得知“這是 ES 的默認性能優化”後,絕大多數人的第一反應往往是抗拒:“我不想要優化,我要精準的數字!老闆要看,前端分頁也要用。”

於是,在許多項目的代碼庫裏,我們都能看到這樣一行“補丁”:

"track_total_hits": true

數字終於準了,變成了 854,321,大家都很滿意。

但沒人注意到,就在這行代碼生效的瞬間,集羣的 CPU 佔用率可能直接翻倍,查詢延遲從 20ms 劣化到了 500ms。

為什麼一個簡單的“計數”,會成為拖垮集羣性能的隱形殺手?

今天,我們不談玄學,從 Lucene 底層原理 層面,揭秘這背後的代價。

現象:消失的“尾部數據”

在 ES 7.0 之前,查詢默認會算出精確總數。但在 7.0 之後,默認的響應體變成了這樣:

"hits": {
    "total": {
        "value": 10000,
        "relation": "gte"  // 注意:Greater Than or Equal to (大於等於)
    },
    ...
}

這不僅是一個數字的變化,更是搜索引擎檢索邏輯的根本性範式轉移:

從“找出所有匹配文檔”進化為“只找出最有價值的 Top N”。

深度解析:Block-Max WAND 算法的智慧

要理解為什麼“數不準”能帶來性能飛躍,必須深入 Lucene 的底層,瞭解 Block-Max WAND (BMW) 算法。

1. 倒排索引的物理結構:Block

在 Lucene 的倒排索引(Inverted Index)中,一個 Term(詞項)對應的 Postings List(文檔 ID 列表)並不是一整條長鏈,而是被切分成了多個 Block(塊),通常包含 128 個文檔 ID。

關鍵點在於:

每個 Block 都有一個 Block Header,裏面記錄了這個塊的元數據,其中最重要的一個是:

Max Score(塊內最大分數):即這個 Block 裏所有文檔中,能產生的最高相關性得分是多少。

2. 競速機制:Min Competitive Score

當 ES 執行查詢(比如查詢“手機”)並索要 Top 10 結果時,它會維護一個 最小競爭分數(Min Competitive Score)。

  • 剛開始,Top 10 沒滿,最小競爭分數是 0。

  • 隨着掃描進行,找到了 10 個文檔,第 10 名的分數是 5.0。

  • 此時,最小競爭分數變成了 5.0。意味着:任何分數低於 5.0 的文檔,連進決賽圈的資格都沒有。

3. “隔山打牛”的跳躍優化

接下來,神奇的事情發生了。

當遍歷指針來到下一個 Block(假設包含 ID 1000-1127)時,算法不需要解壓這個 Block,也不需要計算具體的文檔分數,而是直接看 Block Header:

算法拷問: “這個 Block 的 Max Score 是多少?”Block 回答: “最高只能得 4.5 分。”算法判斷: “現在的門檻(Min Competitive Score)已經是 5.0 了。你這個 Block 裏的文檔全是‘廢柴’,整個 Block 直接跳過!”

結果: ES 可能直接跳過了數百萬個低分文檔。

代價: 因為跳過了,ES 根本不知道剛才那個 Block 裏有幾個文檔匹配(雖然分低,但確實匹配)。所以,它無法給出精確的總數。

隱形陷阱:為什麼關了 track_total_hits 依然慢?

你以為把 track_total_hits 設為 false 就萬事大吉了?

別天真了。 在某些特定場景下,即使你顯式關閉了計數,ES 依然無法使用 Block-Max WAND 進行剪枝,查詢性能依然會很差。

這就好比你告訴司機“不用數路邊有幾棵樹(不計數)”,但你同時又下令“把路邊每棵樹的葉子都摘一片下來(聚合)”。司機雖然不用數數,但他依然得停在每一棵樹下。

1. 聚合(Aggregations):WAND 的天敵

WAND 算法的核心奧義是 “Skipping(跳過)”。

而聚合(Aggregations)的核心訴求是 “Traversing(遍歷)”。

如果你在查詢中包含任何聚合(例如 termsavgdate_histogram),ES 必須訪問所有匹配的文檔來計算統計值。

  • 衝突點:你不能跳過一個 Block,因為那個被跳過的 Block 裏可能包含聚合所需的數據。

  • 結果:WAND 優化被迫關閉,全量掃描不可避免。

2. 排序與分數的糾結(Sort + track_scores)

Block-Max WAND 依賴於 Max Score 來進行跳躍。

  • 場景:你按時間排序 (sort: [{"timestamp": "desc"}])。  

  • 陷阱:如果你手滑加了 track_scores: true(或者某些 Client 默認開啓)。

  • 後果:雖然是按時間排序,但因為你強行索要 _score,ES 必須對每個文檔計算分數,導致無法利用 BKD Tree 的索引順序進行快速跳躍,也難以通過 WAND 進行分數剪枝(因為排序不是按分數排的)。

代價:track_total_hits 如何摧毀性能

口説無憑,數據為證。我們在 Serverless 8.17 環境下,創建一個6分片1副本的索引,寫入約 2 億條 脱敏文檔,總計約30G數據,查詢qps控制在30 。用實測結果向你展示——track_total_hits 取不同值時的cu消耗及響應時長:

http://oscimg.oschina.net/AiCreationDetail/up-402c3235b9a2211d7434c2467a1f6151.png

CPU 資源消耗對比(true,false,10000)

http://oscimg.oschina.net/AiCreationDetail/up-a03bc5f0419bbc696f687f4cef2be00e.png

平均耗時和 P95 耗時對比(true,false,10000)

當你加上 "track_total_hits": true 時,你實際上是在對底層引擎下達一道“死命令”:

“禁用 Block-Max WAND 優化,禁止跳過任何一個 Block。”這帶來的性能崩塌是顯著的。

1. CPU 的燃燒(計算密集)

  • 優化模式:只解壓和計算高分 Block。

  • 強制計數:必須解壓所有匹配的 Block(使用 Frame Of Reference 編碼),並對每一個文檔 ID 進行解碼和比對。

  • 量級差異:如果查詢匹配 1 億條數據,原本只需計算 Top 1000 的分數,現在必須計算 1 億次。CPU 消耗可能增加幾個數量級。

2. I/O 的雪崩(訪存密集)

  • 優化模式:低分 Block(通常是歷史冷數據)直接跳過,不需要從磁盤讀取。

  • 強制計數:強制讀取所有 Block。

  • 後果:這會導致大量的隨機 I/O。更致命的是,這些本該沉睡的冷數據被加載到內存中,會污染 Page Cache,把真正熱點的數據(比如最近 5 分鐘的日誌)擠出去。

  • 現象:你會發現,為了查一個總數,整個集羣的寫入性能和熱查詢性能都下降了。

決策矩陣:到底什麼時候該用?

我們需要建立一個清晰的決策標準,而不是盲目地 true

http://oscimg.oschina.net/AiCreationDetail/up-d65823c4e2299860a2a8785675fd22d1.png

Serverless 下的治理之道

在自建集羣中,很難限制開發者的代碼行為。但在 阿里雲 ES Serverless 中,我們建議通過配置進行防禦性治理。

1. 軟限制:"Track Total Hits上限"

你可以通過 ES Serverless 控制枱—開啓功能設置,給查詢加上一道“保護鎖”:

http://oscimg.oschina.net/AiCreationDetail/up-9d3340ed3f39441f7739d9f6e1506e78.png
  • 效果:一旦設置,即使客户端請求中帶了 "track_total_hits": true,服務端也會強制忽略,只計算到 10,000。 

  • 價值:從根源上防止了某個實習生的一行代碼搞掛整個生產集羣。

2. 擁抱“模糊的正確”

在雲原生時代,算力是按量計費的(CU)。

“無意義的精確計數” = “直接燒錢”。

利用 Serverless 的彈性與治理能力,將算力集中在真正產生業務價值的 Top N 搜索上,才是降本增效的關鍵。

附錄:代碼與實操

1. 正確的查詢姿勢 (DSL)

場景 A:只要 Top 10 (性能最快)

GET /logs/_search
{
  "query": { "match": { "message": "error" } },
  "size": 10,
  "track_total_hits": false  // 顯式關閉,連 10000 都不數,最快
}

場景 B:需要精確計數 (Java Client)

SearchRequest searchRequest = new SearchRequest("logs");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchQuery("message", "error"));

// ⚠️ 警告:僅在必要時開啓,注意性能損耗
sourceBuilder.trackTotalHits(true); 

// 或者設置一個具體的上限,例如 5萬// sourceBuilder.trackTotalHitsUpTo(50000); 

searchRequest.source(sourceBuilder);

2. 高效替代方案:Cardinality 聚合

如果你只是想知道“大概有多少條日誌”,不要用 track_total_hits,請用 HyperLogLog 算法:

GET /logs/_search
{
  "size": 0,
  "aggs": {
    "total_count_approx": {
      "cardinality": {
        "field": "_id",  // 或者其他高基數主鍵
        "precision_threshold": 10000 
      }
    }
  }
}
  • 優點:性能極快,內存佔用極低。

  • 缺點:有約 5% 以內的誤差。​

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.