ClickHouse AggregatingMergeTree 表引擎详解
文章目录
一、背景与定位
在 OLAP 场景中,我们经常需要对海量明细数据做聚合查询(如 SUM、COUNT、UV 去重等)。如果每次查询都扫描全量明细数据,延迟和资源消耗都不可接受。预聚合(Pre-Aggregation)是解决这一矛盾的核心手段。
ClickHouse 提供的 AggregatingMergeTree 引擎,就是专为"存储聚合中间状态"而设计的表引擎。它继承自 MergeTree 家族,核心能力是:在后台合并(merge)数据分区时,将相同排序键(ORDER BY)的多行合并为一行,并将聚合函数的中间状态(而非最终结果)进行合并存储。
与 SummingMergeTree 只能做简单求和不同,AggregatingMergeTree 支持任意聚合函数(avg、uniq、quantile、topK 等),能力更通用。
二、核心原理
2.1 为什么不能直接存最终值?
以 avg(平均值)为例。假设有两批数据:
- 批次 A:平均值 = 10(来自 3 条记录)
- 批次 B:平均值 = 20(来自 7 条记录)
如果我们只存最终值 10 和 20,合并时无法得到正确的全局平均值。(10 + 20) / 2 = 15 是错的,正确答案是 (10×3 + 20×7) / (3+7) = 17。
这就是 AggregatingMergeTree 存"中间状态"而非"最终值"的根本原因——很多聚合函数的最终结果不可合并(non-mergeable)。类似的例子还有 uniq(去重计数)、quantile(分位数)、avg 等。
2.2 中间状态(Intermediate State)是什么?
每个聚合函数都定义了自己的中间状态数据结构。合并时,两个中间状态可以正确合并为一个新的中间状态;最终查询时,再从中间状态计算出最终结果。
常见聚合函数的中间状态:
| 聚合函数 | 中间状态内容 | 最终计算 |
|---|---|---|
sum | 累计和 | 直接返回 |
count | 计数值 | 直接返回 |
avg | {sum, count} 二元组 | sum / count |
uniq | 哈希值样本集(最多 65536 个) | 基于样本集估算基数 |
uniqHLL12 | HyperLogLog 寄存器(4096 个 5-bit 桶,~2.5KB) | 调和平均估算基数 |
quantile | t-digest 或 DDSketch 结构 | 从分布摘要中取分位点 |
topK | Space-Saving 数据结构 | 取 Top-K 元素 |
2.3 AggregateFunction 数据类型
ClickHouse 专门定义了 AggregateFunction(funcName, argTypes...) 数据类型来存储中间状态。它是一个不透明的二进制 blob,人类不可读,只能通过对应的 -Merge 后缀函数来"解包"得到最终结果。
例如:AggregateFunction(uniq, UInt64) 表示 uniq 函数作用于 UInt64 类型参数时的中间状态。
2.4 后台合并行为
AggregatingMergeTree 的后台合并(background merge)规则:
- 找到 ORDER BY 键相同的多行
- 对每个
AggregateFunction类型的列,调用对应聚合函数的合并逻辑,将多个中间状态合并为一个 - 对非
AggregateFunction类型的列,取第一行的值(类似any) - 输出合并后的一行
关键点:后台合并是异步的、不确定的。在任意时刻,表中可能存在同一个 ORDER BY 键的多行(尚未合并)。因此查询时必须使用 GROUP BY + -Merge 后缀来保证结果正确。
三、-State / -Merge / -MergeState 后缀体系
3.1 -State 后缀
在任何聚合函数后面加 -State,它不再返回最终结果,而是返回中间状态(类型为 AggregateFunction(...))。
SELECT
user_city,
uniqState(user_id) AS uv_state,
sumState(amount) AS amount_state
FROM events
GROUP BY user_city
此查询的结果列 uv_state 的类型是 AggregateFunction(uniq, UInt64),存储的是一个二进制中间状态。
3.2 -Merge 后缀
对 AggregateFunction 类型的列使用 -Merge 后缀,将中间状态合并并计算出最终值。
SELECT
user_city,
uniqMerge(uv_state) AS uv,
sumMerge(amount_state) AS total_amount
FROM agg_table
GROUP BY user_city
uniqMerge 会先合并同一 GROUP BY 组内所有的中间状态,再产出最终的 UV 数值。
3.3 -MergeState 后缀
有时我们需要"合并多个中间状态,但不计算最终值,继续保持中间状态"。这时用 -MergeState:
SELECT
user_city,
uniqMergeState(uv_state) AS merged_uv_state
FROM agg_table
GROUP BY user_city
结果仍然是 AggregateFunction(uniq, UInt64) 类型,可以继续存入另一个 AggregatingMergeTree 表或参与后续合并。这在多级物化视图聚合中非常有用。
四、使用方法:完整实战示例
4.1 场景:页面 UV 和 PV 的实时聚合
假设有一张明细表记录用户访问日志:
CREATE TABLE page_events (
event_date Date,
page_id UInt32,
user_id UInt64,
duration UInt32
) ENGINE = MergeTree()
PARTITION BY event_date
ORDER BY (page_id, event_date);
4.2 创建 AggregatingMergeTree 聚合表
CREATE TABLE page_stats_agg (
event_date Date,
page_id UInt32,
uv_state AggregateFunction(uniq, UInt64),
pv SimpleAggregateFunction(sum, UInt64),
avg_duration_state AggregateFunction(avg, UInt32)
) ENGINE = AggregatingMergeTree()
PARTITION BY event_date
ORDER BY (page_id, event_date);
注意:pv 列用了 SimpleAggregateFunction(sum, UInt64) 而非 AggregateFunction。原因见后文"SimpleAggregateFunction"章节。
4.3 创建物化视图自动聚合
CREATE MATERIALIZED VIEW page_stats_mv
TO page_stats_agg
AS SELECT
event_date,
page_id,
uniqState(user_id) AS uv_state,
count() AS pv,
avgState(duration) AS avg_duration_state
FROM page_events
GROUP BY event_date, page_id;
物化视图的执行逻辑:每次向 page_events 插入新数据时,ClickHouse 对这批新数据执行 SELECT 语句,将结果写入 page_stats_agg。物化视图只处理增量数据(新插入的数据),不会回溯历史数据。
如果建立物化视图前已有历史数据,需要手动补刷:
INSERT INTO page_stats_agg
SELECT
event_date,
page_id,
uniqState(user_id) AS uv_state,
count() AS pv,
avgState(duration) AS avg_duration_state
FROM page_events
WHERE event_date >= '2024-01-01' -- 按需筛选
GROUP BY event_date, page_id;
4.4 查询聚合结果
SELECT
page_id,
uniqMerge(uv_state) AS uv,
sum(pv) AS pv,
avgMerge(avg_duration_state) AS avg_duration
FROM page_stats_agg
WHERE event_date = '2024-12-01'
GROUP BY page_id
ORDER BY uv DESC
LIMIT 10;
强调:即使某些 ORDER BY 键已经在后台合并为一行,查询时也必须带上 GROUP BY 和 -Merge。因为无法保证所有相同键的行都已合并完毕。
五、页面 UV 的中间状态详解
5.1 uniq 函数的中间状态
ClickHouse 的 uniq 函数使用的是一种自适应采样(adaptive sampling)算法,而非 HyperLogLog。其中间状态的核心是一个哈希值样本集合:
- 对每个输入值计算哈希
- 当不同值数量较少时(≤65536),精确存储所有哈希值,结果完全精确
- 当数量超过阈值时,切换到概率采样模式进行基数估算
合并逻辑:将两个样本集合并(取并集),如果超出容量则进行采样压缩。这保证了分布式场景下多个 uniqState 可以正确合并。
5.2 uniqHLL12 函数的中间状态
如果想用 HyperLogLog 算法,应使用 uniqHLL12。其中间状态为:
- 2^12 = 4096 个桶(bucket/register)
- 每个桶存储一个 5-bit 值,记录该桶观察到的"最长前导零+1"
- 总状态大小约 4096 × 5 bit ≈ 2.5 KB(固定大小,与数据量无关)
合并逻辑:对应桶取 max,即 merged[i] = max(state_a[i], state_b[i])。
最终计算:基于所有桶的调和平均值(harmonic mean)估算总基数,标准误差约 1.6%。
5.3 uniqExact:精确去重
如果业务要求零误差,可使用 uniqExact。中间状态是完整的哈希值集合(HashSet),无采样,内存随数据量线性增长。大数据量下资源消耗显著高于 uniq 和 uniqHLL12。
5.4 选型建议
| 函数 | 精度 | 状态大小 | 适用场景 |
|---|---|---|---|
uniq | 高(自适应) | 动态,最大约几百 KB | 大多数场景的默认选择 |
uniqHLL12 | ~1.6% 误差 | 固定 ~2.5KB | 状态大小敏感、大规模去重 |
uniqExact | 100% 精确 | 随数据量线性增长 | 数据量可控且要求精确 |
六、SimpleAggregateFunction 与 AggregateFunction 的区别
6.1 SimpleAggregateFunction
对于某些"最终值即可合并"的聚合函数(如 sum、min、max),不需要存储复杂的二进制中间状态,直接存最终值就能正确合并。ClickHouse 提供了 SimpleAggregateFunction 类型来优化这种场景:
- 存储的是当前聚合值(普通数值),人类可读
- INSERT 时直接写入值(不需要
-State后缀) - 查询时使用普通聚合函数(不需要
-Merge后缀) - 存储和查询开销更低
支持的函数列表:any、anyLast、min、max、sum、sumWithOverflow、groupBitAnd、groupBitOr、groupBitXor、groupArrayArray、sumMap、minMap、maxMap、argMin、argMax。
6.2 对比
| 特性 | AggregateFunction | SimpleAggregateFunction |
|---|---|---|
| 存储内容 | 二进制中间状态 | 当前聚合值 |
| 写入方式 | 需用 -State 后缀 | 直接写入值 |
| 查询方式 | 需用 -Merge 后缀 | 普通聚合函数 |
| 支持的函数 | 所有聚合函数 | 仅上述"可合并"函数 |
| 存储效率 | 较高(二进制压缩) | 更高(普通数值) |
| 可读性 | 不可读 | 可读 |
6.3 使用建议
能用 SimpleAggregateFunction 的优先用。只有当聚合函数不在支持列表中(如 uniq、avg、quantile、topK 等)时,才需要使用 AggregateFunction。
七、物化视图 + AggregatingMergeTree 架构模式
7.1 典型架构
明细表 (MergeTree)
│
├── 物化视图 A ──→ 聚合表 A (AggregatingMergeTree) [按天+页面聚合]
│
├── 物化视图 B ──→ 聚合表 B (AggregatingMergeTree) [按天+城市聚合]
│
└── 物化视图 C ──→ 聚合表 C (AggregatingMergeTree) [按小时+渠道聚合]
同一份明细数据,可以通过多个物化视图写入不同维度的聚合表,满足不同的查询场景。
7.2 多级聚合
通过 -MergeState,可以构建多级物化视图链:
明细表 → MV1 → 小时级聚合表 → MV2 → 天级聚合表
MV2 从小时级聚合表读取中间状态,用 -MergeState 合并后写入天级聚合表,避免重复扫描明细。
7.3 注意事项
-
物化视图只处理增量:创建视图后,只有新写入的数据会被处理。历史数据需要手动 INSERT INTO … SELECT 补刷。
-
INSERT 原子性:物化视图的写入与源表的 INSERT 是原子的。如果物化视图写入失败,源表的 INSERT 也会失败。
-
写入放大:每个物化视图都会产生一次额外的写入。视图越多,写入放大越大,需要权衡。
-
查询必须带 GROUP BY:后台合并时机不确定,查询聚合表时永远要带 GROUP BY +
-Merge。
八、性能优化建议
8.1 ORDER BY 选择
ORDER BY 的列决定了哪些行会在后台合并时被聚合。应选择查询中最常用的 GROUP BY 维度组合。列的顺序影响数据局部性和压缩率,高基数列放后面。
8.2 分区策略
合理的 PARTITION BY(如按天、按月)能加速分区裁剪,也限制了单次合并的数据范围。注意:跨分区的相同 ORDER BY 键不会被合并。
8.3 批量写入
AggregatingMergeTree 和所有 MergeTree 系列一样,适合批量写入(每批 ≥1000 行,最好数万行)。高频小批量写入会产生大量小 part,增加合并压力。
8.4 FINAL 关键字
如果不想在查询中写 GROUP BY,可以使用 FROM table FINAL,它强制在查询时合并所有相同键的行。但 FINAL 会显著降低查询性能(本质是查询时做合并),仅适用于小表或开发调试。
九、完整示例:电商订单聚合
-- 明细表
CREATE TABLE orders (
order_date Date,
shop_id UInt32,
user_id UInt64,
category String,
amount Decimal(10,2),
quantity UInt32
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(order_date)
ORDER BY (shop_id, order_date);
-- 聚合表
CREATE TABLE shop_daily_stats (
order_date Date,
shop_id UInt32,
buyer_uv AggregateFunction(uniq, UInt64),
order_count SimpleAggregateFunction(sum, UInt64),
gmv SimpleAggregateFunction(sum, Decimal(10,2)),
avg_amount AggregateFunction(avg, Decimal(10,2)),
top_categories AggregateFunction(topK(5), String)
) ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(order_date)
ORDER BY (shop_id, order_date);
-- 物化视图
CREATE MATERIALIZED VIEW shop_daily_stats_mv
TO shop_daily_stats
AS SELECT
order_date,
shop_id,
uniqState(user_id) AS buyer_uv,
count() AS order_count,
sum(amount) AS gmv,
avgState(amount) AS avg_amount,
topKState(5)(category) AS top_categories
FROM orders
GROUP BY order_date, shop_id;
-- 查询
SELECT
shop_id,
uniqMerge(buyer_uv) AS buyers,
sum(order_count) AS orders,
sum(gmv) AS total_gmv,
avgMerge(avg_amount) AS avg_order_amount,
topKMerge(5)(top_categories) AS hot_categories
FROM shop_daily_stats
WHERE order_date >= '2024-12-01' AND order_date <= '2024-12-31'
GROUP BY shop_id
ORDER BY total_gmv DESC
LIMIT 20;
十、常见问题 FAQ
Q1:AggregatingMergeTree 和 SummingMergeTree 有什么区别?
SummingMergeTree 只支持对数值列做 SUM 合并,功能单一。AggregatingMergeTree 通过 AggregateFunction 类型支持任意聚合函数,是 SummingMergeTree 的超集。如果只需要 sum,用 SummingMergeTree 更简洁;如果需要 uniq、avg、quantile 等,必须用 AggregatingMergeTree。
Q2:可以直接 SELECT 查看 AggregateFunction 列的值吗?
不能直接查看(显示为乱码二进制)。必须使用 -Merge 后缀函数来读取最终值,或使用 hex() 函数查看十六进制表示(调试用途)。
Q3:如果 ORDER BY 写错了能修改吗?
不能直接修改已有表的 ORDER BY。需要新建一张正确 ORDER BY 的表,然后通过 INSERT INTO … SELECT 迁移数据(注意使用 -MergeState 而非 -Merge,以保持中间状态)。
Q4:分布式集群中怎么使用?
每个 shard 本地建 AggregatingMergeTree 表和物化视图,然后通过 Distributed 表引擎进行跨 shard 查询。查询时的 GROUP BY + -Merge 会自动处理跨 shard 的状态合并。
Q5:物化视图的 SELECT 中能用 WHERE 过滤吗?
可以。物化视图的 SELECT 语句支持完整的 SQL 语法,包括 WHERE、JOIN 等。但要注意 WHERE 条件只作用于新写入的数据批次。
十一、总结
AggregatingMergeTree 的核心设计哲学是"用空间换时间,用中间状态保证正确性":
- 通过
-State将聚合函数的中间状态(而非最终结果)持久化存储 - 后台合并时,利用中间状态的可合并性(mergeability)做增量聚合
- 查询时通过
-Merge合并剩余未合并的状态,得到精确最终结果 - 配合物化视图实现写入即聚合(write-time aggregation),将聚合成本从查询时分摊到写入时
这套机制使得 ClickHouse 能够在保证聚合结果正确性的前提下,将预聚合查询的延迟从秒级降到毫秒级,是构建实时数据看板和 OLAP 系统的核心能力之一。
更多推荐
所有评论(0)