做 Elasticsearch 聚合查询,很多人一上来就盯着桶聚合,terms 一把梭,等到需要去重计数、基础统计、百分位、字符串统计这些指标聚合的时候,反而不知道怎么下手。这篇是 Elasticsearch 笔记系列的第四篇,专门把四种指标聚合一次讲透,包含 DSL 写法、返回结果解读、底层原理和踩坑记录。
如果你已经会写基本的 match、term、range 查询,也用过 terms 和 date_histogram 做分组,那这篇正好帮你补齐指标聚合这块拼图。新手也不用担心,每段都有可以直接抄的请求体,拿真实索引换一下字段名就能跑。
1. 先弄明白:这四种聚合属于哪一类?
1.1 指标聚合和桶聚合,别在这个地方绕晕
Elasticsearch 的聚合框架整体上分三大类:桶聚合(Bucket Aggregation)、指标聚合(Metric Aggregation)、管道聚合(Pipeline Aggregation)。很多人一开始分不清前两者,其实记住一句话就行:桶聚合负责把文档分到不同的“组”里,它本身不产出统计值;指标聚合负责对一组文档计算“数”,输出一个数值或者一个结构体。
常见的 terms、range、date_histogram、filter 聚合都属于桶聚合,它们的结果是一个个 bucket,每个 bucket 里有文档数 doc_count。而 avg、sum、max、min 是大家最熟悉的指标聚合,本篇要讲的去重计数(cardinality)、基础统计(stats / extended_stats)、百分位(percentiles / percentile_ranks)、字符串统计(string_stats)也都是指标聚合,且比那几个老面孔要实用得多。
还有一个容易忽略的点:指标聚合可以单独直接跑在整个索引范围上,也可以嵌在桶聚合里面作为子聚合。实际业务里后者才是主流玩法,比如先按天分桶,再在每个桶里统计 UV,这种组合能力才是聚合查询真正值钱的地方。
1.2 四个聚合各自解决什么问题
这四个指标聚合看上去都是“算数”,但适用场景差异很大。先列一张总表,后面逐个说。
| 聚合类型 | 典型业务问题 | 主要返回 |
|---|---|---|
| cardinality 去重计数 | 这个接口有多少个不同用户调用?活动页 UV 是多少? | 一个数值 |
| stats / extended_stats 基础统计 | 订单金额均值多少、总量多少、方差标准差多大? | 多个数值 |
| percentiles / percentile_ranks 百分位 | 接口 P95、P99 是多少?超过 500ms 的请求占比多少? | 多个数值 |
| string_stats 字符串统计 | 日志里 message 字段长度分布如何?字符信息熵是多少? | 多个数值 |
这四个聚合里,cardinality 和 percentiles 属于近似聚合,底层用了专门的算法来换内存和速度;stats 是精确计算;string_stats 则比较冷门,但处理文本类分析时非常好用。
2. 去重计数:cardinality 聚合,算 UV 的好帮手
2.1 基础语法与返回结果
假设有一个订单索引 order_info,mapping 里 user_id 是 keyword 类型。现在要统计这个索引里一共有多少个不同的用户下过单,DSL 非常简单:
GET /order_info/_search { "size": 0, "aggs": { "distinct_users": { "cardinality": { "field": "user_id" } } } }返回结果长这样:
{ "took": 12, "timed_out": false, "hits": { "total": { "value": 100000, "relation": "eq" }, "max_score": null }, "aggregations": { "distinct_users": { "value": 8234 } } }注意一点:hits.total 是命中的文档总数 100000,而 aggregations.distinct_users.value 是去重后的用户数 8234。这两者千万别搞混,尤其是面试的时候很容易被问到。
如果 user_id 是 long 类型,写法完全一样。但如果 user_id 是 text 类型,直接聚合大概率会报错,这时候需要用到 keyword 子字段,也就是把 field 写成 user_id.keyword。这个坑后面排错部分会专门讲。
2.2 precision_threshold:精度和内存的权衡
cardinality 底层用的是 HyperLogLog++(HLL++)算法。这个算法的核心是:通过概率统计的方式估算集合里不重复元素的个数,不需要把所有去重值全部记下来,所以内存占用非常低。但估算就存在误差,于是 Elasticsearch 给了一个开关参数 precision_threshold。
precision_threshold 的默认值是 3000,最大值是 40000。当去重后的基数小于等于这个阈值时,Elasticsearch 返回的是精确值;一旦超过阈值,就开始用 HLL++ 做估算。这个设计很聪明:小基数场景下精确计算成本不高,直接算;大基数场景下你大概率只需要一个量级正确的结果,毕竟几百万和几百万零几千对于多数业务来说差别可以接受。
参数设置的原则其实就一句话:如果你明确知道去重规模不会特别大,而且业务上对精度敏感,可以把这个阈值调高一些;如果去重规模本身已经几十万上百万,阈值调得再高也拦不住估算,只会白白增加内存压力。
我见过有人把索引里所有 cardinality 聚合的 threshold 无脑设成 40000,结果集群内存使用率明显上涨。这类全局配置真不建议乱来,先按默认值跑,观察真实基数后再决定要不要调。
2.3 实际用的时候要注意什么
第一,cardinality 是近似聚合,这个事实要提前跟业务方讲清楚。有些运营同学拿 UV 数对口径,一旦数据量上来,数值每天有轻微抖动,很容易被质疑“数据是不是有问题”。我的处理方式是:把 precision_threshold 按数据规模设到一个合理水平,跟业务对齐“基数超过阈值后为估算值”这个前提,省得后续扯皮。
第二,字段缺失值会影响统计语义。默认情况下,聚合会忽略没有 user_id 的文档。如果文档里存在大量空值,聚合结果的“去重用户数”其实是“有 user_id 的用户数”,这个口径本身没有错,但很容易被误解。如果确实要把缺失值纳入统计,可以加 missing 参数:
GET /order_info/_search { "size": 0, "aggs": { "distinct_users": { "cardinality": { "field": "user_id", "missing": "UNKNOWN_USER" } } } }我自己的习惯是:统计 UV 前先在 query 层加一个 exists 过滤,把没有 user_id 的文档排除掉,语义更干净。
第三,cardinality 也支持 script,但能不用就不用。脚本聚合性能开销远高于普通字段聚合,遇到大数据量容易成为集群瓶颈。真需要做字段拼接、格式化后再去重,建议在写入阶段提前处理好,而不是在查询时用 script 现算。
3. 基础统计:stats 与 extended_stats,一个顶五个
3.1 stats 能给你哪些指标
stats 聚合是一个复合指标聚合,一次调用返回 count、min、max、avg、sum 五个指标。它非常适合快速摸清一个字段的整体分布,比如订单金额。
GET /order_info/_search { "size": 0, "aggs": { "amount_stats": { "stats": { "field": "amount" } } } }返回结果:
{ "aggregations": { "amount_stats": { "count": 95000, "min": 1.0, "max": 9999.0, "avg": 268.35, "sum": 25493250.0 } } }如果你的业务只是想知道“大概多少钱”,到这里就够了。但有两个细节要提醒一下:count 表示的是参与统计的、该字段有值的文档数,不是查询命中的总文档数;min 和 max 是极值,极值对异常数据非常敏感,所以看到极端大或极端小的数值时,不要急着下结论,先去查一下数据质量。
3.2 extended_stats 扩展出的方差和标准差
如果只看均值不够,要了解数据波动,那就得用 extended_stats。它会在 stats 的基础上额外返回 sum_of_squares(平方和)、variance(方差)、std_deviation(标准差),以及基于标准差自动计算的上下边界。
GET /order_info/_search { "size": 0, "aggs": { "amount_ext_stats": { "extended_stats": { "field": "amount" } } } }返回结果会多出下面这组字段:
{ "aggregations": { "amount_ext_stats": { "count": 95000, "min": 1.0, "max": 9999.0, "avg": 268.35, "sum": 25493250.0, "sum_of_squares": 684123456.0, "variance": 102400.0, "variance_population": 102400.0, "variance_sampling": 102412.3, "std_deviation": 320.0, "std_deviation_population": 320.0, "std_deviation_sampling": 320.06, "std_deviation_bounds": { "upper": 908.35, "lower": -371.65, "upper_population": 908.35, "lower_population": -371.65, "upper_sampling": 908.47, "lower_sampling": -371.77 } } } }这里有两个地方值得展开讲。
首先是 population 和 sampling 的区别。Elasticsearch 返回的方差、标准差都会给总体和样本两套值,区别在于分母是 n 还是 n-1。绝大多数业务分析场景直接用 population 那套就可以,只有当你是在用样本推断总体时才需要关注 sampling 的值。
其次是 std_deviation_bounds。它不是简单的最大值最小值,而是均值加减两倍标准差,也就是一个大致覆盖 95% 数据分布的范围。这个边界在异常检测里非常好用:如果某一天的订单金额均值变化不大,但上下边界突然扩大了很多,说明数据分布变散了,大概率出现了极端订单。
3.3 用一个真实场景练手
比如电商平台想判断大促当天的订单金额是整体上涨还是局部拉升。直接全量跑一个 stats 没什么感觉,更好的是配合时间范围过滤来看某一天的分布。
GET /order_info/_search { "size": 0, "query": { "range": { "order_date": { "gte": "2024-11-11T00:00:00", "lte": "2024-11-11T23:59:59" } } }, "aggs": { "amount_ext_stats": { "extended_stats": { "field": "amount" } } } }跑完之后,如果发现均值比日常涨了 30%,但标准差涨了一倍还多,那就要警惕“局部拉升”。这时候再配合 terms 按商品类目分桶,很快就能定位到底是哪个类目在贡献极端值。
4. 百分位:percentiles 聚合,排查耗时的利器
4.1 为什么平均数不靠谱,P95 才靠谱
先说一个我在实际业务里反复跟人强调过的问题:平均值不适合描述耗时。原因很简单,任何一个超级慢的请求都会把平均值拉高,但绝大多数请求可能都很快。
举个极端例子:一百个请求里九十九个都是 50ms,有一个是 5s,平均值算出来是 99.5ms。这个数字看起来还行,但实际上绝大多数用户的真实感知是 50ms,完全被平均值带偏了。反过来,P99 的意思是“99% 的请求都在某个阈值以下”,这个阈值更贴近用户的真实体验。所以现在但凡正经一点的监控系统,都会把 P95、P99 作为核心指标。
4.2 近似算法的原理与 compression 参数
percentiles 聚合底层用的是 TDigest 算法。TDigest 会把数据映射到多个质心(centroid)上做压缩存储,所以它的结果天然是估算的,不是精确值。和 cardinality 类似,它允许你用参数在精度和内存之间做权衡,这个参数叫 compression,默认值是 100。
compression 越大,质心数量越多,逼近结果越准,相应的内存占用也越高。一般来说,默认参数在大部分场景下够用。如果你对某几个分位数的精度特别敏感,可以微调 compression,但我不建议拉得过高,否则高基数高并发场景下内存开销会有点难受。
另一个实用细节是 percents 参数。默认情况下,percentiles 返回 1、5、25、50、75、95、99 这几个分位。但很多时候你只需要其中几个,可以在请求里显式指定:
GET /order_info/_search { "size": 0, "aggs": { "amount_percentiles": { "percentiles": { "field": "amount", "percents": [50, 95, 99, 99.9] } } } }返回结果默认是对象形式:
{ "aggregations": { "amount_percentiles": { "values": { "50.0": 120.5, "95.0": 780.2, "99.0": 1500.0, "99.9": 4900.8 } } } }如果你写程序解析对象不方便,可以加一个 "keyed": false,返回就会变成数组形式。
4.3 实操:接口响应时延监控与 percentile_ranks
percentiles 和 percentile_ranks 是互补关系:前者给定百分位求阈值,后者给定阈值求百分位。
假设我们有一个 access_logs 索引,字段 latency 记录每次请求的耗时。想监控线上接口的 P99,直接这样查:
GET /access_logs/_search { "size": 0, "aggs": { "latency_percentiles": { "percentiles": { "field": "latency", "percents": [50, 95, 99] } } } }如果返回的 99.0 是 420ms,那就说明线上 99% 的请求耗时都控制在 420ms 以内。只要你提前定好目标阈值,比如 400ms,一眼就能看出是否达标。
有时候我们想知道“多少比例的请求超过了 500ms”,这时候 percentile_ranks 更合适:
GET /access_logs/_search { "size": 0, "aggs": { "latency_ranks": { "percentile_ranks": { "field": "latency", "values": [100, 500] } } } }返回结果里会看到 100.0 和 500.0 各自对应的百分位。假如 500.0 返回的是 96.2,说明 96.2% 的请求耗时在 500ms 以内,那超过 500ms 的请求占比就是 3.8%。
在实际监控里,我最常用的是把 percentiles 和 date_histogram 组合,按小时看 P50、P95、P99 的走势曲线。如果 P95 突然抬升但 P50 没变化,说明有一小撮慢请求变严重了;如果 P50 和 P95 一起抬升,那就是整体变慢,大概率是下游依赖或者机房网络出了问题。这两类问题的排查方向完全不同,分位数一眼就能帮你区分开。
5. 字符串统计:string_stats 聚合,冷门但好用
5.1 string_stats 输出什么
string_stats 是四个聚合里最冷门的一个,它专门处理字符串字段,返回以下统计项:
- count:参与统计的文档数
- min_length:字段值的最短长度
- max_length:字段值的最长长度
- avg_length:字段值的平均长度
- entropy:字符信息熵,通过参数显式开启
这个聚合从 7.6 版本开始提供,早期版本中处于实验性状态。使用前提是字段必须支持 doc_values,一般就是 keyword 类型。text 字段如果要直接用,得先开启 fielddata,但我强烈不建议这么干,正确姿势是使用 keyword 子字段。
来看一个日志场景的例子:
GET /access_logs/_search { "size": 0, "aggs": { "path_stats": { "string_stats": { "field": "request_path.keyword", "entropy": true } } } }返回结果:
{ "aggregations": { "path_stats": { "count": 10000, "min_length": 1, "max_length": 512, "avg_length": 128.6, "entropy": 4.32 } } }看到这几项数据,你就能快速知道这批请求路径整体长什么样:最短的是根路径,最长的是拼了一堆参数的完整 URL,平均长度一百多字节,字符分布熵值中等。
5.2 实操:日志分类与字段长度分析
string_stats 最常见的一个用途,是快速定位“长度异常”的字段。
举个例子,你怀疑日志里的 request_path 被某些攻击脚本拼接得很长,正常请求路径一般不超过 200 字节,但某段时间 max_length 突然飙到几千。先跑一个 string_stats,看到 max_length 异常巨大,再用 sort 或者 terms 把最长的几个 URL 揪出来分析,问题基本就能锁定。
我还用它对 username 字段做过数据质量检查。业务上用户名长度应该集中在 6 到 20 个字符之间,结果发现 avg_length 高得离谱,一排查,原来是写入端把一串序列化的 JSON 错误地塞进了 username 字段。这种脏数据问题,不看长度分布根本发现不了。
5.3 关于 entropy 的一点理解
entropy 返回的是字段值的字符信息熵,用来衡量字符集合的混乱程度。数值越大说明字符越多样、越随机,数值越小说明字符越集中、越有规律。
固定格式的日志路径,比如 /api/v1/user/info,字符构成非常规律,熵会比较低。如果路径里带了随机 token、时间戳这些动态内容,熵值就会明显拉高。这个指标在安全分析里有点用,比如判断某个 query 参数是不是脚本随机拼出来的。但要注意,熵只是一个宏观指标,不能当成唯一判断依据,需要配合样本内容才能下结论。
6. 组合起来用:多个指标聚合嵌套在桶聚合里
6.1 terms + cardinality + stats 的组合
单个指标聚合能力有限,真正发挥威力的是和桶聚合嵌套。最常见的组合是外层 terms 按类目分组,内层挂多个指标聚合,一次请求拿到一组多维统计。
GET /order_info/_search { "size": 0, "aggs": { "by_category": { "terms": { "field": "category.keyword", "size": 10 }, "aggs": { "distinct_users": { "cardinality": { "field": "user_id" } }, "amount_stats": { "stats": { "field": "amount" } }, "latency_percentiles": { "percentiles": { "field": "process_time", "percents": [95, 99] } } } } } }这个请求返回的是:每个商品类目下有多少独立用户、订单金额的 count/min/max/avg/sum,以及处理耗时的 P95、P99。三个指标聚合平行嵌在同一个桶下,互不干扰,这也是指标聚合最标准的组合姿势。
返回结构是一个嵌套的 buckets 数组,外层每个 bucket 里都会带 distinct_users、amount_stats、latency_percentiles 三个子聚合结果。解析的时候按层级取数就行。
6.2 date_histogram 统计每天的 UV 和点击量
业务上经常要看趋势曲线,date_histogram 和 cardinality 是绝配。比如按天统计 UV:
GET /order_info/_search { "size": 0, "aggs": { "daily_orders": { "date_histogram": { "field": "order_date", "calendar_interval": "day" }, "aggs": { "uv": { "cardinality": { "field": "user_id" } } } } } }返回里每个日期一个 bucket,包含 key_as_string(格式化后的日期)和 doc_count(当天订单数),再加上子聚合里的 uv 值。拿这些数据直接画一条 UV 曲线,性能比把原始数据拉回内存自己数好太多倍。
这里有一个很重要的性能认知:如果数据量很大,把几百万条文档拉到应用服务器,然后自己在内存里做 Hash 去重、排序、算分位数,代价极大。Elasticsearch 的聚合框架就是干这个的,能下推到集群里的计算就别搬回来。
6.3 组合查询失败的几个常见原因
组合聚合本身没什么特别的黑魔法,但出了问题的时候,先把原因归类:
- 外层桶聚合的字段类型不对,比如对 text 字段做 terms,大概率报错。
- 内层指标聚合的字段类型不支持,比如对 text 字段做 cardinality 或 percentiles。
- 桶大小设置过大,比如 terms 的 size 设成几万,返回结构巨大,自伤性能。
- 多个聚合嵌套过深,层级太多导致查询解析变慢,维护起来也麻烦。
排查时先拿到一条 min 请求,把外桶去掉单独跑指标聚合,确认是字段问题还是桶聚合问题,再逐步加回去。
6.4 常见报错与排查速查表
把我在实际环境中踩过、帮别人排查过的坑整理成一张速查表:
| 报错信息 | 出现原因 | 解决办法 |
|---|---|---|
| Text fields are not optimised for operations that require per-document field data | 对 text 字段直接做聚合 | 改用 keyword 子字段,或在 mapping 中显式添加 multi-fields |
| Fielddata is disabled on text fields by default | 想对 text 字段开启聚合 | 优先加 keyword 子字段;不要直接开 fielddata,会消耗堆内存 |
| cardinality[precision_threshold] must be between 0 and 40000 | precision_threshold 参数超出合法范围 | 改成 0 到 40000 之间的合法值 |
| No mapping found for [xxx] in order to aggregate on | 字段名写错,或该字段没有 mapping | 用 GET index/_mapping 检查字段是否存在 |
| Unknown aggregation type [string_stats] | 集群版本过低,不支持该聚合 | 将集群升级到 7.6 及以上版本 |
| unexpected field [entropy] | 当前版本不支持 entropy 参数,或者参数位置写错 | 确认 string_stats 的版本支持范围,entropy 需要放在请求体里正确的位置 |
其中 text 字段聚合那个错是最常见的,很多新手第一次跑聚合就会撞上。这里多说一句:text 类型主要给全文检索用,默认不生成 doc_values,所以不适合做聚合、排序这类操作。正确的做法是在 mapping 里给字段加一个 keyword 子字段,类似这样:
{ "mappings": { "properties": { "message": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } } } }查询时用 message.keyword 做聚合,用 message 做全文搜索,两不耽误。
7. 写在最后的一点经验
这四个指标聚合我用了好几年,说点个人体会。cardinality 适合所有“去重后有多少”的问题,但因为是近似算法,做数据看板之前先跟业务口径对齐;stats 和 extended_stats 适合日常数值分布摸底,重点看标准差和上下边界,不要只盯着平均数;percentiles 几乎成了耗时排查的标配,监控告警里建议直接用 P99 而不是平均值;string_stats 虽然冷门,但做日志质量分析和异常发现确实能省不少力气。
最后再分享一个小技巧:遇到拿不准的聚合,先用小索引 sample 一批数据跑一遍,确认返回结构符合预期,再把同样的 DSL 放到生产索引上。别小看这一步,它能帮你避开很多字段类型不匹配、参数范围超限的低级错误,尤其是字段多、类型杂的历史索引,这种检查更是省不了。