StackTraces
OpenSearch已驗證

OpenSearch terms 聚合只返回 10 個桶:預設 size 截斷與 shard_size 誤差

@debug_master2 天前更新閱讀 6 min00

terms 聚合的 size 預設是 10,字段有 12 個取值也只返回 10 個桶,被丟掉的計數彙總在 sum_other_doc_count 裡。調大 size 即可拿全;多分片下每個分片只上報 size * 1.5 + 10 個候選,計數可能不準,誤差上界看 doc_count_error_upper_bound。桶特別多時改用 composite 聚合分頁。適用 OpenSearch 2.x / 3.x,本文對 3.9.0 校驗。

Issue & Env(案發現場與環境)

  • 業務場景:給內容站做類型篩選面板,genre 是個 keyword 字段,一共 12 個取值。前端只渲染出 10 個篩選項,剩下兩個類型在頁面上完全消失,接口也沒報錯。
  • 環境:OpenSearch 3.9.0,Docker 單節點,1 主分片、0 副本,genre 映射為 keyword。
  • 關鍵區分:不是數據沒進去,也不是 mapping 寫錯。_count 是 12,terms 只回 10 個桶,而且響應裡 sum_other_doc_count 正好等於 2。

StackTrace(響應原文)

{
  "aggregations": {
    "by_genre": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 2,
      "buckets": [
        { "key": "action", "doc_count": 1 },
        { "key": "comedy", "doc_count": 1 },
        { "key": "drama", "doc_count": 1 },
        { "key": "horror", "doc_count": 1 },
        { "key": "scifi", "doc_count": 1 },
        { "key": "documentary", "doc_count": 1 },
        { "key": "animation", "doc_count": 1 },
        { "key": "thriller", "doc_count": 1 },
        { "key": "romance", "doc_count": 1 },
        { "key": "mystery", "doc_count": 1 }
      ]
    }
  }
}

12 個類型,10 個桶,sum_other_doc_count 是 2。計數全是 1 時,具體返回哪 10 個並沒有保證——同樣計數的桶之間順序取決於分片內部順序,別拿這份列表當穩定契約。

Reproduce(復現程式碼)

前提:本地 OpenSearch 已啟動,示例值沿用 quickstart(端口 9200、用戶 admin、密碼 Devfix@2026)。

建一個只有 genre 的索引,導入 12 條各不相同的類型:

curl -sk -u admin:Devfix@2026 -X PUT "https://localhost:9200/shows?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": { "index.number_of_shards": 1, "index.number_of_replicas": 0 },
    "mappings": { "properties": { "genre": { "type": "keyword" } } }
  }'

cat > shows.ndjson <<'EOF'
{ "index": { "_index": "shows", "_id": "1" } }
{ "genre": "action" }
{ "index": { "_index": "shows", "_id": "2" } }
{ "genre": "comedy" }
{ "index": { "_index": "shows", "_id": "3" } }
{ "genre": "drama" }
{ "index": { "_index": "shows", "_id": "4" } }
{ "genre": "horror" }
{ "index": { "_index": "shows", "_id": "5" } }
{ "genre": "scifi" }
{ "index": { "_index": "shows", "_id": "6" } }
{ "genre": "documentary" }
{ "index": { "_index": "shows", "_id": "7" } }
{ "genre": "animation" }
{ "index": { "_index": "shows", "_id": "8" } }
{ "genre": "thriller" }
{ "index": { "_index": "shows", "_id": "9" } }
{ "genre": "romance" }
{ "index": { "_index": "shows", "_id": "10" } }
{ "genre": "mystery" }
{ "index": { "_index": "shows", "_id": "11" } }
{ "genre": "fantasy" }
{ "index": { "_index": "shows", "_id": "12" } }
{ "genre": "western" }
EOF

curl -sk -u admin:Devfix@2026 -X POST "https://localhost:9200/_bulk?pretty" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @shows.ndjson

不寫 size,直接聚合:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/shows/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{ "size": 0, "aggs": { "by_genre": { "terms": { "field": "genre" } } } }'

判定標準:buckets 陣列長度是 10、sum_other_doc_count 是 2,即復現成功。

Root Cause(根因分析)

size 預設 10,多餘的桶被摺疊

官方 terms 文檔的參數表寫得很直接:size 是「要返回的桶數,預設 10」。被摺疊的桶不是消失了,它們的文檔數加總進 sum_other_doc_count。所以這不屬於數據丟失,只是返回上限。

分佈式下還有一層近似

terms 不是把全量桶排好序再截斷。每個分片先在本地取候選,再彙總排序。文檔裡 shard_size 的作用是「每個分片收集的候選詞條數」,預設 size * 1.5 + 10;doc_count_error_upper_bound 表示「某個桶的計數可能被低估的最大幅度」。

單分片時這兩個值通常都是 0,看起來一切精確;一旦索引有多個分片、字段取值又很分散,doc_count_error_upper_bound 就可能大於 0。這時不是程式碼寫錯,而是 terms 本身的近似性。

桶的順序也不是「按字母」

order 預設 {"_count": "desc"},按文檔數降序。計數相同的桶之間沒有穩定順序,想要固定順序得顯式指定 {"_key": "asc"}。

The Fix(解決方案)

方案一:顯式設置 size

知道取值上限就直接給足:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/shows/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "aggs": {
      "by_genre": {
        "terms": { "field": "genre", "size": 20, "order": { "_key": "asc" } }
      }
    }
  }'

這次 12 個桶全在,sum_other_doc_count 變成 0,順序也固定成字母序。

方案二:桶多到一頁放不下時用 composite

取值可能有幾百上千個時,terms 的 size 開再大也只是「一次拿一頁」,還費記憶體。composite 聚合支持 after 游標翻頁,適合「把所有類型逐個拉完」的離線任務:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/shows/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "aggs": {
      "genres": {
        "composite": {
          "size": 5,
          "sources": [ { "genre": { "terms": { "field": "genre" } } } ]
        }
      }
    }
  }'

響應裡會帶 after_key,把它塞回請求的 composite.after 就是下一頁。

方案三:需要更準的計數時調 shard_size

多分片場景下,把 shard_size 調大能減少誤差,代價是每個分片多做一輪候選收集:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/shows/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "aggs": {
      "by_genre": {
        "terms": { "field": "genre", "size": 20, "shard_size": 100 }
      }
    }
  }'

注意事項

  • 判斷是否被截斷,看 sum_other_doc_count,別看桶的數量猜。
  • size 只控制返回桶數,不影響 hits;兩者是獨立的兩個參數。
  • 分面面板通常給 size 留一點餘量(比如展示 10 個就設 12),再用 bucket_sort 做最終截斷。
  • 聚合怎麼寫、bucket_sort 怎麼用,見 從零跑通 OpenSearch 聚合與分面。

References(參考來源)

最後更新於 2026-10-27

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法