1. 从一次数据统计需求说起:聚合到底在解决什么问题
刚接触 Elasticsearch 那会儿,我对它的定位就是"一个搜得很快的搜索引擎"。直到有一次,业务方提了个需求:把过去一周的订单数据按省份、按品类分别统计销售额,还要算出每个省份的平均客单价和订单量 Top10 的城市。我第一反应是写 SQL,但数据量摆在那里,传统关系型数据库跑这种多维分组统计,慢得让人想砸键盘。后来同事提醒我,Elasticsearch 的聚合(Aggregations)就是干这个的,而且它和搜索是同一套数据、同一套查询入口,不用再单独搭一套分析链路。
这就是聚合最核心的价值:在检索的基础上做实时统计分析。你可以把它理解成"搜索 + 分组统计 + 指标计算"三合一。搜索负责把符合条件的数据筛出来,聚合负责在这些数据上做分组、求值、排序、嵌套。它和 SQL 里的GROUP BY加聚合函数思路类似,但表达能力和灵活性完全不是一个量级——聚合可以嵌套、可以按范围分桶、可以按时间做直方图、还能对每个桶再算多个指标。
这篇文章适合谁看?如果你已经能写基本的 Elasticsearch 查询,但对聚合的terms、date_histogram、cardinality、pipeline这些概念还停留在"知道名字但不敢用"的阶段,那这篇就是为你准备的。我会从聚合的分类讲起,把桶聚合、指标聚合、管道聚合的边界讲清楚,再结合真实的统计场景给出可直接抄的 DSL,最后重点聊聊那些文档里不会写、但一踩就疼的坑——比如doc_count为什么不准、terms结果为什么只返回 10 个、cardinality的误差到底能不能接受。
需要先明确一个前提:聚合是建立在查询结果之上的。也就是说,query先过滤出文档集合,aggs再在这个集合上做统计。这个顺序非常关键,后面讲性能优化和结果偏差时都会回到这一点。
2. 聚合的三大门派:桶、指标、管道各管什么
Elasticsearch 的聚合类型看着多,但归归类其实就三大门派。搞清楚每个门派负责什么,你在设计聚合 DSL 的时候就不会乱。
2.1 桶聚合:负责"分组",把数据装进不同的筐
桶聚合(Bucket Aggregations)的核心动作是分组。它根据某个字段的值、某个范围、某个时间间隔,把文档分到不同的"桶"里。每个桶里装着一批文档,桶本身会带一个doc_count,告诉你这个桶里有多少条数据。
常见的桶聚合有这么几类:
- Terms:按字段的具体值分组,比如按省份、按商品 ID、按状态码。这是用得最多的一个。
- Date Histogram:按时间间隔分组,比如按天、按小时、按周。做时间趋势分析必用。
- Histogram:按数值区间分组,比如把价格按 100 一档分桶。
- Range:手动指定范围分桶,比如 0-100、100-500、500 以上。
- Filters:按多个查询条件分桶,每个条件一个桶。
- Nested / Reverse Nested:处理嵌套文档的分组。
桶聚合可以嵌套桶聚合,也可以嵌套指标聚合。比如"先按省份分桶,再在每个省份里按品类分桶,最后算每个品类的销售额",这就是两层桶聚合加一层指标聚合。
2.2 指标聚合:负责"算数",对桶里的数据求值
指标聚合(Metric Aggregations)不分组,它是对一批文档做数学计算,输出一个或多个数值。常见的指标聚合包括:
| 聚合类型 | 作用 | 典型场景 |
|---|---|---|
avg | 求平均值 | 平均客单价、平均响应时间 |
sum | 求和 | 总销售额、总流量 |
min/max | 求最小/最大值 | 最低价、最高并发 |
value_count | 统计值的数量 | 有报价的商品数 |
cardinality | 去重计数(近似) | UV、去重用户数 |
stats | 一次返回 count、min、max、avg、sum | 快速概览 |
percentiles | 百分位数 | P95、P99 延迟 |
top_hits | 返回桶内原始文档 | 每个分组取最新一条 |
指标聚合通常作为桶聚合的子聚合出现,作用域是"当前桶内的文档"。它也可以直接挂在顶层,对全部查询结果做计算。
2.3 管道聚合:负责"对聚合结果再加工"
管道聚合(Pipeline Aggregations)比较特殊,它的输入不是文档,而是其他聚合的输出。也就是说,它是对聚合结果做二次计算。
举个典型例子:你按天做了销售额的sum,现在想算"每天销售额的环比增长率",或者"所有天里销售额最高的那一天"。这些都不是对原始文档算的,而是对"每天的 sum 结果"再算。这时候就要用管道聚合,比如derivative(求导,算环比)、cumulative_sum(累计求和)、bucket_script(自定义脚本计算)、max_bucket/min_bucket(找最大/最小的桶)。
管道聚合分两类:父管道聚合(Parent)和兄弟管道聚合(Sibling)。父管道聚合的结果嵌入在父桶聚合里,兄弟管道聚合的结果和它引用的聚合平级。这个区别在写 DSL 时体现在buckets_path的写法上,后面会细说。
提示:管道聚合不能直接对文档操作,它必须引用一个已有的聚合结果。如果你发现自己在管道聚合里想写
field,那多半是方向错了。
3. 手把手写一个多维统计 DSL:从需求到落地
光讲概念没意思,直接上一个真实场景。假设我们有一批订单数据,索引叫order_index,每条文档大概长这样:
{ "order_id": "A1001", "province": "广东省", "city": "深圳市", "category": "数码", "amount": 299.00, "quantity": 1, "user_id": "u_8821", "create_time": "2024-06-01T10:23:00" }需求是:统计 2024 年 6 月每个省份的销售额、订单量、平均客单价,并且每个省份下再按品类拆分,最后按销售额降序取前 5 个省份。
3.1 第一层:时间范围过滤 + 省份分桶
GET order_index/_search { "size": 0, "query": { "range": { "create_time": { "gte": "2024-06-01T00:00:00", "lt": "2024-07-01T00:00:00" } } }, "aggs": { "by_province": { "terms": { "field": "province.keyword", "size": 5, "order": { "total_amount": "desc" } }, "aggs": { "total_amount": { "sum": { "field": "amount" } }, "avg_amount": { "avg": { "field": "amount" } }, "order_count": { "value_count": { "field": "order_id" } } } } } }这里有几个关键点必须解释清楚。
size: 0是干什么的?它告诉 Elasticsearch 不要返回命中的原始文档,只要聚合结果。做统计时几乎都要加这个,否则默认返回 10 条文档,白白浪费带宽和解析时间。
为什么字段要加.keyword?因为province这种字段在映射里通常是text类型,会被分词。分词之后"广东省"可能被拆成"广东"和"省",按这种字段做terms聚合,结果就乱了。所以要么用.keyword子字段,要么在建索引时就把这个字段设成keyword类型。这是新手最容易踩的坑之一。
order里为什么能引用total_amount?因为total_amount是by_province的子聚合。在terms聚合里,可以用子聚合的结果来排序。这里按销售额降序,配合size: 5,就实现了"取销售额前 5 的省份"。
3.2 第二层:省份下再按品类拆分
在by_province里再加一层terms子聚合:
"aggs": { "by_province": { "terms": { "field": "province.keyword", "size": 5, "order": { "total_amount": "desc" } }, "aggs": { "total_amount": { "sum": { "field": "amount" } }, "avg_amount": { "avg": { "field": "amount" } }, "by_category": { "terms": { "field": "category.keyword", "size": 10 }, "aggs": { "category_amount": { "sum": { "field": "amount" } } } } } } }这样返回的结果就是:每个省份一个桶,桶里有该省的总销售额、平均客单价,还有一个by_category的子桶列表,列出该省各品类的销售额。
嵌套聚合的层级不要太深。每多一层,计算量都会放大。一般业务场景下,两到三层足够。如果发现要四层以上,先想想是不是数据建模有问题,或者该用别的方案了。
3.3 用date_histogram做时间趋势
如果需求变成"按天统计销售额趋势",就把terms换成date_histogram:
"aggs": { "sales_over_time": { "date_histogram": { "field": "create_time", "calendar_interval": "day", "format": "yyyy-MM-dd", "min_doc_count": 0, "extended_bounds": { "min": "2024-06-01", "max": "2024-06-30" } }, "aggs": { "daily_amount": { "sum": { "field": "amount" } } } } }calendar_interval和fixed_interval的区别值得说一下。calendar_interval按自然日历走,比如day、month、week,它会自动处理月份天数不同、闰年这些情况。fixed_interval是固定时长,比如30m、12h,适合做固定窗口的统计。做业务报表一般用calendar_interval,做监控指标一般用fixed_interval。
min_doc_count: 0配合extended_bounds的作用是:即使某天没有数据,也要返回那一天的桶,值为 0。否则图表上会出现断点,前端画折线图会很难看。这个组合在做时间序列展示时几乎是标配。
4. 那些文档不会告诉你的聚合陷阱
聚合用起来简单,但坑特别多。下面这几个是我在实际项目里反复踩过、也见过别人踩的,每一个都值得单独拎出来说。
4.1terms默认只返回 10 个桶,而且可能不准
terms聚合默认size是 10。很多人第一次用的时候,发现明明有几十个省份,结果只返回了 10 个,还以为是数据丢了。解决办法就是显式设置size。但这里有个更隐蔽的问题:terms聚合的结果是近似值,不是精确值。
为什么?因为 Elasticsearch 是分布式的,数据分散在多个分片上。每个分片各自算出自己的 Top N,然后协调节点把各分片的结果合并,再取全局 Top N。如果某个值在单个分片上没进前 N,但在全局其实应该进前 N,它就会被漏掉。这就是所谓的"terms聚合不精确"问题。
解决办法有两个:
- 把
size设大一点,比如你实际想要 10 个,就设成10 * 分片数 * 1.5,给合并留足余量。 - 用
shard_size参数,它控制每个分片返回多少个候选桶,默认是size * 1.5 + 10。数据倾斜严重时,手动调大shard_size能明显改善准确性。
如果业务对准确性要求极高,还有一个终极方案:把索引设成单分片。但这样会牺牲扩展性,一般不建议,除非数据量很小。
4.2cardinality去重计数天生有误差
cardinality用的是 HyperLogLog++ 算法,这是一种概率算法,用固定大小的内存去估算基数。它的特点是:内存占用小、速度快,但结果有误差,默认误差在 5% 以内。
做 UV 统计时,如果你发现今天算出来是 10234,明天同样的数据算出来是 10198,别慌,这是正常现象。想减小误差,可以调precision_threshold:
{ "cardinality": { "field": "user_id", "precision_threshold": 40000 } }precision_threshold最大支持 40000。低于这个基数值时,结果基本精确;超过之后,误差开始显现。代价是内存占用会上升。所以要不要调,取决于你的基数规模和精度要求。如果只是看趋势,默认值够用;如果要做精确的对账,那就别用cardinality,老老实实换方案。
4.3 聚合字段的fielddata问题
对text类型字段直接做聚合,会报Fielddata is disabled的错误。原因是text字段为了支持全文检索,用的是倒排索引,做聚合需要正排信息,而正排默认不开。
解决办法有三种:
- 用
.keyword子字段(推荐,前提是映射里配了)。 - 在建索引时把字段设成
keyword类型。 - 临时开启
fielddata(不推荐,非常吃内存,容易把节点搞挂)。
我见过有人为了图省事直接开fielddata,结果一个聚合查询把堆内存打满,节点直接失联。这个操作一定要慎重。
4.4 深嵌套聚合的性能悬崖
聚合的层级越深、桶的数量越多,内存和 CPU 消耗越大。一个三层嵌套、每层几百个桶的聚合,可能比一个简单查询慢上百倍。
优化思路有这么几条:
- 先过滤再聚合。
query阶段能筛掉的数据,绝不留到聚合阶段。过滤越狠,聚合越快。 - 控制桶的数量。
terms的size不要盲目设大,够用就行。 - 避免在高基数字段上做多层嵌套。比如
user_id这种几乎唯一的字段,做terms聚合会产生海量桶,非常危险。 - 用
doc_values。大部分字段默认开启,它对聚合和排序做了列式存储优化,比fielddata高效得多。
注意:聚合查询的
size和terms里的size是两个完全不同的东西。前者控制返回多少条原始文档,后者控制返回多少个桶。别搞混了。
5. 管道聚合实战:算环比、找极值、做占比
管道聚合是聚合里最容易被忽略、但用好了能省很多事的部分。它的核心思想是:不直接碰文档,只对聚合结果做运算。
5.1 用derivative算环比增长
假设已经按天算出了销售额,现在想算每天的环比增长额:
"aggs": { "sales_per_day": { "date_histogram": { "field": "create_time", "calendar_interval": "day" }, "aggs": { "daily_amount": { "sum": { "field": "amount" } }, "amount_derivative": { "derivative": { "buckets_path": "daily_amount" } } } } }buckets_path指向同级的daily_amount,表示"对每天的销售额求导"。返回结果里,第一天没有前值,所以amount_derivative是null,从第二天开始才有值。
5.2 用bucket_script算占比
想算每个省份的销售额占全国总销售额的比例,可以这样写:
"aggs": { "total_sales": { "sum": { "field": "amount" } }, "by_province": { "terms": { "field": "province.keyword", "size": 10 }, "aggs": { "province_sales": { "sum": { "field": "amount" } }, "sales_ratio": { "bucket_script": { "buckets_path": { "provinceAmount": "province_sales", "totalAmount": "total_sales" }, "script": "params.provinceAmount / params.totalAmount" } } } } }这里total_sales是顶层聚合,by_province是另一个顶层聚合。bucket_script通过buckets_path同时引用了子聚合和顶层聚合的值,做除法得到占比。这种跨层级的引用是管道聚合的强项。
5.3 用max_bucket找销售额最高的那一天
"aggs": { "sales_per_day": { "date_histogram": { "field": "create_time", "calendar_interval": "day" }, "aggs": { "daily_amount": { "sum": { "field": "amount" } } } }, "max_daily_sales": { "max_bucket": { "buckets_path": "sales_per_day>daily_amount" } } }buckets_path里的>表示"进入子聚合"。sales_per_day>daily_amount的意思是:在sales_per_day这个桶聚合的每个桶里,取daily_amount这个指标,然后找最大值。返回结果会告诉你最大值是多少,以及它对应哪个桶的 key。
管道聚合的buckets_path语法是重点,总结一下:
| 语法 | 含义 |
|---|---|
agg_name | 引用同级聚合 |
agg_name>metric_name | 进入子聚合取指标 |
agg_name.metric_name | 引用聚合内的单值指标 |
the_sum[0] | 引用多值指标的第 0 个值 |
写错了buckets_path,报错信息往往很含糊,所以建议先用小数据集验证,确认路径对了再上生产。
6. 聚合性能调优:让统计查询从秒级到毫秒级
聚合查询慢,通常不是 Elasticsearch 本身的问题,而是用法不对。下面这几条是我在实际调优中验证过、效果最明显的。
6.1 善用filter上下文和post_filter
query里的条件会参与打分,而聚合其实不关心打分,只关心"哪些文档命中"。所以能用filter就别用must:
"query": { "bool": { "filter": [ { "term": { "status": "paid" } }, { "range": { "create_time": { "gte": "2024-06-01" } } } ] } }filter上下文不计算相关性得分,还能利用查询缓存,比must快不少。
另外,如果某个条件只用于过滤文档、不影响聚合范围,可以用post_filter。它会在聚合之后才过滤文档,聚合本身不受影响。这个在"先看全局统计,再筛选明细"的场景里很有用。
6.2 用doc_values而不是fielddata
前面提过,doc_values是列式存储,天生适合聚合和排序。大部分字段默认开启,不需要额外配置。要避免的是对text字段开fielddata,那是一条性能不归路。
检查一个字段能不能高效聚合,可以看映射:
GET order_index/_mapping/field/province如果返回里doc_values是true,那就放心用。如果是text且没有.keyword,那就得改映射或者换字段。
6.3 控制桶的数量,警惕高基数聚合
高基数字段(比如user_id、order_id)做terms聚合,会产生海量桶,内存消耗呈指数级上升。如果确实需要,考虑这几个替代方案:
- 用
cardinality做去重计数,而不是列出所有值。 - 用
composite聚合做分页遍历,它支持after_key游标,可以一批一批地取,不会一次性把所有桶加载到内存。 - 如果只是要 Top N,把
size控制住,配合shard_size调优。
composite聚合是处理高基数分组的利器,它的写法是这样的:
"aggs": { "my_buckets": { "composite": { "size": 100, "sources": [ { "province": { "terms": { "field": "province.keyword" } } }, { "category": { "terms": { "field": "category.keyword" } } } ] }, "aggs": { "total": { "sum": { "field": "amount" } } } } }返回结果里会带一个after_key,下次请求把它传回去,就能继续取下一批。这种方式内存占用可控,适合做全量分组导出。
6.4 合理设置分片数和副本数
聚合是分片级并行计算的,分片太多会增加协调节点的合并开销,分片太少又无法充分利用多核。经验值是:单个分片大小控制在 10GB 到 50GB 之间,分片总数不要超过节点数的几倍。
副本对聚合查询也有影响。副本能分担查询压力,但会占用额外的存储和写入开销。读多写少的场景可以适当增加副本。
7. 几个真实场景的聚合方案拆解
理论讲完了,最后用几个具体场景把前面的知识串起来。
7.1 电商场景:多维度销售报表
需求:按省份、品类两个维度交叉统计销售额,同时给出每个省份的订单量和平均客单价。
方案就是前面写的两层terms嵌套加指标聚合。要注意的是,如果省份和品类都是高基数字段,交叉之后桶的数量是乘积关系,很容易爆炸。这时候要么限制size,要么改用composite分页。
7.2 日志场景:按小时统计错误率
需求:统计每小时的总请求数和错误请求数,算出错误率。
"aggs": { "per_hour": { "date_histogram": { "field": "@timestamp", "fixed_interval": "1h" }, "aggs": { "total": { "value_count": { "field": "request_id" } }, "errors": { "filter": { "term": { "status": "error" } } }, "error_rate": { "bucket_script": { "buckets_path": { "errCount": "errors._count", "totalCount": "total" }, "script": "params.totalCount > 0 ? params.errCount / params.totalCount : 0" } } } } }这里用了一个filter子聚合来单独统计错误请求,再用bucket_script算比率。errors._count是filter聚合的特殊路径,直接取桶内文档数。脚本里加了除零保护,避免没有请求时算出NaN。
7.3 用户行为场景:去重 UV 和留存分析
UV 用cardinality就够了。留存分析稍微复杂一点,通常需要按天分桶,然后在每个桶里统计"当天活跃且之前某天也活跃"的用户数。这种场景下,cardinality的误差会被放大,如果对精度要求高,建议把用户行为数据同步到专门的分析引擎里做,或者用composite加脚本精确计算。
7.4 监控场景:P95、P99 延迟统计
"aggs": { "latency_percentiles": { "percentiles": { "field": "response_time", "percents": [50, 95, 99] } } }percentiles默认返回 1、5、25、50、75、95、99 这几个分位。可以自定义percents。它底层用的是 TDigest 算法,也是近似值,但在大多数监控场景下精度足够。
8. 我在聚合上踩过的几个印象深刻的坑
说几个具体的、有画面感的经历。
有一次做省份销售排行,terms的size设了 10,结果业务方说"我们明明有 31 个省,为什么只看到 10 个"。我一开始以为是数据问题,查了半天才发现是size默认值。改成 50 之后,又发现排名第 11 到 20 的省份数据对不上,这才意识到是分片合并导致的近似问题。最后把shard_size调到size * 3,数据才稳定下来。这件事让我明白:terms聚合的size和shard_size必须一起考虑,不能只改一个。
还有一次,同事在text字段上直接做聚合,报错之后他上网搜了个方案,开了fielddata: true。测试环境数据少,没出问题;上线之后数据量一上来,节点内存直接飙红,查询大面积超时。后来我们把那个字段改成keyword类型重建了索引,问题才解决。这个教训是:遇到fielddata报错,正确的做法是改映射或加.keyword,而不是开fielddata。
最后一个坑是关于date_histogram的时区。默认情况下,时间聚合用的是 UTC 时区。如果你的业务数据是北京时间,按天聚合时会出现"早上 8 点之前的数据被算到前一天"的情况。解决办法是加time_zone参数:
{ "date_histogram": { "field": "create_time", "calendar_interval": "day", "time_zone": "+08:00" } }这个参数不加,报表数据就会整体偏移 8 小时,而且很难发现,因为数字看起来"差不多对"。做时间维度统计时,时区一定要显式指定,别依赖默认值。
聚合这个功能,入门容易精通难。它的 API 设计得很灵活,但灵活性背后是一堆需要理解的机制:分片合并、近似算法、内存模型、缓存策略。我的建议是,先把terms、date_histogram、sum、avg、cardinality这几个最常用的用熟,再逐步接触composite、pipeline、bucket_script这些进阶玩法。每学一个新聚合,都拿真实数据跑一遍,看看返回结构长什么样,比看十遍文档都管用。