考察点
这道题考察对 ClickHouse 预计算手段的理解深度。面试官想分清你是否知道两者的本质差异:一个改的是「写入链路」,一个改的是「存储布局」。追问常考物化视图为什么查历史数据查不到、Projection 的自动路由条件、以及聚合类物化视图为什么必须配 AggregatingMergeTree。
参考答案
物化视图:写入时的触发器
ClickHouse 的物化视图和 PostgreSQL、Oracle 里的同名物不是一回事。它更像一个 INSERT 触发器:数据写进源表时,视图定义的 SELECT 对新写入的 block 执行一次,结果写到目标表。目标表是真实存在的 MergeTree 表(可以指定 TO 目标表,也可以让它隐式创建),视图本身不存数据。
这个机制带来两个直接推论。第一,视图只对创建之后写入的数据生效,存量历史数据不会回填——想补历史得手工 INSERT INTO 目标表 SELECT,而且要小心回填期间新写入的数据别重复(一般通过分区或时间条件切割处理)。第二,源表和目标表的 schema 是解耦的,视图中间的 SELECT 可以做过滤、转换、聚合,非常灵活。
预聚合的经典用法
最普遍的场景是明细降采样。源表存秒级埋点明细,物化视图按分钟、按维度 GROUP BY,把 count()、sum()、uniqExact() 写成 countState()、sumState()、uniqExactState() 存进 AggregatingMergeTree 目标表。查询侧用 countMerge()、uniqExactMerge() 聚合中间状态。为什么存中间状态而不是最终值?因为查询的时间范围、维度组合是开放的,只有中间状态才能保证任意二次聚合结果正确——最终值没法再合并。一张明细表可以挂多个物化视图,分钟表、小时表、按渠道聚合表并存,各查各的。
代价是写入放大:每条 INSERT 要额外执行每个视图的计算和写入,视图挂多了写入吞吐会掉。而且物化视图是同步链路的一部分,视图目标表写失败会影响整体。
Projection:part 内部的第二种布局
Projection 是 21.x 之后引入的能力,思路完全不同:它不改写入链路,而是在每个 part 内部额外存一份「投影数据」。投影可以是两种形态——按另一组 ORDER BY 重新排序的全量数据(类似多一个排序索引),或者按某组维度预聚合的结果(类似内置的物化聚合)。
优势在查询体验:优化器分析查询后,如果发现用某个 projection 扫描的数据量更小,会自动路由过去,查询 SQL 不用改,对用户透明。而物化视图要查询方明确去查目标表(或者靠改写 SQL)。另一个差别是强一致:projection 和 part 同生共死,不存在视图和源表数据不同步的问题,merge 时也会跟着维护。
两者怎么选
经验规则是这样:需要把数据加工到「另一张表」、供别的查询模式甚至别的团队使用,或者聚合逻辑复杂(多表、多级),用物化视图——它本质是 ETL 管道。只是想给同一张表加速、让不同查询各走各的最优路径,尤其是需要按非主键字段高效过滤的场景,用 Projection——它本质是索引增强。两者可以共存,不冲突。
要注意的是 Projection 占额外存储(每个投影大致多一份对应数据),merge 开销也变大;物化视图有「视图只在单分片本地生效」的问题,集群环境下目标表如果要 Distributed,得在每个分片本地建视图。这些实操细节是区分背题和真用过的分水岭。
一句话对比
| 维度 | 物化视图 | Projection |
|---|---|---|
| 作用层面 | 写入链路,ETL 触发器 | 存储层,part 内冗余布局 |
| 历史数据 | 不回填,需手工补 | 可对存量 part 物化(mutation) |
| 查询方式 | 查目标表,SQL 要指向它 | 优化器自动路由,SQL 不变 |
| 典型场景 | 预聚合表、数据分发 | 多排序键、透明加速 |
可能的追问
- 物化视图建好后发现查不到老数据怎么办?—— 预期行为,只对新写入生效。手工 INSERT INTO SELECT 回填存量,注意用时间或分区边界和新数据流切开,避免重复。
- 聚合视图里为什么要用 uniqExactState 而不是直接存 UV 数值?—— 存最终值无法再聚合,跨天、跨维度合并会错。中间状态(Theta sketch 类结构)支持任意合并后再出终值。
- Projection 能替代二级索引吗?—— 部分可以。排序型 projection 相当于按另一组键建排序索引,比 minmax/bloom 跳数索引强在有真正的排序定位,但代价是整份数据的存储和 merge 开销。