ClickHouse学习笔记(四):物化视图与聚合状态实战
ClickHouse学习笔记(四):物化视图与聚合状态实战
订单明细越积越多,但报表每天都在问同一个问题:“各城市今天支付了多少笔、多少钱、多少人?”
如果每次都从全部明细重新聚合,查询会重复做很多工作。能不能在数据写入时就先整理一部分,查询时再合并这些结果?
这一篇用增量物化视图 + 聚合状态表完成一个小实验,重点理解它处理的是哪些数据,以及为什么不能把每批次的统计结果随意相加。
系列导航:
沿用第二篇的 ClickHouse 25.8 环境。本篇创建独立的
payments_mv_demo、order_daily_metrics和视图,不继续往八条支付记录中追加数据。所有建表和插入语句按顺序执行一次。
一、普通视图与增量物化视图
普通视图主要保存查询定义,读取视图时执行对应查询。增量物化视图则在源表写入新数据块时执行转换,将结果写入目标表。
1 | 普通视图: |
它不是定时把整个源表重新扫描一遍。增量视图通常处理这次新插入的数据块。 机制说明见官方增量物化视图文档。
本文使用显式的 TO target_table,把“如何加工”和“结果存在哪里”分开表达。
二、为什么要保存聚合状态?
2.1 金额可以加,人数不一定可以加
考虑两批支付记录:
1 | 第一批:用户 201 支付 100 元,用户 202 支付 200 元 |
分别统计之后:
| 指标 | 第一批 | 第二批 | 跨批次的正确结果 |
|---|---|---|---|
| 支付笔数 | 2 | 1 | 3 |
| 支付金额 | 300 | 50 | 350 |
| 去重支付人数 | 2 | 1 | 2 |
人数不能直接算成 2 + 1 = 3,因为同一个人出现在两批数据中。
如果保存的是能够合并的去重状态,就可以在合并时处理跨批次重复用户。ClickHouse 用 AggregateFunction 类型表达这类状态,见官方类型说明。
2.2 State 与 Merge 怎么配合?
| 写入时生成状态 | 查询时合并状态 | 用途 |
|---|---|---|
countState() |
countMerge(...) |
合并计数 |
sumState(amount) |
sumMerge(...) |
合并金额 |
uniqExactState(user_id) |
uniqExactMerge(...) |
精确合并去重用户 |
-State 返回的是内部聚合状态,-Merge 合并相应状态并返回最终值。这些后缀是聚合函数组合器,见官方组合器文档。
状态也有成本。uniqExact 的状态需要保留精确去重信息,用户越多,内存和存储开销越大。大规模场景可以评估近似算法,但要先确定业务是否允许误差。
2.3 平均值为什么也要小心?
上述两批的平均支付金额是 150 元和 50 元。直接平均会得到 100 元,而正确结果是:
1 | (100 + 200 + 50) / 3 = 116.666... |
因为两批的记录数不同。可以保存总额和笔数再相除,也可以使用 avgState / avgMerge 保存和合并平均值状态。
一个已经算出来的数值,未必保留了再次合并需要的信息。 这是理解聚合状态最关键的一步。
三、创建源表、目标表与视图
3.1 源表保存支付事实
1 | CREATE TABLE clickhouse_learning.payments_mv_demo |
这一张表里一行仍然表示一次独立的订单成功支付,不是订单状态的多个版本。
3.2 目标表保存每天、每个城市的聚合状态
1 | CREATE TABLE clickhouse_learning.order_daily_metrics |
这张表的粒度是“每天、每个城市”。因此,排序键包含 day 和 city,与后面的分组维度一致,防止不同日期或城市的状态被当作一组。
AggregateFunction(sum, Decimal(12, 2)) 中的类型是聚合函数的输入类型,要与 sumState(amount) 对应,不能随意把目标状态声明成另一种类型。
3.3 创建增量物化视图
1 | CREATE MATERIALIZED VIEW clickhouse_learning.order_daily_metrics_mv |
显式别名与目标表列名对应。AggregatingMergeTree 如何保存和合并状态,可以参考官方引擎示例。
创建顺序是:空源表 → 空目标表 → 视图 → 插入数据。这样本实验不涉及历史回填。
四、分两次插入,验证跨批次合并
4.1 插入第一批
1 | INSERT INTO clickhouse_learning.payments_mv_demo VALUES |
查询目标表:
1 | SELECT |
预期是 2026-10-01、上海、2 笔、300.00 元、2 人。
4.2 第二批出现同一个用户
1 | INSERT INTO clickhouse_learning.payments_mv_demo VALUES |
重新执行上面的查询,预期变成:
| 日期 | 城市 | 支付笔数 | 支付金额 | 去重支付人数 |
|---|---|---|---|---|
| 2026-10-01 | 上海 | 3 | 350.00 | 2 |
用户 201 又买了一次,但仍然只有两个不同用户。结果按固定输入计算,用它检查自己的实验。
4.3 为什么查询仍要 GROUP BY 和 Merge?
后台合并不保证已经将同一个维度的全部状态整理成一行。因此,查询时仍需要按维度分组,并使用匹配的 -Merge 函数。
直接 SELECT * 可能看到多个中间状态,输出也不一定是人能直接读懂的数字。不要把目标表当前的物理行数当作支付笔数。
聚合结果的正确性应该由查询语义保证,不应该依赖“后台应该合并完了”。
五、已经有历史数据怎么办?
如果先插入一年的明细,后来才创建视图,普通 CREATE MATERIALIZED VIEW ... TO ... 不会自动把已有数据全部补入目标表。
为了观察这一点,可以做一个完全独立的小实验:
1 | CREATE TABLE clickhouse_learning.history_payments_demo |
创建表时的 AS other_table 复制结构,不复制记录。目标表此时应为空,因为 3001 在视图创建前就已经写入。
这个实验没有其他写入方,下面的历史回填只执行一次:
1 | INSERT INTO clickhouse_learning.history_daily_demo |
预期是北京、1 笔、80.00 元、1 人。第二次回填会再次累加笔数和金额,因此它不是可随意重复执行的修复命令。
真实环境还需要处理回填与实时写入的交界:可以暂停写入后完成回填再恢复,也可以设计明确的摄入边界,让历史路径与实时路径互不重叠。仅按业务事件时间切分时,还要考虑迟到事件,不能默认旧日期的数据永远不会再来。官方回填指南提供了更完整的方法。
六、三种“看起来会自动发生”的误解
6.1 源表更新,汇总表会自动更正吗?
本篇的增量视图不是持续比较整张源表的变化。源表的 mutation、删除、分区操作或后台合并,不会自动变成对目标表的对应撤销和重新计算。
因此,退款、错误支付和历史修正需要明确的业务方案,例如单独记录调整事实,或重算受影响的汇总范围。官方物化视图指南说明了这种触发边界。
6.2 源表用了 ReplacingMergeTree,就能避免重复金额吗?
不能直接这么推导。
1 | 重复记录先写入源表 |
写入去重、实体版本替换和指标聚合是三种不同机制。重试是否被识别为同一个写入块,也取决于引擎与去重设置,不能将它理解为任意业务事件 ID 的自动幂等。
6.3 视图中 JOIN 了用户表,用户改城市会自动重算吗?
常规增量视图由源表的新插入触发。右侧维度表变化不会单独触发历史明细的重新聚合,JOIN 使用的是触发时读取到的维度数据。
要先明确统计口径:“支付发生时的城市”还是“用户现在的城市”。前者可以随事实一起保存,后者可能需要查询时关联或其他刷新方案,见官方 JOIN 注意事项。
七、什么时候值得预聚合?
| 场景 | 可以考虑的方式 |
|---|---|
| 高频查询固定的日级指标 | 增量物化视图与汇总表 |
| 临时分析不断增加新维度 | 保留明细直接查询 |
| 需要定期重算包含更新的数据集 | 评估可刷新物化视图或批处理 |
| 指标定义尚未稳定 | 先明确口径,避免过早固化汇总 |
预聚合把一部分计算成本移到了写入阶段,也增加了状态存储和维护工作。维度组合很多、精确去重状态很大时,目标表未必像想象中那样小。
学习时可以再做一个练习:将第二批用户改成 203,预期金额仍然是 350 元,但支付人数应该变为 3。使用新的实验表复现,不要向当前三条记录上直接重复插入。
下一篇用执行计划、查询日志和一个较大的合成数据集,练习判断查询慢在哪里。



