
1. 聚合操作到底在解决什么问题刚接触 Elasticsearch 那会儿我总觉得它就是个搜索框——把数据塞进去输入关键词返回匹配的文档完事。直到有一次业务方提了个需求给我统计过去七天每个品类的订单总金额还要按小时看趋势。我第一反应是写个脚本把数据全捞出来在应用层用代码算。结果数据量一上来内存直接爆了查询超时成了家常便饭。后来才明白Elasticsearch 的聚合操作就是为这类场景生的。它让你把计算下推到存储层在数据所在的地方直接完成分组、统计、求极值、算百分位这些活儿只把最终结果拉回来。这跟传统数据库里的GROUP BY加聚合函数是一个思路但 Elasticsearch 的聚合能力要灵活得多尤其是嵌套聚合和管道聚合能玩出很多花样。这篇文章我打算把聚合操作从里到外捋一遍。不管你是刚学会写match查询的新手还是已经用过terms聚合但遇到过分桶不准的老手都能从中找到能直接抄作业的东西。我会讲清楚每种聚合的适用场景、参数怎么调、性能怎么优化还有那些官方文档里不会写的坑。全文基于我实际项目里踩过的雷和总结的经验代码示例可以直接拿去改改就用。聚合操作的核心价值在于把分析能力嵌入到检索流程里。你不需要把数据同步到别的分析引擎也不需要写复杂的 MapReduce 任务一条查询语句就能同时完成筛选和统计。对于日志分析、电商报表、监控告警这些场景这几乎是刚需。2. 聚合操作的整体设计与核心概念拆解2.1 聚合的基本结构从查询到分桶再到计算Elasticsearch 的聚合请求体里aggs和query是平级的。query先负责把符合条件的文档筛出来aggs再在这些文档上做计算。这个顺序很关键——聚合只作用于查询结果集不是全量数据。很多人第一次写聚合时忘了加query结果统计了全库的数据性能差不说数字还对不上。一个典型的聚合请求长这样{ size: 0, query: { range: { order_time: { gte: 2024-01-01, lt: 2024-01-08 } } }, aggs: { category_stats: { terms: { field: category.keyword, size: 10 }, aggs: { total_amount: { sum: { field: amount } } } } } }这里有几个点值得展开说。size: 0是为了不让查询返回原始文档只返回聚合结果。如果你不加这个Elasticsearch 默认返回 10 条文档白白浪费网络带宽和序列化开销。terms聚合按category.keyword分桶每个桶里再嵌套一个sum聚合算总金额。这种分桶聚合套指标聚合的结构是最常见的组合。2.2 分桶聚合与指标聚合的本质区别聚合操作分两大阵营分桶聚合和指标聚合。分桶聚合负责把文档分组每个组就是一个桶指标聚合负责在桶内计算数值比如求和、平均值、最大值。分桶聚合可以嵌套分桶聚合也可以嵌套指标聚合但指标聚合不能再嵌套聚合——它已经是叶子节点了。打个比方分桶聚合像是把一堆卡片按颜色分类指标聚合像是数每类卡片有多少张、总面值是多少。你可以先按颜色分再按大小分最后数数量但不能在数数量这个动作里再分一次类。常用的分桶聚合有terms、range、date_histogram、histogram、filter、filters、nested、geohash_grid等。常用的指标聚合有sum、avg、min、max、count、stats、extended_stats、percentiles、cardinality等。后面我会挑最核心的几个详细讲。2.3 为什么聚合字段要用 keyword 而不是 text这是新手最容易踩的坑。如果你对一个text类型的字段做terms聚合Elasticsearch 会直接报错提示你开启fielddata。但即使你开了fielddata结果也往往不是你想要的——因为text字段在索引时会被分词聚合出来的是一个个词条而不是完整的字段值。举个例子category字段值是电子产品标准分词器可能把它切成电子和产品两个词。你对这个字段做terms聚合得到的是电子和产品两个桶而不是电子产品一个桶。正确的做法是用category.keyword这个子字段它是keyword类型不分词保留原始值。所以在设计索引映射时凡是需要聚合的字符串字段都要加一个keyword子字段。这是铁律我见过太多人因为这个问题排查半天。2.4 聚合的分布式执行模型Elasticsearch 是分布式的聚合操作也分散在多个分片上执行。协调节点把请求发给所有相关分片每个分片在本地计算聚合结果然后协调节点把结果合并。这个模型有个关键问题分片只能看到自己那部分数据。对于sum、avg、min、max这类指标聚合合并很简单各分片算完再汇总就行。但对于terms聚合问题就来了——每个分片返回自己 Top N 的桶协调节点再从中选全局 Top N。如果某个词条在分片 A 排第 11 名但在全局能排进前 10它就会被漏掉。这就是terms聚合结果不准确的根本原因。解决办法是调大shard_size参数让每个分片多返回一些候选桶协调节点有更多选择余地。shard_size默认是size * 1.5 10对于精度要求高的场景可以手动调大。代价是网络传输和内存开销增加需要权衡。3. 核心聚合类型详解与实操要点3.1 terms 聚合最常用也最容易出错terms聚合按字段值分组统计每个值的文档数量。它是电商品类统计、日志错误码分布、用户地域分布这类场景的首选。{ size: 0, aggs: { top_categories: { terms: { field: category.keyword, size: 20, shard_size: 50, min_doc_count: 5, order: { total_sales: desc } }, aggs: { total_sales: { sum: { field: sales } } } } } }这里有几个参数需要重点说。size控制返回多少个桶默认 10。shard_size控制每个分片返回多少候选桶调大能提高准确性。min_doc_count过滤掉文档数少于指定值的桶默认是 1设为 0 会返回所有桶包括空桶。order可以按文档数排序也可以按子聚合的结果排序——上面这个例子就是按销售额降序排而不是按文档数。注意order里引用的子聚合名称必须存在否则会报错。另外按子聚合排序时排序是在协调节点合并后进行的各分片返回的桶可能不是最终顺序这是正常的。我踩过的一个坑是对高基数字段做terms聚合比如用户 ID可能有几百万个唯一值。这时候size设大了内存扛不住设小了又看不全。正确的做法是先想清楚业务需求——你真的需要看所有用户吗还是只需要 Top 100如果确实需要全量考虑用composite聚合分页拉取。3.2 date_histogram 聚合时间序列分析的利器date_histogram按时间间隔分桶是做趋势图、监控报表的核心聚合。它支持固定间隔如day、hour、minute和日历间隔如1M、1w两者有细微差别。{ size: 0, aggs: { sales_over_time: { date_histogram: { field: order_time, calendar_interval: 1d, time_zone: 08:00, min_doc_count: 0, extended_bounds: { min: 2024-01-01, max: 2024-01-07 } }, aggs: { daily_revenue: { sum: { field: amount } } } } } }calendar_interval和fixed_interval的区别在于calendar_interval会考虑时区和夏令时比如1d表示每个自然日fixed_interval是固定的毫秒数比如24h就是严格的 24 小时。做业务报表通常用calendar_interval做技术监控可能用fixed_interval。time_zone参数非常重要。Elasticsearch 默认用 UTC 时间分桶如果你不指定时区东八区的1月1日数据可能被分到12月31日的桶里。我见过有人统计日活数字总是对不上最后发现就是时区没设。min_doc_count: 0配合extended_bounds可以返回没有数据的空桶。比如你想画一张完整的七天趋势图即使某天没有订单也要显示那天的值为 0。不加这两个参数空桶会被直接跳过图表上就缺了一块。3.3 range 聚合自定义区间统计range聚合让你手动定义数值区间适合做价格分布、年龄分段、评分等级这类统计。{ size: 0, aggs: { price_ranges: { range: { field: price, ranges: [ { to: 100 }, { from: 100, to: 500 }, { from: 500, to: 1000 }, { from: 1000 } ] }, aggs: { avg_rating: { avg: { field: rating } } } } } }from是包含的to是不包含的。第一个区间{ to: 100 }表示价格小于 100最后一个{ from: 1000 }表示价格大于等于 1000。每个区间可以起个名字用key字段指定方便结果识别。range聚合也支持keyed: true参数让结果以对象形式返回而不是数组方便按名称取值。这个在代码里处理结果时特别有用。3.4 指标聚合sum、avg、percentiles 怎么选指标聚合是在桶内做数值计算。sum求和、avg求平均、min/max求极值、stats一次性返回 count/sum/avg/min/max、extended_stats还额外返回方差和标准差。percentiles聚合算百分位数对分析响应时间、延迟分布特别有用。比如你想知道 P95 的接口响应时间{ size: 0, aggs: { response_time_percentiles: { percentiles: { field: response_time, percents: [50, 90, 95, 99] } } } }cardinality聚合算去重后的基数相当于COUNT(DISTINCT)。但要注意它是基于 HyperLogLog 算法的近似计算不是精确值。对于百万级数据误差通常在 1% 以内但如果你需要精确去重得用别的方法。实操心得cardinality聚合在数据量小的时候反而可能不准因为算法在小基数下的校正机制。如果数据量少于几千条建议直接用terms聚合数桶的数量。3.5 管道聚合对聚合结果再做聚合管道聚合的输入是其他聚合的输出而不是文档。比如你想算每个品类销售额的平均值可以先按品类分桶算销售额再用avg_bucket管道聚合求这些销售额的平均。{ size: 0, aggs: { categories: { terms: { field: category.keyword }, aggs: { total_sales: { sum: { field: sales } } } }, avg_category_sales: { avg_bucket: { buckets_path: categoriestotal_sales } } } }buckets_path用表示层级关系categoriestotal_sales意思是取categories聚合下每个桶的total_sales值。常用的管道聚合还有min_bucket、max_bucket、sum_bucket、stats_bucket、derivative求导算环比、cumulative_sum累计求和、moving_avg移动平均。derivative管道聚合做环比特别方便。比如按天统计销售额再算每天的环比增长{ aggs: { daily_sales: { date_histogram: { field: order_time, calendar_interval: 1d }, aggs: { revenue: { sum: { field: amount } }, revenue_derivative: { derivative: { buckets_path: revenue } } } } } }这样每个桶里除了revenue还会多一个revenue_derivative表示与前一天相比的差值。4. 聚合性能优化与常见问题排查4.1 字段数据与 doc_values聚合的底层存储Elasticsearch 做聚合时需要快速访问字段值。对于keyword、数值、日期、布尔这些类型默认开启doc_values数据以列式存储在磁盘上聚合时按需读取内存占用小。对于text类型默认没有doc_values需要开启fielddata它会把字段值加载到堆内存里数据量大时极易 OOM。所以第一条优化原则聚合字段一律用keyword或数值类型不要用text。如果历史索引已经建好了改不了至少不要对text字段开fielddata而是用keyword子字段。4.2 分片数量对聚合结果和性能的影响分片数越多聚合的并行度越高但协调节点合并结果的负担也越重。对于terms聚合分片越多每个分片返回的候选桶越多结果准确性越难保证。我一般建议单个索引的分片数控制在 10 到 30 之间具体看数据量和节点数。如果发现terms聚合结果不稳定每次查询返回的桶略有差异大概率是分片间数据分布不均加上shard_size太小导致的。调大shard_size能缓解但根治方法是重新设计分片策略让数据均匀分布。4.3 嵌套聚合的深度与内存控制嵌套聚合层数越多内存消耗越大。每层分桶都要维护一个桶列表桶里还要存子聚合的中间状态。我见过有人写了五层嵌套聚合每个桶里还有percentiles结果查询直接打爆内存。控制嵌套深度的原则能扁平化就扁平化。比如先按品类分桶再按地区分桶如果品类和地区组合数不多可以考虑用multi_terms聚合一次性分桶减少层级。另外terms聚合的size不要设得太大够用就行。4.4 常见问题速查表问题现象可能原因排查方向解决方案terms 聚合报 fielddata 错误对 text 字段做聚合检查字段映射改用 keyword 子字段聚合结果每次略有不同分片间数据不均shard_size 太小查看分片分布调大 shard_size重设分片日期分桶结果偏移一天时区未设置检查 time_zone 参数加上time_zone: 08:00空桶不显示min_doc_count 默认为 1检查是否有空数据设min_doc_count: 0加 extended_bounds聚合查询超时嵌套太深或分桶太多查看慢查询日志减少嵌套调小 size加 filtercardinality 结果不准近似算法误差对比精确去重结果小数据量用 terms 数桶管道聚合报 buckets_path 错误路径写错或引用了不存在的聚合检查聚合名称拼写确保路径指向指标聚合4.5 几个我踩过的坑和对应技巧第一个坑terms聚合的order按子聚合排序时如果子聚合是avg且某些桶没有数据排序结果可能不符合预期。解决办法是给子聚合加missing参数指定默认值。第二个坑date_histogram的extended_bounds只在min_doc_count: 0时生效而且它不会扩展超出数据范围的桶。如果你想要固定范围的桶得确保extended_bounds的范围覆盖了你的需求。第三个坑filter聚合和filters聚合容易混淆。filter是单个过滤器返回一个桶filters是多个过滤器返回多个桶。用错了结果结构完全不对。第四个坑聚合结果里的doc_count是文档数不是去重后的数量。如果你按订单 ID 聚合但一个订单有多个商品行doc_count会偏大。这时候要用cardinality子聚合算去重订单数。实操心得写复杂聚合时先用size: 0加小范围query测试确认结果结构正确后再放开范围。直接上全量数据调试一次超时就要等很久效率极低。5. 聚合操作在实际场景中的组合应用5.1 电商多维度销售报表假设要做一个销售报表需要按品类看销售额和订单数同时按天看趋势还要算每个品类的平均客单价。这个需求可以这样实现{ size: 0, query: { range: { order_time: { gte: 2024-01-01, lt: 2024-02-01 } } }, aggs: { by_category: { terms: { field: category.keyword, size: 20, order: { revenue: desc } }, aggs: { revenue: { sum: { field: amount } }, order_count: { cardinality: { field: order_id, precision_threshold: 1000 } }, avg_order_value: { avg: { field: amount } }, daily_trend: { date_histogram: { field: order_time, calendar_interval: 1d, time_zone: 08:00, min_doc_count: 0, extended_bounds: { min: 2024-01-01, max: 2024-01-31 } }, aggs: { daily_revenue: { sum: { field: amount } } } } } } } }这个查询一次性返回了品类维度的销售额、订单数、客单价以及每个品类下的每日趋势。precision_threshold控制cardinality的精度值越大越准但内存消耗越高默认 3000最大 40000。5.2 日志监控中的错误分布与趋势日志场景常用聚合来统计错误码分布和随时间的变化{ size: 0, query: { term: { level: ERROR } }, aggs: { error_codes: { terms: { field: error_code.keyword, size: 10 }, aggs: { trend: { date_histogram: { field: timestamp, fixed_interval: 1h, min_doc_count: 0 } }, first_seen: { min: { field: timestamp } }, last_seen: { max: { field: timestamp } } } }, total_errors: { value_count: { field: error_code.keyword } } } }这里用fixed_interval而不是calendar_interval因为监控场景通常按固定小时窗口统计不受时区影响。first_seen和last_seen能快速定位某个错误码的出现时间范围。5.3 用 composite 聚合做全量分页前面提到terms聚合在高基数字段上无法一次返回所有桶。composite聚合就是为解决这个问题设计的它支持分页可以逐页拉取所有桶。{ size: 0, aggs: { all_categories: { composite: { size: 100, sources: [ { category: { terms: { field: category.keyword } } }, { region: { terms: { field: region.keyword } } } ] }, aggs: { revenue: { sum: { field: amount } } } } } }第一次查询返回 100 个桶和一个after_key。下次查询把after_key的值放到composite.after里就能拿到下一页。这样循环直到没有after_key就遍历完了所有组合。composite聚合不支持order结果按源字段值排序这是它的限制但换来的是全量遍历能力。注意composite聚合的size是每页桶数不是总桶数。分页拉取时要注意控制并发避免同时发起太多请求压垮集群。6. 聚合操作的监控与调优经验6.1 如何发现慢聚合查询Elasticsearch 有慢查询日志可以在索引级别设置search.slowlog.threshold.query.warn和search.slowlog.threshold.fetch.warn。聚合查询通常体现在 fetch 阶段因为要收集和合并结果。我一般把 warn 阈值设在 5 秒info 设在 2 秒这样能抓到大部分有问题的聚合。另外profile: true参数可以返回查询执行的详细耗时分解包括每个聚合阶段的耗时。调试复杂聚合时非常有用但生产环境不要开开销很大。6.2 聚合缓存与 request cacheElasticsearch 有分片请求缓存对于size: 0的聚合查询如果查询条件完全一样结果会被缓存。但缓存 key 包含整个请求体稍微改一个参数就失效了。所以不要指望缓存能解决所有性能问题关键还是优化聚合本身。doc_values是聚合性能的基石确保它开启。对于不需要聚合的字段可以关闭doc_values节省磁盘空间。对于keyword字段如果只用来精确匹配不聚合也可以关掉。6.3 集群层面的聚合调优聚合是内存密集型操作堆内存大小直接影响能支持的聚合复杂度。我一般建议堆内存不超过物理内存的 50%且不超过 31GB压缩指针的边界。剩下的内存留给文件系统缓存doc_values的读取依赖它。如果聚合查询经常 OOM可以考虑这几个方向减少分片数、降低嵌套深度、调小terms的size、用filter聚合替代大范围terms聚合、把大聚合拆成多个小聚合异步执行。实操心得对于每天跑一次的报表类聚合可以放在业务低峰期执行或者用异步搜索async search提交任务避免阻塞实时查询。异步搜索会返回一个任务 ID你可以轮询获取结果适合耗时超过 10 秒的聚合。6.4 聚合精度与性能的权衡terms聚合的shard_size、cardinality的precision_threshold、percentiles的compression这些参数都在精度和性能之间做权衡。我的经验是先按默认值跑看结果是否满足业务需求如果业务方对精度有明确要求再针对性调参。比如cardinality默认precision_threshold是 3000对于百万级 UV 统计误差可能在 5% 左右。如果业务要求误差小于 1%可以调到 10000 甚至 40000但内存消耗会显著增加。这时候要评估集群是否能承受。7. 从查询到写入聚合对索引设计的影响7.1 映射设计阶段就要考虑聚合需求很多聚合问题其实在索引设计阶段就埋下了。如果一开始没规划好keyword子字段后面加字段需要重建索引代价很大。我的做法是所有字符串字段默认加keyword子字段除非明确知道不需要聚合和排序。数值字段默认开启doc_values除非明确只用于范围过滤。对于需要按多个维度聚合的场景可以考虑用flattened类型或者把多个维度拼成一个字段。但flattened不支持所有聚合类型用之前要确认。7.2 预计算与运行时字段的取舍有些聚合计算量很大比如复杂的百分位或者多级嵌套。如果查询频率高可以考虑预计算——在写入时就算好部分聚合结果存到一个单独的索引里。查询时直接读预计算索引速度会快很多。代价是写入逻辑变复杂数据可能有延迟。Elasticsearch 还支持运行时字段runtime field可以在查询时动态计算字段值再聚合。这适合字段逻辑经常变、不想重建索引的场景但性能比原生字段差因为每次查询都要计算。7.3 索引生命周期与聚合的历史数据对于时间序列数据通常用 ILM索引生命周期管理把老数据转到冷节点或删除。聚合查询如果跨了多个索引会同时查询热、温、冷节点性能受冷节点影响。我的做法是对历史数据的聚合查询单独走一个别名只查温节点以上的索引冷数据用单独的离线任务处理。另外老索引可以强制合并force merge成更少的分片减少聚合时的分片合并开销。但强制合并很耗 I/O要在低峰期做。8. 几个容易被忽略的聚合细节8.1 missing 参数处理空值聚合时如果字段缺失默认会被忽略。但有时候你想把缺失值也统计进去可以用missing参数指定一个默认值{ aggs: { by_status: { terms: { field: status.keyword, missing: unknown } } } }这样没有status字段的文档会被归到unknown桶里。对于数据质量分析很有用。8.2 聚合结果中的 key 类型terms聚合返回的key类型取决于字段类型。对于keyword字段key是字符串对于数值字段key是数字。但如果你用了missing参数指定字符串默认值而字段是数值类型key的类型可能不一致代码处理时要小心。8.3 filter 聚合与 query 的区别filter聚合是在聚合结果里再过滤不影响其他聚合。而query是全局过滤影响所有聚合。如果你只想对某个聚合做过滤用filter子聚合如果想缩小整个查询范围用query。{ aggs: { all_orders: { terms: { field: category.keyword } }, paid_orders: { filter: { term: { status: paid } }, aggs: { by_category: { terms: { field: category.keyword } } } } } }这样all_orders统计所有订单paid_orders只统计已支付的两者互不影响。8.4 聚合中的脚本使用有些聚合需要动态计算比如按价格区间分组但区间边界是动态的或者按自定义逻辑分桶。这时候可以用script{ aggs: { price_tier: { terms: { script: { source: if (doc[price].value 100) { return low } else if (doc[price].value 500) { return mid } else { return high } } } } } }脚本聚合很灵活但性能比原生字段聚合差很多因为要对每个文档执行脚本。能用映射解决的就不要用脚本。如果必须用尽量用painless脚本并开启缓存。9. 聚合操作的未来演进与替代方案Elasticsearch 的聚合能力一直在增强。composite聚合解决了全量分页问题runtime field让动态字段聚合成为可能async search让长耗时聚合不阻塞。但聚合也不是万能的有些场景用其他方案更合适。比如超大规模数据的精确去重cardinality是近似的精确去重要么用terms加分页遍历要么把数据导出到专门的分析引擎。再比如复杂的多表关联聚合Elasticsearch 不支持 join得在应用层做或者用宽表预关联。我的建议是把 Elasticsearch 聚合用在它擅长的场景——单表、近实时、中等复杂度的统计分析。超出这个范围的考虑引入其他工具配合而不是硬扛。最后分享一个小技巧调试聚合时把size设为 0 但加上track_total_hits: true可以同时拿到总文档数和聚合结果方便验证数据范围是否正确。这个参数在只需要聚合结果但想确认命中总数时特别有用。聚合操作这东西看文档觉得简单真到用的时候才发现细节多如牛毛。我上面写的这些基本都是实际项目里踩过坑之后才记住的。你如果刚开始用建议从简单的terms加sum开始跑通了再逐步加嵌套、加管道。遇到结果不对先查字段类型和时区这两个是最常见的罪魁祸首。