4、ClickHouse深度解析:列式存储、MergeTree引擎家族、分布式表、物化视图、性能调优
ClickHouse 这章,我打算换个讲法。不罗列文档,而是把我这些年跟它“死磕”的经验揉碎了说。你把它当做一个工具箱,但每个工具怎么用、什么时候用、用不好会出什么幺蛾子,这才是关键。
核心认知:ClickHouse 不是万能的。它强在单表聚合查询,弱在多表 Join 和点查。选它之前,先想清楚你的业务场景是不是“宽表 + 大吞吐 + 高并发读”。
4.1 列式存储:为什么这么快?
说白了,列式存储就是“按列存数据”。传统行存是一行一行写,列存是一列一列写。你想想看,做 OLAP 查询时,我们通常只关心少数几个字段,比如求某天的销售额。行存得把整行数据都读出来,再扔掉不需要的列;列存直接读那几列的数据块就行。
我遇到过最夸张的例子:一个 200 列的表,行存查询要扫 5 秒,换成列存后只扫 0.1 秒。为什么?因为列存天然支持数据压缩,同一列的数据类型相同,压缩比能做到 5:1 甚至 10:1。数据量小了,IO 自然就快。
ClickHouse 的列存还有几个杀手锏:
- 数据块扫描:按列读取时,一次 IO 读取一整块(默认 8192 行),减少随机读。
- 向量化执行:CPU 一次处理一批数据,而不是一行一行处理。这玩意儿对现代 CPU 的 SIMD 指令集特别友好。
- 稀疏索引:每 8192 行记录一个主键值,查询时先定位到数据块,再在块内扫描。这比 B+ 树索引轻量得多。
我的习惯:建表时尽量把过滤条件频繁的字段放在排序键(ORDER BY)的前面。比如按天查询,就把日期放第一个。这样稀疏索引能快速跳过无关数据块。
4.2 MergeTree 引擎家族:选对引擎,事半功倍
MergeTree 是 ClickHouse 的“心脏”。它不是一个引擎,而是一族引擎。我刚开始用的时候也懵,后来总结了一个口诀:“查用 MergeTree,写用 Buffer,去重用 Replacing,聚合用 Summing,时序用 Collapsing”。
下面这张图是我自己画的,帮你理清 MergeTree 家族的关系:
4.2.1 ReplacingMergeTree:去重利器
我做过一个用户行为分析系统,数据源会重复发送相同的事件。用 ReplacingMergeTree 后,后台合并时会自动保留每个排序键的最后一条记录。注意,它只在合并时去重,不是实时去重。如果你需要查询时看到去重结果,得用 FINAL 修饰符。
CREATE TABLE events (
event_id UInt64,
user_id UInt32,
event_time DateTime,
event_type String
) ENGINE = ReplacingMergeTree(event_time)
ORDER BY (user_id, event_id);
我曾经踩过的坑:ReplacingMergeTree 的版本字段(第二个参数)如果不指定,默认用插入顺序的最后一条。如果你需要按业务时间取最新,一定要显式指定时间字段。
4.2.2 SummingMergeTree:预聚合神器
做报表时,经常需要按维度分组求和。SummingMergeTree 会在合并时自动把相同排序键的数值列加起来。我有个日活报表,原来查询要 3 秒,换成 SummingMergeTree 后降到 0.1 秒。
CREATE TABLE daily_stats (
date Date,
app_id UInt32,
dau UInt32,
revenue Float64
) ENGINE = SummingMergeTree()
ORDER BY (date, app_id);
4.2.3 CollapsingMergeTree:实时修正
这个引擎有意思。它用 Sign 字段(1 或 -1)来标记数据是“新增”还是“取消”。合并时,相同排序键的正负记录会互相抵消。我在订单实时统计中用过,可以处理退款、取消等场景。
CREATE TABLE orders (
order_id UInt64,
amount Float64,
sign Int8
) ENGINE = CollapsingMergeTree(sign)
ORDER BY order_id;
4.3 分布式表:水平扩展的艺术
单机 ClickHouse 再强,也有瓶颈。分布式表(Distributed)是 ClickHouse 的“分身术”。它本身不存数据,只是把查询路由到多个分片(Shard)上。
我习惯这样设计:
- 分片策略:按用户 ID 或时间范围哈希。避免热点。
- 副本策略:每个分片至少 2 个副本,用 ReplicatedMergeTree 保证数据一致性。
- 本地表 + 分布式表:先建本地表(Local),再建分布式表(All)。查询时只查分布式表。
-- 本地表(每个节点都要建)
CREATE TABLE events_local ON CLUSTER 'cluster_3shards_2replicas' (
event_id UInt64,
user_id UInt32,
event_time DateTime
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
ORDER BY (toDate(event_time), user_id);
-- 分布式表(只建一次)
CREATE TABLE events_all AS events_local
ENGINE = Distributed('cluster_3shards_2replicas', 'default', 'events_local', rand());
我的建议:分布式表的 rand() 分片键适合随机写入。如果按用户 ID 分片,用 cityHash64(user_id) 更均匀。
4.4 物化视图:查询加速的“缓存”
物化视图不是传统意义上的视图,它是一张“自动维护的预计算表”。你定义好查询逻辑,ClickHouse 会在数据写入时同步更新结果。我把它叫做“查询加速器”。
举个例子:你需要按小时统计每个页面的 PV。如果每次都扫原始表,数据量大了根本扛不住。建个物化视图,数据写入时自动聚合,查询时直接读结果。
-- 原始表
CREATE TABLE page_views (
page_id UInt32,
view_time DateTime
) ENGINE = MergeTree()
ORDER BY (toDate(view_time), page_id);
-- 物化视图
CREATE MATERIALIZED VIEW page_hourly_pv
ENGINE = SummingMergeTree()
ORDER BY (hour, page_id)
AS SELECT
toStartOfHour(view_time) AS hour,
page_id,
count() AS pv
FROM page_views
GROUP BY hour, page_id;
注意:物化视图的写入是异步的,可能会有秒级延迟。对实时性要求极高的场景,需要评估这个延迟是否可接受。
4.5 性能调优:从“能用”到“好用”
调优是个系统工程。我把它分成三个层面:
| 层面 | 关键参数 | 我的经验值 |
|---|---|---|
| 内存 | max_memory_usage | 单查询不超过物理内存的 60% |
| CPU | max_threads | CPU 核数的一半,避免上下文切换 |
| IO | merge_tree_min_rows_for_concurrent_read | 设为 500000,减少小文件读取 |
| 网络 | max_distributed_connections | 分片数的 2 倍,避免连接风暴 |
几个实战技巧:
- 分区裁剪:查询时尽量带上分区字段,比如
WHERE date = '2024-01-01'。这能跳过大量无关分区。 - 避免大 Join:ClickHouse 的 Join 是内存操作,大表 Join 容易 OOM。我一般用字典(Dictionary)或物化视图替代。
- 监控 Merge 速度:如果后台合并跟不上写入速度,会出现“Too many parts”错误。这时需要调大
merge_tree_max_parts_in_total或降低写入频率。
一句话总结:ClickHouse 的性能调优,本质是“让数据尽量少移动,让计算尽量靠近数据”。列存减少 IO,MergeTree 减少计算量,分布式表分散压力,物化视图预计算结果。把这四件事做好,你的 ClickHouse 就能跑得又快又稳。