ES terms 聚合为什么不准,以及搜索队列打满怎么排查
同一份数据,按品牌统计销量取前 5,单分片索引和 5 分片索引给出的第 4、5 名顺序不一样,计数也差了七十多。这不是 bug,而是 terms 聚合为了避免在分片之间传输全量数据所做的取舍。理解这个取舍,也就理解了聚合查询为什么容易把集群拖垮。
本文在 Elasticsearch 9.5.3 上做了两组实验:一组复现 terms 聚合的排名误差,一组把搜索线程池打满、观察被拒绝的请求。
一、先说结论
- terms 聚合的结果是近似的。 每个分片只上报自己的前
shard_size个词项,长尾词项在一部分分片上排不进名单,那些分片上的计数就丢了,合并后计数偏少,甚至整个词项被漏掉。 - 实测排名确实会错:5 分片索引上取前 5,第 4、5 名返回的是
brand-long-09(344)和brand-long-33(340),真实的第 4、5 名是brand-long-33(414)和brand-long-09(410):顺序颠倒,计数各少了约 70。 doc_count_error_upper_bound是误差上界,大于 0 就说明结果可能不准;sum_other_doc_count大于 0 说明有桶被丢弃。- 提高准确度优先调大
shard_size(默认size × 1.5 + 10),它比调大size便宜;实测调到 1000 后误差归零,结果与单分片一致。 - 聚合查询最容易打满搜索线程池。 实测把线程池调小后,120 个并发聚合请求中 116 个直接返回 429,被拒绝的分片级任务有 587 个。慢聚合会让同一节点上的简单查询一起排队。
二、terms 聚合怎么执行
2.1 两个阶段
聚合和搜索一样分两步走:每个分片各自算出局部结果,协调节点再合并。对 terms 聚合来说:
- 每个分片在本地统计各个词项的文档数,按文档数排序,取前
shard_size个上报; - 协调节点把各分片的结果按词项相加,排序后取前
size个返回。
分片不上报全部词项,是为了避免在高基数字段上传输几十万个桶。代价就是准确性。
2.2 复现排名错误
实验数据:21,531 个文档,5 个分片。3 个热门品牌各有 2000—3000 个文档,另有 40 个长尾品牌,每个 300—420 个文档(固定随机种子和文档 ID,结果可复现)。
POST /agg_demo/_search?size=0
{
"aggs": {
"brands": {
"terms": { "field": "brand.keyword", "size": 5, "show_term_doc_count_error": true }
}
}
}| 名次 | 真实结果 | 默认 shard_size(17) | shard_size = 1000 |
|---|---|---|---|
| 1 | brand-hot-A 3000 | brand-hot-A 3000 | brand-hot-A 3000 |
| 2 | brand-hot-B 2500 | brand-hot-B 2500 | brand-hot-B 2500 |
| 3 | brand-hot-C 2000 | brand-hot-C 2000 | brand-hot-C 2000 |
| 4 | brand-long-33 414 | brand-long-09 344 | brand-long-33 414 |
| 5 | brand-long-09 410 | brand-long-33 340 | brand-long-09 410 |
doc_count_error_upper_bound = 370 | doc_count_error_upper_bound = 0 |
前 3 名没问题,因为热门品牌在每个分片上都是第一梯队。出错的是长尾:brand-long-33 的 414 个文档分散在 5 个分片上,每片约 80 个,和其他 39 个长尾品牌挤在一起,只在一部分分片的局部排名里进了前 17。没进前 17 的分片不上报它,协调节点也就不知道这部分文档,合并后的计数只有 340。brand-long-09 少得少一些,于是两者的名次颠倒。换一份数据,缺得更多的长尾品牌会直接从前 5 里消失。
把同一份数据 reindex 到单分片索引,结果与真实值完全一致,doc_count_error_upper_bound 为 0——因为单分片不需要合并。
2.3 三个诊断字段
| 字段 | 含义 | 怎么用 |
|---|---|---|
doc_count_error_upper_bound | 返回结果中文档数误差的上界 | 大于 0 说明可能不准;实测默认配置下是 370 |
sum_other_doc_count | 没有进入返回结果的文档总数 | 大于 0 说明有桶被丢弃,实测是 13,347 |
每个桶的 doc_count_error_upper_bound | 需要 show_term_doc_count_error: true | 这一个桶的计数误差上界 |
实测返回桶里最大的误差上界是 75,整体的 doc_count_error_upper_bound 是 370。两者回答的问题不同:前者说的是「返回的这些词项,数字可能少算了多少」,后者说的是「没返回的词项,最多可能有多少文档被漏掉」。只要其中一个大于 0,结果就不能当精确值用。
2.4 怎么让结果更准
按代价从低到高:
- 调大
shard_size。 官方建议优先调它而不是size,因为它只增加分片到协调节点的传输量,不改变返回给客户端的桶数。实测调到 1000 后误差归零。 - 减少分片数,或者把聚合限定在单个分片(自定义路由、按时间索引只查一个索引)。
- 确实需要精确结果时换思路:用
composite聚合分页遍历全部桶,或者把统计结果预聚合成另一个索引(比如按天汇总的 rollup 索引),查询时直接读汇总数据。
2.5 别忘了字段类型和内存
- 聚合要用
keyword类型(或.keyword子字段),对text字段聚合需要开启fielddata,会把词项全部加载进堆内存,很容易触发熔断器。 - 高基数字段(用户 ID、订单号)上的 terms 聚合会产生巨量的桶,即使加了
size限制,分片上仍然要维护完整的哈希表。需要「有多少个不同值」时用cardinality聚合(基于 HyperLogLog++ 的近似算法),它的内存占用是可控的。 - 嵌套多层 terms 聚合时,桶数是各层的乘积,很容易爆炸。
search.max_buckets默认会拦住过大的请求。
三、搜索线程池:慢聚合如何影响其他查询
3.1 线程池与队列
每个节点的 search 线程池是固定大小的:
- 线程数默认是
int((可用处理器数 × 3) / 2) + 1; - 队列长度在 Elasticsearch 9.0 起是
1000 × 线程数(8.x 及更早固定为 1000)。实测 2 CPU 的容器里线程数是 4、队列长度是 4000; - 队列满时,新请求被拒绝,返回
429和es_rejected_execution_exception。
注意这里排队的不是「一个用户请求」,而是分片级任务:一个查询打到 5 个分片,就是 5 个任务。
3.2 打满线程池的实测
为了容易复现,启动容器时把线程池调小:
thread_pool.search.size: 2
thread_pool.search.queue_size: 10然后用 60 个并发、共 120 个请求打一个包含嵌套 terms 子聚合的重查询:
4 个请求返回 200
116 个请求返回 429
_cat/thread_pool/search 的 rejected 增加 587rejected 增加了 587,远多于 116,因为每个被拒绝的请求在 5 个分片上都产生了任务。被拒绝的响应体里能看到明确的原因:
{"error":{"root_cause":[{"type":"es_rejected_execution_exception",
"reason":"rejected execution of TimedRunnable{...} on TaskExecutionTimeTrackingEsThreadPoolExecutor[name = .../search, queue capacity = 10, ...]"}}}这解释了原本那个现象:集群资源看起来还有富余,简单查询却变慢甚至失败。线程被少数重聚合占满后,简单查询只能排队;队列一满,所有请求(无论轻重)都会被拒绝。
3.3 排查顺序
GET /_cat/thread_pool/search?v&h=node_name,active,queue,rejected,completedrejected是否在增长:非零且持续增长,说明已经在丢请求。queue是否长期非零:说明处理能力已经跟不上。- 是否集中在个别节点:集中说明数据或查询倾斜,有热分片;普遍说明整体容量不足。
- 找到慢查询:开启慢日志(
index.search.slowlog.threshold.query.warn),或用GET /_tasks?actions=*search&detailed看正在执行的任务。 - 看线程在做什么:
GET /_nodes/hot_threads会采样各节点最忙的线程栈,能直接看出是聚合、排序还是脚本在消耗 CPU。
3.4 处理办法
| 措施 | 说明 |
|---|---|
| 优化查询本身 | 减小 size 与聚合层数,用过滤条件缩小范围,能预聚合的提前算好 |
| 把分析型查询与线上查询隔离 | 用不同的节点或独立集群承载报表类聚合,避免互相影响 |
| 控制并发 | 在应用侧限流,聚合查询用单独的线程池和熔断,失败快速降级 |
| 调整分片 | 分片过多会让一个查询产生大量任务;分片倾斜要重新分配或调整路由 |
| 扩容 | 增加数据节点分摊分片,或增加副本分摊查询 |
不建议做的是简单调大队列。队列变长只会让请求排更久,客户端早已超时,服务端还在处理无人等待的查询,反而浪费资源。线程数也不宜盲目调大,超过 CPU 核数后上下文切换的开销会抵消收益。
四、常见误区
- 「聚合结果一定准确」:terms 聚合是近似的,多分片下排名可能出错。
- 「
doc_count_error_upper_bound为 0 就没问题」:单个桶的误差为 0,仍可能有词项被整体漏掉,要同时看整体的误差上界与sum_other_doc_count。 - 「把
size调大就准了」:优先调shard_size,它更便宜。 - 「429 是客户端并发太高」:也可能是少数重查询占满了线程,让正常请求排队。
- 「队列调大就不会拒绝了」:只是把拒绝换成了更长的等待和无效执行。
小结
terms 聚合用「每个分片只上报前 N 个」换取了可控的传输量和内存,代价是长尾词项可能被漏掉;判断结果是否可信,看 doc_count_error_upper_bound 和 sum_other_doc_count,需要更准时优先调大 shard_size。而聚合查询的成本最终体现在搜索线程池上:rejected 增长、queue 堆积、hot_threads 里都是聚合栈,说明该做的是优化查询和隔离负载,而不是调大队列。
倒排索引、分片路由与刷新机制,见 Elasticsearch 核心机制。
配套实验
- codesphere-labs/storage/elasticsearch-behaviors:terms 聚合在 5 分片与单分片、默认与调大
shard_size时的结果,search 线程池的默认值与打满后的拒绝(验证记录)
参考资料