这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 聚合是 Elasticsearch 成为分析引擎的关键能力。它可以在搜索结果上做分组、统计、嵌套和管道计算,但聚合也最容易把集群打慢。理解聚合执行模型、Doc Values、global ordinals 和分片归并机制,才能安全使用聚合。
26.1 聚合执行模型
一次聚合请求会分发到所有相关分片:
coordinating node
-> shard 0: local aggregation
-> shard 1: local aggregation
-> shard 2: local aggregation
<- merge shard-level results
-> final response
Terms 聚合的典型流程:
- 每个分片统计本分片 top
shard_size词项; - 返回词项和本地计数;
- 协调节点归并;
- 按全局近似计数重排;
- 返回 top
size。
这就是 terms 聚合可能近似的原因。如果某个值在很多分片上都排第 20 名以外,但它的全局总数很高,本地截断可能让协调节点看不到它。
GET orders-v3/_search
{
"size": 0,
"aggs": {
"top_categories": {
"terms": {
"field": "category_id",
"size": 10,
"shard_size": 100
}
}
}
}
提高 shard_size 可以提高准确率,但会增加网络、内存和归并成本。
26.2 Doc Values 与聚合
大多数聚合依赖 Doc Values 正排读取:
doc id -> field value
如果字段没有 Doc Values,聚合会失败或退化为 fielddata。text 字段默认不能直接聚合,因为分词后一个字段会变成多个词项,业务含义不清。
错误示例:
GET products-v3/_search
{
"size": 0,
"aggs": {
"brands": {
"terms": { "field": "brand_name" }
}
}
}
如果 brand_name 是 text,应聚合 brand_name.keyword,或单独设计 brand_id / brand_name_raw keyword 字段。
合理 Mapping:
PUT sales-v1
{
"mappings": {
"properties": {
"city_name": { "type": "keyword" },
"city_text": {
"type": "text",
"fields": {
"keyword": { "type": "keyword" }
}
},
"amount": { "type": "double" }
}
}
}
“text 字段直接聚合”是高频 Mapping 错误之一。生产请求应使用 city_text.keyword。
26.3 Global Ordinals
对于 keyword 等分类型字段,Lucene 可以构建 global ordinals:为每个分片内的唯一值建立全局编号,posting list 和 Doc Values 可以用编号表示,减少字符串比较和存储。
优点:
- terms 聚合更快;
- 内存占用可控;
- 多字段比较更高效。
代价:
- 首次使用前要构建;
- 新 Segment 加入后可能重构建;
- 高基数字段构建成本高;
- eager global ordinals 会把构建提前到 refresh 后。
查看全局序数指标:
GET /_stats/indices/fielddata?human
GET products-v3/_stats/fielddata?human
对于查询延迟敏感、字段基数可控的索引,可以预热:
PUT products-v3/_settings
{
"index": {
"eager_global_ordinals": true
}
}
这不是万能优化。如果字段有一千万唯一值,预热本身可能造成长尾延迟。
26.4 聚合类型与成本
| 聚合 | 成本特点 | 建议 |
|---|---|---|
| terms | 与唯一值数量、shard_size 相关 | 控制 size,优先低基数字段 |
| date_histogram | 桶数量由时间范围和 interval 决定 | 固定 interval,限制范围 |
| range | 桶数量固定 | 比动态 terms 稳定 |
| histogram | 桶数可能爆炸 | 控制 interval 和范围 |
| cardinality | 近似去重,占内存 | 控制 precision_threshold |
| percentile | 近似,内存高 | 明确精度需求 |
| nested | 需要跨嵌套关系映射 | 控制嵌套对象数量 |
| composite | 游标分页拉取全量组合 | 用于导出,不适合首屏 |
| top_hits | 每个 bucket 保留文档 | 控制 size 和 source |
| script | 每文档执行脚本 | 避免在大数据集运行 |
原则:
- 先限制查询范围,再聚合;
- 先减少桶数,再嵌套聚合;
- 先预计算,再实时算;
- 先看监控,再调参数;
- 高基数字段必须白名单管控。
26.5 嵌套聚合
嵌套聚合的成本是乘法关系:
外层 100 个 bucket x 内层 20 个 bucket x 内层 metric 3 个
即使最终返回不多,中间也需要维护大量桶状态。
GET orders-v3/_search
{
"size": 0,
"query": {
"range": { "paid_at": { "gte": "now-1d/d" } }
},
"aggs": {
"categories": {
"terms": { "field": "category_id", "size": 20 },
"aggs": {
"cities": {
"terms": { "field": "city_id", "size": 20 },
"aggs": {
"gmv": { "sum": { "field": "pay_amount" } },
"buyers": { "cardinality": { "field": "user_id" } }
}
}
}
}
}
}
优化方式:
- 缩小时间范围;
- 降低外层
size; - 只在需要时增加嵌套层;
- 把明细聚合拆成多个并行查询;
- 使用预聚合表;
- 大规模分析转到数仓。
26.6 Cardinality
cardinality 使用 HyperLogLog++ 近似算法。
GET orders-v3/_search
{
"size": 0,
"aggs": {
"buyers": {
"cardinality": {
"field": "user_id",
"precision_threshold": 40000
}
}
}
}
precision_threshold 越高越准确,内存越大。阈值内通常接近精确,超过后误差增大。
适合:
- 看板趋势;
- 影响面评估;
- 粗略独立用户数。
不适合:
- 财务结算;
- 精确对账;
- 低容忍度报表;
- 超大基数下强制高精度。
如果必须精确去重,可以在写入侧预聚合,或使用离线计算。
26.7 Date Histogram
时间分桶要控制桶数:
30 天 x 1 分钟间隔 = 43200 个桶
如果还嵌套 100 个类目,中间桶规模会迅速膨胀。
GET orders-v3/_search
{
"size": 0,
"query": {
"range": {
"paid_at": {
"gte": "now-7d/d",
"lte": "now"
}
}
},
"aggs": {
"per_hour": {
"date_histogram": {
"field": "paid_at",
"fixed_interval": "1h",
"time_zone": "Asia/Shanghai",
"min_doc_count": 0
}
}
}
}
优化建议:
- 使用
fixed_interval避免动态日历桶不可控; - 查询范围和 interval 匹配,例如 30 天用小时或天;
- 使用
extended_bounds时必须限制范围; - 看板默认时间不超过必要范围;
- 预聚合分钟级或小时级数据。
26.8 Composite 聚合
Composite 用于游标式拉取维度组合:
GET orders-v3/_search
{
"size": 0,
"aggs": {
"combinations": {
"composite": {
"size": 1000,
"sources": [
{ "channel": { "terms": { "field": "channel" } } },
{ "category": { "terms": { "field": "category_id" } } }
]
},
"aggs": {
"gmv": { "sum": { "field": "pay_amount" } }
}
}
}
}
响应中的 after_key 用于下一页:
"aggregations": {
"combinations": {
"after_key": { "channel": "app", "category": "cat_1" }
}
}
下一页:
"composite": {
"size": 1000,
"after": { "channel": "app", "category": "cat_1" },
"sources": []
}
Composite 适合离线导出、报表生成和维表构建,不适合用户每次打开页面都拉全量组合。
26.9 预聚合
当实时聚合成本过高时,可以把固定维度提前算好。
PUT order_reports_5m-v1
{
"mappings": {
"properties": {
"stat_time": { "type": "date" },
"channel": { "type": "keyword" },
"category_id": { "type": "keyword" },
"gmv": { "type": "double" },
"order_count": { "type": "long" },
"buyer_count_approx": { "type": "long" }
}
}
}
预聚合适合:
- 固定看板;
- 高 QPS 报表;
- 长时间范围对比;
- 多维组合数量可控;
- 数据延迟允许分钟级。
不适合:
- 任意自由探索;
- 新维度频繁变化;
- 精确去重要求高;
- 明细排障场景。
常见架构是“明细短期 + 预聚合长期”:
最近 1-3 天:查明细索引
更长时间:查分钟/小时/天级预聚合
对账与复杂建模:数仓
26.10 聚合监控
查看聚合耗时:
GET /_nodes/stats/indices/search?human
GET orders-v3/_stats/search?human
查看慢查询:
PUT orders-v3/_settings
{
"index.search.slowlog.threshold.query.warn": "5s",
"index.search.slowlog.threshold.query.info": "2s",
"index.search.slowlog.threshold.fetch.warn": "1s"
}
重点指标:
| 指标 | 含义 |
|---|---|
| query_time_in_millis | 查询阶段总耗时 |
| query_current | 当前查询数 |
| fetch_current | 当前 fetch 数 |
| search thread pool rejected | 搜索线程池拒绝 |
| circuit breaker tripped | 熔断次数 |
| fielddata memory | fielddata 内存 |
| request cache hit / miss | 请求缓存 |
| indicessegments memory | Segment 内存 |
聚合慢的典型信号:
- CPU 高但磁盘 IO 不高;
- query time 高;
- heap 使用增长;
- circuit breaker 触发;
- search rejected;
- 响应 JSON 很大;
- Dashboard 同时发出多个大范围聚合。
26.11 聚合优化清单
- 查询必须有明确范围,尤其时间范围;
size: 0获取纯统计结果;- terms 字段使用 keyword 或
.keyword; - 控制 terms 的
size和shard_size; - 高基数字段禁止自由聚合;
- 时间桶数量设置上限;
- 避免多层嵌套聚合;
top_hits只返回必要字段;- 脚本聚合必须评审;
- cardinality 明确精度阈值和用途;
- 长期报表预聚合;
- 慢查询日志和熔断指标接入告警;
- 大范围分析迁移到离线集群或数仓。
本章小结
聚合性能由候选文档数、桶数量、唯一值数量、嵌套深度、Doc Values、global ordinals 和分片归并共同决定。安全的做法是先限制范围和基数,再优化聚合结构,最后用预聚合或离线计算承接重分析。
思考题
- terms 聚合为什么可能是近似结果?
- 为什么 text 字段不能直接用于 terms 聚合?
- global ordinals 的收益和代价是什么?
- 什么情况下应该把实时聚合改为预聚合?
- 一个多维看板查询很慢,你会如何逐层定位?