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(参考来源)
- Terms aggregation — OpenSearch 官方文档opensearch-docs
- Composite aggregation — OpenSearch 官方文档opensearch-docs
- Aggregations — OpenSearch 官方文档opensearch-docs
