ClickHouse虚拟列

文章目录

  • [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 中是一个需要谨慎对待的反模式。


参考资料

相关推荐
海南java第二人1 小时前
ClickHouse 备份与恢复完全指南:从物理拷贝到内置备份的实战选择
clickhouse·备份与恢复
海南java第二人7 小时前
ClickHouse Sharding 分片与 Partitioning 分区:区别、联系与生产实践
clickhouse·分区·分片
狼与自由2 天前
mysql到clickhouse
数据库·mysql·clickhouse
云天AI实战派2 天前
跨境出海全流程实战:用 Medusa + Hyperswitch + ClickHouse 搭建落地页、支付订阅、客服工单与多语言 SEO 闭环
大数据·人工智能·clickhouse·独立开发·跨境出海·medusa
海南java第二人3 天前
ClickHouse 实际应用类面试通关:项目案例、生产踩坑与实战经验
clickhouse·面试·实际应用类
meijinmeng4 天前
ClickHouse Kubernetes集群部署与维护文档
clickhouse
努力攻坚操作系统4 天前
ClickHouse详细教程
大数据·数据库·clickhouse
大帅点兵4 天前
设计一个金融交易监控系统
大数据·clickhouse·flink·spark·kafka·hbase
dinl_vin5 天前
FastAPI 系列 ·(十一):ClickHouse 集成——大数据查询实战
大数据·clickhouse·fastapi