一、背景与定位

在 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 个)基于样本集估算基数
uniqHLL12HyperLogLog 寄存器(4096 个 5-bit 桶,~2.5KB)调和平均估算基数
quantilet-digest 或 DDSketch 结构从分布摘要中取分位点
topKSpace-Saving 数据结构取 Top-K 元素

2.3 AggregateFunction 数据类型

ClickHouse 专门定义了 AggregateFunction(funcName, argTypes...) 数据类型来存储中间状态。它是一个不透明的二进制 blob,人类不可读,只能通过对应的 -Merge 后缀函数来"解包"得到最终结果。

例如:AggregateFunction(uniq, UInt64) 表示 uniq 函数作用于 UInt64 类型参数时的中间状态。

2.4 后台合并行为

AggregatingMergeTree 的后台合并(background merge)规则:

  1. 找到 ORDER BY 键相同的多行
  2. 对每个 AggregateFunction 类型的列,调用对应聚合函数的合并逻辑,将多个中间状态合并为一个
  3. 对非 AggregateFunction 类型的列,取第一行的值(类似 any
  4. 输出合并后的一行

关键点:后台合并是异步的、不确定的。在任意时刻,表中可能存在同一个 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),无采样,内存随数据量线性增长。大数据量下资源消耗显著高于 uniquniqHLL12

5.4 选型建议

函数精度状态大小适用场景
uniq高(自适应)动态,最大约几百 KB大多数场景的默认选择
uniqHLL12~1.6% 误差固定 ~2.5KB状态大小敏感、大规模去重
uniqExact100% 精确随数据量线性增长数据量可控且要求精确

六、SimpleAggregateFunction 与 AggregateFunction 的区别

6.1 SimpleAggregateFunction

对于某些"最终值即可合并"的聚合函数(如 sum、min、max),不需要存储复杂的二进制中间状态,直接存最终值就能正确合并。ClickHouse 提供了 SimpleAggregateFunction 类型来优化这种场景:

  • 存储的是当前聚合值(普通数值),人类可读
  • INSERT 时直接写入值(不需要 -State 后缀)
  • 查询时使用普通聚合函数(不需要 -Merge 后缀)
  • 存储和查询开销更低

支持的函数列表:anyanyLastminmaxsumsumWithOverflowgroupBitAndgroupBitOrgroupBitXorgroupArrayArraysumMapminMapmaxMapargMinargMax

6.2 对比

特性AggregateFunctionSimpleAggregateFunction
存储内容二进制中间状态当前聚合值
写入方式需用 -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 注意事项

  1. 物化视图只处理增量:创建视图后,只有新写入的数据会被处理。历史数据需要手动 INSERT INTO … SELECT 补刷。

  2. INSERT 原子性:物化视图的写入与源表的 INSERT 是原子的。如果物化视图写入失败,源表的 INSERT 也会失败。

  3. 写入放大:每个物化视图都会产生一次额外的写入。视图越多,写入放大越大,需要权衡。

  4. 查询必须带 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 的核心设计哲学是"用空间换时间,用中间状态保证正确性":

  1. 通过 -State 将聚合函数的中间状态(而非最终结果)持久化存储
  2. 后台合并时,利用中间状态的可合并性(mergeability)做增量聚合
  3. 查询时通过 -Merge 合并剩余未合并的状态,得到精确最终结果
  4. 配合物化视图实现写入即聚合(write-time aggregation),将聚合成本从查询时分摊到写入时

这套机制使得 ClickHouse 能够在保证聚合结果正确性的前提下,将预聚合查询的延迟从秒级降到毫秒级,是构建实时数据看板和 OLAP 系统的核心能力之一。

更多推荐