可刷新物化视图

可刷新物化视图 (refreshable materialized view) 是 ClickHouse 一种新的物化视图解决方案,并在24.10版本中标记为生产可用。主要用于解决增量物化视图不适用的场景

一、增量物化视图

增量物化视图是 ClickHouse 中常见的物化视图。下面我们来介绍它的更新时机以及不适用的场景,以引出可刷新物化视图推出的背景。

1.更新时机

从本质上说,增量物化视图是主表insert事件的触发器。当主表有数据插入时,会将本次插入的数据应用物化视图的建表 SQL,并写入物化视图或其 to 表(后面全部用 to 表解释)。
principle
上述是单表增量物化视图更新时机极其数据扭转。

对于多表 join 的增量物化视图,其更新时机略有区别。也正是因为其独特的机制,导致增量物化视图无法应用于所有多表场景。下面构建一个多表 join 的增量物化视图场景:

-- 数据源表 1
create table tbl1(
id Int8
)engine = MergeTree
order by id;

-- 数据源 2
create table tbl2(
id Int8
)engine = MergeTree
order by id;

-- to 表
create table tbl(
id Int8
)engine = MergeTree
order by id;

-- 构建物化视图
create materialized view mv_tbl_1_join_2 to tbl
as
select t1.id
from tbl1 t1 full join tbl2 t2 on t1.id = t2.id;

验证多表的增量物化视图只有当主表发生 insert 操作时才会触发更新,与从表无关:

insert into tbl2 values (1); -- 从表插入数据
select * from tbl; -- to 表无数据
insert into tbl1 values (1); -- 主表插入数据
select * from tbl; -- to 表有数据

同时,该逻辑在 union 场景中也适用,即使从表的数据发生变化,也不会触发物化视图的增量更新。

2.不适合的场景

理解了增量物化视图的更新逻辑后,哪些场景适合使用,哪些场景不适合甚至不能使用就清晰了。总结来说:从表的数据不变或者变化时对结果数据没有影响的场景,不适合使用增量物化视图。典型的场景是维表关联。

即使维表关联可以使用增量物化视图,但需要注意一点,当主表的数据没有关联上维表时,即使后续维表补齐这部分数据,已经处理过的数据也不会重新计算。

二、可刷新物化视图

2.1 基本概念

当对数据新鲜度要求不是特别高,并且需要加速复杂 SQL 查询时,可以使用可刷新物化视图 (Refreshable Materialized View)。
refresh_principle
一个简单的 SQL 示例:

create materialized view rmv_tbl_1_join_2  
refresh every 1 minute
to tbl
as
select id from tbl1
union all
select id from tbl2;

使用上增量物化视图的区别在于只需要指定refresh every 1 minute用于配置物化视图的刷新周期。
增量物化视图与可刷新物化视图的区别主要在于更新时机和更新范围,如下表所示:

增量物化视图全量物化视图
更新时机主表 insert 事件特定时间
更新范围插入的 part全表

可刷新物化视图在每次调度时间到达时,会完整地执行定义的 SQL,将结果进行覆盖。因此,它可以满足所有 SQL 和复杂查询场景,但会牺牲数据的新鲜度。

以下是需要了解的几个重要概念:
整体架构
数据源(多源) -> 查询引擎(完整SQL 和数据) -> 临时存储(缓冲区) -> 原子替换(无锁切换)

刷新的执行流程

  1. 触发调度:时间调度器触发刷新任务
  2. 执行查询:执行完整的 select 语句
  3. 临时存储:将结果写入临时表中
  4. 数据校验:验证计算结果的完整性
  5. 原子替换:将临时表原子性替换为正式表
  6. 清理资源:清理临时数据和中间结果

调度机制

  1. refresh every: 固定时间调度
  2. refresh after: 上次完成后延迟调度
  3. 手动触发

并发控制

  1. 同一视图同时只能有一个刷新任务
  2. 刷新期间不影响查询操作
  3. 查询始终访问最新的完整数据集
    标准 DDL 如下
CREATE MATERIALIZED VIEW [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
REFRESH EVERY|AFTER interval [OFFSET interval]
[RANDOMIZE FOR interval]
[DEPENDS ON [db.]name [, [db.]name [, ...]]]
[SETTINGS name = value [, name = value [, ...]]]
[APPEND]
[TO[db.]name] [(columns)] [ENGINE = engine]
[EMPTY]
[DEFINER = { user | CURRENT_USER }] [SQL SECURITY { DEFINER | NONE }]
AS SELECT ...
[COMMENT 'comment']

interval支持SECOND|MINUTE|HOUR|DAY|WEEK|MONTH|YEAR,对于非整点,可借助offset进行调整

REFRESH EVERY 1 DAY -- 每天零点 
REFRESH EVERY 1 MONTH -- 每月 1 号零点
REFRESH EVERY 1 MONTH OFFSET 5 DAY 2 HOUR -- 每月 6 号凌晨两点
REFRESH EVERY 2 WEEK OFFSET 5 DAY 15 HOUR 10 MINUTE -- 每两周的周六 15 点 10 分
REFRESH EVERY 30 MINUTE -- 半点

Tip: REFRESH AFTER 不支持 offset

同时,ClickHouse 还提供固定时间周期的随机刷新(真有人敢用吗)

REFRESH EVERY 1 DAY OFFSET 2 HOUR RANDOMIZE FOR 1 HOUR -- 每天在 1:30 - 2:30 随机选一个时间

2.2 如何管理

强制触发刷新

system refresh view [db.]name

停止/启动调度

system stop|start [replicated] view [db.]name

如果视图是多副本需要使用replicated关键字,否则只对当前副本生效
取消正在刷新的任务

system cancel view [db.]name

更新调度时间

alter table [db].name modify refresh every|after interval [offset interval]

查看调度任务

select * from system.view_refreshes; 

2.3 与相关技术对比

特性可刷新物化视图增量物化视图定时 ETL实时流处理
SQL 功能完整支持受限支持完整支持受限支持
实时性分钟级别毫秒级分钟级别毫秒级
开发复杂度
运维复杂度
资源消耗中等中等
数据一致性强一致最终一致强一致最终一致

简单理解:可刷新物化视图是 clickhouse 内置的 ETL 工具

2.4 一些思考

  1. 可刷新物化视图主要解决在复杂查询场景中对数据新鲜度要求不高的需求。
  2. 每次调度都是全量数据执行的过程,需要考虑资源与调度周期。
  3. 只要在一个调度周期内有两次用户查询,就可以认为是值得的。

三、实战演示

使用下面脚本进行库表数据初始化

clickhouse client -uroot --queries-file refreshable_materialized_init.cksql

点击下载初始化 sql
计算每个演员的得分

select id,  
any(actor_name) as name,
uniqExact(movie_id) as num_movies,
avg(rank) as avg_rank,
uniqExact(genre) as unique_genres,
uniqExact(director_name) as uniq_directors,
max(created_at) as updated_at
from (
select imdb.actors.id as id,
concat(imdb.actors.first_name, ' ', imdb.actors.last_name) as actor_name,
imdb.movies.id as movie_id,
imdb.movies.rank as rank,
genre,
concat(imdb.directors.first_name, ' ', imdb.directors.last_name) as director_name,
created_at
from imdb.actors
join imdb.roles on imdb.roles.actor_id = imdb.actors.id
left join imdb.movies on imdb.movies.id = imdb.roles.movie_id
left join imdb.genres on imdb.genres.movie_id = imdb.movies.id
left join imdb.movie_directors on imdb.movie_directors.movie_id = imdb.movies.id
left join imdb.directors on imdb.directors.id = imdb.movie_directors.director_id
)
group by id
order by num_movies desc
limit 5;


Query id: 09715e70-aed8-441c-b4fa-0db81c063a7b

┌─────id─┬─name─────────┬─num_movies─┬───────────avg_rank─┬─unique_genres─┬─uniq_directors─┬──────────updated_at─┐
1.45332 │ Mel Blanc │ 9095.7884792542982515191482025-07-07 17:10:34
2.621468 │ Bess Flowers │ 6725.540605094212635203012025-07-07 17:10:34
3.283127 │ Tom London │ 5492.8057034230202023182082025-07-07 17:10:34
4.89951 │ Edmund Cobb │ 5442.72430730046193172032025-07-07 17:10:34
5.356804 │ Bud Osborne │ 5441.9575342420755093161572025-07-07 17:10:34
└────────┴──────────────┴────────────┴────────────────────┴───────────────┴────────────────┴─────────────────────┘

5 rows in set. Elapsed: 0.594 sec. Processed 5.49 million rows, 88.27 MB (9.24 million rows/s., 148.53 MB/s.)
Peak memory usage: 1.22 GiB.

这是一个复杂的 sql,且大概需要 600 毫秒才能执行完。假设我们现在需要加速这个 sql 同时每个数据集都在变化。显然,增量物化视图没办法解决此类问题。下面使用可刷新物化视图来处理
step-1: 创建 to 表

create table imdb.actor_summary  
(
id UInt32,
name String,
num_movies UInt16,
avg_rank Float32,
unique_genres UInt16,
uniq_directors UInt16,
updated_at DateTime
)
engine = MergeTree
order by num_movies;

step-2: 创建可刷新物化视图

create materialized view imdb.actor_summary_mv  
refresh every 1 minute to imdb.actor_summary
as
select id,
any(actor_name) as name,
uniqExact(movie_id) as num_movies,
avg(rank) as avg_rank,
uniqExact(genre) as unique_genres,
uniqExact(director_name) as uniq_directors,
max(created_at) as updated_at
from (
select imdb.actors.id as id,
concat(imdb.actors.first_name, ' ', imdb.actors.last_name) as actor_name,
imdb.movies.id as movie_id,
imdb.movies.rank as rank,
genre,
concat(imdb.directors.first_name, ' ', imdb.directors.last_name) as director_name,
created_at
from imdb.actors
join imdb.roles on imdb.roles.actor_id = imdb.actors.id
left join imdb.movies on imdb.movies.id = imdb.roles.movie_id
left join imdb.genres on imdb.genres.movie_id = imdb.movies.id
left join imdb.movie_directors on imdb.movie_directors.movie_id = imdb.movies.id
left join imdb.directors on imdb.directors.id = imdb.movie_directors.director_id
)
group by id
order by num_movies desc
limit 5;

step-3: 模拟数据更新

insert into imdb.actors values (845466, 'Clicky', 'McClickHouse', 'M');  

insert into imdb.roles
select 845466 as actor_id, id as movie_id, 'Himself' as role, now() as created_at
from imdb.movies
limit 10000, 910;

step-4: 最多等待一分钟,就可以看到最新的数据变化


select * from imdb.actor_summary order by num_movies desc limit 5;

Query id: b4b4b34f-fb81-4b0e-8705-b5ba91ac2207

┌─────id─┬─name────────────────┬─num_movies─┬──avg_rank─┬─unique_genres─┬─uniq_directors─┬──────────updated_at─┐
1.845466 │ Clicky McClickHouse │ 9101.4687939216622025-07-07 17:32:15
2.45332 │ Mel Blanc │ 9095.7884793191482025-07-07 17:10:34
3.621468 │ Bess Flowers │ 6725.540605203012025-07-07 17:10:34
4.283127 │ Tom London │ 5492.8057034182082025-07-07 17:10:34
5.89951 │ Edmund Cobb │ 5442.7243073172032025-07-07 17:10:34
└────────┴─────────────────────┴────────────┴───────────┴───────────────┴────────────────┴─────────────────────┘

5 rows in set. Elapsed: 0.007 sec.

就是这么简单