很多团队把 Elasticsearch 当搜索引擎用着用着,就顺手拿它做起了分析:按地区统计订单量、按时间画趋势曲线、算各品类的平均客单价。这些都靠 ES 的聚合(aggregation)框架。但聚合一旦上量,最容易出两类事故——内存爆掉触发熔断、以及统计数字「不对」。这两件事的根因都在聚合的执行原理里。本文把分桶、指标、两阶段归并和那个臭名昭著的精度问题讲清楚。

场景:聚合到底在算什么

聚合的查询语法看着像套娃,但只有两个基础积木:

  • Bucket(桶)聚合:按某种规则把文档分组。terms(按字段值分)、date_histogram(按时间区间分)、range(按数值区间分)都是分桶。桶可以嵌套桶,形成多维下钻。
  • Metric(指标)聚合:对一组文档算一个数值。sumavgmaxcardinality(去重计数)、percentiles(分位数)都是指标。

一个典型查询长这样:先按「地区」分桶,每个桶里再算「订单总额」和「去重用户数」:

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /orders/_search
{
"size": 0,
"aggs": {
"by_region": {
"terms": { "field": "region", "size": 10 },
"aggs": {
"total": { "sum": { "field": "amount" } },
"uv": { "cardinality": { "field": "user_id" } }
}
}
}
}

注意 "size": 0——它告诉 ES「我只要聚合结果,别返回原始文档」。聚合不需要命中文档明细,省掉取回明细这步能显著降开销,这是第一个该养成的习惯。

机制一:聚合靠 doc_values,不是倒排索引

理解聚合性能,必须先理解它读的是哪份数据。全文搜索走倒排索引(term → 文档列表),回答「哪些文档包含这个词」。但聚合要干的是反向的事:「这一批文档在某字段上的值分别是什么」,好拿来分组求和。用倒排索引做这件事得全表扫描每个 term 再回查,极慢。

所以 ES 为此专门维护了一份列式存储结构 doc_values:按字段把每个文档的值连续存放,本质就是「文档号 → 字段值」的正排。这正是列式存储擅长聚合的原因——要算某列的 sum,把这一列连续读出来扫一遍即可,缓存友好、向量化友好。doc_values 在写入时构建、存在磁盘上、靠 OS page cache 加速,所以它不吃 JVM 堆。

例外是 text 类型字段默认没有 doc_values(它要分词,没法当成单一值做列存),所以你直接对 text 字段做 terms 聚合会报错,提示让你开 fielddata——而 fielddata 是把数据加载进 JVM 堆的老机制,极易 OOM,生产几乎永远不要开。正解是给字段加一个 keyword 子字段,对 keyword 聚合:

1
2
3
4
5
6
7
"properties": {
"tag": {
"type": "text",
"fields": { "raw": { "type": "keyword" } }
}
}
// 聚合用 tag.raw,搜索用 tag

机制二:分布式两阶段归并与精度陷阱

这是聚合最反直觉、也最容易出数据事故的地方。索引被分成多个分片,聚合采用 scatter-gather(分发-归并) 模式:协调节点把请求广播到每个分片,每个分片在本地算出局部聚合结果,回传给协调节点做最终归并。

sumavgmax 这类可加性指标,归并是精确的——各分片的局部 sum 一加就是全局 sum。但对 terms 聚合,问题来了。

假设你要「全局 Top 3 热门标签」,有两个分片。协调节点不可能让每个分片把所有标签都传回来(标签可能有几百万个,网络和内存扛不住)。于是每个分片只传回自己本地的 Top N。陷阱在于:某个标签的全局真实总数可能很高,但它恰好在每个分片里都排不进本地 Top N,于是被各分片提前丢弃,最终全局结果里它就缺席或计数偏低了。

举个极端例子:标签 X 全局排第 3,但它在分片 A 排第 11、在分片 B 排第 12,如果每个分片只传 Top 10,X 在两个分片都被截断——最终汇总里 X 直接消失。这就是为什么 ES 在 terms 聚合结果里返回两个特殊字段:

1
2
doc_count_error_upper_bound  // 被遗漏文档计数的误差上界
sum_other_doc_count // 没进入返回桶的所有文档总数

doc_count_error_upper_bound > 0 就是在警告你:这个 terms 结果可能不精确。

缓解手段是 shard_size:让每个分片返回比最终 size 更多的候选桶,留足缓冲再归并。ES 默认 shard_size = size * 1.5 + 10。要更准就调大它:

1
2
3
"by_tag": {
"terms": { "field": "tag.raw", "size": 10, "shard_size": 200 }
}

代价是分片传回更多数据、协调节点归并更重。这是一个精度 vs 内存/网络的直接权衡,没有免费的准确。

类比:这就像各省先各自报本省 Top10 网红到中央评全国 Top10。一个在每个省都恰好排第 11 的人,全国加起来其实很火,却因为各省上报时被卡在门外而落选。让各省多报几个(shard_size 调大),漏掉真·全国红人的概率才会下降。

机制三:cardinality 是估算,不是精确去重

cardinality(去重计数)用的是 HyperLogLog++ 算法,是近似的,不是精确去重。原因很简单:精确去重要把所有不同值存进集合比对,亿级基数会撑爆内存。HLL 用固定的、很小的内存(受 precision_threshold 控制)换取约 1%~2% 的误差。

1
2
3
4
5
6
"uv": {
"cardinality": {
"field": "user_id",
"precision_threshold": 3000
}
}

precision_threshold 以下基本精确,以上转为估算、误差随基数上升。永远不要拿 cardinality 的结果去对账或做精确计费。 这是另一个高频踩坑:业务方拿去重 UV 数和数据库精确值对,发现差了几百,以为系统有 bug。

工程权衡与踩坑小结

  • 高基数 terms 聚合是头号杀手。 对 user_id 这种百万级基数字段做 terms,会瞬间产生海量桶、吃光内存,直接触发 ES 的 circuit breaker(熔断保护)抛 CircuitBreakingException。需要遍历高基数全集时,用 composite 聚合分页拉取,而不是一把 terms 梭哈。
  • 嵌套聚合是笛卡尔级膨胀。 三层嵌套 terms,桶数是各层基数相乘。务必估算桶规模,必要时收敛每层 size。
  • 善用过滤缩小数据集。 聚合是对命中文档算的。在聚合外层套精确的 filter(filter 不打分、可缓存)先把文档集缩小,再聚合,比对全量聚合快得多。
  • size:0 + doc_values 是基本功,别让聚合顺带把文档明细也取回来。
  • 记住三条精度红线:terms 的 Top N 在分布式下可能不精确(看 error bound、调 shard_size);cardinality 是 HLL 估算;percentiles 同样是近似算法(TDigest)。需要绝对精确的财务级数字,ES 聚合不是合适工具。

聚合框架的设计哲学是「用可控的内存和近似换取分布式下的海量数据实时统计能力」。理解了它读 doc_values、走两阶段归并、并在精度上做了取舍,你就能预判它什么时候会 OOM、什么时候数字会「不对」,并对症下药。