Elasticsearch 聚合核心原理与性能调优实战

5 阅读22分钟

1. 引言

提到 Elasticsearch,很多人的第一反应是"搜索引擎"。确实,ES 凭借倒排索引在全文检索领域表现出色。但实际上,ES 的另一项核心能力——聚合(Aggregation)——才是让它成为数据分析利器的关键。

想象一下这些场景:

  • 电商平台统计"过去 7 天各品类的销售额分布"
  • 日志系统分析"每小时错误日志的数量趋势"
  • 用户画像计算"各年龄段用户的活跃度占比"

这些需求的共同点是:不关心具体某条数据,而是要对海量数据做分组、计数、求和、排序。这正是聚合的用武之地。

问题

当数据量在百万级时,聚合查询通常毫秒级返回。但当数据膨胀到亿级甚至十亿级,你可能会遇到这些问题:

现象可能原因
查询耗时从 200ms 涨到 10s+扫描数据量过大,分片结果合并开销高
偶发 CircuitBreakingException聚合桶数爆炸,撑爆内存熔断器
Terms 聚合结果不准确分布式环境下的精度损失
协调节点频繁 Full GC结果归并阶段堆内存压力过大

这些问题的根源在于:不了解聚合的底层执行机制,只把 ES 当黑盒使用

本文目标

本文将从三个层面建立对 ES 聚合的系统认知:

┌─────────────────────────────────────────────────────┐
                     优化方案                          
          (查询优化 / 建模优化 / 架构优化)                
├───────────────────────▲─────────────────────────────┤
                     执行流程                          
       (Scatter → Map → Reduce → 结果返回)             
├───────────────────────▲─────────────────────────────┤
                   数据结构基础                         
         (Doc Values / Global Ordinals)               
└─────────────────────────────────────────────────────┘

自底向上:先理解数据是怎么存的,再看查询是怎么跑的,最后才能明白优化为什么有效。

读完本文,你将能够:

  1. 说清楚一个聚合请求在集群内部的完整执行路径
  2. 定位聚合慢查询的瓶颈所在
  3. 根据业务场景选择合适的优化策略

接下来,我们从聚合的数据结构基础开始。

2. 核心数据结构:聚合的基石

在深入执行流程之前,我们需要先理解一个关键问题:**ES 的数据是怎么存的?**这决定了聚合能跑多快。

2.1 倒排索引 vs Doc Values

倒排索引:为搜索而生

ES 的搜索能力来自倒排索引。它的结构是"词项(Term) → 文档列表":

倒排索引 (brand 字段)

  Term        Posting List (DocId)
┌─────────┬────────────────────────┐
│  Apple  │  [1, 5, 8, 12, 99...]  │
├─────────┼────────────────────────┤
│  Huawei │  [2, 3, 7, 15, 88...]  │
├─────────┼────────────────────────┤
│  Xiaomi │  [4, 6, 9, 11, 23...]  │
└─────────┴────────────────────────┘

⚠️这里的DocId是物理编号id,不是文档的_id

查询 brand = Apple 时,直接定位到 Apple 这一行,拿到文档 ID 列表,速度极快。

但聚合的场景不一样

绝大多数聚合是在搜索之后进行的,假设用户搜索 price > 5000,ES 筛选出了 Doc 1 和 Doc 5,现在要统计这两个文档的品牌分布。

如果只有倒排索引,ES 必须"盲猜":

1. 去查 "Apple" 的 Posting List:里面有 Doc 1 吗?有。有 Doc 5 吗?有。
2. 去查 "Huawei" 的 Posting List:里面有 Doc 1 吗?没有。有 Doc 5 吗?没有。
3. 去查 "Vivo" 的 Posting List:...
   ...遍历所有品牌

当品牌有上万个时,这个过程会非常慢。

Doc Values:为聚合而生

Doc Values 是 ES 专门为聚合和排序设计的列式存储结构:

Doc Values (brand 字段)

  DocId       Value
┌─────────┬───────────┐
│  Doc 1  │   Apple   │
├─────────┼───────────┤
│  Doc 2  │   Huawei  │
├─────────┼───────────┤
│  Doc 3  │   Huawei  │
├─────────┼───────────┤
│  Doc 4  │   Xiaomi  │
├─────────┼───────────┤
│  Doc 5  │   Apple   │
└─────────┴───────────┘

⚠️Doc Values中value存的值实际是定长的,变长的值会经过多重映射得到一个定长编号值

现在统计品牌数量就简单了:顺序扫描一遍,遇到 Apple 就给 Apple 计数器 +1。

两者对比:

特性倒排索引Doc Values
存储方向Term → Doc IDsDoc ID → Value
适合场景搜索、过滤聚合、排序
访问模式随机读顺序读

Doc Values 默认对 keyword、numeric、date、boolean、geo_point 等类型开启,这也是为什么聚合时应该用 keyword 而不是 text。

Doc Values 的磁盘友好性

Doc Values 存储在磁盘上,通过 mmap 映射到内存,由操作系统管理缓存:

graph LR
    %% --- 1. 定义三套简单的样式 ---
    %% 蓝色:JVM层
    classDef jvm fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
    %% 橙色:OS缓存层
    classDef os fill:#fff3e0,stroke:#e65100,stroke-width:2px;
    %% 灰色:磁盘层
    classDef disk fill:#eeeeee,stroke:#616161,stroke-width:2px;

    %% --- 2. 原图结构 (完全不变) ---
    subgraph JVM["ES 进程 (JVM Heap)"]
        %% 应用样式
        A["业务对象 / Query 解析 / 结果合并"]:::jvm
    end
    
    subgraph OS["OS Page Cache (堆外内存)"]
        B["Doc Values 数据缓存"]:::os
    end
    
    subgraph Disk["磁盘"]
        C[".dvd / .dvm 文件"]:::disk
    end
    
    JVM -->|"mmap 映射"| OS
    OS -->|"缓存未命中时读取"| Disk

    %% --- 3. 微调一下子图边框颜色以匹配内容 (可选) ---
    style JVM stroke:#1565c0,fill:none
    style OS stroke:#e65100,fill:none
    style Disk stroke:#616161,fill:none

这意味着:

  • 热数据被 OS 自动缓存,访问速度接近内存
  • 冷数据触发磁盘 I/O,这就是冷数据聚合慢的原因
  • 不占用 JVM Heap,避免 GC 压力

2.2 Fielddata:不得已的选择

Doc Values 不支持 text 类型字段。text 字段会被分词,ES 不会为分词结果构建 Doc Values。

如果强行对 text 字段聚合,ES 会启用 Fielddata——在运行时将倒排索引"反转",构建出 Doc ID → 分词词项的映射,并加载到 JVM Heap 中

graph LR
    subgraph Inverted["倒排索引 (磁盘)"]
        I["apple → [1,3,5]<br/>iphone → [1,2,4]"]
    end
    
    subgraph Fielddata["Fielddata (JVM Heap)"]
        F["Doc 1 → [apple, iphone]<br/>Doc 2 → [华为, pura, 70]"]
    end
    
    Inverted -->|"首次聚合时加载"| Fielddata

    %% --- 样式定义 (仅修改颜色) ---
    %% 灰色风格:磁盘
    style Inverted fill:#f5f5f5,stroke:#666,stroke-width:2px
    style I fill:#fff,stroke:#999

    %% 橙色风格:内存 (Fielddata)
    style Fielddata fill:#fff3e0,stroke:#e65100,stroke-width:2px
    style F fill:#fff,stroke:#ff9800

Fielddata 的问题:

  • 占用 JVM Heap:不像 Doc Values 在堆外,Fielddata 直接吃堆内存
  • 加载即常驻:不会主动释放,持续挤压内存空间
  • 首次加载慢:全量构建可能导致查询超时

因此 ES 5.x 起默认禁用 Fielddata,强行对 text 聚合会报错。

2.3 Global Ordinals:字符串聚合的加速器

Doc Values 需要按 DocID 直接寻址。数值类型定长,docId * 字节数 直接算偏移量;但字符串变长——"Apple" 5 字节,"Huawei" 6 字节——无法直接定位。

Global Ordinals 的方案是:把字符串映射成整数序号

graph LR
    %% === 样式定义 ===
    %% 文档层:普通的存储单位
    classDef doc fill:#f5f5f5,stroke:#333,stroke-width:1px;
    %% 序号层:关键的中间索引 (指针)
    classDef ordinal fill:#e3f2fd,stroke:#2196f3,stroke-width:2px,shape:circle;
    %% 词项层:实际的字符串内容 (字典)
    classDef term fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,shape:rect;

    %% === 左侧:Doc Values 存储 (只存 ID) ===
    subgraph DV ["Doc Values (仅存储序号)"]
        direction TB
        %% 将文档和它持有的序号分开画,体现"持有"关系
        D1[Doc 1] --> O_D1((0)):::ordinal
        D2[Doc 2] --> O_D2((1)):::ordinal
        D3[Doc 3] --> O_D3((1)):::ordinal
        D4[Doc 4] --> O_D4((2)):::ordinal
    end

    %% === 右侧:Global Ordinals 映射 (字典) ===
    subgraph GO ["Global Ordinals (序号 -> 词项)"]
        direction TB
        %% 字典的定义:序号指向具体的值
        Key0((0)):::ordinal --> Val0[Apple]:::term
        Key1((1)):::ordinal --> Val1[Huawei]:::term
        Key2((2)):::ordinal --> Val2[Xiaomi]:::term
    end

    %% === 核心逻辑:关联 (Lookup) ===
    %% 使用虚线表示这是"引用"关系,不是物理存储在一起
    O_D1 -.-> Key0
    O_D2 -.-> Key1
    O_D3 -.-> Key1
    O_D4 -.-> Key2

    %% 格式微调
    linkStyle 4,5,6,7 stroke:#999,stroke-width:2px,stroke-dasharray: 5 5;
    class D1,D2,D3,D4 doc

这带来两个好处:

  • 存储:Doc Values 变成定长整数数组,支持 O(1) 寻址
  • 聚合:分桶计数变成 counts[ordinal]++,用数组代替 HashMap,更快更省内存

构建时机与开销

Global Ordinals 需要遍历字段的所有唯一值来构建映射表:

graph TD
    subgraph Default ["场景一:默认行为 (Global Ordinals)"]
        A1[聚合请求] --> A2{检查缓存}
        A2 --"首次/未命中"--> A3["构建映射表(耗时)"]
        A3 --> A4[执行聚合]
        A2 --"已缓存"--> A4
    end

    subgraph Eager ["场景二:开启优化 (Eager Global Ordinals)"]
        B1[Refresh] --> B2["后台预构建(异步)"]
        B3[聚合请求] --> B4[直接可用/零延迟]
    end

    style A3 fill:#ffebee,stroke:#ef5350,stroke-width:2px
    style B2 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px,stroke-dasharray: 5 5
    style B4 fill:#e8f5e9,stroke:#66bb6a,stroke-width:2px

何时开启预加载?

场景建议
高频聚合字段(如 brand、category)开启 eager_global_ordinals
低频聚合或高基数字段(如 user_id)保持默认,避免 Refresh 变慢

配置方式:

PUT /index_name/_mapping
{
  "properties": {
    "brand": {
      "type": "keyword",
      "eager_global_ordinals": true
    }
  }
}

小结

结构作用性能关键点
Doc Values列式存储,支撑聚合计算依赖 OS Cache,冷数据触发磁盘 I/O
Fielddatatext 字段聚合的备选方案占用 Heap,应尽量避免
Global Ordinals字符串到数字的映射首次构建有开销,高频字段可预加载

接下来,我们看聚合请求在集群中是如何执行的。

3. 聚合的执行流程

理解了数据结构,现在来看一个聚合请求在集群内部是怎么跑的。

3.1 整体流程:Scatter-Gather 模型

ES 是分布式系统,数据分散在多个分片上。聚合采用经典的 Scatter-Gather 模式:

sequenceDiagram
    participant Client as 客户端
    participant Coord as 协调节点
    participant S0 as 分片0
    participant S1 as 分片1
    participant S2 as 分片2

    Client->>Coord: 聚合请求
    
    rect rgb(230, 245, 255)
        Note over Coord,S2: Scatter 阶段
        Coord->>S0: 转发请求
        Coord->>S1: 转发请求
        Coord->>S2: 转发请求
    end
    
    rect rgb(255, 245, 230)
        Note over S0,S2: Map 阶段 (各分片本地计算)
        S0->>S0: 本地聚合
        S1->>S1: 本地聚合
        S2->>S2: 本地聚合
    end
    
    rect rgb(230, 255, 230)
        Note over Coord,S2: Gather 阶段
        S0->>Coord: 返回本地结果
        S1->>Coord: 返回本地结果
        S2->>Coord: 返回本地结果
    end
    
    rect rgb(245, 230, 255)
        Note over Coord: Reduce 阶段 (合并结果)
        Coord->>Coord: 合并 + 排序 + 截断 + Pipeline计算
    end
    
    Coord->>Client: 返回最终结果

简单说就是四步:分发 → 本地算 → 收集 → 合并

3.2 协调节点阶段

协调节点(Coordinating Node)是接收客户端请求的节点,它负责:

  1. 解析请求:验证 DSL 语法,构建聚合执行计划
  2. 路由分发:确定请求需要发往哪些分片(受 routing、索引别名等影响)
  3. 结果合并:收集各分片结果,执行最终的 Reduce 操作

协调节点本身不存储数据,但在 Reduce 阶段会消耗大量内存,这也是为什么复杂聚合容易把协调节点打爆。

3.3 数据节点执行阶段

每个分片在本地独立完成聚合计算,这是最耗时的阶段。

单层聚合的执行

执行流程很直接:

  1. 遍历查询命中的文档
  2. 通过 Doc Values 读取字段值
  3. 聚合计算

嵌套聚合执行模式:DFS vs BFS

对于嵌套聚合,ES 有两种遍历模式:

#示例:按品牌分组,每个品牌下再按价格区间分组
GET /products/_search
{
  "aggs": {
    "by_brand": {
      "terms": { "field": "brand" },
      "aggs": {
        "by_price_range": {
          "range": {
            "field": "price",
            "ranges": [
              { "to": 100 },
              { "from": 100, "to": 500 },
              { "from": 500 }
            ]
          }
        }
      }
    }
  }
}

假设 brand 字段有 10000 个不同的值,每个品牌下有 3 个价格区间桶。

深度优先(DFS,默认)

ES 只遍历一次文档,边遍历边构建完整的聚合树:

遍历 Doc 1 (brand=Apple, price=299)
  → 找到/创建 Apple 桶,计数+1
  → 在 Apple 桶下找到/创建 "100-500" 子桶,计数+1

遍历 Doc 2 (brand=Huawei, price=50)
  → 找到/创建 Huawei 桶,计数+1
  → 在 Huawei 桶下找到/创建 "<100" 子桶,计数+1

... 一次遍历完成所有工作
graph TD
    subgraph RAM [JVM Heap: 所有节点同时驻留]
        direction TB
        Root((Root)) 
        
        %% 第一层:品牌
        Root --> A[Apple]
        Root --> H[Huawei]
        Root -.-> X["...共 1万个品牌..."]
        
        %% 第二层:价格区间(强调膨胀)
        A --> A1[<100] & A2[100-500] & A3["\>500"]
        H --> H1[<100] & H2[100-500] & H3["\>500"]
        X -.-> X1[<100] & X2[...] & X3["\>500"]
    end

    %% 样式
    style RAM fill:#fff0f0,stroke:#d32f2f,stroke-width:2px,stroke-dasharray: 5 5
    style Root fill:#333,color:#fff
    style A fill:#e3f2fd,stroke:#1565c0
    style H fill:#e3f2fd,stroke:#1565c0
    style X fill:#e3f2fd,stroke:#1565c0
    
    style A1 fill:#ffcdd2,stroke:#c62828
    style A2 fill:#ffcdd2,stroke:#c62828
    style A3 fill:#ffcdd2,stroke:#c62828
    style H1 fill:#ffcdd2,stroke:#c62828
    style H2 fill:#ffcdd2,stroke:#c62828
    style H3 fill:#ffcdd2,stroke:#c62828
    style X1 fill:#ffcdd2,stroke:#c62828
    style X2 fill:#ffcdd2,stroke:#c62828
    style X3 fill:#ffcdd2,stroke:#c62828

内存峰值 = 10000 个父桶 + 30000 个子桶 = 40000 个桶同时存在

  • 优点:数据只扫一遍,IO 开销最小。

  • 缺点不剪枝。哪怕你要 Top 10,它也会先把 1 万个全算出来,内存容易爆炸(OOM)。

广度优先(BFS)

遍历文档分两轮,第一轮只构建父桶,确定 Top N 后释放其他父桶;第二轮只对 Top N 构建子桶:

flowchart LR
    %% --- 样式定义 ---
    %% 蓝色风格:第一轮 (粗筛)
    classDef r1 fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    %% 绿色风格:第二轮 (精算)
    classDef r2 fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px

    %% --- 结构定义 ---
    subgraph Round1["第一轮:只构建父桶 (BFS)"]
        D1["所有文档"]:::r1 --> Parents["Apple: 5000<br/>Huawei: 3000<br/>... 共 10000 个"]:::r1
        Parents --> TopN["取 Top N<br/>释放其他父桶"]:::r1
    end
    
    subgraph Round2["第二轮:对 Top N 构建子桶"]
        TopN --> Filter["再次遍历文档<br/>只处理 Top N 品牌"]:::r2
        Filter --> Children["Apple 的子桶<br/>Huawei 的子桶<br/>... 共 N 个父桶的子桶"]:::r2
    end

    %% --- 虚线框美化 ---
    style Round1 fill:#f9f9f9,stroke:#999,stroke-dasharray: 5 5
    style Round2 fill:#f9f9f9,stroke:#999,stroke-dasharray: 5 5

内存峰值 = max(10000 父桶, 10 父桶 + 30 子桶) = 10000,远小于 DFS 的 40000。

  • 优点:提前剪枝,内存占用少。
  • 缺点:需要两次扫描Doc Values,CPU和IO开销大

DFS vs BFS 对比:

DFS(默认)BFS
遍历次数1 次多次(嵌套层数)
内存峰值所有父桶 + 所有子桶所有父桶 或 Top N 父桶 + 子桶
优势一次遍历,省 CPU 和 I/O内存峰值低
适用场景父桶基数低(大多数情况)父桶基数极高(上万),子桶爆炸

配置方式:

{
  "aggs": {
    "by_brand": {
      "terms": {
        "field": "brand",
        "size": 10,
        "collect_mode": "breadth_first"
      },
      "aggs": {
        "by_price_range": { ... }
      }
    }
  }
}

3.4 结果归并与精度问题

协调节点收到各分片结果后,执行 Reduce 操作:合并、排序、截断。

这个阶段有个关键问题:精度损失。以 Terms 聚合为例,假设要查"销量 Top 3 的品牌":

graph LR
    %% --- 样式定义区 ---
    classDef shardStyle fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,rx:5,ry:5;
    classDef coordStyle fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,rx:5,ry:5;
    classDef resultStyle fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,rx:5,ry:5;
    
    %% 子图样式 (背景透明,只留文字标题和边框)
    style Shard0 fill:none,stroke:#bbdefb,stroke-dasharray: 5 5
    style Shard1 fill:none,stroke:#bbdefb,stroke-dasharray: 5 5
    style Coord fill:none,stroke:#fff59d,stroke-dasharray: 5 5
    style Result fill:none,stroke:#c8e6c9,stroke-dasharray: 5 5

    %% --- 图表内容区 (保持原样) ---
    subgraph Shard0["分片 0 本地 Top 3"]
        S0["Apple: 100<br/>Huawei: 80<br/>Xiaomi: 60"]:::shardStyle
    end
    
    subgraph Shard1["分片 1 本地 Top 3"]
        S1["Huawei: 90<br/>Vivo: 70<br/>Apple: 50"]:::shardStyle
    end
    
    subgraph Coord["协调节点合并"]
        C["Apple: 150<br/>Huawei: 170<br/>Xiaomi: 60<br/>Vivo: 70"]:::coordStyle
    end
    
    subgraph Result["最终 Top 3"]
        R["Huawei: 170<br/>Apple: 150<br/>Vivo: 70"]:::resultStyle
    end
    
    S0 --> C
    S1 --> C
    C --> R

那么问题来了:Xiaomi 在分片 1 可能有 45 条数据(排第 4,没进 Top 3),Vivo在分片 0 没数据,但合并后 Xiaomi 总数应该是 105,本应进入最终 Top 3。

这就是分布式聚合的精度损失:各分片只返回本地 Top N,全局视角下可能漏掉数据。

shard_size 参数

ES 的解决方案是过采样:让每个分片返回比 size 更多的结果,参数为shard_size, 只对 Terms 聚合有效

{
  "aggs": {
    "top_brands": {
      "terms": {
        "field": "brand",
        "size": 10,
        "shard_size": 30
      }
    }
  }
}
  • size:最终返回的桶数量
  • shard_size:每个分片返回的桶数量(默认 size * 1.5 + 10

shard_size 越大,精度越高,但协调节点内存压力也越大。

误差指标

ES 在响应中返回两个误差指标:

{
  "aggregations": {
    "top_brands": {
      "doc_count_error_upper_bound": 46,
      "sum_other_doc_count": 1256,
      "buckets": [...]
    }
  }
}
字段含义
doc_count_error_upper_bound未返回的桶中,文档数量的最大可能误差
sum_other_doc_count未返回的桶的文档总数

如果 doc_count_error_upper_bound 很大,说明结果可能不准,考虑增大 shard_size

小结

graph LR
    subgraph CN ["协调节点 (Coordinating Node)"]
        direction TB
        Scatter([Scatter<br/>分发请求])
        Gather([Gather<br/>收集结果])
        Reduce([Reduce<br/>合并排序])
    end

    subgraph DN ["数据节点 (Data Node)"]
        Map[Map<br/>本地计算]
    end

    %% 流程连接
    Scatter -->|"1.广播请求"| Map
    Map -->|"2.返回中间结果"| Gather
    Gather -->|"3.最终聚合"| Reduce

    %% 关键点标注 (使用虚线连接注释)
    NoteDFS[DFS / BFS 模式<br/>在此阶段生效] -.-> Map
    Reduce -.-> NoteLoss[可能产生<br/>精度损失]

    %% 样式美化
    classDef coord fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
    classDef data fill:#fff3e0,stroke:#e65100,stroke-width:2px;
    classDef note fill:#fff,stroke:#333,stroke-dasharray: 5 5;

    class Scatter,Gather,Reduce coord;
    class Map data;
    class NoteDFS,NoteLoss note;

性能关键点:

阶段瓶颈应对
Map扫描数据量大先 filter 缩小范围
Map嵌套聚合内存爆炸使用 breadth_first
Reduce合并结果量大控制 shard_size
Reduce精度不足增大 shard_size 或用 Composite

接下来,我们看不同类型聚合的实现差异。

4. 不同聚合类型的实现差异

ES 的聚合分为三大类:Metric(指标)、Bucket(分桶)、Pipeline(管道)。它们的执行方式和性能特征差异很大。

类型代表操作输入输出
Metricsum、avg、max、cardinality、stats文档集统计指标
Bucketterms、date_histogram、range文档集多个桶,每个桶含文档子集
Pipelinederivative、bucket_selector其他聚合结果二次计算结果

4.1 Metric 聚合:一次遍历出结果

Metric 聚合对文档集计算单个指标值,实现最为简单。

sum / avg / max / min

这类聚合只需要遍历一次文档,维护一个累加器:

初始化: sum=0, count=0

遍历每个文档:
    sum += doc.price
    count++

返回: avg = sum / count

性能特点:O(N) 时间复杂度,内存开销极小(只需几个变量)。

cardinality:去重计数的挑战

统计某个字段有多少个不同的值(如"有多少独立用户"),看似简单,实则是个难题。

精确计算的代价

精确去重需要:
1. 维护一个 Set 存储所有已见过的值
2. 内存开销 = O(基数)

假设 user_id 有 1 亿个不同值,每个 ID20 字节
→ 需要 2GB 内存,还没算 Set 的额外开销

ES 的解决方案:HyperLogLog++ 算法

精确去重需存所有值,1亿用户要GB级内存。HLL++ 用概率估算,只需12KB。

graph LR
    subgraph S1 ["步骤一:分桶"]
    Input(值) --> Hash[64位哈希]
    Hash --前14位--> Bucket[16384个桶]
    end

    subgraph S2 ["步骤二:记最大前导零"]
    Hash --后50位--> Zeros[前导零个数]
    Zeros --> Max{保留最大值 R}
    end

    subgraph S3 ["步骤三:单桶估算"]
    Max --"概率反推"--> Estimate["单桶约 2^R 个"]
    end

    subgraph S4 ["步骤四:汇总修正"]
    Estimate --> Harmonic["<b>调和平均</b><br/>(平滑 2^R 的波动)"]
    Harmonic --> Final["× 桶数 × 0.7 (修正)"] --> Result(总基数)
    end

    style Harmonic fill:#e1f5fe,stroke:#0277bd,stroke-width:2px
    style Max fill:#fff3e0,stroke:#f57c00
  1. 步骤一:分桶 (Sharding)

    每个值哈希成 64位:

    • 前 14 位:决定落入哪个桶(共 16384 个桶)。
    • 后 50 位:用于后续估算。
  2. 步骤二:记录前导零 (Leading Zeros)

    对每个桶,统计落入该桶所有值的后 50 位前导零个数,只保留最大值 R

  3. 步骤三:估算单桶基数

    利用概率反推:

    • 随机出现 n 个前导零的概率是1/2^n。
    • 若某桶记录的最大前导零是 R,说明该桶约有 2^R 个不同值。
  4. 步骤四:汇总得到总基数

    数据均匀分布到16384个桶,每个桶的估算值是2^R。但2^R只能是2的幂次(2、4、8、16...),单桶波动大,直接算术平均会被极端值带偏。

    HLL++用调和平均来压制极端值:

    调和平均 = n / (1/x₁ + 1/x₂ + ... + 1/xₙ)
    

    最终结果:

    最终公式:
    总基数 = 0.7 × 16384 × 16384 / (1/2^R₁ + 1/2^R₂ + ... + 1/2^R₁₆₃₈₄)
    

    其中0.7是修正系数,补偿系统性偏差。

    内存:每桶 6bit(存0~63),共12KB。误差约2%。

precision_threshold 参数

控制 HLL++ 使用的桶数量。桶越多,估算越准,但内存越大:

{
  "aggs": {
    "unique_users": {
      "cardinality": {
        "field": "user_id",
        "precision_threshold": 3000
      }
    }
  }
}
precision_threshold内存占用精度
100~1.6 KB基数 < 100 时精确,之后误差约 5%
3000(默认)~48 KB基数 < 3000 时精确,之后误差约 2%
40000(最大)~640 KB基数 < 40000 时精确

权衡precision_threshold 越大,精度越高,但内存开销也越大。对于"统计日活用户"这类场景,3000 的默认值通常够用。

4.2 Bucket 聚合:分桶的艺术

Bucket 聚合将文档划分到不同的桶中,每个桶可以继续嵌套子聚合。

terms:基于 Global Ordinals 的分桶

terms 聚合是最常用的分桶方式,它的执行依赖我们在 2.3 节介绍的 Global Ordinals:

sequenceDiagram
    participant Doc as 文档遍历
    participant GO as Global Ordinals
    participant Buckets as 桶数组
    
    Doc->>GO: Doc1.brand 的序号是?
    GO-->>Doc: 序号 = 2
    Doc->>Buckets: buckets[2].count++
    
    Doc->>GO: Doc2.brand 的序号是?
    GO-->>Doc: 序号 = 0
    Doc->>Buckets: buckets[0].count++
    
    Note over Buckets: 遍历完成后,按 count 排序取 Top N
    
    Buckets->>GO: 序号 2 对应的值是?
    GO-->>Buckets: "Xiaomi"

关键点

  • 分桶时用整数序号,避免字符串比较
  • 内存中维护的是 int[] counts,每个桶只占 4 字节
  • 最后才将序号转回字符串

date_histogram:时间区间划分

date_histogram 按时间间隔分桶,ES 会根据 calendar_intervalfixed_interval 计算每个文档落入哪个桶:

{
  "aggs": {
    "orders_over_time": {
      "date_histogram": {
        "field": "order_date",
        "calendar_interval": "month"
      }
    }
  }
}
graph TD
    %% --- 样式定义 ---
    classDef doc fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
    classDef bucket fill:#fff3e0,stroke:#ef6c00,stroke-width:2px;

    %% --- 图表内容 ---
    subgraph Docs["原始文档 (时间戳)"]
        D1["2026-01-15"]:::doc
        D2["2026-01-28"]:::doc
        D3["2026-02-10"]:::doc
        D4["2026-03-05"]:::doc
    end
    
    subgraph Buckets["Date Histogram (按月分桶)"]
        B1["2026-01 : 2 条"]:::bucket
        B2["2026-02 : 1 条"]:::bucket
        B3["2026-03 : 1 条"]:::bucket
    end
    
    %% --- 连线 ---
    D1 --> B1
    D2 --> B1
    D3 --> B2
    D4 --> B3

两种间隔类型的区别

类型示例特点
calendar_intervalmonth, quarter考虑日历规则(2月28天,闰年等)
fixed_interval30d, 1h固定时长,不考虑日历

filters / range:条件匹配分桶

filters 聚合:根据多个查询条件分桶

{
  "aggs": {
    "status_breakdown": {
      "filters": {
        "filters": {
          "errors": { "match": { "level": "error" } },
          "warnings": { "match": { "level": "warn" } }
        }
      }
    }
  }
}

执行时,ES 会对每个文档依次检查是否匹配各个 filter。优化技巧:将匹配文档数最多的 filter 放在前面,利用短路求值减少计算。

range 聚合:数值/日期区间分桶

{
  "aggs": {
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 100 },
          { "from": 100, "to": 500 },
          { "from": 500 }
        ]
      }
    }
  }
}

range 聚合使用二分查找确定文档落入哪个区间,时间复杂度 O(log K),K 为区间数量。

4.3 Pipeline 聚合:对聚合结果的二次计算

Pipeline 聚合不直接处理文档,而是对其他聚合的输出做计算。它有两种模式:

graph TD
    %% 定义样式类
    classDef bucket fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,rx:5,ry:5;
    classDef metric fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,rx:5,ry:5;
    classDef pipeline fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,stroke-dasharray: 5 5,rx:5,ry:5;

    subgraph Parent["Parent Aggregation (父级模式)"]
        direction LR
        P1("fa:fa-chart-bar Date Histogram<br/>(按时间分桶)"):::bucket 
        --> P2("fa:fa-calculator Sum<br/>(计算每个桶的总和)"):::metric
        --> P3("fa:fa-chart-line Derivative<br/>(计算一阶导数/差值)"):::pipeline
    end
    
    subgraph Sibling["Sibling Aggregation (兄弟模式)"]
        direction LR
        S1("fa:fa-tags Terms<br/>(按类别分桶)"):::bucket
        --> S2("fa:fa-dollar-sign Avg Price<br/>(计算平均价格)"):::metric
        
        %% Sibling 聚合实际上是在同级操作,但数据流是基于 S2 的结果
        S2 -.-> S3("fa:fa-trophy Max Bucket<br/>(找出 Avg 最高的桶)"):::pipeline
    end

    %% 添加注释连接(可选,为了美观排版)
    Parent ~~~ Sibling
模式输入输出位置典型用法
Parent父桶内的子聚合结果嵌入到每个父桶中derivative、moving_avg、cumulative_sum
Sibling同级的其他聚合结果与兄弟聚合并列max_bucket、avg_bucket、bucket_selector

常用方法说明

方法功能
derivative计算相邻桶的差值(环比变化量)
moving_avg滑动窗口平均,平滑曲线波动
cumulative_sum累计求和
max_bucket找出指标值最大的桶
avg_bucket对所有桶的指标值求平均
bucket_selector按条件过滤桶,丢弃不满足条件的桶

为什么 Pipeline 只在协调节点执行?

以 derivative(求导)为例,它要计算相邻时间桶之间的差值。单个分片只有局部数据,无法得到完整的时间序列,自然算不出正确的导数。

Pipeline 聚合需要全局视角,因此必须等所有分片结果合并后才能执行。这也意味着 Pipeline 聚合的开销全部落在协调节点。

常见 Pipeline 聚合示例

derivative(求导/环比)

{
  "aggs": {
    "sales_per_month": {
      "date_histogram": {
        "field": "date",
        "calendar_interval": "month"
      },
      "aggs": {
        "total_sales": { "sum": { "field": "amount" } },
        "sales_change": {
          "derivative": { "buckets_path": "total_sales" }
        }
      }
    }
  }
}

输出中每个月会多一个 sales_change 字段,表示相比上月的变化量。

bucket_selector(桶过滤)

{
  "aggs": {
    "sales_per_month": {
      "date_histogram": { "field": "date", "calendar_interval": "month" },
      "aggs": {
        "total_sales": { "sum": { "field": "amount" } },
        "high_sales_only": {
          "bucket_selector": {
            "buckets_path": { "sales": "total_sales" },
            "script": "params.sales > 10000"
          }
        }
      }
    }
  }
}

只保留 total_sales > 10000 的月份,其他桶会被丢弃。

小结

聚合类型执行位置内存特征性能关键点
Metric数据节点极小(几个变量)cardinality 的 precision 设置
Bucket数据节点与桶数量成正比控制基数,避免桶爆炸
Pipeline协调节点取决于输入聚合的结果大小复杂计算全压在协调节点

选择建议

  • 需要精确去重且基数不高 → 用 terms + 计数
  • 需要估算超大基数 → 用 cardinality + 合适的 precision_threshold
  • 需要时间序列分析 → date_histogram + pipeline 聚合
  • 需要过滤聚合结果 → bucket_selector 比在应用层过滤更高效

5. 常见性能瓶颈分析

聚合慢或失败,通常是三类资源出了问题:内存、CPU、I/O。

5.1 内存:桶爆炸

现象:协调节点 OOM 或触发熔断器(CircuitBreakingException)

原因:桶数量失控。常见场景:

高基数字段 terms 聚合:
  user_id 有 1000 万个不同值
  → 每个分片返回大量桶
  → 协调节点合并时内存爆炸

多层嵌套聚合(默认 DFS 模式):
  品牌(1万) × 城市(300) × 渠道(10) = 3000 万个桶
  → 同时驻留内存

排查:看 buckets 数量和 doc_count_error_upper_bound

5.2 CPU:计算密集

现象:聚合耗时长,CPU 占用高

原因

场景说明
Script 聚合每个文档都执行一次脚本,无法利用索引
正则表达式regexp 查询或脚本中的正则匹配
大量 Pipeline 聚合全部在协调节点执行,单点瓶颈

典型案例

// 每个文档都要执行字符串拼接,很慢
{
  "aggs": {
    "by_name": {
      "terms": {
        "script": "doc['first_name'].value + ' ' + doc['last_name'].value"
      }
    }
  }
}

5.3 I/O:冷数据

现象:同样的聚合,有时快有时慢

原因:Doc Values 依赖 OS Page Cache。

热数据:已缓存在内存 → 直接读取 → 快
冷数据:不在缓存中 → 触发磁盘读取(Page Fault) → 慢

排查:监控 Page Fault 次数,或对比冷热数据聚合耗时。

小结

瓶颈类型典型现象常见原因
内存OOM、熔断高基数 terms、多层嵌套聚合
CPU耗时长Script、正则、复杂 Pipeline
I/O时快时慢冷数据未命中 Page Cache

定位瓶颈后,下一节讲具体怎么优化。

6. 优化方案全景

针对上节提到的三类瓶颈,优化思路可以分为四个层面:

  • 硬件与架构层:冷热分离、内存配置(治本)

  • 建模与配置优化:eager_global_ordinals、Rollups 预聚合

  • 缓存利用:Shard Request Cache

  • 查询侧优化:Filter、Sampler、Composite、避免 Script(治标)

6.1 查询侧优化

先过滤再聚合

聚合前用 Filter Context 缩小数据范围,减少扫描量:

{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        { "term": { "status": "completed" } },
        { "range": { "created_at": { "gte": "2024-01-01" } } }
      ]
    }
  },
  "aggs": {
    "by_brand": { "terms": { "field": "brand" } }
  }
}

采样聚合(Sampler)

只对每个分片的前 N 个文档做聚合,适合快速了解数据分布:

{
  "aggs": {
    "sample": {
      "sampler": { "shard_size": 1000 },
      "aggs": {
        "by_brand": { "terms": { "field": "brand" } }
      }
    }
  }
}

注意:结果只反映样本数据,不是对全量数据的估算。适用于探索性分析,不适用于精确统计。

避免 Script

Script 聚合每个文档都要执行脚本,很慢。替代方案:

场景优化方式
字段拼接写入时用 copy_to 预处理
字段转换用 Ingest Pipeline 预计算
条件分桶filters 聚合替代

Terms 聚合优化

{
  "aggs": {
    "by_brand": {
      "terms": {
        "field": "brand",
        "size": 10,
        "shard_size": 30,
        "collect_mode": "breadth_first"
      }
    }
  }
}
  • size:最终返回桶数
  • shard_size:每个分片返回的桶数,适当调大可提高精度
  • collect_mode:高基数 + 嵌套聚合时用 breadth_first 控制内存

Composite 聚合:海量数据分页

Terms 聚合不适合遍历所有桶。Composite 支持桶key序游标分页拉取:

// 第一次请求
{
  "size": 0,
  "aggs": {
    "all_brands": {
      "composite": {
        "size": 1000,
        "sources": [
          { "brand": { "terms": { "field": "brand" } } }
        ]
      }
    }
  }
}

// 后续请求,用 after 翻页
{
  "aggs": {
    "all_brands": {
      "composite": {
        "size": 1000,
        "after": { "brand": "上一页最后一个值" },
        "sources":  [
          { "brand": { "terms": { "field": "brand" } } }
        ]
      }
    }
  }
}

6.2 建模与配置优化

预加载 Global Ordinals

Global Ordinals 默认在首次聚合时构建,高基数字段首次查询会很慢。开启预加载后,构建工作转移到 Refresh 阶段:

json

PUT /index/_mapping
{
  "properties": {
    "brand": {
      "type": "keyword",
      "eager_global_ordinals": true
    }
  }
}

适用于:高频聚合字段。不适用于:写多读少的字段(会拖慢写入)。

数据预聚合(Rollups)

历史数据不需要明细,可以预聚合成粗粒度:

原始数据:每秒一条,保留 7Rollup:按小时聚合,保留 1

查询近期数据走原始索引,查询历史走 Rollup 索引,聚合秒级完成。

基数控制

超高基数字段(如 user_id)直接 terms 聚合会很慢。方案:

  • cardinality + precision_threshold 估算
  • 业务上限制查询范围(如只查某个城市的用户)

6.3 缓存利用

Shard Request Cache

ES 会缓存 size=0 的聚合请求结果。命中条件:

  • 请求完全相同
  • 分片数据未变更(无新写入、无 Refresh)
// 能命中缓存
{ "size": 0, "aggs": { ... } }

// 不能命中缓存(size > 0)
{ "size": 10, "aggs": { ... } }

设计查询以命中缓存

  • 固定时间范围:用 "gte": "2024-01-01" 而不是 "gte": "now-7d"
  • 聚合和搜索分离:聚合请求单独发,设 size=0

6.4 硬件与架构层

冷热分离

热节点:高速 SSD + 大内存
  → 存放近期数据,聚合查询走这里

冷节点:普通硬盘 + 普通配置
  → 存放历史数据,低频查询

高频聚合跑在热节点,数据在 Page Cache 中,速度快。

堆内存 vs 堆外内存

Doc Values 存在磁盘,通过 mmap 映射到 OS Page Cache(堆外内存)。

常见误区:把 JVM Heap 调得很大,反而挤压 Page Cache 空间,聚合变慢。

建议:Heap 不超过物理内存的 50%,且不超过 32GB,剩余留给 OS Cache。

小结

优化层面方法解决的瓶颈
查询侧Filter、Sampler、Composite、避免 Script减少扫描量、控制桶数
建模配置eager_global_ordinals、Rollups减少首次构建开销、减少数据量
缓存Shard Request Cache相同请求直接返回
硬件架构冷热分离、合理配置 Heap

7. 案例分析

场景

7000 万文档,每个文档有 categories(多值字段,共 140 多个标签)和 contentType(6 个值)。需求:统计指定 10 个分类标签下,各 contentType 的文档数量。

问题

第一种写法,耗时 6-10 秒:

{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        { "terms": { "categories": ["标签1", "标签2", "...共10个"] } }
      ]
    }
  },
  "aggs": {
    "groupByTags": {
      "terms": {
        "field": "categories",
        "include": ["标签1", "标签2", "...共10个"]
      },
      "aggs": {
        "groupByContentType": {
          "terms": { "field": "contentType" }
        }
      }
    }
  }
}

第二种写法,耗时约 100ms ~ 600ms:

{
  "size": 0,
  "aggs": {
    "groupByTags": {
      "filters": {
        "filters": {
          "标签1": { "term": { "categories": "标签1" } },
          "标签2": { "term": { "categories": "标签2" } },
          ...
          "标签10": { "term": { "categories": "标签10" } 
        }
      },
      "aggs": {
        "groupByContentType": {
          "terms": { "field": "contentType" }
        }
      }
    }
  }
}

同样的业务逻辑,为什么性能差几倍甚至几十倍?

原因分析

terms 聚合的执行方式

遍历所有匹配文档(至少2000w万条)
  → 对每个文档,读取 categories 的所有值(多值字段)
  → 为每个值在对应的桶里计数
  → 最后用 include 过滤出 10 个桶返回

虽然 include 只返回 10 个桶,但遍历和计数的工作量是全量的,要处理 2000 万文档的所有标签值。

filters 聚合的执行方式

执行 10 个独立的 filter 查询
  → 每个 filter 走倒排索引:标签 → doc_ids
  → 直接拿到每个标签的文档集合
  → 不需要遍历文档读取字段值

倒排索引是预构建的,查询时直接命中,不需要遍历文档。

当然上面的第二种方式聚合中,filter获取到doc_ids后,子聚合还是要遍历doc_ids的文档,但 contentType 只有 6 个值,基数低,分桶很快。

核心区别

聚合方式执行逻辑数据来源
terms遍历文档 → 读字段值 → 分桶计数Doc Values(正排)
filters查倒排索引 → 直接拿 doc_ids倒排索引

当只需要聚合少量已知值时,filters 聚合比 terms + include 更高效。terms 适合"不知道有哪些值,想看 Top N"的场景;filters 适合"明确知道要哪几个值"的场景。

8. 总结

本文从原理到应用,系统讲解了 ES 聚合的核心知识:

数据结构层:聚合依赖 Doc Values(列式存储)和 Global Ordinals(字符串转数字映射),理解它们是优化的基础。

执行流程:Scatter-Gather 模型,各分片本地计算后协调节点合并。DFS/BFS 两种模式影响内存峰值,分布式聚合存在精度损失。

聚合类型:Metric 聚合计算指标,Bucket 聚合分桶,Pipeline 聚合做二次计算。cardinality 使用 HLL++ 算法在 12KB 内存内估算任意基数。

性能瓶颈:内存(桶爆炸)、CPU(Script/正则)、I/O(冷数据)。

优化思路

  • 减少扫描范围:先过滤再聚合
  • 控制桶数量:Composite 分页、breadth_first
  • 利用预计算和缓存:eager_global_ordinals、Rollups、Shard Request Cache
  • 合理配置资源:冷热分离,Heap 留空间给 Page Cache