ClickHouse 深度实战:当列式存储成为实时分析引擎——从 MergeTree 引擎家族、向量化执行到分布式查询与生产级落地的完整工程指南(2026)
2026 年,OLAP 战场已经分化成两条清晰的路线:一条是 DuckDB 代表的「进程内分析引擎」,一条是 ClickHouse 代表的「分布式实时分析数据库」。前者把数据库塞进一个进程,后者把数据库变成一套能在 PB 级数据上做秒级响应的分析底座。本文不堆参数、不抄文档,而是从一个工程师的视角,把 ClickHouse 的「为什么快」「怎么用对」「如何调优」讲透,并配上能直接跑起来的代码。
一、背景:为什么 2026 年还要认真聊 ClickHouse?
很多人第一次听到 ClickHouse,是在「Yandex 开源的列式数据库」这种介绍里。但到了 2026 年,ClickHouse 已经不是「又一个 OLAP 数据库」了——它背后站着一整套实时可观测性(ClickStack)、云数仓、以及大量互联网公司的日志/指标/事件分析系统。
先说结论:ClickHouse 解决的核心矛盾,是「数据量极大」与「查询要快且要便宜」之间的冲突。
我们做个对比,更直观:
| 维度 | PostgreSQL | DuckDB | ClickHouse |
|---|---|---|---|
| 定位 | 通用 OLTP/OLAP 混合 | 进程内 OLAP | 分布式实时 OLAP |
| 存储模型 | 行存为主,可加列存索引 | 列存(单进程) | 列存(可分布式) |
| 写入模型 | 事务、行级更新 | 追加/批量 | 批量追加、后台合并 |
| 典型规模 | TB 级以内舒服 | 单机内存/磁盘 | PB 级 |
| 实时更新 | 强 | 中 | 弱(靠合并去重) |
| 典型场景 | 业务库、中小分析 | 本地数据分析、笔记本 | 日志、指标、事件、用户行为 |
关键点在于:ClickHouse 牺牲了「行级实时更新」和「事务一致性」,换来了「极致的扫描吞吐」和「极低的存储成本」。 它不追求像 PostgreSQL 那样每行可改,而是假设你的数据主要是「不断追加的事件流」,然后通过后台异步合并(Merge)来收敛数据。这个设计取舍,决定了它适合什么、不适合什么。
到 2026 年,ClickHouse 已经迭代到 v26.x(26.3 是 LTS,26.6 是最新稳定版)。v25 到 v26 这一代最大的变化,是它在「易用性」上补齐了大量短板:轻量删除/更新(mutations)成熟、倒排索引支持文本检索、JSON 成为一等公民、物化投影(Projection)普遍可用、以及对 Iceberg / Delta / Unity Catalog 等开放表格式的直接查询支持。换句话说,它正在从「极客的性能怪兽」变成「能直接当实时数仓用的生产系统」。
二、核心概念:列式存储与 MergeTree 引擎家族
2.1 列式存储到底改变了什么?
传统行存数据库(MySQL、PostgreSQL)按行把一整条记录写在一起。当你要 SELECT sum(amount) FROM orders 时,它得把每条订单的整行都读出来,再摘出 amount 字段——大量 IO 被浪费在无关字段上。
ClickHouse 按列存储:同一列的数据物理上挨在一起。算 sum(amount) 时,它只读取 amount 这一列的数据块,IO 量直接缩小到「查询涉及的列 / 全部列」。这就是 OLAP 场景下降本增效的根本来源。
但列存只是第一步,真正让 ClickHouse 起飞的是两件配套设计:
- 向量化执行(Vectorized Execution):不再是「一次算一行」,而是「一次对一个数据块(Block,通常 8192 行)做批量运算」。CPU 分支预测、函数调用开销被摊薄,还能用 SIMD 指令加速。
- 高压缩比:同一列数据类型相同、取值范围相近,压缩率远高于行存(常见 5~10 倍)。压缩不仅省磁盘,更省 IO——而 IO 往往是分析查询的瓶颈。
2.2 主键不是「唯一约束」,而是「稀疏索引」
这是 ClickHouse 最容易踩的认知坑:它的 PRIMARY KEY 不保证唯一,也不做去重,它是一棵「稀疏索引」(sparse index)。
工作原理:
- 数据按
ORDER BY排序后,每index_granularity(默认 8192)行取一个「标记」(主键列的值 + 该 granule 在磁盘上的偏移)。 - 查询时,ClickHouse 用主键索引快速定位到可能包含目标数据的 granule 区间,跳过其余 99% 的数据块。
- 因为索引是「每 8192 行一个标记」,所以索引本身极小,可以全放内存。
-- 注意:PRIMARY KEY 默认等于 ORDER BY。
-- 主键是稀疏索引(用于跳过数据块),ORDER BY 决定物理排序。
CREATE TABLE events
(
event_date Date,
user_id UInt64,
event_type String,
amount Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date) -- 按月分区
ORDER BY (user_id, event_date, event_type) -- 物理排序键
PRIMARY KEY user_id -- 主键可以是 ORDER BY 的前缀
SETTINGS index_granularity = 8192;
设计建议:ORDER BY 要选「查询里最常用来过滤 + 高基数的列」。比如用户行为分析里 user_id 高基数、常被范围过滤,放前面能最大化索引跳过效果。PRIMARY KEY 通常设成 ORDER BY 的前缀,用于更粗粒度的裁剪。
2.3 MergeTree 引擎家族:一套底座,多种合并策略
MergeTree 是 ClickHouse 所有表的基石。它在后台做的事情很直白:不断把小数据 parts 合并成更大的 parts,并在合并时应用特定规则。不同引擎的区别,就在于「合并时应用什么规则」。
(1)MergeTree —— 最朴素的追加表
就是上面的例子。写入快、扫描快,但相同主键不会自动去重。如果你重复插入相同数据,查出来就是两份。适合纯追加的事件流。
(2)ReplacingMergeTree —— 合并时按版本去重
CREATE TABLE user_profile
(
user_id UInt64,
name String,
updated_at DateTime,
is_deleted UInt8 DEFAULT 0
)
ENGINE = ReplacingMergeTree(updated_at) -- 合并时保留 updated_at 最大的一行
ORDER BY user_id;
合并时,相同 ORDER BY(这里是 user_id)的多行只保留 updated_at 最大的那条。注意两个坑:
- 去重只在合并发生时生效,不是写入即生效。新插入的重复行在合并前查得到。
- 要立刻看到去重结果,用
SELECT ... FROM user_profile FINAL;,但FINAL会强制现场合并,代价高,生产查询别滥用。正确做法是非热点查询依赖后台合并,或定期OPTIMIZE TABLE user_profile FINAL(在低峰做)。
(3)SummingMergeTree —— 合并时预聚合求和
CREATE TABLE daily_sales
(
date Date,
region String,
orders UInt64,
revenue Float64
)
ENGINE = SummingMergeTree
ORDER BY (date, region);
相同 (date, region) 的行在合并时被「加」成一行:orders 求和、revenue 求和,非聚合列取遇到的第一行。这对实时指标汇总极友好——写入明细,查询直接读聚合结果,快得离谱。
(4)AggregatingMergeTree —— 存储「聚合中间态」
这是物化视图实时聚合的核心。它存的不是最终值,而是聚合函数的中间状态(比如计算 uniqState、sumState),查询时再用 *-Merge 函数收口。
CREATE TABLE uv_states
(
date Date,
page String,
uv AggregateFunction(uniq, UInt64) -- 注意类型声明
)
ENGINE = AggregatingMergeTree
ORDER BY (date, page);
-- 写入时用 -State 版本,把中间态存进去
INSERT INTO uv_states
SELECT date, page, uniqState(user_id)
FROM raw_clicks
GROUP BY date, page;
-- 查询时用 -Merge 收口
SELECT date, page, uniqMerge(uv) AS uv_count
FROM uv_states
GROUP BY date, page;
uniqState 存的是 HyperLogLog 的草图,合并时再算近似去重。这是 ClickHouse 能在亿级数据上秒出 UV 的秘诀。
(5)CollapsingMergeTree / VersionedCollapsingMergeTree —— 行级「正负抵消」
用 +1 / -1 的 sign 列表示「新增 / 撤销」,合并时相互抵消,实现类似「更新」的语义。比 ReplacingMergeTree 更细,适合需要精确表达「这一行被改/被删」的场景,但写入端要自己发成对的正负行,工程复杂度高。
选型清单(背下来):
- 纯事件流、不怕重复 →
MergeTree - 需要「按最新版本去重」→
ReplacingMergeTree - 需要「明细自动累加成指标」→
SummingMergeTree - 需要「任意聚合的实时物化」→
AggregatingMergeTree+ 物化视图 - 需要「精确增删改表达」→
CollapsingMergeTree
三、架构解析:向量化执行引擎与分布式骨架
3.1 向量化执行:Block、IColumn 与「少调用函数」
ClickHouse 的执行引擎核心数据结构是 Block(一组同长度的 Column)和 IColumn(某一列的连续内存)。查询被拆成一串 Processor(处理器),数据以 Block 为单位在 Processor 之间流动:
Source(读列) → Expression(算表达式) → Filter(过滤) → Aggregator(聚合) → Sink(输出)
每个 Processor 一次处理一整个 Block(8192 行),而不是一行。好处:
- 函数调用次数从「行数」降到「Block 数」(除以 8192)。
- 内部用模板 + 编译期分派替代运行时虚函数,配合 SIMD 批量算。
- 列数据是连续内存,CPU cache 命中率高。
你可以用 EXPLAIN PIPELINE 直接看一条查询被拆成了哪些 Processor:
EXPLAIN PIPELINE
SELECT region, sum(revenue) FROM daily_sales GROUP BY region;
输出会是一棵处理器树,你能清楚看到 AggregatingTransform、ResizingTransform 等在怎么协作。做性能分析时,这比看执行计划更贴近真实执行。
3.2 ClickHouse Keeper:ZooKeeper 的 Raft 替代
老版本 ClickHouse 用 ZooKeeper 做副本协调(记录 parts、DDL 锁)。ZooKeeper 是个外部依赖,运维痛。新版本内置 ClickHouse Keeper——一个用 C++ 实现、基于 Raft 共识、协议兼容 ZooKeeper 的组件。部署 3 节点 Keeper 即可替代外部 ZK,副本元数据的可靠性不变,运维却简单一个量级。
3.3 分布式:分片 + 副本 + Distributed 表
ClickHouse 的分布式不是「自动分库分表」,而是「手动声明、显式路由」:
- 分片(Shard):数据按分片键切到不同节点,负责水平扩展容量和吞吐。
- 副本(Replica):同一分片的多份拷贝,负责高可用。靠
ReplicatedMergeTree+ Keeper 同步。 - Distributed 表:一张「逻辑表」,本身不存数据,只把查询转发到各分片并把结果合并回来。
<!-- 集群定义在 config 里(简化) -->
<remote_servers>
<ch_cluster>
<shard>
<replica><host>ch-node-1</host></replica>
<replica><host>ch-node-2</host></replica>
</shard>
<shard>
<replica><host>ch-node-3</host></replica>
<replica><host>ch-node-4</host></replica>
</shard>
</ch_cluster>
</remote_servers>
-- 本地表(每个节点都有一份 ReplicatedMergeTree)
CREATE TABLE events_local ON CLUSTER ch_cluster
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date);
-- 分布式逻辑表,查询打它就自动路由到各分片
CREATE TABLE events AS events_local
ENGINE = Distributed(ch_cluster, default, events_local, rand());
写入 events(分布式表)时,ClickHouse 按分片键(这里 rand() 随机)把数据落到对应分片;查询 events 时,它把 SQL 下推到各分片并行执行,再在发起节点做最终合并。这就是 MPP(大规模并行处理)的朴素实现。
四、代码实战:从零搭一套能跑的生产级 ClickHouse
4.1 Docker 一键起(含 Keeper)
# docker-compose.yml
services:
keeper:
image: clickhouse/clickhouse-keeper:25.8
ports: ["9181:9181"]
command: ["/etc/clickhouse-keeper/keeper.xml"]
clickhouse:
image: clickhouse/clickhouse-server:25.8
ports: ["8123:8123", "9000:9000"]
environment:
CLICKHOUSE_PASSWORD: "secret"
volumes: ["./config.xml:/etc/clickhouse-server/config.d/cluster.xml:ro"]
启动后用 HTTP 接口就能查:
# 健康检查
curl 'http://localhost:8123/?user=default&password=secret' --data 'SELECT version()'
# 批量插入(TSV)
echo -e "1\talice\t2026-07-01\n2\tbob\t2026-07-02" | \
curl 'http://localhost:8123/?user=default&password=secret' \
--data 'INSERT INTO events FORMAT TSV'
4.2 实战:用户行为实时漏斗
假设我们有一张原始点击表,要实时算出「每天每页的 UV 和 PV」。用 AggregatingMergeTree + 物化视图:
-- 原始明细
CREATE TABLE raw_clicks
(
event_date Date DEFAULT today(),
user_id UInt64,
page String,
ts DateTime DEFAULT now()
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (page, ts);
-- 物化视图:插入 raw_clicks 时自动聚合进 uv_states
CREATE MATERIALIZED VIEW mv_daily_uv
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (day, page)
AS
SELECT
toDate(ts) AS day,
page,
uniqState(user_id) AS uv,
countState() AS pv
FROM raw_clicks
GROUP BY day, page;
-- 业务写入明细,视图自动物化
INSERT INTO raw_clicks (user_id, page) VALUES
(1, '/home'), (1, '/home'), (2, '/home'), (3, '/product');
-- 查询实时指标(秒回)
SELECT
day, page,
uniqMerge(uv) AS uv,
countMerge(pv) AS pv
FROM mv_daily_uv
GROUP BY day, page
ORDER BY pv DESC;
关键认知:ClickHouse 的物化视图不是「查询时的视图」,而是「写入时的触发器」。 数据进 raw_clicks 的瞬间,就会被增量聚合写入目标表,之后查询只读已算好的聚合结果——这是实时看板的性能根基。
4.3 Python 客户端(clickhouse-connect)
官方推荐的现代 Python 驱动是 clickhouse-connect(比老 clickhouse-driver 更快、更省内存):
import clickhouse_connect
client = clickhouse_connect.get_client(
host='localhost', port=8123,
username='default', password='secret', database='default'
)
# 建表
client.command("""
CREATE TABLE IF NOT EXISTS events_py
(
event_date Date,
user_id UInt64,
event_type String,
amount Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date)
""")
# 批量插入(用列格式,避免逐行 INSERT)
rows = 1_000_000
client.insert(
'events_py',
[
[20260701 + (i % 30) for i in range(rows)], # event_date
[i % 500_000 for i in range(rows)], # user_id
[f"type_{i % 5}" for i in range(rows)], # event_type
[float(i % 100) for i in range(rows)], # amount
],
column_names=['event_date', 'user_id', 'event_type', 'amount']
)
# 查询(返回 Arrow Table,零拷贝给 pandas/分析栈)
result = client.query("SELECT event_type, sum(amount) FROM events_py GROUP BY event_type")
print(result.result_rows)
clickhouse-connect 默认走 HTTP,返回 Apache Arrow 格式,能零拷贝喂给 Pandas/Polars/DuckDB,整个 Python 分析链路打通。
4.4 Go 客户端(clickhouse-go)
Go 服务里高频写 ClickHouse,用 clickhouse-go(原生 TCP 协议,支持批量):
package main
import (
"context"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
)
func main() {
conn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{"localhost:9000"},
Auth: clickhouse.Auth{Database: "default", Username: "default", Password: "secret"},
})
if err != nil { panic(err) }
defer conn.Close()
ctx := context.Background()
batch, err := conn.PrepareBatch(ctx, "INSERT INTO events_py")
if err != nil { panic(err) }
// 用 Append 攒一个 batch 再一次性发,吞吐远高于逐条 INSERT
for i := 0; i < 100_000; i++ {
if err := batch.Append(
time.Now(), // event_date
uint64(i), // user_id
"type_0", // event_type
float64(i%100), // amount
); err != nil { panic(err) }
}
if err := batch.Send(); err != nil { panic(err) }
}
要点:永远批量写(batch),别在循环里逐条 INSERT。逐条 INSERT 每次都要建连接、建 parts,吞吐能差两个数量级。ClickHouse 的设计哲学就是「攒一批、一次落」。
五、性能优化:索引、分区与查询调优
5.1 跳数索引(二级索引):主键之外的第二把刀
主键索引只能按 ORDER BY 前缀裁剪。如果你的过滤条件不在主键前缀上(比如按 event_type 过滤),主键就帮不上忙。这时用跳数索引(skip index):
CREATE TABLE logs
(
ts DateTime,
level LowCardinality(String),
service String,
trace_id String,
msg String,
INDEX idx_service service TYPE bloom_filter(0.01) GRANULARITY 4,
INDEX idx_trace trace_id TYPE tokenbf_v1(1024, 2, 0) GRANULARITY 4,
INDEX idx_level level TYPE set(100) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (ts, service);
四种常用类型:
minmax:记录 granule 内列的最大最小值,范围查询高效。set(N):记录 granule 内出现的去重值集合,等值过滤高效。bloom_filter(p):布隆过滤器,适合高基数列的等值/IN 查询。tokenbf_v1/ngrambf_v1:分词/ngram 布隆,适合LIKE和文本包含查询。
GRANULARITY 4 表示「每 4 个 granule(8192×4 行)建一个索引项」。索引会占用存储和写入开销,只在「高频且无法靠主键裁剪的过滤列」上加。
5.2 分区策略:按月,别按天也别按年
PARTITION BY toYYYYMM(date) 是经验最优解:
- 按月分区,单分区数据量适中,合并效率好。
- 按天分区会让 parts 数量爆炸,合并压力大、元数据膨胀。
- 按年分区会让单分区过大,删除/迁移一个分区代价太高。
分区还带来一个隐藏能力:分区级 TTL 和 DROP PARTITION。冷数据直接 ALTER TABLE x DROP PARTITION '202501' 秒删,比 DELETE 便宜一万倍。
5.3 关键 settings:先会看,再会调
影响最大的几个:
| setting | 作用 | 建议 |
|---|---|---|
max_threads | 查询并行线程数 | 默认等于 CPU 核数,复杂查询可适当上调 |
max_memory_usage | 单查询内存上限 | 集群里设成节点内存的 70%~80% |
use_uncompressed_cache | 是否缓存解压后的块 | 反复扫描同列时开,提升缓存命中 |
max_insert_block_size | 单次插入 Block 大小 | 批量写时调到 1e6 量级 |
send_logs_level | 日志级别 | 排查时设 trace 看执行细节 |
5.4 用 query_log 做「性能法医」
ClickHouse 自带 system.query_log,每条查询的资源消耗都被记下来,这是它比很多数据库好调优的地方:
SELECT
query,
query_duration_ms,
read_rows,
memory_usage,
formatReadableSize(memory_usage) AS mem
FROM system.query_log
WHERE event_date = today() AND type = 'QueryFinish'
ORDER BY query_duration_ms DESC
LIMIT 10;
配合 EXPLAIN PLAN / EXPLAIN PIPELINE,你能精确知道一条慢查询是「读太多行」「内存爆了」还是「某个 join 算法选错」。这套自带的 observability,是生产运维的救命绳。
5.5 投影(Projection):让一张表「换种排序」被自动选中
有时候同一张表既要按 (user_id, ts) 查,又要按 (event_type, ts) 查。建第二个 ORDER BY 的物理表太浪费。用 Projection:
ALTER TABLE events ADD PROJECTION proj_by_type
(
SELECT * ORDER BY (event_type, ts)
);
插入数据后执行 ALTER TABLE events MATERIALIZE PROJECTION proj_by_type(回填已有数据)。之后按 event_type 过滤的查询,ClickHouse 会自动选用这个投影,无需改 SQL。投影本质是「同一份数据的另一种物理排序」,查询优化器会自动挑最优的那份。
六、总结与展望:2026 年的 ClickHouse 该怎么用
6.1 v25 → v26 的关键进化
把前面提到的点收一下,这一代 ClickHouse 最值得关注的:
- 轻量删除/更新(mutations)成熟:
DELETE/UPDATE不再需要重写整分区,后台异步、影响可控,让「需要偶尔改数」的业务也能上车。 - 倒排索引(Inverted Index):原生支持文本检索,日志场景可以直接用 ClickHouse 做全文搜索,少养一套 ES。
- JSON 一等公民:
JSON类型 + schema 推断,半结构化日志不用先拍平成固定列。 - 开放表格式直读:直接
SELECTIceberg / Delta / Hudi / Unity Catalog 上的数据,ClickHouse 当「查询引擎」贴在湖上。 - 投影与查询条件缓存普遍可用:同表多维度加速、重复子查询缓存,进一步压榨性能。
6.2 选型边界(最重要的一节)
用 ClickHouse,如果你:
- 数据主要是追加型事件流(日志、指标、埋点、IoT)。
- 查询以「大范围扫描 + 聚合」为主,对单行点查、事务一致性要求低。
- 数据量大(GB 起步,到 PB),且希望存储成本可控。
- 需要实时看板、实时聚合。
别用 ClickHouse,如果你:
- 业务系统需要事务、行级强一致更新(用 PostgreSQL / MySQL)。
- 数据是笔记本上的小文件、临时分析(用 DuckDB 更顺手)。
- 高并发单行点查、低延迟 KV 访问(用 Redis / Valkey)。
- 需要复杂 join 的 TP 型业务。
一句话:ClickHouse 是「分析型实时底座」,不是「万能数据库」。 它和 PostgreSQL、DuckDB、Valkey 是互补关系——一个现代数据栈里,它们经常同时存在,各管一段。
6.3 给工程师的三条建议
- 先想 ORDER BY,再想建表。 排序键决定了索引裁剪效率和 80% 的查询性能,是 ClickHouse 设计的「第一性原理」。
- 批量写、攒批次。 无论哪个客户端,都走 batch 插入,这是吞吐的生命线。
- 物化视图做实时,投影做多维。 实时聚合交给 AggregatingMergeTree + MV;同一张表的多维加速交给 Projection。两者配合,能在 PB 级数据上稳定给出亚秒级响应。
ClickHouse 的魅力,不在于它「快」这一个字,而在于它用一套清晰的设计取舍(列存 + 向量化 + 后台合并 + 稀疏索引),把「大规模实时分析」这件原本很贵的事,做得既快又便宜。理解它为什么这么设计,比背会任何配置项都重要——这也是 2026 年还值得认真聊它的原因。
本文代码示例基于 ClickHouse v25/v26 系列,客户端使用 clickhouse-connect(Python)与 clickhouse-go(Go)的当前主流版本。生产环境请结合官方文档核对具体版本的行为差异。