Elasticsearch聚合实战:桶、指标、管道聚合与性能调优

发布时间:2026/10/17 15:16:03
Elasticsearch聚合实战:桶、指标、管道聚合与性能调优
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_histogramaggs: { 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_daydaily_amount } } }buckets_path里的表示进入子聚合。sales_per_daydaily_amount的意思是在sales_per_day这个桶聚合的每个桶里取daily_amount这个指标然后找最大值。返回结果会告诉你最大值是多少以及它对应哪个桶的 key。管道聚合的buckets_path语法是重点总结一下语法含义agg_name引用同级聚合agg_namemetric_name进入子聚合取指标agg_name.metric_name引用聚合内的单值指标the_sum[0]引用多值指标的第 0 个值写错了buckets_path报错信息往往很含糊所以建议先用小数据集验证确认路径对了再上生产。6. 聚合性能调优让统计查询从秒级到毫秒级聚合查询慢通常不是 Elasticsearch 本身的问题而是用法不对。下面这几条是我在实际调优中验证过、效果最明显的。6.1 善用filter上下文和post_filterquery里的条件会参与打分而聚合其实不关心打分只关心哪些文档命中。所以能用filter就别用mustquery: { 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这些进阶玩法。每学一个新聚合都拿真实数据跑一遍看看返回结构长什么样比看十遍文档都管用。