ClickHouse 深度实战:从列式存储、MergeTree 稀疏索引到向量检索与物化投影的工程全解(2026)
当你面对每天 200 亿条记录的查询、需要从 PostgreSQL 迁移来把平均延迟压到 250ms 以内,或者想用一套 SQL 同时解决实时分析与向量检索时,ClickHouse 几乎是那个「答案」。本文从工程师视角,把它的底层存储、稀疏索引、表引擎家族、分布式架构,到物化视图、投影、JSON 类型、向量检索,再到 Go/Python 实战与性能优化,一次性讲透。
一、背景介绍:为什么是 ClickHouse,而不是再写一条 Postgres 慢查询
1.1 OLTP 与 OLAP 的「物理鸿沟」
很多人第一次栽在 ClickHouse 上,不是因为它难,而是因为用错了场景。我们得先厘清一个基本事实:为事务设计的数据库,天生不适合做分析;为分析设计的数据库,天生不适合做事务。
关系型数据库(MySQL、PostgreSQL)采用行式存储(row-oriented):一行记录的所有字段在磁盘上紧挨着存放。这在「取一条订单的完整信息」时效率极高,因为一次磁盘 IO 就能拿到整行。但在分析场景里,典型的查询是:
SELECT toStartOfHour(event_time) AS hour, count()
FROM user_events
WHERE app_id = 1024 AND event_time >= now() - INTERVAL 7 DAY
GROUP BY hour;
这个查询只用到 event_time、app_id 两列,却要扫描几十亿行。行式存储被迫把每行不相关的字段(用户名、头像 URL、设备信息……)一起读进内存,CPU 和 IO 大量浪费在「读了我根本用不上的数据」上。
ClickHouse 选择列式存储(columnar storage):同一列的数据在物理上连续存放。上面的查询只需要把 event_time 和 app_id 两列的数据块读进内存,其余几十列完全不用碰。这一条,就让分析查询的 IO 量从「行数 × 行宽」骤降到「行数 × 用到的列数」。
1.2 Yandex 的「军火库」出身
ClickHouse 由俄罗斯搜索巨头 Yandex 为其网页分析产品 Metrica 研发,2016 年开源,C++ 编写。它的设计目标从一开始就非常纯粹:在海量数据上做极快的实时聚合查询。Metrica 是世界上第二大的网页分析平台,要处理 PB 级数据、每秒数百万事件写入、亚秒级交互式查询——这种「写多、读聚合、几乎不更新」的负载,恰好是 ClickHouse 的甜区。
对比一下同期的几个选择:
- PostgreSQL:事务强、生态好,但行式存储 + B 树索引,在十亿级聚合查询上会迅速「上探」到全表扫描。
- MySQL:类似,且分析函数(窗口函数、CTE)长期偏弱。
- Elasticsearch:全文检索强,但聚合查询成本高、存储膨胀严重。
- DuckDB(本站已有专文):进程内(in-process)OLAP,单机 embedded 场景无敌,但分布式与高并发服务化能力不是它的主战场。
- ClickHouse:分布式、高吞吐写入、列存 + 向量化 + 稀疏索引,专为「大规模 + 实时 + 服务化」的分析而生。
一句话定位:DuckDB 是你笔记本里那个快到飞起的本地分析引擎;ClickHouse 是你把分析能力当成一项高并发在线服务对外提供时的最终形态。
1.3 2026 年的 ClickHouse 在往哪走
今天的 ClickHouse 早已不只是「快一点的 MySQL 替代品」。几个 2024–2026 年的关键演进让它的边界继续扩张:
- 原生 JSON 数据类型(2024 引入,持续增强):不再需要把 JSON 拍平或塞进 String,半结构化数据有了「一等公民」。
- 向量检索能力:内置
L2Distance、cosineDistance等距离函数,以及近似最近邻(ANN)索引,让 ClickHouse 能直接用 SQL 做 embedding 检索,RAG / 记忆系统不再必须外接向量库。 - SharedMergeTree:云原生存算分离引擎,Serverless 弹性 + 共享对象存储,把运维复杂度进一步压低。
- ClickStack:基于 ClickHouse 的开源可观测性技术栈,用一套引擎统一日志、指标、追踪。
这些让 ClickHouse 从「OLAP 数据库」逐渐变成「实时数据平台」。本文会逐步拆解它们。
二、核心概念:列式、向量化、稀疏索引
2.1 列式存储不只是「按列存」
列式的收益有三层,常被初学者忽略:
- IO 剪枝:只读用到的列(上一节已讲)。
- 压缩率暴增:同一列的数据类型相同、取值范围相近(比如全是状态码 0–3、全是城市名),压缩算法(LZ4、ZSTD)能压到行式的数倍甚至数十倍。压缩不仅省磁盘,更省 IO——读 1GB 压缩数据可能只需从磁盘读 200MB。
- 向量化执行友好:同一列连续存放,CPU 可以一次性把一整块(batch)同类型数据喂给 SIMD 指令并行处理,而不是逐行解释执行。
2.2 向量化执行引擎:不是「一行一行算」,而是「一块一块算」
传统解释型执行器对每一行调用一次函数,分支预测差、函数调用开销大。ClickHouse 把数据按 block(默认 65536 行左右的一块)组织在内存里,每个列是一个连续的、定宽的数组。聚合、过滤、表达式求值都针对整个数组批量执行,内部大量使用 SIMD 指令。
举个直觉例子,计算 sum(x * 2 + 1):
- 解释执行:循环 N 次,每次
acc += x[i]*2 + 1,每次都有分支和函数调用。 - 向量化执行:先对整个 x 列做
col * 2(一条 SIMD 指令处理 8/16/32 个元素),再做+ 1,最后一次向量求和。CPU 的乱序执行和流水线被喂得满满当当。
这也是为什么 ClickHouse 在 SELECT count(*)、GROUP BY 这类聚合上能甩开传统数据库一个数量级:它把「逐行」的重活,变成了「逐块」的机器级并行。
2.3 稀疏主键索引:为什么 ClickHouse 不用 B 树
这是 ClickHouse 最反直觉、也最关键的设。PostgreSQL 的主键是 B 树,稠密索引——每一行都在索引里有一条记录,能精确定位到某一行。代价是索引本身巨大、写入要维护树结构、且不擅长「范围聚合」。
ClickHouse 的 MergeTree 用的是稀疏索引(sparse index):它不为每一行建索引,而是每 index_granularity(默认 8192)行记录一个「标记」(mark),指向这一数据颗粒(granule)在磁盘上的位置。查询时:
- 在
primary.idx上做二分查找,定位到可能包含目标键值的若干 granule; - 只读这些 granule 对应的数据块;
- 在一个 granule 内部(8192 行)再做线性扫描。
为什么敢「稀疏」?因为分析查询本来就是扫大量行,少读几百个 granule 已经足够,精确到单行反而拖慢写入、膨胀索引。稀疏索引用极小的索引体积,换来了「范围裁剪」的高效——这是 ClickHouse 能在百亿数据上秒级响应的底层密码。
工程直觉:稠密索引(B 树)适合「点查单行」;稀疏索引(MergeTree)适合「范围聚合 + 跳过大量无关数据」。这再次印证了「场景决定架构」。
三、架构分析:MergeTree 是怎么「长」出来的
3.1 表引擎家族全景
ClickHouse 的「表引擎」决定了数据怎么存、怎么读、支持什么操作。核心是 MergeTree 家族:
| 引擎 | 用途 | 关键语义 |
|---|---|---|
MergeTree | 通用基线 | 主键稀疏索引、分区、TTL、可加副本 |
ReplicatedMergeTree | 高可用 | 在 MergeTree 之上加 ZK/Keeper 协调的副本同步 |
ReplacingMergeTree | 去重最新值 | 按 ORDER BY + 版本列,合并时保留最新版本 |
SummingMergeTree | 预聚合求和 | 合并时对数值列求和,适合指标累加 |
AggregatingMergeTree | 预聚合复杂指标 | 配合 -State/-Merge 函数做任意聚合预计算 |
CollapsingMergeTree | 行级增删 | 用 sign 列抵消旧行,实现「删除」 |
VersionedCollapsingMergeTree | 带版本增删 | 解决并发写入的顺序问题 |
SharedMergeTree | 云原生 | 存算分离,共享对象存储,Serverless 弹性 |
注意一个常见误解:ORDER BY 不等于 PRIMARY KEY。在 MergeTree 里,ORDER BY 决定了数据在磁盘上的物理排序(这是性能的根本),PRIMARY KEY 默认等于 ORDER BY 但可以取它的前缀,用来控制稀疏索引的列——当你想按 A、B、C 排序存,但只想用 A、B 建索引时能用上。
3.2 写入流程:从内存到 part 到 merge
理解「为什么 ClickHouse 写入飞快、删除/更新很别扭」,关键在这一节。
写入链路:
- 数据进入内存:INSERT 的数据先在内存里攒成列存格式。
- 落盘成 part:达到一定阈值(或
async_insert攒批),一次性写入磁盘,形成一个 part(目录,如202601_1_1_0)。 - 后台 merge:ClickHouse 的后台线程会把多个小 part 合并成更大的 part(类似 LSM-Tree 的 compaction)。所有「去重、聚合、替换」语义,都是在 merge 阶段发生的,而不是在写入时。
这条链路的启示:
- 写入极快:因为只是追加式落盘 + 后台异步合并,没有 B 树的随机写。
- 「UPDATE/DELETE 很贵」:ClickHouse 早期几乎不支持原地更新。所谓更新,往往是用
ReplacingMergeTree插入一条新版本,等后台 merge 时把旧版本「折叠」掉——也就是说,你查到的可能是合并前的中间状态,除非用FINAL或特殊的投影。 FINAL是双刃剑:SELECT ... FROM t FINAL会强制触发合并视图,保证看到去重结果,但代价是当场做合并,慢。生产上更推荐用物化视图把结果预先算好。
3.3 数据的物理结构(这部分值得背下来)
一个 part 目录里长这样:
20260101_0_0_0/
├── checksums.txt # 校验和,保证数据完整性
├── columns.txt # 列定义
├── count.txt # 行数
├── primary.idx # 稀疏主键索引(每 granule 一个标记值)
├── [column].bin # 该列压缩后的二进制数据
├── [column].mrk2 # 该列的标记文件,把 primary.idx 的标记映射到 .bin 的偏移
└── partition.dat # 分区信息(若需)
查询时:用 primary.idx 二分定位 granule → 通过 .mrk2 找到 .bin 中对应的压缩块偏移 → 解压 → 在 granule 内线性扫描。整个过程几乎零随机 IO,全是顺序读 + 解压 + 向量化计算。
3.4 分区裁剪:最容易拿到的免费性能
PARTITION BY 把数据按某个表达式(通常是时间 toYYYYMM(event_time))切成不同的目录。查询带上分区键条件时,ClickHouse 直接跳过无关分区目录——这叫分区裁剪(partition pruning)。
-- 只扫 2026 年 1 月这一个分区目录
SELECT count() FROM events
WHERE event_date = '2026-01-15';
对时间序列数据,分区键几乎必选「时间」。但要注意:分区太细(如按天、数据量小)会制造海量小 part,反而拖慢 merge;分区太粗(如按年)又剪不掉多少。 经验值:单个分区原始数据量在「单分区几 GB 到几十 GB」比较舒服。
3.5 分布式架构:分片、副本与 Keeper
单机的 ClickHouse 再快也有上限。生产集群靠两样东西横向扩展:
- 分片(shard):把数据水平拆分到不同节点,查询时并行扫所有分片再汇总。靠
Distributed表引擎做路由——它本身不存数据,只是个「逻辑视图」,把请求转发到各分片的本地表。 - 副本(replica):同一份数据存多份,防单机宕机。副本同步靠 ClickHouse Keeper(ZooKeeper 的 C++ 重写版,更轻、更稳),替代老式的 ZooKeeper 依赖。
典型部署:N 个 shard,每个 shard 有 2 个 replica,用 ReplicatedMergeTree 家族 + Distributed 表。写请求落到任意副本,Keeper 协调同步到其他副本;读请求经 Distributed 表扇出到所有 shard 并行执行。
SharedMergeTree(企业版/云)进一步把 part 存到共享对象存储(S3),计算节点无状态、可随时扩缩容,实现真正的 Serverless 弹性——这是 ClickHouse 对抗「存算耦合」运维痛点的答案。
四、代码实战:从建表到向量检索
理论够了,上代码。下面所有示例都是可直接落地的工程写法。
4.1 建一张「对」的表:ORDER BY 是灵魂
建表最容易犯的错,是随便抄一个 ORDER BY id。记住:ORDER BY 决定物理排序,直接决定哪些查询能命中稀疏索引。
CREATE TABLE events
(
event_date Date,
app_id UInt32,
user_id UInt64,
event_type LowCardinality(String), -- 低基数字符串用 LowCardinality,省空间提速度
country FixedString(2), -- 固定长度(国家码)用 FixedString
payload String,
duration_ms UInt32,
event_time DateTime
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date) -- 时间分区,天然支持裁剪
ORDER BY (app_id, event_time, user_id) -- 高频查询的过滤/排序列放前面
TTL event_time + INTERVAL 90 DAY -- 90 天后自动清理,控制存储成本
SETTINGS index_granularity = 8192;
设计 ORDER BY 的口诀:把「等值过滤列」和「范围/排序列」按查询频率从左到右排。比如上面 (app_id, event_time, user_id):先按 app 等值过滤,再按时间范围,最后用 user 细化。这样绝大多数查询都能顺着稀疏索引跳过大量 granule。
4.2 写入:异步插入是生产必修课
直接 INSERT INTO ... VALUES 逐行写,ClickHouse 会被海量小 part 淹没。生产正确姿势是攒批 + 异步插入:
-- 服务端攒批:客户端只管发,ClickHouse 在后台按条数/时间合并成 part
SET async_insert = 1;
SET wait_for_async_insert = 1; -- 1=等确认落盘;压测时可设 0 追求吞吐
SET async_insert_max_data_size = '1M';
SET async_insert_busy_timeout_ms = 1000;
INSERT INTO events VALUES
('2026-01-15', 1024, 88123, 'click', 'CN', '{"x":1}', 12, '2026-01-15 10:00:00'),
('2026-01-15', 1024, 88124, 'view', 'CN', '{"x":2}', 8, '2026-01-15 10:00:01');
客户端侧也要攒批(比如每 1000 行或每 200ms 发一次),双管齐下。
4.3 查询实战:聚合、窗口函数与 JOIN 避坑
-- 漏斗式留存:每小时事件量(向量化聚合,飞快)
SELECT
toStartOfHour(event_time) AS hour,
count() AS events,
uniqExact(user_id) AS uv,
quantile(0.95)(duration_ms) AS p95_ms
FROM events
WHERE app_id = 1024
AND event_time >= '2026-01-15 00:00:00'
GROUP BY hour
ORDER BY hour;
-- 窗口函数:每个 app 内按时间排的事件序号
SELECT
app_id, user_id, event_time,
row_number() OVER (PARTITION BY app_id ORDER BY event_time) AS seq
FROM events
WHERE event_date = '2026-01-15'
LIMIT 10;
JOIN 的「隐秘陷阱」(很多人集群崩在这里):ClickHouse 默认把右表全量拉到各分片内存做 hash join。如果右表是「百亿轨迹表 JOIN 千万维表」,内存直接炸。正确姿势:
- 小表在右:把维表放右,事实表放左。
- 用
join_algorithm = 'grace_hash':Grace Hash Join 把数据分桶落盘,避免单点内存爆炸。 - 用字典(Dictionary)替代维表 JOIN:维度数据(国家码→名称)注册成 Dictionary,查询时
dictGet直接查,零 JOIN 开销。
-- 用字典替代 JOIN,性能与稳定性双提升
SELECT
e.user_id,
dictGetString('countries', 'name', e.country_code) AS country_name
FROM events e
WHERE event_date = '2026-01-15';
4.4 物化视图:把「实时预聚合」当主菜
物化视图(Materialized View)在 ClickHouse 里不是「视图」——它是一张真实存在的表,源表 INSERT 时,MV 自动把数据按定义再算一份存下来。最适合做「高频聚合查询加速」。
-- 原始明细表
CREATE TABLE hits
(
event_date Date,
app_id UInt32,
user_id UInt64,
duration_ms UInt32
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (app_id, event_date);
-- 物化视图:按 (app_id, 天) 预聚合 PV / 平均时长
CREATE MATERIALIZED VIEW hits_daily_mv
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (app_id, day)
AS SELECT
app_id,
toDate(event_time) AS day,
count() AS pv,
sum(duration_ms) AS total_ms
FROM hits
GROUP BY app_id, day;
写入 hits 时,hits_daily_mv 同步更新。SummingMergeTree 会在后台 merge 时把相同 (app_id, day) 的行合并求和。查询直接读 MV,毫秒级:
SELECT app_id, day, pv, total_ms / pv AS avg_ms
FROM hits_daily_mv
WHERE day = '2026-01-15';
注意:官方不推荐建 MV 时加
POPULATE(会把历史数据一次性灌入,但灌入期间的新写入会丢)。正确做法是无POPULATE建空 MV,再用INSERT INTO mv SELECT ... FROM source手动回填历史。
4.5 投影(Projection):应用无感知的「第二种排序」
MergeHouse 的 ORDER BY 只有一种物理排序,但查询模式可能多样。物化视图要额外存一份数据;而 Projection(投影) 是「挂」在原始表上的另一种预排序/预聚合,查询时 ClickHouse 自动判断是否命中投影,对应用完全透明。
ALTER TABLE events
ADD PROJECTION p_user
(
SELECT *
ORDER BY (user_id, event_time) -- 针对「按用户查历史」的查询模式
);
-- 触发投影物化(对存量数据)
ALTER TABLE events MATERIALIZE PROJECTION p_user;
-- 这个查询会自动走 p_user 投影,而不是全表扫
SELECT * FROM events WHERE user_id = 88123 ORDER BY event_time DESC LIMIT 20;
投影相比物化视图的优势:不重复建表、不占额外命名空间、查询优化器自动路由。代价是写入时要多算一份。高基维度上的「另一种排序」场景,投影是首选。
4.6 原生 JSON 类型:半结构化数据的一等公民
过去要在 ClickHouse 存 JSON,要么 String 存了再 JSONExtract,要么用 Nested 拍平——都别扭。2024 起官方 JSON 类型让 JSON 成为原生列:
CREATE TABLE logs
(
ts DateTime,
app String,
body JSON
)
ENGINE = MergeTree
ORDER BY (app, ts);
INSERT INTO logs VALUES
('2026-01-15 10:00:00', 'web',
'{"user":{"id":1,"name":"alice"},"tags":["vip","cn"],"amount":99.5}');
-- 直接按 JSON 路径查询,类型自动推断
SELECT
body.user.name,
body.amount,
body.tags[1]
FROM logs
WHERE body.user.id = 1;
ClickHouse 会为 JSON 里出现过的路径自动创建隐藏的子列(列式存储),既保留灵活性,又不丢失列存性能。对日志、埋点、IoT 这种 schema 漂移频繁的场景,体验拉满。
4.7 向量检索:用 SQL 做 embedding 搜索(RAG / 记忆系统)
这是 2024–2026 年最让人兴奋的能力。ClickHouse 内置距离函数 + ANN 索引,可以直接当向量库用:
CREATE TABLE memories
(
id UInt64,
user_id UInt64,
embedding Array(Float32), -- 向量
content String,
created DateTime
)
ENGINE = MergeTree
ORDER BY id
-- ANN 索引:用 HNSW 做近似最近邻,distance 函数指定度量
SETTINGS index_granularity = 8192;
-- 写入时用向量(这里用占位,实际来自 embedding 模型)
INSERT INTO memories VALUES
(1, 88123, [0.1,0.2,0.3, ...], '用户喜欢 Rust', now()),
(2, 88123, [0.3,0.1,0.4, ...], '关注分布式系统', now());
-- 语义检索:找与给定向量最相似的 top-5(L2 距离)
SELECT
id, content,
L2Distance(embedding, [0.12,0.18,0.33, ...]) AS dist
FROM memories
WHERE user_id = 88123
ORDER BY dist
LIMIT 5;
配合 cosineDistance(余弦相似度)与 ANN 索引,ClickHouse 能在数十亿向量上做低延迟语义检索。这意味着 RAG 应用的「知识库 + 记忆」可以不必再外接一个独立的向量数据库——一套 ClickHouse 同时搞定结构化分析、日志与向量检索,架构大幅简化,成本也更好控制。
4.8 Go 客户端实战(clickhouse-go)
服务端用 Go 的常见,官方 clickhouse-go 驱动很好用:
package main
import (
"context"
"fmt"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
)
func main() {
conn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{"clickhouse:9000"},
Auth: clickhouse.Auth{
Database: "default",
Username: "default",
Password: "",
},
// 压缩:减少网络传输
Compression: &clickhouse.Compression{
Method: clickhouse.CompressionLZ4,
},
})
if err != nil {
panic(err)
}
ctx := context.Background()
// 批量写入:用 Batch 而不是逐条 Exec
batch, err := conn.PrepareBatch(ctx, "INSERT INTO events")
if err != nil {
panic(err)
}
for i := 0; i < 1000; i++ {
err := batch.Append(
time.Now(), // event_date
uint32(1024), // app_id
uint64(i), // user_id
"click", // event_type
"CN", // country
"{}", // payload
uint32(10), // duration_ms
time.Now(), // event_time
)
if err != nil {
panic(err)
}
}
if err := batch.Send(); err != nil { // 一次网络发送整批
panic(err)
}
// 查询
var (
hour time.Time
events uint64
)
rows := conn.QueryRow(ctx,
"SELECT toStartOfHour(event_time), count() FROM events WHERE app_id=? GROUP BY 1",
uint32(1024))
for rows.Next() {
if err := rows.Scan(&hour, &events); err != nil {
panic(err)
}
fmt.Printf("%s -> %d events\n", hour, events)
}
}
要点:PrepareBatch + Append + Send 是批量写入的标准姿势,比逐条 Exec 快一个量级;开启 CompressionLZ4 省带宽。
4.9 Python 客户端实战(clickhouse-connect)
数据分析/后端用 Python 的,clickhouse-connect 是官方推荐:
import clickhouse_connect
client = clickhouse_connect.get_client(
host='clickhouse', port=8123,
username='default', password='', database='default',
)
# 批量插入(用列字典,client 自动攒批)
rows = [
{"event_date": "2026-01-15", "app_id": 1024, "user_id": 1, "duration_ms": 12},
{"event_date": "2026-01-15", "app_id": 1024, "user_id": 2, "duration_ms": 8},
]
client.insert("events", rows)
# 查询
result = client.query(
"SELECT app_id, count() AS c FROM events GROUP BY app_id"
)
print(result.result_rows) # [[1024, 2]]
print(result.column_names) # ['app_id', 'c']
# 直接读成 pandas / polars 做后续分析
df = client.query_df("SELECT * FROM events LIMIT 1000")
clickhouse-connect 默认用 HTTP 接口(8123),对云环境和代理友好;query_df 直接对接 pandas,数据科学链路丝滑。
五、性能优化:把 ClickHouse 真正「调」出来
5.1 第一性原则:ORDER BY 设计好,性能就赢了 80%
重申:ORDER BY 是 ClickHouse 性能的总开关。所有优化里,先把高频查询的过滤/排序/分组列排进 ORDER BY 前缀。一个糟糕的 ORDER BY (id) 会让几乎所有分析查询退化成全表扫;一个贴合查询模式的 ORDER BY (app_id, event_time, user_id) 能让稀疏索引大显身手。
5.2 数据类型:能小则小,能定则定
- 整数用最小的:状态码用
UInt8(0–255)而不是Int64,列存体积和向量化速度都受益。 - 低基数用
LowCardinality(String):性别、国家、事件类型这种取值有限的字符串,包一层LowCardinality,ClickHouse 会用字典编码,省空间、提比较速度。 - 固定长度用
FixedString(N):国家码FixedString(2)、MD5FixedString(32)比String更高效。 - 避免
Nullable:Nullable列实际存成「值 + 空标记」两列,且无法进某些索引优化。能用默认值(如0、'')代替就用默认值。 DateTime64指定精度:不需要微秒就别用DateTime64(6),精度越高存储越贵。
5.3 分区与 TTL:控制「数据的体积」
- 分区键用时间,粒度让单分区落在 GB 级。
TTL自动清理冷数据,避免存储无限膨胀。也可以做 TTL 移动到冷存储(对象存储):TTL event_time + INTERVAL 30 DAY TO VOLUME 'cold', event_time + INTERVAL 90 DAY DELETE;
5.4 异步插入与攒批:写入吞吐的命门
前面 4.2 讲过。async_insert + 客户端攒批,是扛住高写入吞吐的基础。压测时 wait_for_async_insert=0 能进一步提吞吐,但要接受「极端情况下可能丢一点」的代价。
5.5 物化视图 / 投影:用空间换时间
高频聚合查询(dashboard、监控指标)一定要预计算。规则:
- 需要对外暴露成独立表、或聚合逻辑复杂 → 物化视图(
SummingMergeTree/AggregatingMergeTree)。 - 只是想给原表加「另一种排序/预聚合」且不想多建表 → 投影。
- 维度 JOIN → 字典(
dictGet),别用大表 JOIN。
5.6 关键 Settings 调优
几个最能影响查询体验的 session 级设置:
SET max_threads = 16; -- 并行线程数,通常等于 CPU 核数
SET max_memory_usage = '32G'; -- 单查询内存上限,防 OOM 拖垮集群
SET use_uncompressed_cache = 1; -- 热数据解压后缓存,重复扫同列时加速
SET max_insert_block_size = 1048576; -- 写入块大小
SET join_algorithm = 'grace_hash'; -- 大表 JOIN 防内存爆炸
SET optimize_read_in_order = 1; -- 按 ORDER BY 顺序读,跳过无关 granule
system.settings 里可查全部参数;生产上建议把这些写进 users.xml 或按需 per-query 设。
5.7 查询诊断:别靠猜,看 plan 和日志
-- 看执行计划,确认是否走了索引/分区裁剪
EXPLAIN PLAN SELECT count() FROM events
WHERE app_id = 1024 AND event_time >= '2026-01-15';
-- 看实际读了多少 granule、用了哪些索引
EXPLAIN indexes = 1 SELECT ...;
-- 慢查询分析:从系统表里捞真实执行数据
SELECT
query, query_duration_ms, read_rows, memory_usage
FROM system.query_log
WHERE type = 'QueryFinish'
AND query_duration_ms > 1000
ORDER BY query_duration_ms DESC
LIMIT 20;
system.query_log、system.merges、system.parts 是排障三件套:query_log 看慢查询,merges 看后台合并是否堆积,parts 看 part 数量是否过多(过多说明写入太碎,要调 async_insert 或 parts_to_throw_insert)。
5.8 常见坑位清单
SELECT *在大宽表上极慢:列存优势是「只读要的列」,你全选就废了。Nullable列进不了部分优化:见 5.2。- 大表 JOIN 右表是事实表:内存炸,改字典或
grace_hash。 - 频繁小 INSERT:part 爆炸,务必攒批 + 异步。
- 滥用
FINAL:实时去重要用物化视图预先算,别在线上查FINAL。 - 分区过细:按天分区但每天数据只有几 MB,part 成千上万,merge 跟不上。
六、总结与展望:ClickHouse 的边界与未来
6.1 它擅长什么,不擅长什么
先用一句话收口:
- 擅长:海量数据的实时聚合分析、高吞吐写入、时序/日志/埋点/指标、服务化在线分析、以及(现在)向量检索与半结构化 JSON。
- 不擅长:频繁单行 UPDATE/DELETE、强事务(跨行 ACID)、点查单行、作为业务主库。
它和本站前文讲过的 DuckDB(进程内 OLAP)、PostgreSQL 18(事务 + 新异步 IO)、Valkey(内存 KV 缓存)是互补关系,不是替代。一个现代数据栈的画像往往是:PostgreSQL 扛事务、Valkey 扛缓存、ClickHouse 扛分析、DuckDB 在本地做即席探索。
6.2 2026 的三个确定性方向
- 分析 + AI 融合:向量检索原生支持,让 ClickHouse 直接进入 RAG / 智能体记忆系统的技术栈,少一个外部依赖、少一份同步一致性负担。
- 可观测性一体化:ClickStack 用一套引擎统一 logs/metrics/traces,按资源(而非按事件数/主机数)计费,正在重塑可观测性的成本模型——这也是本站读者做监控架构时值得重点关注的范式。
- 云原生存算分离:SharedMergeTree + 对象存储,让「弹性」从口号变成默认形态,中小企业也能用上 PB 级分析而不必养一支专职运维。
6.3 给工程师的上手建议
如果你今天想认真把 ClickHouse 用起来,我的路径建议是:
- 先用 Docker 起一个单机实例,按本文 4.1 建表,灌一批自己的数据(哪怕几百万行),跑 4.3 的聚合查询,感受「秒级」是什么体验。
- 刻意练习 ORDER BY 设计:故意设计一个糟糕的
ORDER BY,再用EXPLAIN indexes=1看扫描了多少 granule,对比优化后的差异——这一课比看十篇文档都管用。 - 上手物化视图和投影:把你的高频 dashboard 查询改写成 MV,体会毫秒级返回的爽感。
- 再上集群:理解了 part / merge / Keeper 之后,再碰分片副本,才不会在半夜被「part 堆积」「ZK 抖动」叫醒。
ClickHouse 不是银弹,但它是「当分析变成一项高并发在线服务」时,工程师手里最锋利的那把刀。理解它「为什么是列存、为什么是稀疏索引、为什么写入快更新慢、为什么 ORDER BY 是灵魂」,你就不再是一个「会写 SQL 的人」,而是一个真正懂数据库物理代价的人——而这,恰恰是区分「会用工具」和「懂系统」的分水岭。
参考资料与延伸阅读:ClickHouse 官方文档(clickhouse.com/docs)、阿里云 ClickHouse 企业版 SharedMergeTree 技术解析、ClickHouse Inc. 关于 JSON 类型与向量检索的官方博客、Momentic 从 PostgreSQL 迁移到 ClickHouse 的工程复盘(2026-07)。本文所有 SQL/Go/Python 示例均经过工程化简化,可直接作为生产模板的起点。