上一篇解决了 Query DSL 的查询体系——全文、精确、模糊、地理查询和 bool 组合。这一篇进入在搜索结果之上怎样做实时统计分析。

搜索返回匹配文档的列表。但很多场景不只需要文档列表,还需要统计信息:按类别分组计数、平均价格、日期趋势。ES 的 Aggregation 框架在搜索结果之上提供实时分析能力,不需要把数据导出到 OLAP 系统。

本文只抓一个问题:Aggregation 的三类聚合怎样工作,以及嵌套组合怎样构建复杂分析。

三类聚合

ES 的聚合分为三类,各有不同职责:

1
2
3
4
搜索结果集
→ Bucket Aggregation 把文档分到不同的桶里
→ Metric Aggregation 在每个桶内计算度量值
→ Pipeline Aggregation 在聚合结果上再计算

Bucket 聚合按某种规则把文档分组——类似 SQL 的 GROUP BY。常见的 bucket 聚合:

聚合 分桶规则 类比 SQL
terms 按字段值分组 GROUP BY field
date_histogram 按时间间隔分组 GROUP BY DATE_TRUNC(field, interval)
range 按数值范围分组 CASE WHEN …
histogram 按固定数值间隔分组 WIDTH_BUCKET
filters 按多个查询条件分组 CASE WHEN query1 THEN …

Metric 聚合在文档集合上计算统计值——类似 SQL 的聚合函数:

聚合 计算 类比 SQL
avg 平均值 AVG(field)
sum 求和 SUM(field)
min / max 最小 / 最大值 MIN / MAX
value_count 值计数 COUNT(field)
cardinality 去重计数(近似) COUNT(DISTINCT field)
percentiles 百分位数 PERCENTILE_CONT
stats min + max + avg + sum + count 组合

Pipeline 聚合对其他聚合的输出做二次计算——没有直接的 SQL 对应:

聚合 作用
derivative 对父聚合的 metric 做差值(环比增长)
moving_avg 移动平均
cumulative_sum 累积求和
bucket_sort 对桶按 metric 排序
bucket_selector 按条件过滤桶

聚合与 Doc Values

聚合需要遍历大量文档的字段值来做分组和计算。这个需求恰好是 Doc Values 的设计目标——列式存储让聚合只需要顺序扫描一列数据。

倒排索引回答"哪些文档包含这个词",Doc Values 回答"这个文档的某个字段值是什么"。聚合用的是后者。

text 字段默认没有 Doc Values(因为分词后的词项列表通常不适合聚合)。对 text 字段做聚合需要启用 fielddata——这会把字段值加载到 JVM 堆内存中,代价高昂。推荐用 multi-field 同时映射为 keyword 类型,对 keyword 子字段做聚合。

嵌套聚合

聚合可以嵌套:在 bucket 内再放 bucket 或 metric。这是 ES 聚合框架最强大的特性——通过嵌套组合构建复杂分析。

三层嵌套的数据流向——外层 Bucket 把文档分桶,桶内嵌套子 Bucket 再分一层,最内层 Metric 在最细粒度的桶上算统计值:

graph TD
    A["搜索结果集<br/>全部匹配文档"] --> B["date_histogram<br/>按月分桶"]
    B --> C1["2026-01 桶<br/>3 docs"]
    B --> C2["2026-02 桶<br/>3 docs"]
    C1 --> D1["terms: electronics"]
    C1 --> D2["terms: books"]
    C2 --> D3["terms: electronics"]
    C2 --> D4["terms: books"]
    C2 --> D5["terms: clothing"]
    D1 --> E1["avg_price: 224.99"]
    D2 --> E2["avg_price: 29.99"]
    D3 --> E3["avg_price: 599.99"]
    D4 --> E4["avg_price: 19.99"]
    D5 --> E5["avg_price: 79.99"]

    style A fill:#f0f0f0,stroke:#333
    style B fill:#4a90d9,color:#fff
    style C1 fill:#5ba0e0,color:#fff
    style C2 fill:#5ba0e0,color:#fff
    style D1 fill:#7bc47f
    style D2 fill:#7bc47f
    style D3 fill:#7bc47f
    style D4 fill:#7bc47f
    style D5 fill:#7bc47f
    style E1 fill:#f5a623
    style E2 fill:#f5a623
    style E3 fill:#f5a623
    style E4 fill:#f5a623
    style E5 fill:#f5a623

蓝色节点是 Bucket 聚合(分桶),绿色是子 Bucket(再分桶),橙色是 Metric 聚合(算值)。每一层嵌套对应 JSON 请求里的一层 aggs

实验:创建一个带日期和分类的 index,做多层嵌套聚合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PUT /agg-demo
{
"mappings": {
"properties": {
"category": { "type": "keyword" },
"price": { "type": "double" },
"sold_at": { "type": "date" }
}
}
}

POST /agg-demo/_bulk
{"index":{}}
{"category":"electronics","price":299.99,"sold_at":"2026-01-15"}
{"index":{}}
{"category":"electronics","price":149.99,"sold_at":"2026-01-20"}
{"index":{}}
{"category":"books","price":29.99,"sold_at":"2026-01-18"}
{"index":{}}
{"category":"books","price":19.99,"sold_at":"2026-02-10"}
{"index":{}}
{"category":"electronics","price":599.99,"sold_at":"2026-02-14"}
{"index":{}}
{"category":"clothing","price":79.99,"sold_at":"2026-02-20"}

三层嵌套聚合:按月分桶 → 每月内按分类分桶 → 每个分类计算平均价格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GET /agg-demo/_search
{
"size": 0,
"aggs": {
"monthly": {
"date_histogram": {
"field": "sold_at",
"calendar_interval": "month"
},
"aggs": {
"by_category": {
"terms": { "field": "category" },
"aggs": {
"avg_price": {
"avg": { "field": "price" }
}
}
}
}
}
}
}

size: 0 表示不返回搜索结果,只返回聚合。返回结构:

1
2
3
4
5
6
7
8
9
10
monthly (date_histogram)
├── 2026-01 (3 docs)
│ ├── by_category (terms)
│ │ ├── electronics: avg_price = 224.99
│ │ └── books: avg_price = 29.99
├── 2026-02 (3 docs)
│ ├── by_category (terms)
│ │ ├── electronics: avg_price = 599.99
│ │ ├── books: avg_price = 19.99
│ │ └── clothing: avg_price = 79.99

聚合的执行路径

在分布式环境中,聚合和搜索一样走 scatter-gather:

1
2
3
4
coordinating node → 广播到所有 shard
每个 shard 本地执行聚合(遍历 Doc Values)
返回本地聚合中间结果
coordinating node ← 合并所有 shard 的中间结果 → 最终聚合

Pipeline 聚合的数据流和前两类不同——它不直接操作文档,而是接收其他聚合的输出作为输入:

graph LR
    A["文档集合"] --> B["date_histogram<br/>按月分桶"]
    B --> C["每月 sum<br/>月销售额"]
    C --> D["cumulative_sum<br/>累积销售额"]
    C --> E["derivative<br/>环比增长"]

    subgraph "Bucket + Metric"
        B
        C
    end

    subgraph "Pipeline"
        D
        E
    end

    style A fill:#f0f0f0,stroke:#333
    style B fill:#4a90d9,color:#fff
    style C fill:#f5a623
    style D fill:#e74c3c,color:#fff
    style E fill:#e74c3c,color:#fff

Pipeline 聚合(红色)的输入是 Metric 聚合(橙色)的输出,不是原始文档。cumulative_sum 对每个桶的 sum 做累加,derivative 对相邻桶的 sum 做差值——两者都需要按顺序遍历桶序列,所以只能用在有序的 bucket 聚合(如 date_histogramhistogram)之上。

terms 聚合的精度问题:每个 shard 返回本地 top-N 的桶,coordinating node 合并。如果一个低频词在每个 shard 上都排不进 top-N,但全局加起来频率很高,它可能被遗漏。shard_size(默认 size × 1.5 + 10)可以增大每个 shard 返回的桶数来提高精度。

cardinality 聚合使用 HyperLogLog++ 算法做近似去重计数,结果不精确但内存开销恒定。精确去重在大数据量下成本过高。

模式提炼:分桶-度量-管道三层聚合

1
documents → bucket (分组) → metric (计算) → pipeline (二次计算)
层次 输入 输出 类比
Bucket 文档集合 多个分组(桶) SQL GROUP BY
Metric 一个桶内的文档 统计值(数字) SQL AVG/SUM
Pipeline 多个桶的 metric 衍生统计值 窗口函数

工程迁移表

概念 Elasticsearch SQL (MySQL/PG) Solr MongoDB
分桶 Bucket aggregation GROUP BY Facet / JSON Facet $group
度量 Metric aggregation AVG/SUM/COUNT stats facet $avg / $sum
嵌套分桶 嵌套 aggs 子查询 / 窗口函数 sub-facet 嵌套 $group
近似去重 cardinality (HLL++) COUNT(DISTINCT)(精确) 无原生 $addToSet(精确)
百分位 percentiles (t-digest) PERCENTILE_CONT 无原生 无原生
二次计算 Pipeline aggs 窗口函数 $setWindowFields
数据来源 Doc Values(列式) 行扫描或索引 DocValues 文档扫描

常见误解

误解一:聚合可以用在 text 字段上。 默认不行。text 字段没有 Doc Values。需要在 mapping 中配置 multi-field 的 keyword 子字段,对 keyword 做聚合。

误解二:cardinality 聚合是精确的。 cardinality 使用 HyperLogLog++ 算法,结果是近似值。precision_threshold 参数(默认 3000)控制精度——低于此值时通常精确,高于此值时有误差。

误解三:聚合总是很快。 聚合需要遍历所有匹配文档的 Doc Values。如果匹配文档数量很大(百万级),聚合可能耗时较长。用 query 缩小范围是优化聚合性能的第一步。

练习

  1. 在 agg-demo index 上做一个 terms 聚合,按 category 分组并显示文档数。对比 ES 结果和等价 SQL SELECT category, COUNT(*) FROM agg_demo GROUP BY category

  2. date_histogram 聚合的基础上嵌套一个 sum metric,计算每月总销售额。再用 cumulative_sum pipeline 聚合计算累积销售额。

  3. cardinality 聚合统计不同 category 的数量,对比 value_count 的结果,理解去重计数和普通计数的区别。

系列导航

上一篇 下一篇
Query DSL 深入:从 match 到 bool 的查询体系 分片与路由:数据分布的核心机制

参考资料