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

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法