资讯动态

Elasticsearch统计类聚合实战:去重计数、百分位与字符串统计

发布时间:2026/10/17 21:42:57 来源:尧图企业网站定制
写这个 Elasticsearch 笔记系列的时候我一直有个观点聚合查询做得好不好基本决定了一个人用 ES 的水平。业务上百分之八九十的查询都是简单的 term 检索和范围过滤真正要从数据里挖出价值、做报表、做监控、做分析靠的全是聚合。前面几篇把 metrics 聚合、bucket 聚合这些基础能力过了一遍这一篇专门聊聊几个平时容易被忽略、但关键时刻特别顶用的统计类聚合去重计数cardinality、基础统计stats / extended_stats、百分位percentiles、字符串统计string_stats。这四个聚合听起来不像 terms 和 date_histogram 那么常用但一旦你开始做用户分析、性能监控、数据质量治理几乎躲不开它们。比如统计一个活动页的独立访客数、给订单金额做一个分布画像、排查接口 P99 延迟是否超标、检测商品描述字段是否被塞入了大量无意义文本。这些场景用普通查询很难搞用聚合则是一条直线。这篇笔记我尽量写得实在一些每一个聚合都会给可直接复用的查询示例顺带讲清楚背后的原理和踩过的坑。适合刚接触 ES 聚合、准备在自己的项目里用统计类分析的人也适合已经用了 terms 聚合但想进一步挖数据特征的同学。1. 去重计数 cardinality数量级估算的正确打开方式1.1 基数聚合的基础查询写法与使用场景先看最基础的一行查询。假设我有一个订单索引orders里面有一条订单记录包含用户 ID、商品、金额、地区等字段。要统计某个月有多少独立用户下单直接这样写GET /orders/_search { size: 0, query: { range: { timestamp: { gte: 2024-11-01, lt: 2024-12-01 } } }, aggs: { unique_users: { cardinality: { field: user_id } } } }返回结果里value就是去重后的用户数。{ aggregations : { unique_users : { value : 4582 } } }这里要特别注意cardinality是一个近似聚合底层用的是 HyperLogLog 算法。换句话说4582 这个数字不是精确的。对于大部分业务场景比如看 DAU 量级、评估活动曝光人群规模误差在 1% 以内完全够用。但如果你的业务是财务对账、精确库存扣减那千万别用这个聚合。什么时候适合用 cardinality我总结下来主要三类独立用户数、独立 IP 数、独立设备数这类量级指标需要按天/按周做趋势对比比如每天的独立访客变化评估字段的基数高低为后续优化索引或设计分片策略做参考1.2 precision_threshold 参数精度和内存怎么权衡cardinality有一个关键参数叫precision_threshold默认是 3000。它的含义是当实际基数低于这个值时聚合结果会非常接近精确值当基数超过这个值后误差会逐渐增大。这个参数最大可以设置到 40000超过 40000 也只能按 40000 算。GET /orders/_search { size: 0, aggs: { unique_users: { cardinality: { field: user_id, precision_threshold: 10000 } } } }我在实际项目里通常是这么权衡的如果字段基数大概率在几万以内把precision_threshold调高一点能明显提升准确性但如果你本来就只关心百万级基数的量级3000 默认值就够了调高只会白白增加内存。这里要补充一个生活化类比。HyperLogLog 就像一个压缩抽屉抽屉数量固定往里塞的纸条越多纸条之间的碰撞就越频繁你数出来的数量就越模糊。precision_threshold可以理解为抽屉的精细化程度抽屉分得越多容纳精确纸条的数量就越多但抽屉本身占空间也更大。搞清楚这个逻辑你就明白为什么这个参数不能瞎调了。1.3 HyperLogLog 带来的坑什么时候不能信它我在排障时遇到过几次明明有 5 万用户聚合出来却只有 4.2 万的情况。这里除了精度问题还有一个更关键的坑cardinality 在分片上的合并方式。ES 的聚合是先在每个分片上独立计算然后由协调节点合并结果。HyperLogLog 在合并时不是简单相加而是基于 sketch 结构做并集估算。如果索引分片数特别多每个分片上的基数都比较低合并后的误差会比单分片场景更大。另外一个注意点是如果同一个字段在文档里的取值是数组比如一个用户可能关联多个标签cardinality会把所有值都纳入去重统计。虽然这通常符合直觉但排查数据异常时要想清楚字段类型别把数组字段当成单值字段来解读。提示涉及线上数据核对、上下游对账时不要用 cardinality 的结果当“精确值”去跟数据库 COUNT(DISTINCT) 对。它更适合做趋势监控和量级估算真要对账还是得走精确聚合或外部数仓。2. 基础统计 stats 与 extended_stats给字段做一次快速体检2.1 stats 聚合的五个基础指标stats聚合是五个基础指标的集合体count、min、max、avg、sum。一个查询全部返回非常适合快速了解某个数值字段的分布概貌。比如我想看看订单金额的整体情况GET /orders/_search { size: 0, aggs: { amount_stats: { stats: { field: amount } } } }返回大概是这个样子{ aggregations: { amount_stats: { count: 12834, min: 9.9, max: 8999.0, avg: 187.43, sum: 240578.21 } } }这里有个很多人容易忽略的细节count不是文档总数而是字段值非空的文档数。如果某些订单没有amount字段或者字段值是null这个count会比hits.total小。做报表时如果发现聚合出来的count跟预期对不上先查一下是不是有脏数据或者字段缺失。stats聚合对字段类型有硬性要求必须是数字类型long、double、integer 等或者能通过脚本转成数字的字段。如果你在一个keyword字段上直接跑statsES 会直接报错这一点我在后面常见问题里会细说。2.2 extended_stats 多出来的方差和标准差怎么用extended_stats在stats基础上额外返回sum_of_squares平方和、variance方差、std_deviation标准差、std_deviation_bounds标准差边界。GET /orders/_search { size: 0, aggs: { amount_stats: { extended_stats: { field: amount } } } }返回示例{ aggregations: { amount_stats: { count: 12834, min: 9.9, max: 8999.0, avg: 187.43, sum: 240578.21, sum_of_squares: 7845321.5, variance: 183944.21, std_deviation: 428.89, std_deviation_bounds: { upper: 1045.21, lower: -670.35 } } } }std_deviation_bounds默认按sigma2计算也就是均值加减两倍标准差。如果业务上把订单金额当作用户消费能力的信号这个区间就很有参考价值平均值 187 看着不高但标准差有 428说明订单金额波动很大不是所有人都集中在 200 元附近而是存在一批高额订单把波动撑起来了。这里提醒一句方差和标准差的具体口径在 ES 不同版本里有调整有的版本返回的是总体标准差有的版本内部计算逻辑是样本标准差。如果你要用这个数据做统计分析一定先拿自己版本的返回结果跟 Excel、Python 里的计算结果对一下确认口径后再固化到报表逻辑里。2.3 实战组合统计字段画像与异常定位stats和extended_stats不只能单独用配合date_histogram或者terms做分组统计才是最常用的姿势。比如我想按天看订单金额的均值变化同时想找出哪一天出现了极端高额单可以这样GET /orders/_search { size: 0, aggs: { daily_stats: { date_histogram: { field: timestamp, calendar_interval: day }, aggs: { amount_stats: { extended_stats: { field: amount } } } } } }这种查询一眼就能看出每天的订单均值、最大值、波动情况。我在实际排查“订单均价突然被拉高”的问题时就是在date_histogram下面挂extended_stats锁定某一天之后再下钻到具体订单效率比一条条翻日志高得多。还有一个乘法场景很多时候单一数值字段不够需要算“金额 × 数量”这类派生指标。用script可以直接在聚合里算不用改索引结构GET /orders/_search { size: 0, aggs: { total_value_stats: { stats: { script: { source: doc[amount].value * doc[quantity].value } } } } }不过这里我要给个忠告script在聚合里执行时每个文档都要跑一遍脚本对 CPUs 和堆内存都有压力。可以接受小规模索引这么玩几千万数据以上的索引还是建议在写入时把派生字段算好存进去查询时直接用字段省心也省资源。3. 百分位 percentiles从 P50 到 P99 的性能指标分析3.1 percentiles 的查询写法与 TDigest 原理百分位聚合用来回答一个非常实际的问题我的数据分布到底长什么样比如订单金额平均值 187 元但 p50 可能只有 80 元p99 可能 3000 元。平均值很容易被极端值带偏百分位才是更健壮的分布描述。GET /orders/_search { size: 0, aggs: { amount_percentiles: { percentiles: { field: amount, percents: [50, 90, 95, 99] } } } }返回结果里每个百分位对应一个值{ aggregations: { amount_percentiles: { values: { 50.0: 108.5, 90.0: 586.9, 95.0: 912.3, 99.0: 3487.0 } } } }percentiles底层用的是 TDigest 算法核心思路是把数据分成很多个小簇每个簇用均值和权重代表查询百分位时在这些簇之间插值估算。这也是近似算法所以百分位结果不等于精确排序后算出来的值但误差通常控制在可接受范围内尤其适合监控告警和趋势分析。在性能监控场景里这个聚合几乎是标配。我之前排查接口响应变慢的问题时就是直接在日志索引上跑percentiles一眼看到 p95、p99 显著上升而 p50 变化不大说明不是整体性能退化而是少量慢请求拖高了尾部延迟方向一下就明确了。3.2 compression 参数对精度和性能的影响percentiles支持一个compression参数默认值是 100。它控制 TDigest 算法的压缩程度值越大簇的粒度越细估算越精确但内存占用和计算成本也越高值越小性能越好但精度下降。GET /orders/_search { size: 0, aggs: { amount_percentiles: { percentiles: { field: amount, percents: [50, 95, 99], compression: 200 } } } }我在实际项目里的经验是默认 100 已经覆盖了绝大多数场景不需要动它。如果确实需要更高精度比如要用 p99 做容量规划可以调到 200 或 500。但如果调到 1000 以上就要权衡一下堆内存特别是索引分片多、并发查询多的情况下可能每个分片都要维护一套 TDigest sketch内存成本会被放大。还有一个技巧percents数组不建议传太多值。每多一个百分位计算成本都会增加。如果只是日常监控p50、p90、p95、p99 四个点足以描述大部分分布特征了。3.3 percentile_ranks反过来查百分位有时候我们需要的是反过来的问题已知一个阈值想知道有多少比例的数据落在这个阈值以下。这时候用percentile_ranks。比如运营想评估“订单金额超过 500 元的订单占比多少”直接这样写GET /orders/_search { size: 0, aggs: { amount_ranks: { percentile_ranks: { field: amount, values: [500, 2000] } } } }返回结果里每个值对应的百分位意义是“小于等于这个值的文档占比”{ aggregations: { amount_ranks: { values: { 500.0: 91.2, 2000.0: 96.7 } } } }也就是说91.2% 的订单金额在 500 元以下只有约 8.8% 的订单超过 500 元。这个查询在做分层运营、风控阈值设定时特别好用。我自己的习惯是先跑percentiles看分布再基于分布选几个关键阈值跑percentile_ranks两个聚合一套组合拳数据画像就立体了。4. 字符串统计 string_stats别忽略文本字段的信息量4.1 string_stats 能输出哪些指标string_stats是这几个聚合里相对“冷门”的一个但功能很独特。它专门用来统计字符串字段的长度分布和多样性输出count、min_length、max_length、avg_length和entropy香农熵。拿商品描述字段举例GET /orders/_search { size: 0, aggs: { description_stats: { string_stats: { field: description } } } }返回结果{ aggregations: { description_stats: { count: 12834, min_length: 2, max_length: 256, avg_length: 37.8, entropy: 4.32 } } }这里entropy是最有意思的指标。它衡量的是字符串内容的多样性如果所有描述文本都是同一个字符串熵接近 0如果每一篇描述都千变万化、字符出现频率差异很大熵就高。直观理解就是“信息量大小”。我拿它做过一个很实用的事情数据质量巡检。某个字段如果大量文档里塞了同样的占位文本比如一串空格、一个固定模板entropy会明显偏低。通过定期跑string_stats不用扫每一条数据就能发现这种异常。4.2 字段类型与版本兼容性坑点string_stats不是所有版本都支持我记得是在 7.6 左右引入的8.x 里比较稳定。如果你用的 ES 版本比较旧很可能直接报 “unknown aggregation type”。更常见的坑是字段类型。这个聚合要求字段是可索引的字符串类型。如果字段是text默认情况下无法直接用于聚合因为text字段经过分词器处理后会存成倒排索引原始字符串并不会完整保留。直接对text字段跑string_stats大概率会报Text fields are not optimised for operations that require per-document field data之类的错。解决办法有两个如果字段有.keyword子字段比如description.keyword直接对子字段做聚合如果没有 keyword 子字段需要显式给text字段开启fielddata: true才能聚合显式开启 fielddata 我不建议在生产环境轻易做因为 text 字段把原始字符串全部加载到内存里字段数据量大的时候非常吃堆内存。我在测试环境验证过几百万文档的中等长度文本开 fielddata 之后堆占用肉眼可见地涨。所以能用 keyword 子字段就用 keyword 子字段别嫌字段名多一个后缀。GET /orders/_search { size: 0, aggs: { description_stats: { string_stats: { field: description.keyword } } } }4.3 文本质量分析场景举例string_stats除了做数据质量巡检还能做内容库的多样性分析。举个例子如果运营团队要清理一批商品库里的劣质描述我可以跑一个按分类分组的string_statsGET /orders/_search { size: 0, aggs: { by_category: { terms: { field: category, size: 20 }, aggs: { desc_stats: { string_stats: { field: description.keyword } } } } } }这样一眼就能看到哪些分类的描述平均长度过短、熵值过低。平均长度只有 5 个字符、熵接近 1 的分类基本可以判断是模板化文本泛滥或者数据没有好好录入。这个洞察对内容运营和研发都很有价值而且查询成本比全量扫描文本低得多。我用这个思路帮一个模拟项目找出过一批“空壳描述”数据后来团队针对这些分类重新配置了录入校验规则。整个过程没有写过一条扫描脚本全靠聚合结果定位这就是统计类聚合的效率优势。5. 常见问题与排查技巧实录5.1 聚合结果不准是因为这些原因这几个统计类聚合都是近似算法或者有特定口径稍微不留神就会得出“看起来不对”的结果。我把常见的原因整理成一个表格方便排查。现象可能原因解决办法cardinality 结果偏小分片合并误差 / 基数超过 precision_threshold调高 precision_threshold上限 40000cardinality 结果不稳定每次查询结果波动确认是否多个分片且分片数据不均匀stats.count 比文档总数少字段存在 null 或缺失检查索引映射和写入端数据质量percentiles 结果与精确排序不符TDigest 近似估算增大 compression 或接受近似结果string_stats 报错text 字段未开启 fielddata改用 keyword 子字段或显式开启 fielddata聚合在 keyword 字段上报错字段类型不匹配确认字段实际类型数值字段才能跑 stats我踩过最经典的坑就是stats跑到keyword字段上ES 直接抛异常。后来养成了一个习惯写聚合之前先看 mapping确认字段类型再动手。5.2 性能太差怎么优化统计类聚合虽然好用但也不是没有代价。如果你发现聚合查询响应时间明显比普通查询长排查顺序一般是这样的第一看聚合是否在大量分片上并发执行。统计聚合理论上可以并行但协调节点的合并也是额外工作。如果索引分片数过多可以适当减少分片数或者用routing限制查询范围。第二看脚本是否拖慢了聚合。聚合里用了script的话先把脚本逻辑简化能用字段就直接用字段。我在项目里见过有人在一个几十亿文档的索引上跑带正则表达式的脚本聚合直接把节点 CPU 打满了。这种情况除了优化业务逻辑别无他路。第三看是否一次查询里挂了太多聚合。一个 search 请求里能塞很多个 aggs这没问题但每个聚合都是独立计算。如果同时跑了三四个percentiles加两个extended_stats再叠加大terms分组整个查询的重量会非常可观。建议把不相关的聚合拆分到不同查询里或者用filter聚合先缩窄数据范围。提示统计类聚合的结果通常不需要非常实时。如果业务上允许可以配合定时任务把聚合结果物化到单独的小索引里查询时直接读结果性价比远高于每次都跑全量聚合。5.3 字段值缺失导致聚合结果“缩水”这个坑我反复遇到过值得单独拿出来说。ES 文档里如果某个字段缺失或者值为null那么大部分聚合都会直接跳过这个文档不会把它计入count。这意味着如果你的索引里只有 80% 的文档有这个字段stats.count就只有总数的 80%而不是全部文档数。这个现象在cardinality里更是如此用户 ID 字段缺失的订单自然不会被去重统计进去。如果你发现“独立用户数怎么比之前少了”先排查是不是上游有大量数据没写这个字段。我自己的排查习惯是每次跑完统计聚合先看一眼count跟文档总量的比例如果比例明显偏低就要警惕数据质量而不是直接信任聚合数字。这个习惯帮我避免了很多次“对不上数”的返工。6. 写在最后把这四个聚合串起来用这四个聚合单独用已经能解决不少问题但真正发挥价值的是组合起来。我在处理一个业务数据质量评估需求时会同时跑一组查询cardinality看独立用户覆盖stats看核心金额字段的均值与总量percentiles看分布尾部string_stats看关键文本字段的质量。四个指标放在一张看板上数据健康状况一目了然比单独看任何一个指标都可靠。另外一个小技巧这几种聚合都可以挂在terms或date_histogram下面做分组得到“每个分类的金额 p95”“每天的用户去重数”“每月的描述熵值”这类多维指标。先分组再统计是挖掘数据特征的万能套路。最后分享一点个人经验聚合查询不要只依赖默认参数每个聚合的精度控制参数都有存在的理由。遇到结果不符合预期第一步不是怀疑 ES而是确认字段类型、版本口径和算法特性。把这些基础问题想清楚统计类聚合基本不会让你失望。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑