Skip to content

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 聚合来说:

  1. 每个分片在本地统计各个词项的文档数,按文档数排序,取前 shard_size 个上报;
  2. 协调节点把各分片的结果按词项相加,排序后取前 size 个返回。
5 个分片各自上报前 shard_size 个品牌(默认 size × 1.5 + 10 = 17)分片 0上报前 17 个分片 1上报前 17 个分片 2上报前 17 个分片 3上报前 17 个分片 4上报前 17 个协调节点合并按 doc_count 排序取前 5实测结果:第 4、5 名返回 brand-long-09 (344)、brand-long-33 (340)真实第 4、5 名:brand-long-33 (414)、brand-long-09 (410) ← 调大 shard_size 后结果正确
图 1 · 每个分片只上报自己的前 shard_size 个词项,长尾词项在一部分分片上进不了名单,这些分片上的计数就没有算进去,合并后计数偏少、排名出错

分片不上报全部词项,是为了避免在高基数字段上传输几十万个桶。代价就是准确性。

2.2 复现排名错误 ​

实验数据:21,531 个文档,5 个分片。3 个热门品牌各有 2000—3000 个文档,另有 40 个长尾品牌,每个 300—420 个文档(固定随机种子和文档 ID,结果可复现)。

json
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
1brand-hot-A 3000brand-hot-A 3000brand-hot-A 3000
2brand-hot-B 2500brand-hot-B 2500brand-hot-B 2500
3brand-hot-C 2000brand-hot-C 2000brand-hot-C 2000
4brand-long-33 414brand-long-09 344brand-long-33 414
5brand-long-09 410brand-long-33 340brand-long-09 410
doc_count_error_upper_bound = 370doc_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 怎么让结果更准 ​

按代价从低到高:

  1. 调大 shard_size。 官方建议优先调它而不是 size,因为它只增加分片到协调节点的传输量,不改变返回给客户端的桶数。实测调到 1000 后误差归零。
  2. 减少分片数,或者把聚合限定在单个分片(自定义路由、按时间索引只查一个索引)。
  3. 确实需要精确结果时换思路:用 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 打满线程池的实测 ​

为了容易复现,启动容器时把线程池调小:

text
thread_pool.search.size: 2
thread_pool.search.queue_size: 10

然后用 60 个并发、共 120 个请求打一个包含嵌套 terms 子聚合的重查询:

并发请求120 个search 线程池size = 2队列queue_size = 10429 拒绝es_rejected_execution线程满了进队列队列满了直接拒绝实测:200 响应 4 个,429 响应 116 个,_cat/thread_pool 的 rejected 增加 587(按分片级任务计数)ES 9.x 默认队列是 1000 × 线程数(2 CPU 容器实测 4 个线程、队列 4000);示例中特意调小以便复现
图 2 · 线程忙完后新请求进入队列,队列也满时直接返回 429;实测 120 个并发请求中 116 个被拒绝
text
4 个请求返回 200
116 个请求返回 429
_cat/thread_pool/search 的 rejected 增加 587

rejected 增加了 587,远多于 116,因为每个被拒绝的请求在 5 个分片上都产生了任务。被拒绝的响应体里能看到明确的原因:

json
{"error":{"root_cause":[{"type":"es_rejected_execution_exception",
  "reason":"rejected execution of TimedRunnable{...} on TaskExecutionTimeTrackingEsThreadPoolExecutor[name = .../search, queue capacity = 10, ...]"}}}

这解释了原本那个现象:集群资源看起来还有富余,简单查询却变慢甚至失败。线程被少数重聚合占满后,简单查询只能排队;队列一满,所有请求(无论轻重)都会被拒绝。

3.3 排查顺序 ​

text
GET /_cat/thread_pool/search?v&h=node_name,active,queue,rejected,completed
  1. rejected 是否在增长:非零且持续增长,说明已经在丢请求。
  2. queue 是否长期非零:说明处理能力已经跟不上。
  3. 是否集中在个别节点:集中说明数据或查询倾斜,有热分片;普遍说明整体容量不足。
  4. 找到慢查询:开启慢日志(index.search.slowlog.threshold.query.warn),或用 GET /_tasks?actions=*search&detailed 看正在执行的任务。
  5. 看线程在做什么: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 核心机制。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权