ElasticsearchNotes

第 26 章:聚合与性能优化

zjc 于 2026-01-26 发布

这是《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 聚合的典型流程:

  1. 每个分片统计本分片 top shard_size 词项;
  2. 返回词项和本地计数;
  3. 协调节点归并;
  4. 按全局近似计数重排;
  5. 返回 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_nametext,应聚合 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 可以用编号表示,减少字符串比较和存储。

优点:

代价:

查看全局序数指标:

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" } }
          }
        }
      }
    }
  }
}

优化方式:

  1. 缩小时间范围;
  2. 降低外层 size
  3. 只在需要时增加嵌套层;
  4. 把明细聚合拆成多个并行查询;
  5. 使用预聚合表;
  6. 大规模分析转到数仓。

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
      }
    }
  }
}

优化建议:

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" }
    }
  }
}

预聚合适合:

不适合:

常见架构是“明细短期 + 预聚合长期”:

最近 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 内存

聚合慢的典型信号:

26.11 聚合优化清单

本章小结

聚合性能由候选文档数、桶数量、唯一值数量、嵌套深度、Doc Values、global ordinals 和分片归并共同决定。安全的做法是先限制范围和基数,再优化聚合结构,最后用预聚合或离线计算承接重分析。

思考题

  1. terms 聚合为什么可能是近似结果?
  2. 为什么 text 字段不能直接用于 terms 聚合?
  3. global ordinals 的收益和代价是什么?
  4. 什么情况下应该把实时聚合改为预聚合?
  5. 一个多维看板查询很慢,你会如何逐层定位?