ES terms 聚合为什么不准,以及搜索队列打满怎么排查
同一份数据,按品牌统计销量取前 5,单分片索引和 5 分片索引给出的第 4、5 名顺序不一样,计数也差了七十多。这不是 bug,而是 terms 聚合为了避免在分片之间传输全量数据所做的取舍。理解这个取舍,也就理解了聚合查询为什么容易把集群拖垮。
本文在 Elasticsearch 9.5.3 上做了两组实验:一组复现 terms 聚合的排名误差,一组把搜索线程池打满、观察被拒绝的请求。
一、terms 聚合怎么执行
1.1 两个阶段
聚合和搜索一样分两步走:每个分片各自算出局部结果,协调节点再合并。对 terms 聚合来说:
- 每个分片在本地统计各个词项的文档数,按文档数排序,取前
shard_size个上报; - 协调节点把各分片的结果按词项相加,排序后取前
size个返回。
分片不上报全部词项,是为了避免在高基数字段上传输几十万个桶。代价就是准确性。
1.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——因为单分片不需要合并。
1.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,结果就不能当精确值用。
1.4 怎么让结果更准
按代价从低到高:
- 调大
shard_size。 官方建议优先调它而不是size,因为它只增加分片到协调节点的传输量,不改变返回给客户端的桶数。实测调到 1000 后误差归零。 - 减少分片数,或者把聚合限定在单个分片(自定义路由、按时间索引只查一个索引)。
- 确实需要精确结果时换思路:用
composite聚合分页遍历全部桶,或者把统计结果预聚合成另一个索引(比如按天汇总的 rollup 索引),查询时直接读汇总数据。
1.5 别忘了字段类型和内存
- 聚合要用
keyword类型(或.keyword子字段),对text字段聚合需要开启fielddata,会把词项全部加载进堆内存,很容易触发熔断器。 - 高基数字段(用户 ID、订单号)上的 terms 聚合会产生巨量的桶,即使加了
size限制,分片上仍然要维护完整的哈希表。需要「有多少个不同值」时用cardinality聚合(基于 HyperLogLog++ 的近似算法),它的内存占用是可控的。 - 嵌套多层 terms 聚合时,桶数是各层的乘积,很容易爆炸。
search.max_buckets默认会拦住过大的请求。
二、搜索线程池:慢聚合如何影响其他查询
2.1 线程池与队列
每个节点的 search 线程池是固定大小的:
- 线程数默认是
int((可用处理器数 × 3) / 2) + 1; - 队列长度在 Elasticsearch 9.0 起是
1000 × 线程数(8.x 及更早固定为 1000)。实测 2 CPU 的容器里线程数是 4、队列长度是 4000; - 队列满时,新请求被拒绝,返回
429和es_rejected_execution_exception。
注意这里排队的不是「一个用户请求」,而是分片级任务:一个查询打到 5 个分片,就是 5 个任务。
2.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, ...]"}}}这解释了原本那个现象:集群资源看起来还有富余,简单查询却变慢甚至失败。线程被少数重聚合占满后,简单查询只能排队;队列一满,所有请求(无论轻重)都会被拒绝。
2.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。
2.4 处理办法
| 措施 | 说明 |
|---|---|
| 优化查询本身 | 减小 size 与聚合层数,用过滤条件缩小范围,能预聚合的提前算好 |
| 把分析型查询与线上查询隔离 | 用不同的节点或独立集群承载报表类聚合,避免互相影响 |
| 控制并发 | 在应用侧限流,聚合查询用单独的线程池和熔断,失败快速降级 |
| 调整分片 | 分片过多会让一个查询产生大量任务;分片倾斜要重新分配或调整路由 |
| 扩容 | 增加数据节点分摊分片,或增加副本分摊查询 |
不建议做的是简单调大队列。队列变长只会让请求排更久,客户端早已超时,服务端还在处理无人等待的查询,反而浪费资源。线程数也不宜盲目调大,超过 CPU 核数后上下文切换的开销会抵消收益。
倒排索引、分片路由与刷新机制,见 Elasticsearch 核心机制。
配套实验
- codesphere-labs/storage/elasticsearch-behaviors:terms 聚合在 5 分片与单分片、默认与调大
shard_size时的结果,search 线程池的默认值与打满后的拒绝(验证记录)
参考资料