菜市场打烊后,管理员老肖面前摊着几百张交易小票:谁买了什么、买了多少、花了多少钱。他想知道今天白菜卖了多少斤、哪个品类最受欢迎、全场价格最高的前 10% 的菜贵到哪一档——一张张翻小票?那不现实。Elasticsearch 的聚合查询(Aggregations),就是给搜索引擎配了一位「统计员」:它把原始文档先分筐、再称重、最后拉总账,一条请求输出整份统计报表。这篇文章用老肖的菜市场贯穿全程,带你拆解桶聚合、指标聚合、管道聚合的执行机制,以及 cardinality 去重与 percentiles 分位数的近似算法取舍。
对 AI 从业者而言,聚合查询是日志分析、用户画像、特征统计等数据底座的核心能力;掌握它的执行机制与精度取舍,能让你在造报表、算特征、做监控时又快又准。
目录
可视化演示
展示Elasticsearch的桶聚合分桶机制、指标聚合统计计算及嵌套聚合执行顺序与管道后处理流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
terms桶聚合 文档归桶 size限制 Bucket=terms(field,size) 按字段值分桶terms的size参数控制返回桶数影响精度
聚合概览:分类统计台的三件套
菜市场打烊后,管理员老肖面前摊着一摞交易小票。他先摆出几个大筐,贴上「蔬菜」「水果」「粮油」「干货」的标签,然后把小票按品类依次丢进对应的筐。这个「分筐」的动作,就是 ES 里最核心的桶聚合;筐里装了多少张票、总金额多少,则是接下来要讲的指标聚合。
在 Elasticsearch 中,聚合查询把搜索引擎从「查文档」升级为「做统计」。普通查询只负责把满足条件的小票挑出来,聚合则负责把这些小票分门别类、算出报表。两者通常写在同一请求里:先用 query 圈定范围,再用 aggs 做统计。
聚合按功能分成三大类。桶聚合负责把文档分进不同的组,组被称为「桶」;指标聚合负责在桶内计算数值,如求和、平均、最大最小;管道聚合则以其他聚合的输出为输入做二次计算。三者可以自由组合成一个树形结构:最外层是桶,桶里挂指标,指标之上再挂管道。
老肖的统计台从功能上拆成三件套:筐(桶聚合)、秤(指标聚合)、汇总计算表(管道聚合)。理解了这三件套,就理解了 ES 聚合查询的骨架;接下来我们一个个看它们是怎么工作的。
- D 表示全部文档集合聚合先拿到满足查询条件的完整文档集,这是所有统计的原料。
- B_k 表示第 k 个桶每个桶装着一组拥有相同字段值的文档,如同贴好标签的筐。
- 并集覆盖全部文档每个文档必然落入某个桶,不会有人被遗漏在统计之外。
- 交集为空表示互不重叠一个文档只属于一个桶,不会同时出现在两个筐里。
桶聚合成框:terms 是怎么分筐的
老肖把 300 张小票按品类分筐,发现「蔬菜」的筐最满。terms 桶聚合做的事情一模一样:扫描索引里所有文档,读取指定字段的值,把每个唯一值变成一个桶,并把拥有该值的文档「丢」进去。
桶聚合里最常用的是 terms,它天然适合「分组统计」场景:按品类分组、按状态分组、按城市分组。分组过程中,每个桶会自动记录 doc_count,也就是桶里的文档数,这个数字是后续一切排序和筛选的基础。
这里有一个新手必踩的坑:terms 默认只返回文档数最多的前 10 个桶,其余的唯一值被直接截断,不会合并成「其他」。要返回更多桶,需要显式指定 size;size 越大,内存和耗时越高,所以报表场景里通常会把 size 控制在几十以内。
老肖的台面上永远只摆最热闹的 10 个筐,冷门品类被留在后厨台账里。这个「截断」设计,保证了统计台面不堆满几千个几乎没人买的品类。
- terms_key 取分类字段ES 读取每个文档里指定字段的值,作为「分配进哪个筐」的依据。
- 集合筛选只留匹配项花括号内定义了一个子集,只保留字段值恰好等于 k 的文档。
- 竖线符号表示取大小外层的一对竖线表示「这个集合里有多少元素」,也就是桶内的文档数。
- 得到桶的文档计数doc_count 就是 terms 聚合返回的每个桶的「重量」,排序和 size 截断都依据它。
指标聚合称重:avg 和 sum 怎么算账
筐分好之后,老肖给每个筐贴上价签:总重量(sum)、平均单价(avg)、最重与最轻(min/max)。指标聚合就是这些「筐上计算器」。
指标聚合不产生新的桶,只在已有桶或全量文档上计算数值。最常用的是 sum(求和)与 avg(平均),此外还有 min、max,以及一次返回多项统计的 stats。
avg 的执行机制很巧妙:分布式环境下,每个分片先计算局部 sum 和局部 count,协调节点把所有分片的局部结果汇总——总 sum 除以总 count——得到全局平均。这样既不用把全量文档搬到一台机器,又能保证结果与「先合并再计算」完全一致。sum 同理,只是少了除法这一步。
老肖的分区称重法很简单:各个摊位先各自把卖出的斤两记在本子上,打烊后统一报到总台。总台把斤两相加、再除以摊位数,就是全场的平均销量。ES 的分片协作,就是这个流程的数字化版本。
- n 是桶内文档总数分子分母中的 n 都来自该桶的 doc_count。
- x_i 是每个文档的字段值对桶内第 i 个文档取出要求平均的数值字段。
- 求和符号把所有值加总Σ 把 n 个 x_i 累加,得到 sum 聚合的原始结果。
- 除以 n 得到平均值总和均匀摊到每个文档上,这就是 avg 输出的含义。
嵌套聚合分层:大筐里套小筐
光知道「白菜卖了 500 斤」还不够,老肖还想知道这些白菜里,山东产和本地产各占多少。于是他在「白菜」这个大筐里,又摆了「山东」「河北」「本地」几个小筐。
嵌套聚合就是「大筐套小筐」的直接实现。在一个桶聚合内部再挂一个子聚合,子聚合只处理父桶里的文档,整个过程先外层后内层逐级执行。
执行顺序严格自顶向下:先完成外层桶划分,再在每个外层桶内运行内层子聚合,最终返回一个树形 JSON。每个层级既可以是桶也可以是指标,因此能自由组合出「按品类分桶 → 按产地分桶 → 算平均价」的多级报表。嵌套较深时,ES 默认采用深度优先遍历以节省内存;若外层桶数量巨大,可切换为广度优先,先构建本层桶再处理子聚合。
老肖的统计台从一层变两层:大筐套小筐,标签越贴越细。只要筐足够多、层级足够深,再复杂的分类统计也能一层层展开,这正是嵌套聚合的价值。
- B_k 是外层桶的文档集内层子聚合的输入不是全量文档,而是父桶筛过的子集。
- inner_key 再取内层字段在父桶内部,再次按另一个字段划分,相当于大筐里摆小筐。
- 子桶互斥且完整覆盖父桶父桶被拆成一堆互不重叠的小桶,合起来不多不少。
- 形成树形层级结果有多少父桶,每个父桶下就有多少子桶,最终输出像一棵倒挂的树。
管道聚合联动:把筐的结果再抄一张表
分完筐、称完重,老肖把每个筐的平均价抄到一张小表上,然后对这张小表再算一次平均——找出哪些筐的均价高于全场。这个「对小表再统计」的动作,就是管道聚合。
管道聚合不直接处理文档,它把其他聚合的输出当作输入。最常见的场景是:先做 terms 桶,桶里挂 avg 指标,再用 avg_bucket 管道对「所有桶的平均值」再做一次平均。
管道聚合分两类:父管道像 avg_bucket 或 sum_bucket,输入是某个桶内的指标值;兄弟管道像 max_bucket 或 min_bucket,输出是在兄弟桶之间比较的结果。管道的结果会作为新字段挂到响应里,常用来算「占比」「环比」等衍生指标。使用时要注意:输入聚合必须已经返回,因此管道总是写在内层聚合之后。
老肖的小表方法论,让统计从「一筐一筐看」升级为「跨筐比较」。管道聚合就是那张小表,它把散落在各筐的结论收集起来,提炼出更高一层的洞察。
- v_k 来自各桶的指标结果管道聚合不吃原始文档,吃的是桶聚合算好的数值。
- 对全部桶的指标值求和把各桶的平均价格、平均耗时等收集起来累加。
- 除以桶数 K得到「所有桶指标的平均」,即 avg_bucket 的语义。
- 与 sum_bucket 对比sum_bucket 只求和不平均,便于计算总量与占比。
基数去重统计:抓一把豆子估算一缸
老肖想知道今天市场来了多少位顾客。但同一顾客可能买了三次菜,小票上有重复。精确去重需要拿个小本子一个个记下所有顾客 ID,人一多本子就爆了。于是他改用「抓一把豆子估一缸」的思路:看看这一把里有多少颗,按比例推算整缸。
ES 的 cardinality 聚合解决的就是「去重计数」问题。它的特点是:不精确,但足够准,而且内存占用极小。代价和收益都很明确——极低的内存,换来约 95% 以上的精度。
cardinality 采用 HyperLogLog 算法:把每条记录的字段值做哈希,取哈希值的一部分决定它进入哪个「寄存器」,另一部分统计二进制尾部连续 0 的个数。所有寄存器记录各自见过的最大连续 0 个数,最后用调和平均估算出基数。默认配置下,误差约为 1%,而内存只需几 KB,远小于精确去重所需的集合大小。
老肖从不记得每位顾客的名字,只记得「这一把豆子的密度」。HLL 用极小的「采样记忆」把亿级去重压缩到几 KB,这正是海量日志统计里的经典取舍。
- m 是哈希寄存器的个数HLL 把哈希值分成 m 个寄存器,每个寄存器记录一段哈希的特征。
- M_j 记录最长连续零对落入第 j 个寄存器的哈希值,统计二进制尾部连续 0 的最长长度,记为 M_j。
- 调和平均估算基数把所有寄存器的 2 的负 M_j 次幂求和再取倒数,得到粗略估计 \hat{n}。
- 误差随寄存器数下降寄存器越多越精确,1.04/√m 给出相对误差上界,默认配置下约 1% 左右。
分位数分析:找到 90% 的菜的价钱线
老肖还想知道:「全场 90% 的菜,单价都在多少以下?」这条线就是第 90 百分位。平均价会被几斤进口松露拉高,但分位数只关心「排在最前面的 90% 只到哪一档」,对极端值不敏感。
percentiles 聚合返回指定的分位数,如 p50、p90、p99。它比 avg 更能刻画数据分布形态,是分析长尾问题的标准工具。
percentiles 在内部使用 TDigest 近似算法:把数据压缩成一组带权重的「质心」,用很少的内存描绘出数据分布的大致形状,再根据质心插值出任意分位数。它对长尾分布特别友好——这正是日志耗时、请求延迟这类数据的典型形态。需要注意:分位数是近似值,精度与内存成正比,但默认配置已足够日常监控使用。
老肖把 300 张小票按价格从小到大排成一列,从第 270 张的位置划一条线——这条线就是 p90。分位数让统计从「大概什么水平」进阶到「边界在哪」,这是识别长尾问题的关键视角。
- 先对样本按值排序把桶内所有字段值从小到大排成一列,这是分位数的基础。
- p/100 是目标比例p90 表示比例 0.9,要找「有 90% 的数据小于它」的那个值。
- 乘以 n 算出排名位置比例乘以样本总数得到目标位置,向上取整避免小数下标。
- 取该位置的值为分位数排好序后,第 pos 个值就是 p 分位数的近似输出。
AI 应用场景
电商后台的实时销售大盘
阶段:数据准备与特征统计
解决的问题:传统手工 SQL 面对千万级订单,GROUP BY 复杂且响应慢;AI 特征表需要分钟级更新的聚合指标。ES 聚合一次请求同时输出各品类的销售额(sum)、客单价(avg)与订单数,支撑实时大屏。
具体案例:某电商运营每天打开大盘,看到「手机品类今日 GMV 128 万、客单价 3199 元、较昨日 +5%」,背后就是 terms(品类)+ sum(金额)+ avg(金额)的嵌套聚合,老肖的「分筐称重」在线上每天跑几千次。
微服务日志的 p99 延迟排查
阶段:系统监控与模型观测
解决的问题:只靠平均耗时会被慢请求掩盖,无法定位长尾问题;精准定位需要分位数。percentiles 聚合能直接给出 p50/p90/p99,把「边界在哪」变成一条可监控的指标。
具体案例:SRE 在 Kibana 里对「接口耗时」字段做 percentiles 聚合,发现 p99 从 200ms 飙到 800ms,定位到某次发布引入的慢 SQL;再按 error_status 做 terms 桶聚合缩小范围,半小时内完成回滚。
用户画像的日活去重统计
阶段:特征工程与数据治理
解决的问题:精确 COUNT(DISTINCT user_id) 在亿级数据上内存爆炸;cardinality 用 HLL 近似,几百 KB 内存就能算出误差在 1% 范围的日活数,适合特征表每日刷新。
具体案例:推荐系统团队每天统计曝光用户数、点击用户数,用 cardinality 在日志索引上跑,秒级出数;再结合按天分桶看 7 日趋势,判断新策略对活跃用户的影响。
Python 演示
# -*- coding: utf-8 -*-"""模拟 ES 聚合查询:terms 桶,avg 指标,嵌套聚合与 HLL 近似去重""" import random class AggEngine: """把文档列表当成本地小索引,复刻 ES 聚合的执行思路""" def __init__(self, docs): self.docs = docs def terms(self, field): buckets = {} for d in self.docs: key = d.get(field) buckets[key] = buckets.get(key, 0) + 1 return sorted(buckets.items(), key=lambda kv: kv[1], reverse=True) def avg(self, field, metric_field, key): matched = [d.get(metric_field, 0) for d in self.docs if d.get(field) == key] return sum(matched) / len(matched) if matched else 0.0 def nested(self, outer_field, inner_field): result = [] for key, _ in self.terms(outer_field): sub = AggEngine([d for d in self.docs if d.get(outer_field) == key]) result.append((key, sub.terms(inner_field))) return result def hll(self, field, buckets=16): register = [0] * buckets for d in self.docs: h = 0 for ch in str(d.get(field)): h = (h * 31 + ord(ch)) & 0x7FFFFFFF idx = h % buckets rest = h // buckets zeros = 0 while rest and (rest & 1) == 0: zeros += 1 rest >>= 1 register[idx] = max(register[idx], zeros + 1) alpha = 0.7213 / (1 + 1.079 / buckets) raw = alpha * buckets * buckets / sum(2.0 ** (-r) for r in register) return int(raw + 0.5) def main(): random.seed(7) cats = ["手机", "电脑", "平板", "耳机"] docs = [] for _ in range(300): price = random.randint(500, 8000) docs.append({ "cat": random.choice(cats), "price": price, "price_bucket": (price // 1000) * 1000, "uid": random.randint(1, 1000), }) eng = AggEngine(docs) print("== terms 桶(按品类分筐) ==") for key, count in eng.terms("cat"): avg = eng.avg("cat", "price", key) print("%s: 文档数=%d, 平均价格=%.1f" % (key, count, avg)) print("== 嵌套聚合(品类 -> 价格段) ==") for key, sub in eng.nested("cat", "price_bucket"): print(key, sub[:3]) exact = len({d["uid"] for d in docs}) estim = eng.hll("uid") print("== cardinality 去重 ==") print("精确去重=%d, HLL 估算=%d, 误差=%.1f%%" % ( exact, estim, abs(estim - exact) * 100.0 / exact)) if __name__ == "__main__": main()
buckets[key] = buckets.get(key, 0) + 1每遇到一个文档就给它所属的桶计数加一,这就是 doc_count 的累加过程。return sorted(buckets.items(), key=lambda kv: kv[1], reverse=True)按文档数降序排列桶,模拟 terms 默认只取最热门的桶。matched = [d.get(metric_field, 0) for d in self.docs if d.get(field) == key]先筛出该桶的全部文档,再取数值字段,对应指标聚合「先定位桶再计算」的流程。h = (h * 31 + ord(ch)) & 0x7FFFFFFF对字符串做哈希,把任意字段值映射成一个整数,这是 HLL 的第一步。register[idx] = max(register[idx], zeros + 1)每个寄存器只保留「见过的最长连续零」,这就是 HLL 极省内存的秘诀。raw = alpha * buckets * buckets / sum(2.0 ** (-r) for r in register)用调和平均估算基数,对应公式中的 \(\hat{n}\) 计算。
常见误区
误区「聚合不就是把我需要的数据筛出来吗?那和查询有什么两样?」初学者常以为写一个聚合就能替代过滤条件。
正解查询负责「筛行」,挑出满足条件的文档;聚合负责「归组统计」,把筛出的文档按字段分桶、算指标。比喻:老肖先挑出「今天卖出的所有小票」(查询),再把它们分筐称重(聚合)。两者配合而非替代。
为何常见:因为聚合结果和命中文档在同一个响应里返回,容易让人以为聚合是一种特殊的查询。
误区「我按城市聚合,明明有 100 个城市的订单,为什么只看到 10 个?数据丢了吗?」
正解terms 默认 size=10,只返回文档数最多的前 10 个桶,其余直接被截断,不会合并成「其他」桶。想要更多桶必须显式传 size,并为此支付更高的内存与耗时。
为何常见:人们习惯了 SQL GROUP BY 返回全部分组,忽略了 ES 出于性能考虑的截断设计。
误区「cardinality 就是 COUNT(DISTINCT user_id),数出来的数字应该是准的。」
正解cardinality 使用 HyperLogLog 近似算法,默认精度约 95%(误差约 1%),换来的是极小的内存和极快的速度。若业务要求精确,应改用精确基数聚合并接受高内存,或把字段存到其他存储去重。
为何常见:名字里带「去重计数」,直觉上会类比 SQL,忽略了 ES 面向海量数据的取舍。
误区「我按品类分桶,再按产地分桶,再加一个价格过滤,不都差不多吗?」
正解嵌套聚合不是过滤,而是「分组的再分组」:内层子聚合只在外层桶的文档集上执行,输出是树形层级。过滤条件应写在 query 里,写在聚合内部是对内层分组的再分类。
为何常见:树形 JSON 输出看起来像多层 where,容易用 SQL 思维理解。
误区「用户平均耗时 200ms,p90 应该也差不多吧?用 avg 就够了。」
正解avg 对极端值非常敏感,少量慢请求就能把平均值拉高;而 p90 表示「有 90% 的请求低于该值」,更能刻画真实体验。一两个 10 秒的请求就能让 avg 翻倍,但 p90 几乎不变。
为何常见:日常习惯用「平均」概括集中趋势,低估了长尾对平均值的污染。
面试题
Q1. ES 中负责把文档划分成多个分组的聚合类型是?
- A.分桶聚合← 正确答案
- B.指标聚合
- C.管道聚合
- D.嵌套聚合
答案:A。分桶聚合等于「分筐」,把文档按字段值归入不同组,是聚合的骨架;指标聚合是在筐内算 avg/sum;管道聚合是对各筐结果再统计;嵌套聚合本质仍是桶的层级组合。
Q2. 指标聚合 avg 的计算过程是?
- A.总和除以文档数← 正确答案
- B.最大值减最小值
- C.计数取中位数
- D.按字段值排序
答案:A。avg = sum ÷ doc_count,好比筐的总重量除以筐里的票数。最大值减最小值是值域;计数取中位数与 avg 无关;按字段值排序只是分位数计算的第一步。
Q3. terms 聚合未显式指定 size 时,默认返回多少个桶?
- A.返回 5 个
- B.返回 10 个← 正确答案
- C.返回 20 个
- D.返回 100 个
答案:B。terms 默认 size=10,只保留文档数最多的前 10 个桶,其余直接截断。老肖的台面永远只摆最热闹的 10 个筐。想返回更多桶需显式调大 size,同时付出更高内存开销。
Q4. 嵌套聚合正确的执行顺序是?
- A.先桶后指标← 正确答案
- B.先指标后桶
- C.指标桶并行
- D.只执行内层
答案:A。聚合永远先划分桶,再在桶内计算指标,子聚合也要等父桶划分完成才能执行。先指标后桶或并行执行都不符合 ES 自顶向下的执行模型。
Q5. cardinality 聚合实现近似去重的核心思路是?
- A.哈希抽样估算← 正确答案
- B.全量精确计数
- C.冒泡排序统计
- D.遍历求取平均
答案:A。cardinality 用 HyperLogLog:对字段值哈希后分寄存器,记录最长连续 0 的个数,再用调和平均估算基数,即「抓一把豆子估一缸」。全量精确计数内存太大;排序和平均与去重无关。
Q6. percentiles 聚合最适合分析哪类数据分布?
- A.长尾分布数据← 正确答案
- B.均匀分布数据
- C.常量分布数据
- D.高斯分布数据
答案:A。分位数刻画边界而非集中趋势,对长尾分布尤其友好——几个极端值会拉爆平均值,却几乎不改变 p90。均匀、常量、高斯分布用 avg 就能描述,长尾才是分位数的用武之地。
Q7. 管道聚合的输入来源可以是?
- A.桶聚合的结果← 正确答案
- B.指标聚合的结果← 正确答案
- C.原始文档集合
- D.索引映射字段
答案:AB。管道聚合不直接处理文档,输入是其他聚合的输出——桶聚合的「桶结果」和指标聚合的「数值结果」。它消费的是统计结果,而不是原始文档或索引字段映射。
Q8. 三个桶的 avg 值分别为 10、20、30,管道 avg_bucket 的结果是?
- A.等于 20← 正确答案
- B.等于 60
- C.等于 10
- D.等于 30
答案:A。avg_bucket = (10 + 20 + 30) ÷ 3 = 20。它平均的是「三个桶的指标值」,与各桶文档数无关。选 60 是把求和当成了平均;选 10 或 30 是误取了边界值。
Q9. 关于聚合查询正确的说法有哪些?
- A.查询负责筛选文档← 正确答案
- B.聚合负责分组统计← 正确答案
- C.桶全部被完整返回
- D.指标可对全局计算← 正确答案
答案:ABD。query 筛行、aggs 统计,两者各司其职;指标聚合既可以挂在桶内,也可以对全量文档直接计算(如全局 avg);terms 默认只返回 10 个桶,「桶全部被完整返回」是常见误解。
Q10. 某 terms 聚合有 100 个唯一值,设置 size=5 后,其余 95 个桶如何处理?
- A.直接被截断丢弃← 正确答案
- B.自动合并为其他
- C.转为精确计数
- D.延迟至下次返回
答案:A。ES 直接丢弃未进入返回列表的桶,不会像 SQL 那样汇总成「其他」,也不会缓存或延迟。这个截断开关正是 terms 控制内存与响应的关键旋钮。