ClickHouse 深度拆解:当列式存储把 PB 级实时分析从「预聚合陷阱」拉回毫秒响应——从 MergeTree 部件模型、稀疏主键索引到物化视图链与分布式查询的全链路实战
关键词:ClickHouse、OLAP、列式存储、MergeTree、稀疏主键索引、物化视图、分布式查询、跳数索引、向量化执行、实时分析
适合读者:被 MySQL 慢查询折磨过的后端、想给日志/埋点/指标系统找个归宿的 SRE、以及所有听到「亿级数据聚合要等 30 秒」就头疼的工程师。
一、背景介绍:我们为什么走到了预聚合的死胡同
先讲一个几乎每个中大型系统都踩过的坑。
你的产品跑起来了,用户行为开始产生数据:点击、曝光、下单、支付。最初你把这些事件一股脑写进 MySQL 的一张 user_events 表里,配几个索引,跑 SELECT COUNT(*) ... GROUP BY day。日活几千的时候,一切岁月静好。
三个月后,日活过百万,单表到了 20 亿行。某天老板要看「过去 90 天、按城市、按渠道、按设备拆分的留存漏斗」。你写下那条 SQL,点击运行,然后去倒了杯咖啡——回来发现查询还在转,连接池被打满,从库延迟飙升,告警电话响了。
传统 OLTP 数据库(MySQL/PostgreSQL)的存储模型是为「点查 + 小范围事务」设计的:行式存储、B+Tree 索引、随机读写友好。但它天生不擅长「扫一大片、聚合一小撮」的分析负载。你被迫走上三条路:
- 上预聚合 Cube:用 ETL 提前算好各种维度的汇总表。维度一多,Cube 数量指数爆炸;业务改个口径,历史数据全要重算。
- 搬进 Hadoop/Spark:T+1 离线批处理,实时性没了,且运维一套 Hadoop 比写业务还累。
- 堆机器 + 加缓存:用钱买时间,但查询延迟依旧随数据量线性恶化。
这三条路的共同问题是:你不再在分析「原始数据」,而是在分析「别人替你算好的结果」。一旦口径变了,或者你想临时加个过滤维度,预聚合帮不了你——这就是「预聚合陷阱」:为了快,你放弃了灵活性;为了灵活性,你又得回到慢查询。
ClickHouse 想做的是另一件事:让原始数据本身就能被毫秒级扫描和聚合。它背后的核心赌注是——只要存储是列式的、执行是向量化的、索引是稀疏且足够聪明的,那么「扫 100 亿行做聚合」也能在亚秒级返回。
这不是空话。ClickHouse 源自 Yandex 的 Metrica(对标 Google Analytics 的网站分析产品),2016 年开源。Metrica 要处理的是每秒数十万条、总量 PB 级的事件流,且要求用户在网页上随便拖拽维度时,查询还得「跟手」。这种极端场景逼出了 ClickHouse 的架构基因。
而到了 2026 年,ClickHouse 的势头还在加速:2026 年 1 月它完成由 Dragoneer 领投的 4 亿美元 D 轮融资,同期收购了 LLM 可观测性公司 Langfuse,并推出原生 Postgres 服务,试图把事务型与分析型工作负载统一。截至 2026 年 8 月,社区版已经迭代到 v26.6 稳定版(26.3 为 LTS)。这篇文章,我们就把它的内核一层层剥开。
二、核心概念:列式、向量化、以及「部件」而不是「行」
要理解 ClickHouse,先要建立三个与行式数据库完全不同的心智模型。
2.1 列式存储:不是把行竖过来那么简单
行式数据库把一行的所有字段存在一起(row-oriented)。当你执行 SELECT sum(amount) FROM orders 时,它得把每一行的整条记录从磁盘读出来,再从中抠出 amount 字段——大量 I/O 被浪费在与本次查询无关的 user_id、address、remark 上。
列式数据库反其道而行:同一列的数据物理上连续存放。执行上面的 sum 时,它只需要读 amount 这一列的文件,其他列碰都不碰。这带来两个直接好处:
- I/O 局部性极佳:聚合类查询只触碰相关列。
- 压缩率爆炸:同一列的数据类型相同、取值分布集中(比如状态码只有几十种、时间戳高度有序),用通用的压缩算法就能压得很小,甚至可以用列式专用编码(Delta、位图、字典)。
一句话:OLAP 查询通常只访问表中少数列,但涉及极多行。列式存储让「读少量列 × 极多行」的成本从「读全表」降为「读那几列」。
2.2 向量化执行:一次算一批,而不是一次算一个
传统解释器式执行是「逐行、逐函数调用」:for row in rows: result = f(row)。每一次函数调用都有分支预测失败、虚函数、CPU 流水线打断的开销。
ClickHouse 用的是向量化(Vectorized)执行:一次处理一个「数据块」(Block,默认约 65536 行),对整列做批量运算。CPU 可以一直待在流水线上,分支预测几乎不失效,还能顺手用上 SIMD 指令。这就是为什么同样的逻辑,ClickHouse 常常比「逐行处理」的实现快一个数量级。
2.3 「部件(part)」是世界的原子,而不是「行」
这是 ClickHouse 最反直觉、也最关键的一点。在 MySQL 里,你脑中的原子是「一行记录」,写入就是 INSERT INTO ... VALUES (...) 插一行,更新就是 UPDATE 改那一行。
在 ClickHouse 的 MergeTree 家族里,原子是「数据部件(part)」:
- 数据以不可变(immutable)的数据块形式批量写入磁盘,每个块称为一个 part。
- 一个 part 内部,数据按主键排序,按列存储。
- 后台有一个合并(merge)线程,定期把多个小的 part 合并成更大的、仍然有序的 part。合并过程会顺手去重、聚合、清理过期数据。
- 写入几乎永远只追加(append-only),几乎不修改已有 part。
这种「LSM 树思想」的设计,让 ClickHouse 的写入吞吐极高(批量顺序写盘),而查询时的有序性又让它能用稀疏索引快速跳过无关数据。代价是:点查(按主键取单行的那种)不擅长,UPDATE/DELETE 是异步的「重写部件」而非原地改——后面会展开。
记住这句话,后面所有架构都围绕它展开:ClickHouse 为了「海量写入 + 极速范围分析」放弃了「实时单行更新」。
三、架构分析:MergeTree 是怎么把一切串起来的
MergeTree(合并树)是 ClickHouse 所有表引擎的祖宗。ReplacingMergeTree、SummingMergeTree、AggregatingMergeTree、CollapsingMergeTree、VersionedCollapsingMergeTree、GraphiteMergeTree,以及它们的 Replicated* 副本版本,全是在 MergeTree 之上加了一层「合并时做什么」的逻辑。理解了 MergeTree,就理解了 ClickHouse 的半壁江山。
3.1 一个 part 在磁盘上长什么样
当你 INSERT 一批数据,ClickHouse 会按 ORDER BY 排序,生成一个 part,落盘到类似这样的目录:
store/<uuid>/<part_name>/
primary.idx # 稀疏主键索引
<col1>.bin # 列1的压缩数据
<col1>.mrk2 # 列1的标记文件(granule 偏移)
<col2>.bin
<col2>.mrk2
...
count.txt # 这个 part 有多少行
checksums.txt # 完整性校验
partition.dat # 分区ID(如果用了 PARTITION BY)
skp_idx_<name>.idx # 跳数索引(如果有)
几个关键点:
- 每一列一个
.bin:这就是列式存储的落地形态。 primary.idx是稀疏索引:它不为每一行建索引,而是每index_granularity(默认 8192)行记一条「标记」。.mrk2(marks 文件)是「定位器」:它记录了「第 N 个 granule(颗粒)的数据,从第几个压缩块、第几个字节开始,解压后对应第几个未压缩偏移」。查询时先查primary.idx定位到 granule,再用.mrk2跳到.bin里的精确字节位置。
3.2 稀疏主键索引:用 8192 分之一的索引干翻 B+Tree
这是 ClickHouse 查询快的秘密武器之一。
传统 B+Tree 是「稠密索引」:每行或每页都有索引项,能精确定位到某一行。但它随数据量线性膨胀,且对「范围扫描」并不友好(你要扫的恰恰是大量连续行,稠密索引反而成了负担)。
ClickHouse 的 PRIMARY KEY 是稀疏索引:
- 数据按
ORDER BY排序后,每 8192 行(一个 granule)取一次排序键的值,作为一条索引项写进primary.idx。 - 查询时,用二分查找在
primary.idx上找到「可能包含目标数据的 granule 区间」,然后只读取这些 granule 对应的列数据,其余 granule 直接跳过。
代价:最多可能多读一个 granule(8192 行)的误差。但在分析场景里,你本来就要扫百万行,多扫 8192 行根本无感;收益是索引极小——10 亿行也只要约 12 万条索引项,轻松放进内存。
-- 经典的排序键/主键设计:把「最常用于范围过滤、基数适中」的列放前面
CREATE TABLE events
(
event_date Date,
event_time DateTime,
user_id UInt64,
city LowCardinality(String),
channel LowCardinality(String),
event_type LowCardinality(String),
amount Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date) -- 按月分区
ORDER BY (event_date, city, user_id) -- 排序键 = 默认主键
TTL event_date + INTERVAL 180 DAY; -- 180天后自动过期
注意一个高频误区:ORDER BY 和 PRIMARY KEY 不是一回事。ORDER BY 决定数据在 part 内如何物理排序;PRIMARY KEY 决定稀疏索引用什么键。如果不显式写 PRIMARY KEY,它默认等于 ORDER BY。但你可以让主键是排序键的前缀,例如 ORDER BY (event_date, city, user_id) 而 PRIMARY KEY (event_date, city)——这样索引更短,且依然能利用前缀做裁剪。主键必须是排序键的前缀,反之不成立。
3.3 分区:让「时间范围」查询直接消失一半数据
PARTITION BY 让数据在物理上按分区键分目录存放。查询里带了分区键的过滤条件时,ClickHouse 可以直接剪掉不相关的分区目录,连打开都不用打开。
对时序/日志/事件数据,PARTITION BY toYYYYMM(date) 或 toDate(date)(按天)是标配。它可以让你「删上个月数据」变成「直接 DROP PARTITION」,是 O(1) 的元数据操作,而不是扫全表。
但分区不是越多越好:每个分区独立合并,分区过多会导致 part 数量爆炸、merge 压力陡增。经验值是「分区数控制在数百以内」,按天分区对大多数业务 OK,按小时分区要谨慎。
3.4 跳数索引(Skipping Index):给稀疏索引打补丁
稀疏主键索引只能加速「排序键前缀」上的范围过滤。如果你的高频过滤条件不在排序键前缀上怎么办?比如用户总爱按 event_type 过滤,但它排在排序键很后面。
这时用跳数索引(data skipping index):它对一组 granule 预计算某种摘要,查询时先判断「这批 granule 里有没有可能包含我要的数据」,没有就整批跳过。
CREATE TABLE events
(
...
INDEX idx_type event_type TYPE set(100) GRANULARITY 4,
INDEX idx_amount amount TYPE minmax GRANULARITY 1,
INDEX idx_city_bf city TYPE bloom_filter(0.01) GRANULARITY 1
)
ENGINE = MergeTree
...
常见类型:
minmax:记录每个 granule 内某列的最小/最大值。过滤条件落在 [min,max] 之外就跳过。适合有序或范围过滤的列。set(N):记录 granule 内出现的 N 个(或更少)不同值。适合低基数列的等值过滤。bloom_filter(p):布隆过滤器,适合高基数列的「这个值可能存在吗」判断,有误判率p。ngrambf_v1/tokenbf_v1:用于LIKE/ 字符串包含匹配,比如日志里搜关键词。
GRANULARITY N 的意思是「每 N 个 granule 共用一份摘要」。
3.5 写入路径与后台合并:为什么写这么快
写入时序:
- 客户端批量 INSERT(ClickHouse 强烈建议大批量,比如每次几万到几百万行)。
- 数据在内存中按
ORDER BY排序,生成一个新的、有序的、不可变 part,直接落盘(顺序写,极快)。 - 立刻返回成功——注意,此时数据只是「多了一个新 part」,并没有和旧数据合并。
- 后台 merge 线程周期性地把同一分区的多个 part 合并成更大的 part,过程中应用去重/聚合/TTL 清理逻辑。
这意味着:刚写入的数据可能分散在多个小 part 里,查询时要扫多个 part(但每个 part 内部有序、可索引)。merge 之后 part 数减少、查询更快。这就是为什么 ClickHouse 建议控制写入频率、攒批写入——每秒插一行会制造海量小 part,把 merge 和查询都拖垮。
async_insert 是官方的「攒批」帮手:开启后,服务端会把短时间内到达的零散 INSERT 在内存里缓冲一下,再合并成一批落盘,客户端无感知地获得高吞吐。
3.6 物化视图:把聚合「推」到写入时做
物化视图(Materialized View)在 ClickHouse 里的语义和 MySQL 完全不同,这是第二个高频踩坑点。
在 MySQL 里,物化视图≈一个「定时刷新的结果缓存」,查询时从源表实时算或直接读缓存。在 ClickHouse 里,物化视图是一个「写入触发器」:当数据 INSERT 进源表时,物化视图会拿这批新数据跑一遍它的 SELECT,把结果写进它自己对应的目标表。它不存「源表的全量视图」,只存「新数据流过时算出的增量」。
这让它成为预聚合的完美载体,且不丢失原始数据灵活性:
-- 1) 原始明细表(保留全部数据,按需即席查询)
CREATE TABLE events ... ENGINE = MergeTree ... ;
-- 2) 预聚合目标表:按 天+城市+渠道 汇总
CREATE TABLE events_daily
(
event_date Date,
city LowCardinality(String),
channel LowCardinality(String),
pv UInt64,
uv AggregateFunction(uniq, UInt64) -- 用聚合函数态,支持增量合并
)
ENGINE = AggregatingMergeTree
ORDER BY (event_date, city, channel);
-- 3) 物化视图:写入 events 时,自动把增量聚合进 events_daily
CREATE MATERIALIZED VIEW mv_events_daily
TO events_daily
AS SELECT
event_date,
city,
channel,
count() AS pv,
uniqState(user_id) AS uv -- 注意是 uniqState,存的是「聚合态」
FROM events
GROUP BY event_date, city, channel;
查询时,对 events_daily 用 uniqMerge(uv) 就能得到精确 UV,且聚合是「分片内已预聚合」的,速度极快:
SELECT
event_date,
city,
sum(pv) AS total_pv,
uniqMerge(uv) AS total_uv
FROM events_daily
WHERE event_date >= '2026-08-01'
GROUP BY event_date, city
ORDER BY total_pv DESC;
AggregatingMergeTree + *-State / *-Merge 函数对,是 ClickHouse 做「可增量合并的精确去重/聚合」的核心技巧。uniqState 存的是 HyperLogLog 草图,多个草图可以 uniqMerge 合并成最终结果,所以即使数据分散在多个 part、多次写入,UV 依然是精确的。
你甚至可以串成物化视图链:源表 → MV1 算粗粒度 → MV2 基于 MV1 的目标表再算更粗粒度,层层下钻,把计算成本摊到写入时。
3.7 分布式表与副本:从单机到集群
单机 MergeHouse 撑住的是「一台机器的磁盘和 CPU」。要上 PB 级、要高可用,靠两张表:
Distributed引擎表:它自己不存数据,只是一张「路由表」。它指向一个集群(在config.xml的<remote_servers>里定义),把查询拆发给各分片(shard),汇总结果返回。写入时按sharding_key把数据散列到不同分片。Replicated*引擎(如ReplicatedMergeTree):通过 ClickHouse Keeper(或 ZooKeeper)在多副本间同步 part 元数据,实现高可用。每个分片可以有多个副本。
<!-- 简化版集群配置 metrika.xml -->
<clickhouse>
<remote_servers>
<my_cluster>
<shard>
<replica><host>ch-node-1</host><port>9000</port></replica>
<replica><host>ch-node-2</host><port>9000</port></replica>
</shard>
<shard>
<replica><host>ch-node-3</host><port>9000</port></replica>
<replica><host>ch-node-4</host><port>9000</port></replica>
</shard>
</my_cluster>
</remote_servers>
</clickhouse>
-- 本地表(每个节点上真实存数据)
CREATE TABLE events_local ... ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}') ...;
-- 分布式视图(应用连接这张表即可,自动路由)
CREATE TABLE events AS events_local
ENGINE = Distributed(my_cluster, default, events_local, cityHash64(user_id));
写入 events(分布式表)时,按 cityHash64(user_id) 把行散列到不同分片;查询时各分片本地算完,由发起节点汇总。这构成了 MPP(大规模并行处理)的基本形态。
3.8 Mutations 与 TTL:更新/删除是「异步重写」
ClickHouse 支持 ALTER TABLE ... DELETE WHERE 和 UPDATE(轻量级删除 DELETE FROM 也已支持),但它们不是原地改,而是 mutations:
- 引擎为这次变更创建一个 mutation 任务,后台把这些受影响的 part 读出来、应用变更、写成新 part,再把旧 part 标记为过期。
- 过程是异步的、批量的、最终一致的。你不能假设「执行完 ALTER 立刻看到新值」。
这意味着 ClickHouse 不适合「高并发单行 UPDATE」的场景(那是 MySQL 的活)。它的更新/删除是为「偶尔修正、批量清理」设计的。
TTL 则是声明式的数据生命周期:可以是行级(TTL event_date + INTERVAL 180 DAY → 过期行被后台清理),也可以是列级(col TTL ... → 过期后该列被置为默认值或移到冷盘)。配合分层存储(storage policy),你可以让热数据在 SSD、冷数据自动落到 S3 对象存储——这正是 2026 年 ClickHouse 主推的「存算分离 + 对象存储」路线(SharedMergeTree / S3 零拷贝复制)。
3.9 压缩与编码:让列式存储再快一倍
ClickHouse 列数据默认用 LZ4 压缩,但你可以按列指定更聪明的 codec 组合:
CREATE TABLE metrics
(
ts DateTime CODEC(Delta, ZSTD(1)), -- 时间差编码 + 轻量zstd
value Float64 CODEC(Gorilla, ZSTD(3)),-- Gorilla 对浮点/时间戳序列极省
status UInt8 CODEC(ZSTD(1)),
payload String CODEC(LZ4)
)
ENGINE = MergeTree ORDER BY ts;
Delta/DoubleDelta:对单调递增或等差的序列(时间戳、自增 ID)先算差值再压,压缩率暴增。Gorilla:Facebook 提出的浮点时间序列编码,对监控指标类数据效果惊人。T64:对 64 位整数做转置压缩。ZSTD(n):比LZ4压得更小,代价是 CPU 略高,n 越大越狠。
四、代码实战:从建表到写入到查询的全链路
光说不练假把式。下面是一套能直接跑的实战,覆盖建表、批量写入、Python/Go 客户端、物化视图聚合、跳数索引、分布式查询。
4.1 建表:一张「事件明细 + 预聚合」的最小可用模型
-- 明细表
DROP TABLE IF EXISTS events;
CREATE TABLE events
(
event_date Date DEFAULT toDate(event_time),
event_time DateTime64(3),
user_id UInt64,
city LowCardinality(String),
channel LowCardinality(String),
event_type LowCardinality(String),
duration_ms UInt32,
amount Decimal(18,2),
-- 跳数索引:高频等值过滤
INDEX idx_type event_type TYPE set(50) GRANULARITY 4,
INDEX idx_dur duration_ms TYPE minmax GRANULARITY 1,
-- 投影:为另一种查询模式预存不同排序
PROJECTION p_by_user
(
SELECT user_id, event_type, count()
GROUP BY user_id, event_type
)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, city, user_id)
TTL event_date + INTERVAL 180 DAY
SETTINGS index_granularity = 8192,
min_bytes_for_wide_part = 0; -- 演示用:强制 wide 格式(每列独立文件)
PROJECTION 是 ClickHouse 的「物化投影」:它在同一个 part 里额外存一份按不同维度组织的数据。当查询模式匹配投影时,优化器会自动改写去读投影,而不必扫主排序。相当于「一个表内嵌多个排序视图」,比额外建表省心。
4.2 用内置函数造数据:快速填充测试表
不用外部文件,直接用 generateRandom / numbers 造亿级数据:
INSERT INTO events (event_time, user_id, city, channel, event_type, duration_ms, amount)
SELECT
now() - INTERVAL intDiv(number, 1000) SECOND AS event_time,
rand64() % 5000000 AS user_id,
['北京','上海','广州','深圳','杭州','成都'][rand64()%6 + 1] AS city,
['app','web','mini','h5'][rand64()%4 + 1] AS channel,
['click','view','order','pay'][rand64()%4 + 1] AS event_type,
rand64() % 10000 AS duration_ms,
(rand64() % 100000) / 100.0 AS amount
FROM numbers(50_000_000); -- 灌 5000 万行,几秒搞定
造完用 SYSTEM FLUSH LOGS 后查 system.parts 看 part 与行数:
SELECT
partition,
name AS part_name,
rows,
formatReadableSize(bytes_on_disk) AS size,
active
FROM system.parts
WHERE table = 'events' AND active
ORDER BY rows DESC
LIMIT 10;
4.3 Python 客户端:clickhouse-connect 读写
2026 年官方主推的 Python 驱动是 clickhouse-connect(比老牌 clickhouse-driver 更轻、更快):
# pip install clickhouse-connect
import clickhouse_connect
client = clickhouse_connect.get_client(
host='ch.example.com',
port=8443,
username='default',
password='******',
database='default',
secure=True,
)
# 1) 直接跑 SQL
rows = client.query(
"SELECT city, count() AS pv "
"FROM events "
"WHERE event_date >= '2026-08-01' "
"GROUP BY city ORDER BY pv DESC LIMIT 10"
)
print(rows.result_rows) # [('北京', 1234567), ...]
# 2) 用参数化查询防注入
rows = client.query(
"SELECT event_type, uniqExact(user_id) AS uv "
"FROM events WHERE city = %(city)s GROUP BY event_type",
parameters={'city': '上海'}
)
# 3) 批量插入(强烈建议列式/批量,而非逐行)
import random, datetime
BATCH = 100_000
data = {
'event_time': [datetime.datetime.now() for _ in range(BATCH)],
'user_id': [random.randint(1, 5_000_000) for _ in range(BATCH)],
'city': [random.choice(['北京','上海','广州','深圳','杭州','成都']) for _ in range(BATCH)],
'channel': [random.choice(['app','web','mini','h5']) for _ in range(BATCH)],
'event_type': [random.choice(['click','view','order','pay']) for _ in range(BATCH)],
'duration_ms':[random.randint(0, 10000) for _ in range(BATCH)],
'amount': [round(random.uniform(0, 1000), 2) for _ in range(BATCH)],
}
client.insert('events', data,
column_names=['event_time','user_id','city','channel','event_type','duration_ms','amount'])
4.4 Go 客户端:clickhouse-go 高并发写入
// go get github.com/ClickHouse/clickhouse-go/v2
package main
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)
func main() {
conn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{"ch-node-1:9000"},
Auth: clickhouse.Auth{Database: "default", Username: "default", Password: "******"},
})
if err != nil {
panic(err)
}
ctx := context.Background()
// 批量准备 + 异步插入
batch, err := conn.PrepareBatch(ctx, "INSERT INTO <table_name> VALUES")
if err != nil {
panic(err)
}
cities := []string{"北京", "上海", "广州", "深圳", "杭州", "成都"}
for i := 0; i < 100_000; i++ {
err := batch.Append(
time.Now(),
rand.Uint64()%5_000_000,
cities[rand.Intn(len(cities))],
"app",
"click",
rand.Uint32()%10000,
float64(rand.Intn(100000))/100.0,
)
if err != nil {
panic(err)
}
}
if err := batch.Send(); err != nil { // 一次性落盘
panic(err)
}
var total uint64
if err := conn.QueryRow(ctx,
"SELECT count() FROM events WHERE event_date >= '2026-08-01'").Scan(&total); err != nil {
panic(err)
}
fmt.Println("rows:", total)
}
4.5 物化视图链:明细 + 小时级 + 天级三级聚合
-- 小时级聚合
CREATE TABLE events_hourly
(
event_hour DateTime,
city LowCardinality(String),
channel LowCardinality(String),
pv UInt64,
uv AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree
ORDER BY (event_hour, city, channel);
CREATE MATERIALIZED VIEW mv_events_hourly
TO events_hourly
AS SELECT
toStartOfHour(event_time) AS event_hour,
city, channel,
count() AS pv,
uniqState(user_id) AS uv
FROM events
GROUP BY event_hour, city, channel;
-- 天级聚合(基于小时级再做一层,链起来)
CREATE TABLE events_daily
(
event_date Date,
city LowCardinality(String),
channel LowCardinality(String),
pv UInt64,
uv AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree
ORDER BY (event_date, city, channel);
CREATE MATERIALIZED VIEW mv_events_daily
TO events_daily
AS SELECT
toDate(event_hour) AS event_date,
city, channel,
sum(pv) AS pv,
uniqMergeState(uv) AS uv -- 注意:对聚合态再 merge 成态,用 *MergeState
FROM events_hourly
GROUP BY event_date, city, channel;
查询天级报表时直接读 events_daily,uniqMerge(uv) 即可,亿级数据秒回。原始 events 明细表依然保留,随时支持临时口径的即席查询——预聚合和灵活性,这次你要全都要。
4.6 跳数索引与投影的查询验证
-- 走 idx_type 跳数索引:只扫包含 'pay' 的 granule
EXPLAIN indexes = 1
SELECT count() FROM events WHERE event_type = 'pay';
-- 走 p_by_user 投影:按 user 维度聚合被优化器自动改写
EXPLAIN
SELECT user_id, event_type, count()
FROM events
GROUP BY user_id, event_type;
-- 输出里能看到「Read from projection p_by_user」字样即命中
EXPLAIN indexes = 1 会打印每条索引被「使用/跳过」的 granule 数,是验证索引是否生效的利器。
4.7 分布式查询:一次查穿整个集群
-- 在 Distributed 表上查询,优化器自动把计算下推到各分片
SELECT
city,
uniqExact(user_id) AS uv,
sum(amount) AS gmv
FROM events
WHERE event_date BETWEEN '2026-08-01' AND '2026-08-18'
AND channel = 'app'
GROUP BY city
ORDER BY gmv DESC;
ClickHouse 的分布式查询会尽量做两阶段聚合:各分片先本地聚合(partial),再由发起节点做最终合并(final),网络只传输聚合后的小结果,而不是原始行。这也是它能横向扩展的关键。
五、性能优化:把 ClickHouse 榨到极限的 12 条军规
架构懂了,实战跑了,但「能跑」和「跑得榨干 CPU」之间隔着十二条经验。
5.1 排序键即命根:把「最常范围过滤 + 基数适中」的列放最前
稀疏索引只对你 ORDER BY 的前缀有效。如果你的查询总按 event_date 过滤、偶尔按 city,那 ORDER BY (event_date, city, ...) 才能让日期范围裁剪生效。把高基数列(如 user_id)放排序键最前,索引几乎失效(每条索引项都不同,二分找不到区间)。
5.2 分区键别太细
按天分区对日志类 OK;按小时分区会让 part 数爆炸、merge 压力翻 24 倍。规则:总分区数控制在数百级别。
5.3 跳数索引按「非排序键高频过滤」建
排序键前缀已经能裁剪的,别重复建跳数索引。跳数索引留给那些「不在排序键上、但查询常带」的列,类型按数据特征选:minmax 给有序/范围列,set 给低基数列,bloom_filter 给高基数列,ngrambf_v1 给字符串 LIKE。
5.4 编码与压缩按列定制
时间戳用 Codec(Delta, ZSTD);浮点监控指标用 Codec(Gorilla, ZSTD);低基数字符串(渠道、状态码)用 LowCardinality(String) 类型本身就有字典编码。默认 LZ4 够快,想省空间换 ZSTD(1~3)。
5.5 写入必须攒批
每次 INSERT 至少几万行;开 async_insert=1,并配合 max_insert_block_size(默认 1048576)。千万别「一行一插」或「一秒百插」——你会被小 part 淹没。
-- 客户端连接串带上 async_insert
SET async_insert = 1;
SET wait_for_async_insert = 1; -- 等服务端真的落盘再返回(保证持久性)
5.6 用 PREWHERE 把过滤推到读列之前
PREWHERE 先按过滤条件挑出要读的行号,再只加载这些行的其他列,能大幅减少 I/O:
SELECT user_id, amount
FROM events
PREWHERE event_type = 'pay' -- 先筛,再读 user_id/amount 列
WHERE amount > 100;
ClickHouse 现在大多能自动把 WHERE 里「只涉及少量列」的条件提升为 PREWHERE,但显式写更稳。
5.7 避免 SELECT *
列式存储的红利建立在「只读用到的列」上。SELECT * 把所有列都读出来,红利瞬间归零。
5.8 物化视图做「写时预聚合」,但别忘保留明细
把高频、固定维度的聚合下沉到物化视图目标表(AggregatingMergeTree),查询读聚合表而非扫明细。明细表保留以备即席分析。这是「既要快又要灵活」的标准答案。
5.9 善用 Projections 替代「为不同排序建多张表」
一个表内嵌多个投影,覆盖不同查询模式的排序需求,优化器自动选择,运维成本远低于手动维护多张冗余表。
5.10 监控三件套:system 表
ClickHouse 自带的 system 库就是它的「体检报告」:
-- 慢查询
SELECT query, query_duration_ms, read_rows, memory_usage
FROM system.query_log
WHERE type = 'QueryFinish' AND event_date = today()
ORDER BY query_duration_ms DESC
LIMIT 20;
-- 正在进行的 merge(merge 卡顿的元凶)
SELECT *
FROM system.merges
ORDER BY elapsed DESC
LIMIT 10;
-- 卡住的 mutation(DELETE/UPDATE 迟迟不完)
SELECT * FROM system.mutations WHERE NOT is_done;
-- part 数量告警:单表 part 过多说明写入太碎
SELECT table, count() AS parts
FROM system.parts
WHERE active GROUP BY table ORDER BY parts DESC LIMIT 10;
5.11 关键 session settings 调优
SET max_threads = 16; -- 并行线程,通常 = CPU 核数
SET use_uncompressed_cache = 1; -- 热数据解压后缓存,重复扫描更快
SET max_memory_usage = 10000000000; -- 单查询内存上限(按机器调)
SET optimize_aggregation_in_order = 1;-- 利用排序键做有序聚合,省内存更快
SET input_format_allow_errors_*` -- 脏数据容忍,批量导入更稳
5.12 分层存储与对象存储:冷数据进 S3
配 storage_policy 把旧 part 自动迁到 S3,热数据留本地 SSD;2026 年的 SharedMergeTree + Zero-copy Replication 更是把「数据放对象存储 + 多副本零拷贝」做成开箱即用,PB 级存储成本直接打下来。
六、总结与展望:ClickHouse 的位置与边界
把全文串起来,ClickHouse 的哲学其实一句话:为「海量写入 + 极速范围分析」而生,为此主动放弃「实时单行更新」。
它的核心武器是四件套:
- 列式存储 + 向量化执行:让「扫极多行、聚少数列」的成本断崖式下降。
- MergeTree 部件模型 + 稀疏主键索引:用 8192 分之一大小的索引,靠 granule 二分 + marks 定位,跳过 99% 无关数据。
- 物化视图链 + AggregatingMergeTree:把聚合成本摊到写入时,查询读的是「已经算好的增量」,亿级秒回,同时明细表仍在,灵活性不丢。
- 分布式表 + 副本 + 两阶段聚合:把计算下推到各分片,横向扩展到 PB 级。
它也清楚地有边界,选型时要诚实:
- 点查弱:「按主键取一行」不是它的强项,老老实实用 MySQL/Redis。
- 更新/删除是异步的:高并发单行 UPDATE 场景别来。
- JOIN 大表要谨慎:ClickHouse 的 JOIN 不如传统数仓成熟,最好用「大表 JOIN 小维表」或预先打宽。
- 事务不存在:它是 AP 系统,不是 TP,别指望 ACID 跨行事务。
2026 年的几个值得跟进的方向
- AI 可观测性:收购 Langfuse 后,ClickHouse 正把自己的分析能力与 LLM Tracing 打通,trace、token、cost 这类高写入低延迟查询场景天然契合。
- 原生 Postgres 服务:官方在推「一份数据,既能 OLTP 又能 OLAP」的统一体验,值得关注它与独立 ClickHouse 的取舍。
- 与数据湖互操作:ClickHouse 已能通过表函数直接读 Apache Iceberg / Hudi / Delta 等湖格式,以及 S3 上的 Parquet,正在从「孤立的 OLAP 仓」走向「湖仓之上的高速查询层」。
- 存算分离:SharedMergeTree + 对象存储 + Zero-copy Replication,让 ClickHouse 在云上能像 Snowflake 那样弹性伸缩,同时保住它「快」的祖传手艺。
最后给一句话建议:如果你的痛点是「数据量大、查询慢、还要随时换口径」,ClickHouse 几乎是目前开源世界里最锋利的刀;如果你的痛点是「高并发交易、强一致、频繁改单行」,那它不该出现在这张架构图里。 把对的人放在对的位置,这才是工程师真正的「深度拆解」。
本文示例代码基于 ClickHouse v26.x 系列(截至 2026-08 稳定版 v26.6),SQL/客户端 API 在不同小版本间基本兼容;生产环境请结合官方文档与你的数据特征调参。