文章目录
- [ClickHouse 虚拟列:为什么要谨慎使用,以及正确的替代方案](#ClickHouse 虚拟列:为什么要谨慎使用,以及正确的替代方案)
-
- 一、什么是"虚拟列"
- [二、为什么虚拟列在 ClickHouse 中代价很大](#二、为什么虚拟列在 ClickHouse 中代价很大)
-
- [2.1 索引完全失效](#2.1 索引完全失效)
- [2.2 向量化执行的额外 CPU 开销](#2.2 向量化执行的额外 CPU 开销)
- [2.3 破坏压缩优势和 Cache 效率](#2.3 破坏压缩优势和 Cache 效率)
- [2.4 GROUP BY 虚拟列的代价尤其大](#2.4 GROUP BY 虚拟列的代价尤其大)
- 三、不同位置的虚拟列,影响程度不同
- 四、正确的替代方案
-
- [4.1 WHERE 条件:改写为原始列的范围表达式](#4.1 WHERE 条件:改写为原始列的范围表达式)
- [4.2 GROUP BY:用物化视图预聚合](#4.2 GROUP BY:用物化视图预聚合)
- [4.3 用物化列(MATERIALIZED Column)替代虚拟列](#4.3 用物化列(MATERIALIZED Column)替代虚拟列)
- [4.4 用 DEFAULT 列实现可覆盖的预计算](#4.4 用 DEFAULT 列实现可覆盖的预计算)
- [4.5 建表时冗余字段(空间换时间)](#4.5 建表时冗余字段(空间换时间))
- 五、什么时候虚拟列是可以接受的
-
- [5.1 只在 SELECT 输出中使用](#5.1 只在 SELECT 输出中使用)
- [5.2 低频的临时分析查询](#5.2 低频的临时分析查询)
- [5.3 数据量已经被大幅缩减后](#5.3 数据量已经被大幅缩减后)
- 六、决策框架
- [七、与 MySQL 的思维差异总结](#七、与 MySQL 的思维差异总结)
- 参考资料
ClickHouse 虚拟列:为什么要谨慎使用,以及正确的替代方案
一、什么是"虚拟列"
这里说的"虚拟列"不是 ClickHouse 官方定义的 Virtual Columns(如 _part、_partition_id 等系统隐藏列),而是指查询时通过表达式临时计算出来的列------在 SELECT、WHERE、GROUP BY 中对物理列做函数运算产生的派生结果。
sql
-- 这些都是"虚拟列"
SELECT toStartOfHour(event_time) AS hour, ... -- SELECT 中的虚拟列
WHERE toYear(event_date) = 2024 -- WHERE 中的虚拟列
GROUP BY cityHash64(user_id) % 10 -- GROUP BY 中的虚拟列
ORDER BY length(url) -- ORDER BY 中的虚拟列
在 MySQL 中,优化器足够智能,能对部分函数调用做反推(如 YEAR(date_col) = 2024 可以转化为范围条件)。但 ClickHouse 的优化器在这方面相对保守,虚拟列带来的性能损失更加显著。
二、为什么虚拟列在 ClickHouse 中代价很大
ClickHouse 的高性能建立在"数据物理结构与查询模式高度对齐"的前提上。虚拟列破坏了这个对齐关系,具体体现在四个层面。
2.1 索引完全失效
ClickHouse 的稀疏索引和跳数索引都是基于物理列的原始值构建的。对物理列做任何函数变换后,索引无法识别变换后的值与原始索引项的对应关系。
sql
-- 索引失效的典型场景
-- 场景1:对分区键做函数运算
-- event_date 是分区键,按月分区
SELECT count() FROM events
WHERE toYear(event_date) = 2024 AND toMonth(event_date) = 3;
-- ClickHouse 无法从 toYear/toMonth 反推出 event_date 的范围
-- 结果:所有分区都要扫描,分区裁剪失效
-- 场景2:对排序键做函数运算
-- ORDER BY (city, user_id)
SELECT count() FROM events
WHERE lower(city) = 'beijing';
-- lower(city) 不等于 city,稀疏索引无法定位
-- 结果:全表扫描
-- 场景3:对跳数索引列做函数运算
-- INDEX idx_amount amount TYPE minmax GRANULARITY 4
SELECT count() FROM events
WHERE amount * 1.1 > 100;
-- minmax 索引记录的是 amount 的原始 min/max,不是 amount*1.1 的
-- 结果:跳数索引无法排除任何 granule
对比 MySQL :MySQL 8.0+ 支持函数索引(Functional Index),可以对 lower(city) 这类表达式建索引。ClickHouse 没有这个能力,你能做的只有在建表时保证数据已经是你需要的形态。
2.2 向量化执行的额外 CPU 开销
ClickHouse 的执行引擎以 Block 为单位(默认 65536 行)批量处理数据。物理列是直接从磁盘读进内存就能参与计算的,虚拟列则需要对每个 Block 额外执行一次函数运算。
物理列的数据路径:
磁盘 → 解压 → Block(直接可用)
虚拟列的数据路径:
磁盘 → 解压 → Block → 函数计算 → 新Block(额外一步)
看似只多了"一步",但在大数据量下累积效应显著:
假设扫描 10 亿行数据:
Block 数量 = 10亿 / 65536 ≈ 15000 个 Block
每个 Block 执行一次 toStartOfHour() ≈ 0.1ms
总额外开销 ≈ 15000 × 0.1ms = 1.5 秒
如果是更复杂的表达式(如正则匹配、字符串拼接):
每个 Block 可能耗时 1-5ms
总额外开销 ≈ 15-75 秒
这个开销在简单聚合查询(原本只需 1-2 秒)中是不可忽视的。
2.3 破坏压缩优势和 Cache 效率
压缩层面:物理列的值是经过排序的(受 ORDER BY 影响),相邻值相近或重复,压缩率极高。虚拟列的计算结果不会被存储,更不会被压缩------每次查询都是从原始列重新算。
缓存层面:ClickHouse 有 Mark Cache 和 Uncompressed Cache,物理列的数据可以被缓存复用。虚拟列的计算结果是临时的,不进入任何缓存,下次相同查询还得重新计算。
OS Page Cache:物理列的文件会被操作系统缓存在内存中,热数据几乎不需要磁盘 IO。虚拟列本身不是文件,没有这个优势。
2.4 GROUP BY 虚拟列的代价尤其大
GROUP BY 的核心操作是构建 HashTable。当 GROUP BY 的 key 是虚拟列时:
每一行数据都要:
1. 读取原始列值
2. 执行函数计算得到虚拟列值
3. 对虚拟列值做哈希
4. 在 HashTable 中查找/插入
相比物理列 GROUP BY:
1. 读取原始列值(已排序,可能连续相同)
2. 对原始值做哈希
3. 在 HashTable 中查找/插入
多出的"函数计算"步骤在 10 亿行级别下会累积成数秒的额外开销。更关键的是,物理列如果是低基数且连续排列(比如 city 在 ORDER BY 前面),ClickHouse 可以用优化的聚合路径(连续相同 key 直接累加,不需要哈希)。虚拟列无法触发这个优化。
三、不同位置的虚拟列,影响程度不同
| 位置 | 影响程度 | 原因 |
|---|---|---|
| WHERE 中 | 最严重 | 索引完全失效,可能导致全表扫描 |
| GROUP BY 中 | 严重 | 每行都要额外计算,且破坏聚合优化路径 |
| ORDER BY 中 | 中等 | 排序前每行都要计算,数据量大时开销明显 |
| SELECT 输出中 | 较小 | 只对最终结果集计算,结果集通常远小于扫描量 |
| HAVING 中 | 较小 | 作用于聚合后的结果,数据量已大幅减少 |
四、正确的替代方案
4.1 WHERE 条件:改写为原始列的范围表达式
sql
-- ❌ 虚拟列:索引失效
WHERE toYear(event_date) = 2024
-- ✅ 原始列范围:分区裁剪 + 稀疏索引都能生效
WHERE event_date >= '2024-01-01' AND event_date < '2025-01-01'
sql
-- ❌ 虚拟列
WHERE toStartOfHour(event_time) = '2024-03-15 14:00:00'
-- ✅ 范围表达式
WHERE event_time >= '2024-03-15 14:00:00'
AND event_time < '2024-03-15 15:00:00'
sql
-- ❌ 虚拟列
WHERE lower(city) = 'beijing'
-- ✅ 建表时就存小写,或者用物化列
-- 方案1:写入时处理好
-- 方案2:使用物化列(见 4.3)
4.2 GROUP BY:用物化视图预聚合
如果某个虚拟列的 GROUP BY 是高频查询,应该用物化视图把计算前置到写入阶段:
sql
-- ❌ 每次查询都现算
SELECT toStartOfHour(event_time) AS hour, count(), sum(amount)
FROM events
WHERE event_date = '2024-03-15'
GROUP BY hour;
-- ✅ 物化视图预聚合
CREATE TABLE events_hourly (
hour DateTime,
cnt UInt64,
total Float64
) ENGINE = SummingMergeTree()
ORDER BY hour;
CREATE MATERIALIZED VIEW events_hourly_mv
TO events_hourly
AS SELECT
toStartOfHour(event_time) AS hour,
count() AS cnt,
sum(amount) AS total
FROM events
GROUP BY hour;
-- 查询时直接读预聚合结果
SELECT hour, sum(cnt), sum(total)
FROM events_hourly
WHERE hour >= '2024-03-15 00:00:00' AND hour < '2024-03-16 00:00:00'
GROUP BY hour;
4.3 用物化列(MATERIALIZED Column)替代虚拟列
ClickHouse 支持在建表时定义物化列,写入时自动计算并物理存储:
sql
CREATE TABLE events (
event_time DateTime,
user_id UInt64,
city String,
amount Float64,
-- 物化列:写入时自动计算,物理存储,可以建索引
event_hour DateTime MATERIALIZED toStartOfHour(event_time),
city_lower String MATERIALIZED lower(city),
event_month UInt32 MATERIALIZED toYYYYMM(event_time)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (city_lower, user_id, event_time);
物化列的特点:
- 写入时自动计算,结果物理存储在磁盘上
- 享受列存的所有优势(压缩、缓存、索引)
- 可以作为 ORDER BY 的一部分(如上面的
city_lower) - INSERT 时不能手动指定值,完全由表达式决定
- SELECT * 不会返回物化列,需要显式指定列名
sql
-- 使用物化列查询:索引生效,无需实时计算
SELECT event_hour, count() FROM events
WHERE city_lower = 'beijing'
GROUP BY event_hour;
4.4 用 DEFAULT 列实现可覆盖的预计算
如果希望大多数时候自动计算,但保留手动指定的能力:
sql
CREATE TABLE events (
event_time DateTime,
event_hour DateTime DEFAULT toStartOfHour(event_time)
) ENGINE = MergeTree()
ORDER BY event_hour;
DEFAULT 列和 MATERIALIZED 列的区别:
- DEFAULT:INSERT 时可以手动指定值,不指定时用表达式计算;SELECT * 会返回
- MATERIALIZED:INSERT 时不能手动指定,永远由表达式计算;SELECT * 不返回
4.5 建表时冗余字段(空间换时间)
对于高频使用的计算结果,最直接的方案就是在写入阶段计算好,作为普通列存储:
sql
CREATE TABLE events (
event_time DateTime,
event_date Date, -- 冗余:从 event_time 提取
event_hour UInt8, -- 冗余:从 event_time 提取小时
user_id UInt64,
city LowCardinality(String),
city_lower LowCardinality(String), -- 冗余:city 的小写形式
amount Float64,
amount_level UInt8 -- 冗余:预计算的金额档位
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, city_lower, user_id);
写入时在应用层(Java/Flink)计算好这些冗余字段。列存的压缩率很高,冗余字段的额外存储成本通常很小,但查询性能的提升可能是 10-100 倍。
五、什么时候虚拟列是可以接受的
虚拟列并非绝对不能用,以下场景的开销通常可接受:
5.1 只在 SELECT 输出中使用
sql
-- 可以接受:虚拟列只用于展示,不参与过滤和聚合
-- 最终结果集通常只有几十到几千行,计算开销忽略不计
SELECT
event_time,
formatDateTime(event_time, '%Y-%m-%d %H:%M') AS display_time,
concat(city, '-', toString(user_id)) AS user_label
FROM events
WHERE event_date = '2024-03-15' AND city = 'BJ'
LIMIT 100;
5.2 低频的临时分析查询
sql
-- 可以接受:临时分析,不是高频查询,不要求秒级响应
SELECT
toStartOfWeek(event_date) AS week,
uniq(user_id) AS weekly_uv
FROM events
WHERE event_date >= '2024-01-01'
GROUP BY week
ORDER BY week;
5.3 数据量已经被大幅缩减后
sql
-- 可以接受:WHERE 已经通过索引把数据缩减到很小范围
-- 在小数据集上做函数计算开销不大
SELECT toHour(event_time) AS hour, count()
FROM events
WHERE event_date = '2024-03-15' -- 分区裁剪:只扫描一天的数据
AND city = 'BJ' -- 稀疏索引:进一步缩小范围
GROUP BY hour;
六、决策框架
遇到"是否使用虚拟列"的选择时,按以下顺序判断:
1. 这个表达式出现在 WHERE 条件中吗?
→ 是:必须改写为原始列的范围表达式,否则索引失效
→ 否:继续判断
2. 这个表达式出现在 GROUP BY 中,且是高频查询?
→ 是:用物化视图预聚合,或建表时用物化列/冗余列
→ 否:继续判断
3. 扫描的数据量大吗?(超过 1 亿行)
→ 是:考虑物化列或冗余字段,避免大量行的函数计算
→ 否:虚拟列可以接受
4. 只在 SELECT 输出中使用?
→ 是:通常可以接受,结果集很小
七、与 MySQL 的思维差异总结
| 维度 | MySQL 的习惯 | ClickHouse 的正确做法 |
|---|---|---|
| WHERE 中用函数 | 优化器可能能处理(函数索引、条件改写) | 必须手动改写为范围条件 |
| 查询时临时计算 | 数据量小,计算开销不明显 | 数据量大,每行的计算开销累积显著 |
| 冗余存储 | 尽量避免(范式设计) | 主动冗余(列存压缩率高,空间成本低) |
| 设计哲学 | 灵活适应各种查询模式 | 查询模式和数据结构必须高度对齐 |
ClickHouse 的核心设计哲学是把计算前置到写入阶段:建表时就把数据组织成查询需要的形态,而不是查询时再临时计算。这和 MySQL "先存再算"的习惯有根本区别。理解这一点,就理解了为什么虚拟列在 ClickHouse 中是一个需要谨慎对待的反模式。