首页 / 资讯中心 / 文章详情

Elasticsearch聚合操作实战:从桶与指标到性能优化

Elasticsearch聚合操作实战:从桶与指标到性能优化 ★ FEATURED ARTICLE
1. 聚合操作到底在解决什么问题刚接触 Elasticsearch 的人十有八九会把精力全砸在查询上——match、term、bool组合来组合去觉得能把数据捞出来就算入门了。但真正到了业务场景里你很快会发现一个残酷的事实用户要的往往不是哪些文档符合条件而是符合条件的数据整体长什么样。比如运营想看最近七天各渠道的订单总额、技术负责人想统计各服务节点的错误率分布、数据分析师想按小时看用户活跃的峰谷曲线。这些需求查询 API 一个都搞不定必须上聚合。聚合操作的本质是在检索结果集之上做一层分组统计计算。你可以把它理解成 SQL 里的GROUP BY加上SUM、AVG、COUNT、MAX这一整套东西但 Elasticsearch 的聚合比 SQL 灵活得多它支持多层嵌套、支持在聚合结果上再做聚合、支持管道式的后处理。这也是为什么很多团队明明有数据仓库做实时看板时还是愿意走 Elasticsearch 聚合——快、灵活、能扛住高并发。这篇文章我打算把聚合操作从设计思路到落地实操完整拆一遍。适合两类人看一类是刚上手 Elasticsearch、被buckets和metrics绕晕的新手另一类是已经能用聚合但总在性能和数据准确性上踩坑的老手。我会尽量用大白话把原理讲透同时给出可以直接抄的 DSL 和参数计算过程让你看完就能在自己的项目里跑起来。先说清楚一个前提聚合是建立在查询之上的。你发一个聚合请求Elasticsearch 会先执行query部分筛出符合条件的文档再对这些文档做聚合。如果query不写默认匹配全部文档。这个顺序非常关键后面讲性能优化时我们会反复回到这一点。2. 聚合的两大支柱桶与指标要理解聚合先得把两个核心概念刻进脑子里桶Bucket和指标Metric。这两个词听起来抽象其实用生活例子一讲就通。2.1 桶聚合把数据分门别类装进不同的筐桶聚合干的事情就是分组。想象你有一筐混在一起的水果桶聚合就是拿来几个不同的筐按颜色分、按大小分、按产地分每个筐就是一个桶。在 Elasticsearch 里最常见的桶聚合有这么几种terms按字段的值分组比如按channel字段分出微信抖音搜索几个桶。这是用得最多的一个。date_histogram按时间区间分组比如按天、按小时、按分钟切分做时间序列分析必备。range按数值区间分组比如把订单金额分成 0-100、100-500、500 以上几档。histogram按固定步长分组和 range 类似但更规整比如每 10 岁一个年龄段。filters按你自定义的多个查询条件分组每个条件一个桶。每个桶里装的是落进这个分组的文档集合桶本身不返回文档内容只返回文档数量doc_count。如果你想要桶里的具体数据得在桶里再挂子聚合。2.2 指标聚合对每个筐里的数据算个数指标聚合干的事情是计算。它作用在某个桶内的文档集合上算出一个或多个数值。常见的指标聚合sum求和比如每个渠道的订单总额。avg求平均比如每个服务的平均响应时间。min/max最小值和最大值。value_count统计某字段有值的文档数。cardinality去重计数比如统计有多少个不同的用户 ID。这个要特别注意它是近似值不是精确值。stats一次性返回 count、min、max、avg、sum 五个值省得你写五个聚合。percentiles百分位数做性能分析时看 P95、P99 特别有用。指标聚合可以放在aggs顶层此时作用于整个查询结果集也可以嵌在某个桶聚合内部此时作用于该桶的文档集合。这个嵌套关系是聚合灵活性的来源也是新手最容易搞混的地方。2.3 桶和指标怎么配合一个典型的组合长这样先按渠道分桶再在每个桶里算订单总额和平均客单价。用 DSL 写出来就是{ size: 0, aggs: { by_channel: { terms: { field: channel }, aggs: { total_amount: { sum: { field: amount } }, avg_amount: { avg: { field: amount } } } } } }注意那个size: 0这是聚合查询的标配。因为聚合场景下你通常不关心命中的原始文档把size设为 0 可以让 Elasticsearch 跳过返回hits的步骤省下不少网络传输和序列化开销。我见过太多人聚合查询不写size: 0结果每次返回一堆没用的文档白白拖慢响应。提示terms聚合默认只返回文档数最多的前 10 个桶。如果你要更多得显式指定size比如terms: { field: channel, size: 50 }。但 size 开太大会显著增加内存和协调节点的合并压力后面性能章节会细讲。3. 从零搭一个可复现的聚合实操环境光讲概念没意思我们直接搭一个能跑的环境把聚合的各个环节都摸一遍。我用一个模拟的电商订单场景字段设计尽量贴近真实业务。3.1 索引设计与映射定义先建索引。这里有个关键点参与聚合的字段尤其是字符串字段必须考虑是否开启 fielddata 或者使用 keyword 类型。这是新手翻车率最高的地方我单独拎出来讲。PUT /order_agg_demo { mappings: { properties: { order_id: { type: keyword }, channel: { type: keyword }, city: { type: keyword }, amount: { type: double }, quantity: { type: integer }, user_id: { type: keyword }, status: { type: keyword }, created_at: { type: date } } } }为什么channel、city、status这些都用keyword而不是text因为text类型会被分词聚合时会把一个词拆成多个词条分别统计结果完全不是你想要的。比如北京朝阳被分成北京和朝阳两个桶这显然不对。keyword保留原始值聚合才准确。那如果字段既要全文搜索又要聚合怎么办用multi-field主字段是text用于搜索子字段是keyword用于聚合product_name: { type: text, fields: { raw: { type: keyword } } }聚合时用product_name.raw就行。这个设计模式在实际项目里非常常见几乎每个有搜索统计需求的索引都会用到。3.2 灌入测试数据我准备了 8 条模拟订单数据覆盖多个渠道、城市和时间点方便后面演示各种聚合POST /order_agg_demo/_bulk {index:{}} {order_id:A001,channel:wechat,city:beijing,amount:120.5,quantity:2,user_id:u1,status:paid,created_at:2024-06-01T09:15:00} {index:{}} {order_id:A002,channel:wechat,city:shanghai,amount:88.0,quantity:1,user_id:u2,status:paid,created_at:2024-06-01T10:30:00} {index:{}} {order_id:A003,channel:douyin,city:beijing,amount:300.0,quantity:3,user_id:u1,status:paid,created_at:2024-06-01T14:00:00} {index:{}} {order_id:A004,channel:douyin,city:guangzhou,amount:45.0,quantity:1,user_id:u3,status:refunded,created_at:2024-06-02T08:20:00} {index:{}} {order_id:A005,channel:search,city:beijing,amount:520.0,quantity:5,user_id:u2,status:paid,created_at:2024-06-02T11:45:00} {index:{}} {order_id:A006,channel:search,city:shanghai,amount:210.0,quantity:2,user_id:u4,status:paid,created_at:2024-06-02T16:10:00} {index:{}} {order_id:A007,channel:wechat,city:beijing,amount:75.5,quantity:1,user_id:u3,status:paid,created_at:2024-06-03T09:00:00} {index:{}} {order_id:A008,channel:douyin,city:shanghai,amount:660.0,quantity:6,user_id:u4,status:paid,created_at:2024-06-03T20:30:00}灌完之后先refresh一下让数据可搜然后我们就能开始各种聚合实验了。3.3 验证环境是否就绪跑一个最简单的聚合确认环境没问题GET /order_agg_demo/_search { size: 0, aggs: { total_orders: { value_count: { field: order_id } } } }如果返回的total_orders.value是 8说明环境 OK。这一步别省我踩过好几次坑——数据没 refresh、字段类型建错、索引名打错都会让聚合返回空结果先做个冒烟测试能省下大量排查时间。4. 核心聚合场景的完整实操环境搭好了接下来把实际业务里最常遇到的几个聚合场景逐个拆解。每个场景我都会给出完整 DSL、返回结果解读以及背后的设计考量。4.1 按渠道统计订单总额与客单价这是最经典的分组求和平均三件套GET /order_agg_demo/_search { size: 0, query: { term: { status: paid } }, aggs: { by_channel: { terms: { field: channel, size: 10 }, aggs: { total_amount: { sum: { field: amount } }, avg_amount: { avg: { field: amount } }, order_count: { value_count: { field: order_id } } } } } }这里我特意加了query过滤掉退款订单因为统计营收时退款不该算进去。返回结果里by_channel.buckets是一个数组每个元素包含key渠道名、doc_count该渠道订单数以及三个子聚合的结果。你可以直接把这个结构喂给前端图表库渲染柱状图。有个细节值得说avg_amount算的是该渠道内所有订单金额的平均值分母是doc_count。如果你想要的是总金额除以去重用户数这种自定义口径avg就满足不了得用bucket_script管道聚合自己算这个后面会讲。4.2 按天统计订单量走势时间序列分析离不开date_histogramGET /order_agg_demo/_search { size: 0, aggs: { orders_over_time: { date_histogram: { field: created_at, calendar_interval: day, format: yyyy-MM-dd, min_doc_count: 0, extended_bounds: { min: 2024-06-01, max: 2024-06-05 } }, aggs: { daily_amount: { sum: { field: amount } } } } } }这里有几个参数必须解释清楚不然你很容易做出缺天的图表calendar_interval和fixed_interval的区别前者按自然日历切分day、week、month后者按固定时长切分如30m、12h。做按天统计用calendar_interval: day做每 30 分钟用fixed_interval: 30m。用错了会出现时区或边界问题。min_doc_count: 0默认情况下没有数据的日期区间不会出现在结果里。设成 0 后即使某天没订单也会返回一个doc_count: 0的桶图表才不会断档。extended_bounds配合min_doc_count: 0使用强制把时间范围扩展到指定的起止日期。不然 Elasticsearch 只会从有数据的第一天开始补零前面缺的天还是补不上。实操心得做日报看板时min_doc_count: 0和extended_bounds几乎必开。我见过一个团队因为没开这两个参数周末没订单的日子直接从图表上消失了运营以为系统出故障排查了半天。4.3 多级嵌套聚合渠道下再分城市聚合可以一层套一层做出类似数据透视表的效果GET /order_agg_demo/_search { size: 0, aggs: { by_channel: { terms: { field: channel, size: 10 }, aggs: { by_city: { terms: { field: city, size: 10 }, aggs: { total_amount: { sum: { field: amount } } } } } } } }返回结构就是渠道 → 城市 → 金额的三层嵌套。这种写法在生成多维度报表时特别有用一次请求就能拿到完整的分层数据不用发多次查询。但嵌套层数不是越多越好。每多一层协调节点需要维护的桶数量就成倍增长。假设第一层 10 个桶、第二层 10 个桶、第三层 10 个桶那就是 1000 个桶每个桶都要占内存。经验值是嵌套不超过 3 层总桶数控制在几万以内否则很容易触发内存告警。4.4 去重计数与百分位数cardinality用来统计去重后的数量比如有多少个不同的用户下过单{ aggs: { unique_users: { cardinality: { field: user_id, precision_threshold: 40000 } } } }这里必须强调cardinality返回的是近似值不是精确值。它底层用的是 HyperLogLog 算法用极小的内存代价估算基数。precision_threshold控制精度值越大越准但内存占用越高最大 40000。对于百万级以下的去重设成 40000 基本能保证误差在 1% 以内如果数据量特别大又要求精确那就得换思路比如用terms聚合把所有 user_id 拉出来自己数但这样内存会爆或者干脆在写入时预聚合。百分位数聚合做性能监控时是刚需{ aggs: { latency_percentiles: { percentiles: { field: amount, percents: [50, 90, 95, 99] } } } }它会返回 P50、P90、P95、P99 对应的值。为什么看 P99 而不是平均值因为平均值会被极端值掩盖一个服务 99% 的请求都是 10ms1% 的请求是 5 秒平均值可能只有 60ms 看起来很美但用户体验其实很糟。P99 能真实反映长尾延迟。4.5 管道聚合在聚合结果上再算一层管道聚合Pipeline Aggregation是 Elasticsearch 聚合的高级玩法它的输入不是文档而是其他聚合的输出。最常用的两个是bucket_script和bucket_sort。bucket_script可以对桶内的多个指标做四则运算。比如算客单价 总金额 / 订单数{ aggs: { by_channel: { terms: { field: channel }, aggs: { total_amount: { sum: { field: amount } }, order_count: { value_count: { field: order_id } }, avg_order_value: { bucket_script: { buckets_path: { total: total_amount, count: order_count }, script: params.total / params.count } } } } } }buckets_path里引用的就是同级子聚合的名字。这个能力非常实用很多业务指标没法用单个聚合直接算出来但通过管道聚合组合一下就有了。bucket_sort则用来对桶排序或分页。比如按总金额降序取前 3 个渠道{ aggs: { by_channel: { terms: { field: channel, size: 10 }, aggs: { total_amount: { sum: { field: amount } }, amount_sort: { bucket_sort: { sort: [{ total_amount: { order: desc } }], size: 3 } } } } } }注意terms的size要先取足够多的桶这里取 10bucket_sort才能在这 10 个里排出前 3。如果terms只取 3 个那排序就失去意义了——你排的只是文档数最多的 3 个渠道不是金额最高的 3 个。这个坑我踩过当时怎么排都不对后来才反应过来是terms的 size 限制了候选集。5. 性能优化与常见问题排查聚合用起来爽但数据量一上来性能问题就会暴露。这一章把我这些年踩过的坑和对应的解法整理出来都是实战里验证过的。5.1 聚合慢的三大元凶元凶一字段没设 doc_values 或用了 text 类型。聚合依赖doc_values列式存储来快速读取字段值。keyword、数值、日期类型默认开启doc_values但text类型没有。如果你对一个text字段做聚合Elasticsearch 会尝试用fielddata把词条加载到堆内存这玩意儿极其吃内存数据量大时直接 OOM。解法就是前面说的用keyword或multi-field。元凶二桶数量爆炸。terms聚合的size设得太大或者嵌套层数太多都会让协调节点需要合并的桶数量激增。每个桶都要在内存里维护几万个桶就可能吃掉几个 G。解法是控制size能用composite聚合分页就别一次性拉全量。元凶三高基数字段做 terms。对一个有几百万唯一值的字段比如 user_id做terms聚合即使 size 只取 10Elasticsearch 内部也要先统计所有唯一值再排序开销巨大。这种场景要么改用cardinality估算要么用composite聚合分批拉取。5.2 composite 聚合大数据量分页的正确姿势terms聚合的 size 有上限受search.max_buckets限制默认 65536而且一次性返回所有桶对内存压力很大。composite聚合专为分页设计它支持after游标可以一批一批地拉GET /order_agg_demo/_search { size: 0, aggs: { my_buckets: { composite: { size: 2, sources: [ { channel: { terms: { field: channel } } }, { city: { terms: { field: city } } } ] }, aggs: { total_amount: { sum: { field: amount } } } } } }返回结果里会带一个after_key下次请求把它塞进composite.after就能接着拉下一批。这种方式内存占用稳定适合导出全量分组数据。缺点是它不支持order排序按 key 的自然顺序返回也不支持子聚合的排序所以做Top N还是得用terms。5.3 常见问题速查表问题现象可能原因排查与解决聚合返回空 buckets字段类型是 text 未加 keyword检查 mapping改用 keyword 或 multi-field报错 fielddata is disabled对 text 字段聚合同上或临时开启 fielddata不推荐生产用桶数量比预期少terms 默认 size 为 10显式设置 size时间序列缺天未设 min_doc_count 和 extended_bounds补上这两个参数聚合结果不准cardinality 是近似算法提高 precision_threshold 或换精确方案查询超时桶太多或嵌套太深减少 size、降低嵌套层数、用 composite 分页内存告警fielddata 或桶数量过大检查 text 聚合控制桶总数5.4 几个容易被忽略的实操技巧技巧一用 filter 聚合替代 query 做多口径统计。有时候你想在一次请求里同时统计已支付订单和退款订单的金额用filters聚合比发两次请求高效{ size: 0, aggs: { status_breakdown: { filters: { filters: { paid: { term: { status: paid } }, refunded: { term: { status: refunded } } } }, aggs: { total_amount: { sum: { field: amount } } } } } }技巧二missing参数处理空值。如果某字段有文档是 null默认这些文档不会进任何桶。用missing给它们指定一个默认桶{ terms: { field: channel, missing: unknown } }技巧三善用stats聚合省请求。需要同时看 count、min、max、avg、sum 时一个stats顶五个聚合DSL 更短执行也略快。技巧四聚合字段尽量用数值类型而非字符串。数值类型的 doc_values 是定长的读取效率比字符串高。比如状态字段如果能用枚举数字表示就别存字符串。6. 我在实际项目里的一些体会聚合这块内容文档看一遍觉得都会了真到项目里还是会不断遇到新问题。我自己印象最深的一次是做一个实时大屏需要按分钟统计各接口的调用量和 P99 延迟。一开始用date_histogram加percentiles数据量小的时候没问题上线后 QPS 一上来查询直接超时。后来排查发现两个问题一是percentiles在高基数下计算开销很大二是date_histogram的fixed_interval设成了1m一天就是 1440 个桶再乘以接口数量桶总数直接上万。最后的解法是把统计粒度从分钟降到 5 分钟同时把percentiles的percents从默认的 7 个精简到 3 个查询耗时从 8 秒降到了 400 毫秒。这件事给我的教训是聚合的性能瓶颈往往不在查询本身而在桶的数量和指标的计算复杂度。设计聚合方案时先估算一下桶的总数再想想每个指标聚合的算法开销比事后调优省事得多。另外还有一个经常被忽略的点聚合结果最好做缓存。很多看板的数据并不需要秒级实时缓存个 30 秒到 1 分钟完全能接受但能给 Elasticsearch 省下大量重复计算。我们后来在应用层加了一层短时缓存集群负载直接降了一半。如果你刚开始用聚合我的建议是从最简单的termssum开始把桶和指标的关系摸熟再逐步上嵌套、管道聚合这些高级特性。别一上来就追求复杂 DSL先把基础场景跑通、跑稳后面遇到复杂需求自然就知道怎么组合了。
阅读完成 · 觉得有帮助?
咨询建站