背景
SELECT 查询。
与 Postgres 这类事务型数据库不同,ClickHouse 的 materialized view 本质上只是一个触发器:当数据块写入某个表时,它会对这些块执行查询。该查询的结果会写入第二个“目标”表。如果后续再写入更多行,结果会再次发送到目标表,并在那里对中间结果进行更新和合并。这个合并后的结果,就等同于在全部原始数据上运行该查询得到的结果。
使用 Materialized Views 的主要动机在于,写入目标表的结果代表了对行进行聚合、过滤或转换后的结果。这些结果通常是原始数据的更小表示形式 (在聚合场景下,则可能是部分 sketch) 。再加上从目标表读取结果所需的查询通常较为简单,因此相比直接在原始数据上执行相同计算,查询会更快,从而将计算开销 (以及查询延迟) 从查询时转移到写入时。
在 ClickHouse 中,materialized view 会随着数据流入其所基于的表而实时更新,作用更像是持续更新的索引。这与其他数据库不同,在那些数据库中,Materialized Views 通常是查询结果的静态快照,必须刷新 (类似于 ClickHouse 的 可刷新materialized view) 。
示例
toStartOfDay 函数,在 ClickHouse 中实现这一查询相当简单:
votes 中的数据执行上述 SELECT 查询,并将结果写入 up_down_votes_per_day:
TO 子句至关重要,用于指定结果的写入目标,即 up_down_votes_per_day。
我们可以用之前的 insert 操作重新填充 Votes 表:
up_down_votes_per_day 的行数——应该是每天 1 行:
votes 中的 2.38 亿减少到了 5000。不过,关键在于,如果有新的投票数据被插入 votes 表,相应日期的新值就会被发送到 up_down_votes_per_day,并在后台自动异步合并——这样每天只会保留一行。因此,up_down_votes_per_day 将始终保持小巧且最新。
由于行合并是异步进行的,因此用户发起查询时,每天可能会存在多于一条投票记录。为了确保所有尚未完成合并的行都能在查询时完成合并,我们有两个选择:
- 在表名上使用
FINALmodifier。我们在上面的计数查询中就是这样做的。 - 按最终表中使用的排序键进行聚合,即
CreationDate,并对指标求和。通常这种方式更高效,也更灵活 (该表还可用于其他用途) ,不过前者对某些查询来说可能更简单。下面我们会同时展示这两种方式:
更复杂的示例
Score 的第 99.9 百分位数,以及 CommentCount 的平均值。用于计算这些统计信息的查询可能如下所示:
posts 表时执行上述查询。
为了便于演示,并避免从 S3 加载 posts 数据,我们将创建一个与 posts 具有相同 schema 的副本表 posts_null。不过,这个表不会存储任何数据,只会在插入行时供 materialized view 使用。为了防止存储数据,我们可以使用 Null 表引擎类型。
/dev/null。当 posts_null 表在写入时接收到行数据时,materialized view 会计算并存储汇总统计信息——它本质上只是一个触发器。不过,原始数据不会被存储。虽然在我们的场景中,可能仍然希望保留原始 posts,但这种方法可用于计算聚合结果,同时避免原始数据带来的存储开销。
因此,materialized view 变为:
State。这样返回的就是函数的聚合状态,而不是最终结果。它会包含额外信息,以便该部分状态能与其他状态合并。例如,对于平均值,它会包含该列的计数和总和。
部分聚合状态是计算正确结果所必需的。例如,计算平均值时,如果只是对各子范围的平均值再取平均,结果会不正确。现在,我们为此视图
post_stats_per_day 创建用于存储这些部分聚合状态的目标表:
SummingMergeTree 存储计数已经足够,但对于其他函数,我们需要一种更高级的引擎类型:AggregatingMergeTree。
为确保 ClickHouse 知道这里存储的是聚合状态,我们将 Score_quantiles 和 AvgCommentCount 定义为 AggregateFunction 类型,并指定部分状态对应的源函数及其源列的类型。与 SummingMergeTree 类似,具有相同 ORDER BY 键值的行会被合并 (在上面的示例中为 Day) 。
要通过 materialized view 填充 post_stats_per_day,只需将 posts 中的所有行插入 posts_null:
在生产环境中,你很可能会将 materialized view 挂载到最终查询需要在函数名后使用posts表。这里我们使用posts_null来演示 null 表。
Merge 后缀 (因为这些列存储的是部分聚合状态) :
GROUP BY,而不是 FINAL。
其他应用
过滤与转换
posts_null 表,再通过 SELECT 查询在写入 posts 表之前对行进行过滤。比如,假设我们想转换 posts 表中的 Tags 列。该列包含一个由竖线分隔的标签名称列表。将其转换为数组后,我们就能更方便地按单个标签值进行聚合。
我们也可以在执行下面展示了用于此转换的 materialized view:INSERT INTO SELECT时完成这种转换。materialized view 允许我们将这部分逻辑封装在 ClickHouse DDL 中,并让INSERT保持简洁,同时将转换自动应用到所有新行。
查找表
comments 表:
PostId 过滤的查询对该表进行了优化。
假设用户想按特定的 UserId 进行过滤,并计算其平均 Score:
UserId 查找对应的排序键值 PostId。随后即可利用这些值进行高效查找。
在这个示例中,我们的 materialized view 可以非常简单:只需在 insert 时从 comments 中选取 PostId 和 UserId。这些结果随后会写入按 UserId 排序的表 comments_posts_users。下面我们会创建一个使用 Null 引擎的 Comments 表版本,并用它来填充我们的视图和 comments_posts_users 表:
链式 / 级联 materialized view
materialized views 与 JOIN
可刷新materialized view以下内容仅适用于增量materialized view。可刷新materialized view 会定期在完整目标数据集上执行其查询,并且完全支持 JOIN。如果可以接受结果新鲜度有所下降,复杂 JOIN 场景可考虑使用它们。
JOIN 操作,但有一个关键限制:materialized view 只会在 source 表 (即查询中最左侧的表) 发生 insert 时触发。 JOIN 右侧的表即使数据发生变化,也不会触发更新。这种行为在构建增量materialized view 时尤其重要,因为数据是在写入时完成聚合或转换的。
当增量materialized view 使用 JOIN 定义时,SELECT 查询中最左侧的表就是 source。当新行被 insert 到这张表时,ClickHouse 只会用这些新插入的行来执行 materialized view 查询。JOIN 右侧的表会在执行过程中被完整读取,但仅这些表自身发生变化并不会触发该视图。
这种行为使得 Materialized Views 中的 JOIN 更像是针对静态维度数据执行的快照 join。
这非常适合使用参考表或维度表来富化数据。不过,右侧表 (例如用户元数据) 的任何更新,都不会回溯更新 materialized view。若要看到更新后的数据,source 表中必须有新的 insert 发生。
示例
users 表的用户显示名称。
提醒一下,我们的表 schema 如下:
users 表中已预先填入数据:
分组与排序对齐materialized view 中的
GROUP BY 子句必须包含 DisplayName、UserId 和 Day,以与 SummingMergeTree 目标表中的 ORDER BY 保持一致。这可确保各行被正确聚合并合并。省略其中任何一项都可能导致结果错误或合并效率低下。daily_badges_by_user 表。
materialized view 中 JOIN 的最佳实践
-
将最左侧的表作为触发器。 只有
SELECT语句左侧的表会触发 materialized view。右侧表中的变更不会触发更新。 - 预先写入 JOIN 所需的数据。 确保在向源表插入行之前,关联表中的数据已存在。JOIN 会在写入时求值,因此如果数据缺失,就会出现未匹配的行或 NULL 值。
- 限制从 JOIN 中取回的列。 只选择关联表中必需的列,以尽量减少内存占用并降低写入时延迟 (见下文) 。
- 评估写入时性能。 JOIN 会增加插入成本,尤其是在右侧表较大时。请使用具有代表性的生产数据对插入速率进行基准测试。
- 简单查找优先使用字典。对于键值查找 (例如将用户 ID 映射为名称) ,请使用 字典,以避免开销较高的 JOIN 操作。
-
对齐
GROUP BY和ORDER BY以提高合并效率。 使用SummingMergeTree或AggregatingMergeTree时,请确保GROUP BY与目标表中的ORDER BY子句一致,以便高效合并行。 - 使用明确的列别名。 当多个表中存在同名列时,请使用别名以避免歧义,并确保目标表中的结果正确。
- 考虑写入量和频率。 JOIN 在中等写入负载下通常表现良好。对于高吞吐量摄取,请考虑使用暂存表、预 JOIN,或字典以及 Refreshable Materialized Views 等其他方式。
在过滤器和 JOIN 中使用源表
示例场景
说明
mvw1 和 mvw2。它们执行的操作相似,但在引用源表 t0 的方式上略有不同。
在 mvw1 中,表 t0 在 JOIN 右侧的 (SELECT * FROM t0) 子查询中被直接引用。当数据插入 t0 时,materialized view 的查询会执行,此时 t0 会被替换为新插入的数据块。这意味着,JOIN 操作只会针对新插入的行执行,而不是针对整个表。
第二种情况是连接 vt0,此时该视图会读取 t0 中的全部数据。这样可以确保 JOIN 操作考虑的是 t0 中的所有行,而不只是新插入的数据块。
关键区别在于 ClickHouse 在 materialized view 查询中处理源表的方式。当 materialized view 由插入操作触发时,源表 (这里是 t0) 会被替换为插入的数据块。这种行为可用于优化查询,但也需要谨慎处理,以避免出现意外结果。
使用场景与注意事项
IN (SELECT id FROM t0) 构建出的 Set 仅包含新插入的行,这有助于用它来过滤 t1。
以 Stack Overflow 为例
users 表中的用户显示名称。
badges 表的写入延迟产生显著影响,例如:
users 表添加一个过滤器:
2936484 对应的一行。借助表的排序键 Id,这次查找也得到了优化。
materialized view 与 UNION
UNION ALL 查询通常用于将多个源表中的数据合并为一个结果集。
虽然增量materialized view 不直接支持 UNION ALL,但你可以为每个 SELECT 分支分别创建一个 materialized view,并将结果写入同一个目标表,以达到相同的效果。
在本示例中,我们将使用 Stack Overflow 数据集。请看下面的 badges 和 comments 表,它们分别表示用户获得的徽章,以及他们在帖子下发表的评论:
INSERT INTO 命令填充这些内容:
badges 或 comments 插入新行时此表也能随之更新,一个比较直观的做法可能是尝试使用前面的 union 查询创建一个 materialized view:
comments 表插入数据时触发。例如:
badges 表插入数据不会触发该视图,导致 user_activity 无法收到更新:
comments 表插入数据:
badges 表中的数据也会反映到 user_activity 表中:
并行处理与串行处理
parallel_view_processing。
默认情况下,此设置的值为 0 (false) ,这意味着 Materialized Views 会按照 uuid 顺序依次执行。
例如,假设有如下 source 表和 3 个 Materialized Views,它们都会将数据行写入 target 表:
target 表之前,都会先暂停 1 秒,同时写入其名称和插入时间。
向 source 表插入一行大约需要 3 秒,因为每个视图都会依次执行:
SELECT 确认每一行的数据都已到达:
uuid 相对应:
parallel_view_processing=1 的情况下插入一行时会发生什么。启用后,这些视图会并行执行,因此无法保证各行到达目标表的顺序:
何时使用并行处理
parallel_view_processing=1 可以显著提升插入吞吐量,如上所示,尤其是在单个表上关联了多个 Materialized Views 时。不过,理解其中的权衡也很重要:
- 插入压力增加:所有 Materialized Views 都会同时执行,从而增加 CPU 和内存占用。如果每个视图都涉及大量计算或 JOIN,系统可能会过载。
- 需要严格的执行顺序:在少数工作流中,如果视图的执行顺序很重要 (例如存在链式依赖) ,并行执行可能导致状态不一致或出现竞态条件。虽然可以通过设计规避这类问题,但这类配置通常比较脆弱,而且在未来版本中可能失效。
历史默认值与稳定性由于错误处理较为复杂,顺序执行在很长一段时间里一直是默认方式。历史上,一个 materialized view 执行失败可能会导致其他视图无法执行。较新版本通过按块隔离故障改进了这一点,但顺序执行仍然能提供更清晰的失败语义。
parallel_view_processing=1:
- 你有多个彼此独立的 Materialized Views
- 你希望将插入性能最大化
- 你清楚系统具备处理并发视图执行的能力
- Materialized Views 之间存在依赖关系
- 你需要可预测且有序的执行
- 你正在调试或审计插入行为,并希望进行确定性的重放
materialized views 与公共表表达式 (CTE)
公共表表达式 不会被物化ClickHouse 不会物化 CTE;相反,它会将 CTE 的定义直接替换到查询中,这可能导致同一 expression 被多次求值 (如果该 CTE 被使用了不止一次) 。
- 如果你的 CTE 引用了一个不同于源表 (即 materialized view 所附加的那张表) 的表,并且在
JOIN或IN子句中使用,它的行为更像子查询或 join,而不是触发器。 - materialized view 仍然只会在主源表发生 insert 时被触发,但 CTE 会在每次 insert 时重新执行,这可能会带来不必要的开销,尤其是在所引用的表很大时。