ClickHouse 深度拆解:当关系型数据库学会「按列思考」——从 MergeTree 引擎、向量化执行到稀疏索引与生产级 OLAP 完全指南(2026)
当前稳定线:v25.8 LTS(2026 年 7 月仍在持续打补丁)。本文从工程师视角,把 ClickHouse 这台「分析机器」拆到螺丝级别:它为什么快、数据在磁盘上长什么样、主键为什么不是唯一约束、以及你如何亲手把它跑成一个生产级 OLAP 管道。
一、背景介绍:为什么行存数据库救不了分析查询
如果你做过业务后端,大概率对 MySQL / PostgreSQL 这种「行存数据库」很熟:一行用户的订单、一篇文章的元数据、一条评论,统统作为一个整体存进磁盘的连续块里。这是为 OLTP(在线事务处理) 量身定做的——一次插入、一次按主键点查、一次按索引范围更新,都是「拿到完整的一行」最高效。
但一旦你面对的是 OLAP(在线分析处理) 场景,事情就反过来了:
- 一张
events表有 200 个字段,可你的报表只关心COUNT(DISTINCT user_id)和SUM(revenue); - 数据量是百亿级,而不是百万级;
- 查询模式是「扫描几亿行、聚合出几个数字」,而不是「按 id 取一条」。
这时候行存数据库会非常痛苦:为了算出 SUM(revenue),它得把每一行的 200 个字段全部从磁盘读上来,再丢掉 199 个,只留下 revenue。IO 被无谓地浪费了 99% 以上。
ClickHouse 的诞生正是为了解决这类问题。它来自 Yandex 的 Metrica(类似 Google Analytics 的产品),需要在PB 级网页埋点数据上做实时聚合。后来 Yandex 把它开源,成立 ClickHouse Inc.,如今是一个独立的列式 OLAP DBMS。截至 2026 年 7 月,最新的稳定线是 v25.8 LTS,社区与商业云都在持续迭代。
为了把定位说清楚,先看一张对比表:
| 维度 | MySQL / PostgreSQL | ClickHouse | DuckDB |
|---|---|---|---|
| 存储模型 | 行存为主 | 列存 | 列存(进程内) |
| 主战场 | OLTP、点查、事务 | OLAP、海量扫描聚合 | 进程内 OLAP、替换 pandas |
| 部署形态 | 服务端 | 服务端(也可嵌入式) | 库(链接进进程) |
| 事务/ACID | 强 | 弱(最终一致、异步 merge) | 弱 |
| 写入方式 | 行级、随机写 | 批量 append、后台 merge | 进程内 |
| 典型场景 | 业务系统 | 日志、埋点、监控、实时数仓 | 数据分析脚本、笔记本 |
1.1 一个具体的反例:用 OLTP 数据库硬扛分析
假设你的产品每天产生 2 亿条埋点,一年就是 700 亿行。业务表 events 有 180 个字段。现在老板要看「昨天每个渠道的 UV 和 GMV」。
用 Postgres 你会写:
SELECT channel, COUNT(DISTINCT user_id), SUM(amount)
FROM events
WHERE event_date = '2026-07-16'
GROUP BY channel;
这条语句的执行代价是:扫描当天全部行(可能几十亿),每行要把 180 个字段从磁盘读上来,却只为了取 3 个;COUNT(DISTINCT) 还要建哈希表去重。在行存 + B+ 树索引的世界里,如果没有完美匹配的复合索引,就只能全表扫描。结果往往是:查询跑几分钟甚至超时,期间还把 buffer pool 搅得天翻地覆,连带着在线事务都变慢。
ClickHouse 的解法是另一套哲学:数据本来就是按列躺着、按 (channel, user_id, event_time) 排好序的,稀疏索引直接跳过无关 granule,向量化执行器一次算几千行。同样一条聚合,常常是亚秒级返回,而且不干扰任何写入。这不是「把 Postgres 调优调出来的」,而是「从存储模型起就为这种查询而生的」。
1.2 ClickHouse 的「代价清单」
没有银弹。ClickHouse 为了快,明确放弃了什么:
- 没有跨行事务:你不能在一个事务里「先插 A 再更新 B 再回滚」。它是 append-only 的,更新/删除是异步 mutation。
- 更新删除很重:早期
ALTER TABLE ... DELETE WHERE会重写整个 part(mutation),成本极高;后来的 Lightweight Delete 缓解了但仍有边界。 - 点查单条慢:按主键取一行不是它的强项,该用 KV / 行存数据库。
- 最终一致:去重、求和发生在后台 merge,刚插入的「脏数据」可能暂时可见。
理解这份代价清单,你才能正确判断「该不该上 ClickHouse」。
一句话:ClickHouse 不是「更快的 MySQL」,而是「为扫描和聚合重新设计的数据库」。它用「放弃你习以为常的某些东西」(比如强事务、行级实时更新)换来了「在正确场景下的数量级优势」。理解这一点,是读懂它全部设计的钥匙。
二、核心概念:ClickHouse 为什么快
很多文章把 ClickHouse 的快归因于「列存」。列存确实重要,但它只是冰山一角。真正的加速器有四个,彼此叠加:
2.1 列存:只搬你需要的列
行存把一行连续存放;列存把一列连续存放。当你的查询只碰 3 个字段时,ClickHouse 只需要读取这 3 列对应的文件,而不是整行。在宽表 + 少数字段聚合的场景下,这直接把 IO 量砍掉一个数量级。
更妙的是,同一列的数据类型相同、取值范围相近,压缩率极高。ClickHouse 默认用 LZ4,也可选 ZSTD 等更激进的 codec。一列 event_type(几十种取值)配合 LowCardinality 字典编码,体积可以压到原来的几十分之一。压缩不仅省磁盘,更省 IO——读更少字节、解压用 CPU 换带宽,而 CPU 解压远快于磁盘吞吐。
2.2 向量化执行:一次算一批,而不是一行
传统火山模型(Volcano)是「拉一行、算一行、吐一行」,每行都要过一遍虚函数调用,CPU 流水线被频繁打断,SIMD 完全用不上。ClickHouse 采用 向量化(vectorized)执行:数据以「列块(Column Block)」为单位在算子间流动,每个算子一次性对几千行做批量运算。
这意味着:
- 一次循环处理 8192 行,函数调用开销被摊薄;
- 内层循环是紧凑的数值运算,编译器可以自动向量化(SIMD),一条指令并行处理多个值;
- CPU 缓存命中率更高,因为处理的是同一种类型的一段连续内存。
你可以把向量化理解成「用 NumPy 的广播思维去写数据库内核」。这也是为什么 ClickHouse 在聚合、过滤、表达式求值上能碾压逐行解释的执行器。
2.3 主键不是「唯一约束」,而是「稀疏索引」
这是 ClickHouse 最容易让新手踩坑、也最能体现设计哲学的一点。在 MySQL 里,PRIMARY KEY 意味着「唯一、聚簇、用来定位单行」。在 ClickHouse 的 MergeTree 里,ORDER BY(排序键)同时充当主键,但它:
- 不保证唯一:你可以插入两行完全相同的
ORDER BY值,ClickHouse 照单全收; - 不用于点查定位单行,而是用于「裁剪要扫描的数据范围」;
- 是 稀疏索引(sparse index):它不为每一行建索引,而是为每 8192 行(一个 granule,颗粒) 记一条索引项。
为什么稀疏就够了?因为分析查询本来就是「扫一大片」。稀疏索引把需要扫描的 granule 数量从「总行数」降到「总行数 / 8192」,再用二分查找在这些 granule 边界里定位候选区间。索引本身极小(例如 10 亿行也才约 12 万个索引项),可以常驻内存,所以即便没有精确到行的索引,裁剪效率依然极高。
2.4 并行:按 part 横向分治
数据在磁盘上被切成多个 part(数据 part),查询时可以并行扫描不同 part、不同 granule,再把结果汇总。part 之间是相互独立、可并行合并的,这让 ClickHouse 能很自然地把多核、多磁盘的算力吃满。
把四者叠加:列存减少 IO → 高压缩进一步减少 IO → 向量化把 IO 省下来的带宽转化为 CPU 吞吐 → 稀疏索引 + 并行只扫该扫的部分。这就是「数量级优势」的工程来源,而不是某个黑科技单点。
三、架构分析:从一次 INSERT 到一次 SELECT
3.1 MergeTree 引擎家族
ClickHouse 的存储核心是 MergeTree(Merge + Tree,借鉴 LSM 的合并思想)。它有一整个家族,按「合并时做什么」区分:
MergeTree:基础款,按ORDER BY排序、按PARTITION BY分区、后台异步合并 part。ReplacingMergeTree(ver):合并时,同一排序键的重复行只保留ver最大的一条——去重用。SummingMergeTree:合并时,把非主键的数值列自动相加——预聚合用。AggregatingMergeTree:合并时,对*State聚合函数结果做合并——配合物化视图做实时 rollup。CollapsingMergeTree/VersionedCollapsingMergeTree:用 sign 位「抵消」旧状态,做可变数据的增删。GraphiteMergeTree:专为 Graphite 时序指标优化。
注意一个关键心智模型:这些「去重 / 求和 / 聚合」发生在后台 merge 时,而不是写入时。所以刚插入的重复行、未合并的 part 里,你用普通 SELECT 仍可能看到「脏数据」。要看到「最终态」,要么等 merge 完成,要么用 ... FINAL 在查询时强制合并(代价更高)。这是 ClickHouse「最终一致」的本质。
3.2 一次 INSERT 背后发生了什么
当你 INSERT 一批数据:
- 数据按
ORDER BY在内存中排序; - 排序后写入一个新的 part 目录(
/store/xxx/yyy/,里面每列一个.bin压缩文件、每列一个.mrkmarks 文件、主键索引primary.idx、分区 minmax 信息、跳数索引等); - 这个 part 立即可查(无需 merge 就能被读到);
- 后台线程在「合适的时机」(part 数量、大小、时间触发)把同分区的多个小 part 合并(merge) 成一个大 part,过程中顺手完成去重、求和、聚合。
这套「写入即生成 part + 后台 merge」机制,让写入可以极高吞吐地 append,而不必像 B+ 树那样为每行寻址、加锁、原地更新。代价是:点查单条慢、更新/删除重。典型的「用写入与一致性的复杂度,换读取与分析的极速」。
3.3 物理存储布局:磁盘上的数据长什么样
一个 part 目录里大致是:
part_dir/
primary.idx # 主键(排序键前缀)的稀疏索引
[column].bin # 每列的压缩数据块
[column].mrk2 # 每列 marks:记录该列每个 granule 在 .bin 中的偏移
partition.dat # 分区值
minmax_<col>.idx # 分区内某些列的 min/max,用于分区级裁剪
skp_idx_<name>.idx # 跳数索引(如果定义了)
读取一条查询时,ClickHouse 的流程是:
- 用
PARTITION BY的 minmax 信息,直接跳过无关分区; - 用
primary.idx稀疏索引做二分,定位候选 granule 区间; - 用
.mrkmarks 跳到对应.bin的字节偏移,只读需要的列、需要的 granule; - 对每个 granule 应用向量化算子,并行推进,最后汇总。
这就是为什么「选对 ORDER BY」是 ClickHouse 表设计里最致命的一件事:它决定了稀疏索引能否有效裁剪、数据在磁盘上是否聚簇、merge 是否高效。
3.4 跳数索引与 ClickHouse Keeper
除了主键稀疏索引,ClickHouse 还支持 跳数索引(skipping index),用于主键之外的列做粗粒度裁剪,比如 bloom_filter、minmax、set、ngrambf_v1 等。它不会精确定位行,而是告诉执行器「这个 granule 里肯定没有你要的值 / 可能有」,从而跳过整段。
高可用方面,ReplicatedMergeTree 依赖 ClickHouse Keeper——一个用 C++ 重写、基于 Raft 的 ZooKeeper 替代品,负责存储副本的元信息、协调 merge、选主。相比早期直接依赖 ZooKeeper,Keeper 更轻、性能更好,如今已是推荐路径。
3.5 查询执行流水线
一条 SQL 会经历:Parser → Analyzer → Optimizer(含谓语下推、常量折叠)→ 生成 QueryPlan → 物化成 Pipeline(由各 Stage、Transform 组成的执行图)→ 多线程执行 → 汇总。你可以用 EXPLAIN PIPELINE 看到这张执行图,用 EXPLAIN indexes = 1 看到索引是怎么被用的——这是做性能调优时最该看的两张「X 光片」。
3.6 合并的代价与艺术:part 数量、merge 风暴与分区选择
后台 merge 不是免费的午餐,它有三个工程直觉值得记住:
- part 越多,merge 越累。每次写入生成一个 part,小 part 海会让 merge 线程疲于奔命,也会拖慢启动和元数据加载。所以「批量写、缓冲写」不只是吞吐问题,更是健康度问题。经验上,单表稳定状态下的 part 数应控制在几千以内,而不是几十万。
- merge 是「写放大」的来源。一次逻辑写入可能在后台被反复合并多次,总写入量可能是原始数据的数倍。这正是用「写入放大」换「读取极速」的代价兑现时刻。
- 分区粒度要匹配查询。分区只在「按分区键裁剪」时有效。如果你的查询从不按分区键过滤,分得太细只会增加 part、毫无收益;如果常查「最近 7 天」却按年分区,又会扫太多无关数据。一个实用心法:让「一次典型查询需要碰到的分区数」尽可能小,同时单个分区不要大到 merge 不动(经验上限约数十 GB)。
四、代码实战:亲手跑起一个生产级 ClickHouse
光说不练假把式。下面从零搭一个可运行的例子,覆盖建表、写入、各种 MergeTree 变种、物化视图与 Python 接入。
4.1 起一个本地实例
docker run -d \
--name clickhouse \
-p 8123:8123 -p 9000:9000 \
-v $(pwd)/ch_data:/var/lib/clickhouse \
clickhouse/clickhouse-server:25.8
等几秒后用 HTTP 接口探活:
curl 'http://localhost:8123/' # 返回 "Ok.\n"
4.2 基础 MergeTree 表:分区、排序键、TTL
CREATE TABLE events
(
event_date Date,
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String), -- 低基数字典编码,省空间又快
country LowCardinality(String),
url String,
duration UInt32,
revenue Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date) -- 按月分区,方便冷热分离与裁剪
ORDER BY (event_type, user_id, event_time) -- 排序键=主键,决定稀疏索引与聚簇
TTL event_date + INTERVAL 365 DAY; -- 一年后数据自动过期
这里有个工程直觉:ORDER BY 第一列应当是你最常用作过滤/范围扫描、且选择性好的列。因为稀疏索引是从排序键前缀开始生效的。比如常按 event_type 过滤,把它放第一列,索引裁剪最有效。
4.3 ReplacingMergeTree:用「最终合并」做去重
业务里常有「同一用户的最新画像要覆盖旧画像」的需求,但数据可能重复到达:
CREATE TABLE user_profile
(
user_id UInt64,
name String,
score UInt32,
updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at) -- 合并时保留 updated_at 最大的一行
ORDER BY user_id;
插入两条同 user_id 的数据:
INSERT INTO user_profile VALUES (1, 'Alice', 10, '2026-07-01 10:00:00');
INSERT INTO user_profile VALUES (1, 'Alice', 99, '2026-07-02 10:00:00');
直接 SELECT * 可能看到两行(因为还没 merge)。要看干净结果:
SELECT * FROM user_profile FINAL; -- 查询时强制合并,保留 updated_at 最大者
FINAL 方便但开销大,生产上更常见的是:接受「最终一致」,靠后台 merge 周期性收敛,或在应用层用 argMax 自己取最新。
4.4 SummingMergeTree:把求和下推到合并期
如果你只关心「每个 (date, country) 的点击总次数和总收益」,与其每次都扫原表 SUM,不如让 merge 帮你提前加好:
CREATE TABLE daily_stats
(
date Date,
country LowCardinality(String),
clicks UInt64,
revenue Float64
)
ENGINE = SummingMergeTree
ORDER BY (date, country);
插入明细后,后台 merge 会把相同 (date, country) 的非主键数值列自动相加。查询时仍需 GROUP BY + SUM(因为可能有多个未合并 part):
SELECT date, country, SUM(clicks), SUM(revenue)
FROM daily_stats
GROUP BY date, country;
4.5 AggregatingMergeTree + 物化视图:实时 Rollup 的标配
SummingMergeTree 只能做加法,要算 COUNT(DISTINCT user_id) 这种「可合并的聚合」,得用 AggregatingMergeTree 配合物化视图。这是 ClickHouse 做实时数仓最经典的套路:
-- 源表明细
CREATE TABLE events_src
(
event_time DateTime,
country LowCardinality(String),
user_id UInt64,
revenue Float64
)
ENGINE = MergeTree
ORDER BY (country, event_time);
-- 聚合目标表,注意列要用 *State 函数
CREATE MATERIALIZED VIEW mv_daily
ENGINE = AggregatingMergeTree
ORDER BY (date, country)
AS SELECT
toDate(event_time) AS date,
country AS country,
uniqState(user_id) AS uu, -- 去重计数的「中间态」
sumState(revenue) AS rev -- 求和的「中间态」
FROM events_src
GROUP BY date, country;
关键点:物化视图是一个「触发器」。当你 INSERT INTO events_src 时,新插入的那批行会被喂给 mv_daily 的 SELECT,聚合结果写进 mv_daily;它只处理「新插入的行」,不会把全表重算一遍。查询时再合并中间态:
SELECT
date,
country,
uniqMerge(uu) AS dau,
sumMerge(rev) AS revenue
FROM mv_daily
GROUP BY date, country
ORDER BY date;
这样,无论底层插入多少明细,mv_daily 里每个 (date, country) 只有极少的预聚合行,查询几乎是「读现成结果」,毫秒级返回。这就是实时大盘的底层逻辑。
4.6 跳数索引:为主键之外的列加速
假如你常按 url 过滤,但 url 不在排序键里,主键索引帮不上忙。加一个布隆跳数索引:
CREATE TABLE events2
(
event_date Date,
event_time DateTime,
user_id UInt64,
url String,
INDEX idx_url url TYPE bloom_filter(0.01) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_time);
bloom_filter(0.01) 表示约 1% 假阳性率。GRANULARITY 4 表示每 4 个 granule 共享一个索引项。查询 WHERE url = '/checkout' 时,执行器先问布隆过滤器「这 4 个 granule 里有没有这个 url」,没有就整段跳过。
4.7 数据写入:批量、异步、别用单条 INSERT
ClickHouse 厌恶「一行一行插」。正确姿势是大批量的:
-- 从文件批量导入
INSERT INTO events SELECT * FROM file('events.csv', CSVWithNames);
-- 从 Parquet 直接查(无需先落表)
SELECT count() FROM file('data.parquet', Parquet);
-- 跨表搬运
INSERT INTO events SELECT * FROM events_archive WHERE event_date >= '2026-07-01';
服务端还可以开启 async insert,把短时间内的小插入在内存里攒成批再落盘:
INSERT INTO events SETTINGS
async_insert = 1,
wait_for_async_insert = 1
VALUES (...);
生产环境常见架构是前面挂一层 Kafka 引擎表 或 Buffer 表,把高频小写入缓冲成大块,再 flush 进 MergeTree,避免 part 爆炸(part 太多会让 merge 和元数据吃紧)。
4.8 Python 接入:clickhouse-connect 端到端
官方 Python 驱动 clickhouse-connect 用法很直白:
import clickhouse_connect
client = clickhouse_connect.get_client(
host='localhost', port=8123,
username='default', password='', database='default'
)
# 建表(如果还没建)
client.command("""
CREATE TABLE IF NOT EXISTS events
(
event_date Date,
user_id UInt64,
event_type LowCardinality(String),
revenue Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_type, user_id)
""")
# 批量插入(column-oriented:每列一个列表)
data = {
'event_date': ['2026-07-01', '2026-07-01', '2026-07-02'],
'user_id': [101, 102, 103],
'event_type': ['click', 'view', 'click'],
'revenue': [0.0, 1.5, 2.0],
}
client.insert('events', data, column_names=list(data.keys()))
# 查成 DataFrame(依赖 pyarrow / pandas)
df = client.query_df(
"SELECT event_type, SUM(revenue) AS rev "
"FROM events GROUP BY event_type"
)
print(df)
clickhouse-connect 支持直接返回 Pandas / PyArrow DataFrame,也支持类型化的 query 返回列式 ColumnarResult,非常适合数据分析管线。配合 Dask / Ray 还能做分布式抽取。
4.9 接入流数据:Kafka 引擎表与 Buffer 缓冲
生产环境几乎不会让你的应用直接 INSERT,而是让 ClickHouse 自己「喝」Kafka。Kafka 引擎表直接扮演消费者:
-- Kafka 引擎表:本身就是消息队列的消费端
CREATE TABLE events_kafka
(
user_id UInt64,
event_type String,
revenue Float64
)
ENGINE = Kafka
SETTINGS
kafka_broker_list = 'kafka:9092',
kafka_topic_list = 'events',
kafka_group_name = 'ch_consumer',
kafka_format = 'JSONEachRow';
-- 用物化视图把 Kafka 流入的数据搬到 MergeTree
CREATE MATERIALIZED VIEW events_mv TO events AS
SELECT user_id, event_type, revenue, now() AS event_time
FROM events_kafka;
对于高频小写入的场景,还可以在应用和 MergeTree 之间架一层 Buffer 表,在内存里攒批:
CREATE TABLE events_buffer AS events
ENGINE = Buffer(
currentDatabase(), events, -- 目标库、目标表
16, -- 层数
10, 60, -- 最小/最大停留秒数
10000, 100000, -- 最小/最大行数阈值
10000000, 100000000 -- 最小/最大字节阈值
);
当满足任一「最大」阈值、或超过「最小」时间,Buffer 就会把积攒的数据 flush 进真正的 events 表。
4.10 Projections:一张表的多种排序视图
如果同一张表既要按 (event_time) 查、又要按 user_id 聚合,排序键只能选一个。Projections 允许你为同一份数据额外维护「另一种排序/预聚合」:
CREATE TABLE events_p
(
event_date Date,
user_id UInt64,
event_type LowCardinality(String),
revenue Float64,
PROJECTION p_by_user
(
SELECT user_id, SUM(revenue)
FROM events_p
GROUP BY user_id
)
)
ENGINE = MergeTree
ORDER BY (event_date, event_type);
当查询只按 user_id 过滤/聚合时,ClickHouse 会自动选用 p_by_user 这个投影,而不必扫主排序。代价是写入时要额外维护投影,属于「以写换读」的标准取舍。
4.11 原生 JSON 类型与列级 TTL
早期 ClickHouse 只能把 JSON 塞进 String 再用 JSONExtract 抠字段。如今原生 JSON 类型 越来越成熟:
CREATE TABLE logs
(
ts DateTime,
payload JSON
)
ENGINE = MergeTree
ORDER BY ts;
INSERT INTO logs VALUES (now(), '{"level":"error","trace":"abc","ctx":{"uid":1}}');
SELECT payload.level, payload.ctx.uid FROM logs; -- 直接按路径访问
TTL 也支持到列级别,不只是整行过期:
CREATE TABLE sessions
(
user_id UInt64,
last_seen DateTime,
name String TTL last_seen + INTERVAL 30 DAY, -- 30 天后该列置为默认值
profile String
)
ENGINE = MergeTree
ORDER BY user_id;
4.12 分布式:Distributed 表引擎与分片
单机终究有上限。ClickHouse 用 Distributed 引擎 把多分片屏蔽成一张逻辑表:
CREATE TABLE events_dist AS events
ENGINE = Distributed(cluster_name, currentDatabase(), events, rand());
rand() 是分片键(也可按某列哈希),写入会按分片键路由,查询会并行下发到各分片再汇总。注意 Distributed 本身不存数据,它只是「路由 + 汇总」的视图层,真正的 part 在底层本地表里。
五、性能优化:把吞吐榨到极限
ClickHouse 给你「快」是默认能力,但用得不好也能很慢。下面是工程师最常踩、也最该做的几件事。
5.1 表设计:ORDER BY 是命门
- 把最高频过滤列放 ORDER BY 最前面,让稀疏索引最大化裁剪;
- 排序键不宜过多(通常 3~5 列),否则索引变「宽」、聚簇性下降;
- 分区粒度要适度:按天分区在「数据量中等」时很好,但如果是千亿级且查询常跨月,按月甚至更粗的分区能减少 part 数与 merge 压力;反之超细分区(按小时)会导致 part 爆炸。
5.2 写入优化
- 永远批量写:单条 INSERT 是性能杀手,攒成每批几万~几十万行;
- 用 async_insert 或前置 Buffer / Kafka 表做写入缓冲;
- 控制 part 数量,避免「小 part 海」拖垮 merge 与启动;
- 对固定取值的列用 LowCardinality(String),对枚举用枚举类型,能显著压缩并加速比较。
5.3 查询优化
- 别
SELECT *:只取需要的列,这是列存最大的红利; - ClickHouse 会自动把 WHERE 里用于过滤的小列提到 PREWHERE(先读这几列过滤、再读其余列),你也可显式写
PREWHERE微调; - 善用 物化视图 把重聚合预计算掉,查询只读 rollup;
- 用
EXPLAIN PIPELINE和EXPLAIN indexes = 1看执行计划和索引命中,别靠猜; - 调
max_threads(并行度,默认等于核数)、max_memory_usage(单查询内存上限)、max_bytes_before_external_group_by(聚合内存不够时落盘)等设置,避免大查询 OOM 或被杀。
5.3 一张「诊断速查表」
| 现象 | 可能原因 | 抓手 |
|---|---|---|
| 查询扫了大量 granule | ORDER BY 首列不是过滤列 | 调整排序键,让稀疏索引能裁剪 |
| 小 part 太多、merge 跟不上 | 写入太碎 | 批量写 / async_insert / Buffer / Kafka |
| 大查询被 OOM 杀掉 | max_memory_usage 太小或没落盘 | 调大内存上限,开 max_bytes_before_external_group_by |
SELECT * 很慢 | 读了不需要的列 | 只取需要的列,利用列存红利 |
| 点查单条慢 | 用错了场景 | 改行存 / KV 数据库 |
| 去重/求和结果「不对」 | 还没 merge,看到脏数据 | 等 merge,或 FINAL/argMax 自查 |
文本检索只能 LIKE | 没建倒排索引 | 加 inverted 跳数索引 |
5.4 何时用 ClickHouse,何时别用
- 适合:日志/埋点/监控/广告/风控等「写多读少、 append 为主、海量扫描聚合」的场景;实时数仓、OLAP 服务层。
- 不适合:需要强事务、行级频繁更新删除、按主键点查单条、作为业务系统的「唯一真相源」。这些请留给 Postgres / MySQL。
- 与 DuckDB 的关系:DuckDB 是「进程内」的分析引擎,适合笔记本、脚本、单节点替换 pandas;ClickHouse 是「服务端」的分布式 OLAP,适合多节点、高并发、海量数据。两者不冲突,常组合:DuckDB 在边缘做轻量分析,ClickHouse 在中心做重负载数仓。
5.5 关于基准数字的一句话
业界公开基准与官方演示普遍显示:在典型的「宽表 + GROUP BY + 聚合」负载下,ClickHouse 相比行存数据库有 1~3 个数量级的吞吐优势,压缩比常达 5~20 倍。但具体数字高度依赖表设计、硬件与查询形态——与其迷信某个「官方 TPC-H 成绩」,不如拿你自己的真实 schema 和查询跑一遍 EXPLAIN,那才是属于你的数字。
六、总结展望:ClickHouse 的边界与未来
把全文收个尾,给程序员几句实在话:
1. ClickHouse 是「取舍型」数据库。 它主动放弃了行级强一致更新、跨行事务,换来分析场景的极致速度。选型时先问自己:我的瓶颈是「扫得多、聚得快」吗?是,就上;不是,别勉强。
2. 表设计决定命运。 ORDER BY / PARTITION BY 一旦选错,再牛的硬件也救不回来。稀疏索引、聚簇、merge 效率全部挂在排序键上,这是 ClickHouse 区别于「随便建个表就能跑」的数据库的核心。
3. 物化视图是实时数仓的发动机。 AggregatingMergeTree + MV 这套组合,把「每次查询都扫全表」变成「读预聚合结果」,是做实时大盘、监控、风控的最强武器。
4. 近年演进让它的边界在扩张。 v25.x 系列里,原生 JSON 类型 越来越成熟(不用再全塞进 String 再用 JSONExtract 抠字段);倒排索引 让文本检索不再只能靠 LIKE;Lightweight Delete 让删除不再是重写整个 part 的重操作;Projections 允许一张表有「另一种排序视图」而无需冗余;云原生侧 SharedMergeTree / Shared 数据库 把存储与计算分离,拥抱对象存储。这些都在让 ClickHouse 从「纯 OLAP」缓慢但坚定地走向「更通用的分析平台」。
5. 云原生与多模是主旋律。 ClickHouse Cloud、SharedMergeTree、对 S3 对象存储的原生支持,标志着它正从「单机猛兽」进化成「弹性分析云」。对团队来说,这意味着你可以用更低的运维成本,撑起过去要养一整套 Hadoop/Spark 才能扛的负载。
最后回到工程师视角:ClickHouse 最迷人的地方,不在于它「快」这个结果的结论,而在于它把「为什么快」写进了每一寸架构——列存、向量化、稀疏索引、part 合并、最终一致。当你真正理解了这些齿轮如何咬合,你就不只是「会写一条 SELECT」,而是拥有了一套在任意数据分析系统里都能复用的心智模型。这,才是「深度拆解」真正想交给你的东西。
本文基于 ClickHouse v25.8 LTS 线撰写,代码示例均可在该版本直接运行。版本特性持续演进,落地前请以官方文档与你的实际环境为准。