增量物化视图 —— 不是保存的查询,而是 INSERT 触发器
一句话总结
ClickHouse 的(增量)物化视图并不是保存了结果的快照,而是一个每当源表收到 INSERT,就只对进来的块运行 SELECT,并把结果追加到目标表的触发器。视图创建之前的行、右侧连接表的变化、源表的 UPDATE、DELETE,视图都看不到。
为什么需要它
在前面的模块中,我们看到 SummingMergeTree、AggregatingMergeTree 会在合并时把行合并起来。可是,如果仪表板每秒都要查询每天不断累积的源日志的“各商店日销售额”,每次都对源表几亿行重新聚合,就是一种浪费。我们想把计算从查询时挪到写入时——官方文档所举的物化视图的动机,就是这句话。
PostgreSQL 的物化视图是一个快照,执行 REFRESH MATERIALIZED VIEW 时会把整个查询重新跑一遍。ClickHouse 选了另一条路。即使源表有几十亿行,也让成本只与“新进来的块大小”成正比,所以把视图做成挂在 INSERT 路径上的触发器。代价很明确:视图只认识自己见过的块。如果不了解这个限制就去使用,数字会悄悄出错。
工作原理
CREATE MATERIALIZED VIEW mv.daily_sales_mv TO mv.daily_sales AS
SELECT toDate(ts) AS day, shop, count() AS orders, sum(qty * price) AS revenue
FROM mv.orders GROUP BY day, shop;
TO 是发送结果的目标表。CREATE VIEW 文档用一句话描述了它的行为——向源表 INSERT 时,所插入数据的一部分会被这个 SELECT 转换后进入视图。而且即使有 GROUP BY,也只会在所插入的一个块内聚合,不会进一步合并。在这个实验 Pod 中,一次 INSERT 写入 20 万行,目标表得到了(商店 8 × 日期 15)= 120 行。多次写入,同一个(shop, day)就会有多行——实测中 240 个键有 720 行。所以目标表要用在合并时合并相同键的引擎(SummingMergeTree、AggregatingMergeTree),把排序键与视图的 GROUP BY 对齐,并且查询时也一定要用 sum() 重新累加。因为合并何时发生是不确定的。
视图的生命周期中容易忽略的三点:
| 情况 | 视图所做的事 |
|---|---|
| 视图创建之前源表中已有的行 | 什么也不做——目标表从 0 行开始 |
| 源表的 ALTER UPDATE、DELETE、DROP PARTITION | 不会修改目标表 |
| JOIN 右侧表中增加了行 | 视图不会运行——触发条件只有最左侧的表 |
填补第一行这一栏的工作叫回填(backfill)。方法有两种。创建时加上 POPULATE,或者在创建视图之后用同样的 SELECT 亲自执行 INSERT INTO 대상 SELECT ... FROM 원천 WHERE (뷰가 못 본 구간)(占位符依次为目标表、源表、视图没有看到的区间)。根据 26.8 的文档,普通 CREATE 的 POPULATE 现在会对源表加一个很短的排他锁,使并发的 INSERT 各只错过一次(设置 materialized_views_populate_atomically 默认为 1——已在 Pod 上确认)。尽管如此,陷阱依然存在——如果 TO 表里已经有行,回填的行会被追加;重新执行失败的 CREATE,已经写入的行又会写入一次;而 CREATE OR REPLACE 和 Replicated 数据库中,要么是旧的非原子方式,要么干脆被禁止。所以现场通常是划定区间,亲自 INSERT。关键是“不要再次写入视图已经见过的区间”。
无法相加的值存储的是中间状态,而不是结果。把每天统计的去重用户数相加,跨越多天到来的人就会被重复计数。用 uniqExactState(user_id) 把状态放进 AggregateFunction(uniqExact, UInt32) 列,查询时用 uniqExactMerge 合并,就和对源表重新计数的结果一样了。
也可以级联。目标表同样是一张表,所以向它 INSERT 时,以它为源表的视图也会再运行一次。想不保存原始数据,就把源表设为 ENGINE = Null,Null 表什么也不存,却能唤醒视图——是只用作入口的表。
JOIN 文档另外给出了警告。最左侧的表会被替换为所插入的块,而右侧的表则是整张被读取。所以如果订单比商品先进来,INNER JOIN 会丢弃这个订单,商品之后进来也不会把它救回来。一个源表上有多个视图时,默认(parallel_view_processing = 0)按视图 uuid 的顺序逐个运行。
还有定期把整个查询重新运行的刷新视图(REFRESH EVERY 1 HOUR)。它没有 INSERT 触发器,对 JOIN、UNION 也没有限制,代价是结果会晚到上一次刷新的时点。结果会因时刻而变化,所以本实验不涉及。
在现场相遇的样子
最常见的事故是“视图部署之后,仪表板上上个月的数字是空的”。视图只看部署那一刻之后的 INSERT,所以是漏掉了回填。第二种事故是急着回填,把部署之后的区间也写了进去,最近几天翻了一倍。回填时要划定边界时刻,只写它之前的部分。
第三种是维度表连接。创建了给订单附加商品类别的视图,新品的订单却从类别聚合中消失了。每逢商品主数据比订单更晚同步的日子,都会悄悄漏掉。文档建议的办法,是把连接推迟到查询时,或者使用字典或刷新视图。
第四种是把目标表像 SELECT orders FROM daily_sales WHERE ... 这样不累加就直接读取的代码。合并完成的日子是对的,刚进来 INSERT 的日子会出现两行而出错——变成无法复现的 bug。
下一项实验要做什么
向 mv.orders 写入第 1 批 20 万条,创建 SummingMergeTree 目标表和视图。写入第 2 批,记录源表与目标表的差距,只回填第 1 批,让两张表对上。写出用 sum 读取目标表的查询,并把不同购买者的状态放进 AggregatingMergeTree。通过 Null 表只流入 paid 订单来确认级联,最后统计商品表填充得较晚时,JOIN 视图丢失了多少订单。