1. 引言
提到 Elasticsearch,很多人的第一反应是"搜索引擎"。确实,ES 凭借倒排索引在全文检索领域表现出色。但实际上,ES 的另一项核心能力——聚合(Aggregation)——才是让它成为数据分析利器的关键。
想象一下这些场景:
- 电商平台统计"过去 7 天各品类的销售额分布"
- 日志系统分析"每小时错误日志的数量趋势"
- 用户画像计算"各年龄段用户的活跃度占比"
这些需求的共同点是:不关心具体某条数据,而是要对海量数据做分组、计数、求和、排序。这正是聚合的用武之地。
问题
当数据量在百万级时,聚合查询通常毫秒级返回。但当数据膨胀到亿级甚至十亿级,你可能会遇到这些问题:
| 现象 | 可能原因 |
|---|---|
| 查询耗时从 200ms 涨到 10s+ | 扫描数据量过大,分片结果合并开销高 |
| 偶发 CircuitBreakingException | 聚合桶数爆炸,撑爆内存熔断器 |
| Terms 聚合结果不准确 | 分布式环境下的精度损失 |
| 协调节点频繁 Full GC | 结果归并阶段堆内存压力过大 |
这些问题的根源在于:不了解聚合的底层执行机制,只把 ES 当黑盒使用。
本文目标
本文将从三个层面建立对 ES 聚合的系统认知:
┌─────────────────────────────────────────────────────┐
优化方案
(查询优化 / 建模优化 / 架构优化)
├───────────────────────▲─────────────────────────────┤
执行流程
(Scatter → Map → Reduce → 结果返回)
├───────────────────────▲─────────────────────────────┤
数据结构基础
(Doc Values / Global Ordinals)
└─────────────────────────────────────────────────────┘
自底向上:先理解数据是怎么存的,再看查询是怎么跑的,最后才能明白优化为什么有效。
读完本文,你将能够:
- 说清楚一个聚合请求在集群内部的完整执行路径
- 定位聚合慢查询的瓶颈所在
- 根据业务场景选择合适的优化策略
接下来,我们从聚合的数据结构基础开始。
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 IDs | Doc 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 |
| Fielddata | text 字段聚合的备选方案 | 占用 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)是接收客户端请求的节点,它负责:
- 解析请求:验证 DSL 语法,构建聚合执行计划
- 路由分发:确定请求需要发往哪些分片(受 routing、索引别名等影响)
- 结果合并:收集各分片结果,执行最终的 Reduce 操作
协调节点本身不存储数据,但在 Reduce 阶段会消耗大量内存,这也是为什么复杂聚合容易把协调节点打爆。
3.3 数据节点执行阶段
每个分片在本地独立完成聚合计算,这是最耗时的阶段。
单层聚合的执行
执行流程很直接:
- 遍历查询命中的文档
- 通过 Doc Values 读取字段值
- 聚合计算
嵌套聚合执行模式: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(管道)。它们的执行方式和性能特征差异很大。
| 类型 | 代表操作 | 输入 | 输出 |
|---|---|---|---|
| Metric | sum、avg、max、cardinality、stats | 文档集 | 统计指标 |
| Bucket | terms、date_histogram、range | 文档集 | 多个桶,每个桶含文档子集 |
| Pipeline | derivative、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 亿个不同值,每个 ID 占 20 字节
→ 需要 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
-
步骤一:分桶 (Sharding)
每个值哈希成 64位:
- 前 14 位:决定落入哪个桶(共 16384 个桶)。
- 后 50 位:用于后续估算。
-
步骤二:记录前导零 (Leading Zeros)
对每个桶,统计落入该桶所有值的后 50 位前导零个数,只保留最大值 R。
-
步骤三:估算单桶基数
利用概率反推:
- 随机出现 n 个前导零的概率是1/2^n。
- 若某桶记录的最大前导零是 R,说明该桶约有 2^R 个不同值。
-
步骤四:汇总得到总基数
数据均匀分布到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_interval 或 fixed_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_interval | month, quarter | 考虑日历规则(2月28天,闰年等) |
| fixed_interval | 30d, 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)
历史数据不需要明细,可以预聚合成粗粒度:
原始数据:每秒一条,保留 7 天
Rollup:按小时聚合,保留 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