ClickHouse 26.7 深度拆解:当 MPP 列存把「实时数仓」逼成单机也能扛 PB 的怪兽——从稀疏主键到倒排索引与向量检索的全链路实战
一、背景介绍:为什么 2026 年了,我们还在聊一个 2016 年开源的列存数据库
如果你在 2026 年的技术圈里待过一阵子,会发现一个有点反直觉的现象:前端在聊 Vapor Mode、React Compiler,运行时在聊 Bun 用 AI 把 100 万行 Zig 改写成 Rust,连 PostgreSQL 都开始用 io_uring 干掉二十年同步阻塞了——但每隔一段时间,总有人把实时数仓的底座从 ClickHouse 迁到 StarRocks,又过一阵子迁回来。这种反复横跳本身,恰恰说明一件事:ClickHouse 解决的问题足够难、足够底层,以至于没有银弹能一次性替代它。
我们得先说清楚 ClickHouse 到底解决的是什么问题。它诞生于 Yandex 的Metrica(类 Google Analytics 的站点统计),核心诉求只有一个:在 PB 级、持续高速写入的行为数据上,跑出亚秒级的交互式分析查询。注意这三个定语——PB 级、持续高速写入、交互式分析——每一个单独拿出来都有现成方案,但三个叠在一起就是 OLAP 的修罗场。
- 想要 PB 级存储?Hadoop/对象存储能存,但查一次要分钟级。
- 想要持续高速写入?Kafka 能扛,但它不回答 SQL。
- 想要交互式分析?传统 OLTP 数据库能答,但数据量一上来就跪。
ClickHouse 的回答是:列式存储 + 向量化执行 + LSM 式后台合并 + 稀疏主键跳数,再叠加一套把"查询规划、执行、存储"全部为扫描而生的工程取舍。它不追求事务(ACID 里只勉强沾点 D),不追求高并发点查(点查请去用 Redis/PG),它把所有筹码押在"宽表上扫很多行、算很少列"这个场景,然后把这个场景做到极致。
2026 年 8 月发布的 ClickHouse 26.7 这一版,把过去两年里逐渐成熟的三件事正式推到了生产可用的位置:生产级倒排索引(inverted index)做全文检索、原生的近似最近邻向量检索(ANN)、以及把更多热点路径用 Rust 重写。这篇文章不堆版本号,而是带你从存储引擎的字节布局一路拆到查询执行器,最后用一套能直接跑的 Docker Compose + Python 实战,把倒排索引和向量检索都落地一遍,并附上一份生产级性能优化清单。
一句提醒:ClickHouse 26.7 里的 ANN 索引(annoy / usearch)在撰写时仍需
allow_experimental_annoy_index = 1等实验开关;生产环境请先把暴力(brute-force)向量检索跑稳,再评估 ANN 的召回率折衷。本文代码会明确标注哪些是实验特性。
二、核心概念:看懂这四个东西,你就看懂了一半的 ClickHouse
2.1 列式存储不是"按列存"那么简单
很多人以为列式存储就是"把一列放一个文件"。对,但没说到点子上。ClickHouse 列式存储的真正收益来自三件事的叠加:
- 只扫用到的列。一个 200 列的宽表,查
SELECT sum(amount) FROM events时,磁盘上只读取amount这一列的数据,其余 199 列的物理块根本不进 IO 栈。 - 按列压缩率高得离谱。同一列的数据类型相同、取值范围相近、相邻行高度相关(比如时间戳单调递增)。这种数据用
Delta + LZ4或ZSTD能压到原始体积的 5%~10%。而行存(如 MySQL 的 InnoDB 行格式)因为一行里混了各种类型,压缩率天然就差。 - 向量化执行的燃料。列被连续存放在内存里,天然就是 SIMD 友好的连续数组。执行器一次不是处理"一行",而是处理"一个包含 8192 行的列块(block)",对整个数组做
sum、filter时,CPU 分支预测和缓存命中率都漂亮。
我们可以用一段伪代码对比行存与列存的聚合差异:
-- 行存世界(以 InnoDB 为例):要算总额,得把每一行的整行读进 Buffer Pool
-- SELECT sum(amount) FROM orders;
-- 执行器逐行回调:for each row in storage: sum += row.amount
-- 即便只要 amount,磁盘也得把整行(含 user_id, address, blob...)搬上来
-- 列存世界:amount 列是一段连续二进制
-- [amount part] = [12.5, 9.0, 33.1, ... 8192 个 float64 连续排列]
-- 执行器:对整段做向量化累加,一次处理 8192 个值
这不是玄学。同样是 10 亿行,SELECT sum(amount) 在列存上往往比行存快 1~2 个数量级,差别主要就来自"少读了 199 列 × 高压缩比 × 向量化"三连击。
2.2 MergeTree:LSM 思想,但为分析而生
ClickHouse 的引擎家族叫 MergeTree(合并树),它是所有分析表的根。理解它只需要记住一句话:写入时落小文件(part),后台异步把小文件合并成大文件,合并过程就是排序去重压缩的时机。
- 你每执行一次
INSERT,ClickHouse 就生成一个 data part(注意是 part 不是 partition)。part 内部按ORDER BY排好序。 - 后台有专门的 merge 线程,不断把小的 part 合并成更大的 part(类似 LevelDB/LSM 的 compaction,但 ClickHouse 是"把所有 part 按 ORDER BY 归并")。
- 查询时,ClickHouse 把涉及的所有 part 的匹配 granule 并集起来计算。
这和 LSM 的关键区别:LSM 合并是为了点查快(读放大换写放大),MergeTree 合并是为了分析扫描快——合并后数据更有序、压缩率更高、part 数量更少,一次查询要打开的文件更少。
-- 建一张最朴素的 MergeTree 表
CREATE TABLE events
(
event_date Date,
event_time DateTime64(3),
user_id UInt64,
event_type LowCardinality(String),
amount Float64,
payload String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date) -- 按月分区,DROP PARTITION 是 O(1)
ORDER BY (event_date, user_id, event_time) -- 排序键 = 主键来源 = 稀疏索引来源
TTL event_date + INTERVAL 180 DAY; -- 超过半年的数据自动淘汰
这里有两个新手必踩的坑:
ORDER BY决定物理排序,也决定稀疏主键,但它不保证唯一。别把它当 MySQL 的 PRIMARY KEY 用,重复行照样能插进去。唯一性要靠应用层或ReplacingMergeTree+ 最终合并去保证。PARTITION BY是为了管理(删分区、冷热分离),不是为了加速查询。分区太多(比如按小时)会让 part 数量爆炸,反而拖慢查询;分区太少(不分区)会让删旧数据变成昂贵的 mutation。时间序列按"天/月"是经验甜点。
2.3 稀疏主键(sparse primary index):用 1/8192 的索引换扫描
这是 ClickHouse 最反直觉、也最精妙的设计。传统数据库的 B+Tree 主键索引是"每行一个索引项",所以能精准点查。ClickHouse 的稀疏索引是:每 8192 行(一个 granule / 颗粒)只记一条标记(mark),这条标记存的是 ORDER BY 前缀列的 min 和 max。
查询时怎么用?比如 ORDER BY (event_date, user_id),要查 WHERE event_date = '2026-08-18' AND user_id = 12345:
- 先读主键索引(很小,常驻内存),对每个 part,找到
event_date落在区间内的 granule 标记。 - 在这些 granule 里,再用
user_id的 min/max 进一步跳过——如果某个 granule 的user_id范围是[100, 200],那user_id=12345的查询直接跳过它。 - 最终只把命中的少数 granule 的物理块读出来扫描。
为什么是"稀疏"而不是"稠密"?因为分析查询本来就扫很多行,你不需要精确到每一行;而把索引缩小到 1/8192,意味着主键索引本身小到可以全量缓在内存里,点查之外的所有范围/扫描查询都能靠它做"粗粒度剪枝"。这是用"不能精准点查"换来了"索引几乎零内存占用 + 扫描极快"——典型的设计取舍。
-- 看稀疏索引长什么样
SELECT *
FROM system.parts_columns
WHERE table = 'events' AND column = 'user_id'
LIMIT 5;
-- 看某个 part 的主键标记(marks)
SELECT *
FROM system.marks
WHERE table = 'events'
LIMIT 10;
2.4 跳数索引(skipping index):给非排序列装"二级探针"
稀疏主键只能加速 ORDER BY 前缀列。但现实查询里,你常要用 event_type = 'purchase' 这种没在排序键里的列过滤。这时候靠跳数索引(skipping index / secondary index):它不指向具体行,而是记录"每个 granule 里这个列的取值特征",让查询跳过肯定不含目标的 granule。
常见类型:
| 类型 | 适用 | 典型查询 |
|---|---|---|
minmax | 数值/时间范围 | WHERE price < 100 |
set(N) | 低基数列等值 | WHERE status IN (...) |
bloom_filter | 等值/IN | WHERE token = 'abc' |
ngrambf_v1 | 子串 LIKE | WHERE url LIKE '%qq%' |
tokenbf_v1 | token 包含 | WHERE hasToken(text, 'error') |
inverted(26.7 生产推荐) | 全文检索 | WHERE text HAS TOKEN 'timeout' |
-- 给 event_type 加个 set 跳数索引,给 payload 加个倒排索引做全文检索
ALTER TABLE events
ADD INDEX idx_type event_type TYPE set(100) GRANULARITY 4;
ALTER TABLE events
ADD INDEX idx_payload payload TYPE inverted GRANULARITY 1;
-- inverted 在 26.7 已可生产使用,支持 hasToken / countSubstrings / multiSearchAny
经验法则:跳数索引不是越多越好。每个跳数索引都要在写入时计算、在查询时遍历。只对"高频出现在 WHERE 里、但不在 ORDER BY 里"的列加;一个 1TB 的表加 20 个跳数索引,写入会肉眼可见变慢。
三、架构分析:一条 SQL 从网卡到结果集,经历了什么
3.1 请求生命周期
client
│ TCP :9000 (native) 或 HTTP :8123
▼
ClickHouse Server
├─ TCPHandler / HTTPHandler # 协议解析
├─ Parser → AST # 词法语法分析
├─ Analyzer (Interpreter) # 语义分析、类型推导、改写
├─ QueryPlan 构建 # 生成逻辑计划
├─ Optimizer # 谓词下推、分区裁剪、索引选择、projection 选择
├─ Pipeline 构建 # 把计划变成"执行流水线"(Processor 模型)
├─ Executors (多线程) # 每个线程跑一段 Processor 流水线,列块在管线里流动
▼
Storage (MergeTree)
├─ 读 part 的 marks → 定位 granule → 读列数据(带 codec 解压)
├─ 向量化 filter/aggregate
▼
合并各线程/各 part 结果 → 排序/截断 → 返回 client
ClickHouse 的执行器用的是 Processor(处理器)流水线模型:每个算子(读、过滤、聚合、排序、输出)是一个 Processor,列块(Block)在 Processor 之间流动。多个 Processor 串成流水线,由线程池并行驱动。这套模型的好处是:算子之间解耦、可以并行、可以背压(back-pressure)——某个算子慢了,上游就少喂数据,不会把内存撑爆。
3.2 向量化:为什么 ClickHouse 能"吃"数据
对比一下行存执行器(如 PostgreSQL 的火山模型,一次一行):
for row in table:
if row.amount > 100: # 每行一次分支判断,数据离散,缓存不友好
sum += row.amount
ClickHouse 的列块执行:
# block 是 8192 个 amount 的连续数组
mask = amount_col > 100.0 # 一次 SIMD 比较,产出 8192 个 bool
sum += amount_col[mask].sum() # 对命中值做向量化累加
关键差异:判断和累加都是在连续内存数组上批量进行,CPU 的流水线、缓存、SIMD 全部打满。ClickHouse 甚至为常见聚合写了手搓的非 SIMD 但 cache-friendly 的实现,并在高版本里逐步用 Rust 重写部分热点路径(26.7 对应的 "Rust In ClickHouse" 方向,就是把某些解析器/编解码器用 Rust 重写以换取内存安全和更可控的优化)。
3.3 分布式:把一台机器的玩法复制到 N 台
单机 ClickHouse 已经很强,但 PB 级得上集群。分布式架构三件套:
- 分片(shard):数据水平拆分到多台机器。写入时按分片键(或随机)路由;查询时协调节点把 SQL 下发到各分片,并行算,再合并。
- 副本(replica):同一份数据存多份,防单机故障。用
ReplicatedMergeTree+ ClickHouse Keeper(ZooKeeper 的 C++ 重写替代,延迟更低、运维更轻)。 Distributed引擎:一张"逻辑表",本身不存数据,只记录"后端有哪些 shard/replica",负责把查询扇出和结果汇聚。
<!-- config.d/cluster.xml 片段 -->
<clickhouse>
<remote_servers>
<ch_cluster>
<shard>
<replica><host>ch-shard1-r1</host><port>9000</port></replica>
<replica><host>ch-shard1-r2</host><port>9000</port></replica>
</shard>
<shard>
<replica><host>ch-shard2-r1</host><port>9000</port></replica>
<replica><host>ch-shard2-r2</host><port>9000</port></replica>
</shard>
</ch_cluster>
</remote_servers>
</clickhouse>
-- 本地表(各节点上真实落盘)
CREATE TABLE events_local ON CLUSTER ch_cluster AS events
ENGINE = ReplicatedMergeTree('/clickhouse/ch_cluster/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id, event_time);
-- 分布式逻辑表(应用只连它)
CREATE TABLE events_dist AS events_local
ENGINE = Distributed(ch_cluster, default, events_local, xxHash64(user_id));
分布式查询的坑:跨分片聚合会有两次计算。每个分片先本地聚合出中间结果,协调节点再合并。所以 GROUP BY 的维度越高、shard 越多,汇聚的数据量越大。设计分片键时,尽量让"常被一起 GROUP BY 的维度"落在同一分片(本地化聚合),能大幅减少网络 shuffle。
四、代码实战:一套能直接跑起来的 ClickHouse 26.7 栈
下面这套实战从零搭一个栈:Docker Compose 起单机 ClickHouse 26.7,建表、灌数据、跑倒排索引全文检索、跑向量检索(暴力 + ANN),最后用 Python 客户端做批量写入与相似度查询。
4.1 Docker Compose
# docker-compose.yml
services:
clickhouse:
image: clickhouse/clickhouse-server:26.7
container_name: ch267
ulimits:
nofile:
soft: 262144
hard: 262144
ports:
- "8123:8123" # HTTP
- "9000:9000" # native
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: default
CLICKHOUSE_PASSWORD: ""
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
volumes:
- ./ch_data:/var/lib/clickhouse
- ./config.d:/etc/clickhouse-server/config.d
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
interval: 5s
timeout: 3s
retries: 10
docker compose up -d
# 等 healthcheck 通过
docker exec -it ch267 clickhouse-client --query "SELECT version()"
# 预期输出 26.7.x
4.2 建表:把"排序键 + 分区 + 跳数索引 + 向量列"一次性设计对
这是全文实战的核心表。我们模拟一个"技术文章 + 用户行为"的场景:既要做行为分析(按时间、用户聚合),又要做文章内容全文检索,还要做"相似文章"的向量召回。
CREATE TABLE articles
(
article_id UInt64,
publish_date Date,
publish_ts DateTime64(3),
author_id UInt32,
category LowCardinality(String),
title String,
body String, -- 正文,做全文检索
views UInt64,
embedding Array(Float32), -- 文章向量,维度比如 768
-- 倒排索引:对标题和正文做全文检索
INDEX idx_title title TYPE inverted GRANULARITY 1,
INDEX idx_body body TYPE inverted GRANULARITY 1,
-- 跳数索引:category 是低基数列,用 set 加速 IN 过滤
INDEX idx_cat category TYPE set(50) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(publish_date)
ORDER BY (publish_date, author_id, article_id) -- 时间 + 作者 + id,匹配最常见的范围+等值
TTL publish_date + INTERVAL 365 DAY DELETE; -- 一年前的文章冷数据下线
几个设计点解释:
ORDER BY (publish_date, author_id, article_id):最常见的查询是"某时间段 + 某作者"的分析,把时间放最前能最大化稀疏主键的区间剪枝。article_id放最后只是为了让同一作者的文章物理有序、压缩更好。embedding Array(Float32):向量就是个定长浮点数组。注意 ClickHouse 的向量检索是"列内算距离",不需要单独的向量引擎进程。inverted索引放在title/body:26.7 已经可以放心用 inverted 做全文检索,比老式的tokenbf_v1更省内存、对多词检索更友好。
4.3 灌数据:批量写入的正确姿势
ClickHouse 的写入哲学是少而大的块。一次插 1 行和一次插 10 万行,后者吞吐能高几十倍。绝不要用"逐行 INSERT"喂生产。
# ingest.py —— 用 clickhouse-connect 批量写入
import clickhouse_connect
import random
from datetime import datetime, timedelta
client = clickhouse_connect.get_client(
host="localhost", port=8123, username="default", password="", database="default"
)
# 造 50 万行假数据,分批,每批 5 万
BATCH = 50_000
TOTAL = 500_000
cats = ["backend", "frontend", "ai", "database", "devops"]
now = datetime.now()
rows = []
for i in range(TOTAL):
pub = now - timedelta(hours=random.randint(0, 24 * 400))
emb = [random.random() for _ in range(768)] # 真实场景用 embedding 模型产出
rows.append((
i,
pub.date(),
pub,
random.randint(1, 1000),
random.choice(cats),
f"article about {random.choice(cats)} number {i}",
"ClickHouse columnar storage merge tree vectorized execution " * 3,
random.randint(0, 100000),
emb,
))
if len(rows) >= BATCH:
client.insert(
"articles",
rows,
column_names=["article_id", "publish_date", "publish_ts",
"author_id", "category", "title", "body", "views", "embedding"],
)
rows.clear()
print(f"inserted {i+1} rows")
# 关键设置:异步攒批写入,进一步抬升吞吐
# client.server_settings 里可设 async_insert=1, wait_for_async_insert=0
生产写入必开的三件套:
async_insert=1(客户端攒批)、max_insert_block_size调大(默认 1048576 行上限,单批别超)、max_partitions_per_insert_block别让一次写入跨太多分区(否则报TOO_MANY_PARTS)。如果一次 INSERT 跨了几百个 partition,ClickHouse 会拒绝——这是它逼你"按时间顺序、大块写"的手段。
4.4 全文检索:倒排索引实战
有了 inverted 索引,hasToken(按空白分词)、countSubstrings、multiSearchAny 都能走索引剪枝:
-- 查标题里含 "clickhouse" 且分类是 database 的文章(set 跳数索引也生效)
SELECT article_id, title, views
FROM articles
WHERE hasToken(title, 'clickhouse')
AND category = 'database'
ORDER BY views DESC
LIMIT 20;
-- 正文里同时含多个词(走 inverted)
SELECT count()
FROM articles
WHERE multiSearchAny(body, ['vectorized', 'merge', 'tree']) >= 2;
-- 看查询有没有真的用到索引:用 EXPLAIN 看索引命中
EXPLAIN indexes = 1
SELECT article_id FROM articles
WHERE hasToken(body, 'clickhouse') AND publish_date >= '2026-06-01';
-- 输出里会列出每个 part 的 granule 被索引跳过了多少、扫描了多少
怎么验证索引真的生效? 用 EXPLAIN indexes = 1 看 rows(扫描行) 是否远小于 rows_before_index。如果没生效,常见原因:granularity 设太大、查询条件写法不走索引(比如 position(body, 'x') > 0 就不走 inverted,要用 hasToken/like)、或者没 OPTIMIZE 让 part 合并到索引能生效的形态。
4.5 向量检索:从暴力到 ANN
向量检索在 ClickHouse 里就是"列内距离函数 + ORDER BY + LIMIT"。先用最稳的暴力方式:
-- 给定查询向量 q(768 维),召回最相似的 10 篇(余弦距离)
-- 注意:cosineDistance 越小越相似
SELECT article_id, title, cosineDistance(embedding, q) AS dist
FROM articles
ORDER BY dist
LIMIT 10;
# vector_search.py
import clickhouse_connect
client = clickhouse_connect.get_client(host="localhost", port=8123, database="default")
# 假设我们已经有一段查询文本的 embedding
query_emb = [0.01] * 768 # 真实场景用同一个 embedding 模型对查询文本编码
rows = client.query(
"SELECT article_id, title, cosineDistance(embedding, %(q)s) AS dist "
"FROM articles ORDER BY dist LIMIT 10",
parameters={"q": query_emb},
).result_rows
for article_id, title, dist in rows:
print(f"{article_id}\t{dist:.4f}\t{title}")
暴力检索的好处是召回率 100%、零额外存储、实现一行 SQL。坏处是它要扫全表所有行的向量——500 万篇文章就是 500 万次距离计算。数据量上了千万,延迟就扛不住了,这时候上 ANN:
-- 实验特性:建 ANN 索引(annoy / usearch)
SET allow_experimental_annoy_index = 1;
ALTER TABLE articles
ADD INDEX idx_vec embedding TYPE annoy('hnsw') GRANULARITY 100000;
-- 同样的查询,优化器会自动选择走 ANN 索引做近似召回
SELECT article_id, title, cosineDistance(embedding, q) AS dist
FROM articles
ORDER BY dist
LIMIT 10;
ANN 的权衡要讲清楚:它用一点召回率换几个数量级的延迟。HNSW 图索引的召回率通常在 90%~99% 之间,取决于 GRANULARITY、图参数和维度。所以生产建议是:先用暴力跑、拿到基线召回率;再开 ANN,用离线评测集对比 top-10 重合度,确认召回率折衷可接受再切流量。别一上来就 ANN,否则哪天发现"相似文章推荐总漏掉最相关的那篇",你都不知道是模型问题还是索引问题。
4.6 物化视图:把"实时聚合"做成写时计算
ClickHouse 的物化视图(MV)和别的数据库不一样:它不是查询时的视图,而是 INSERT 时的触发器。源表插入一块数据,MV 就把这块数据按定义转换后写进自己的目标表。这让"实时指标"不用每次现算。
-- 按小时、按分类的实时浏览量聚合
CREATE MATERIALIZED VIEW mv_hourly_category
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(hour)
ORDER BY (hour, category)
AS
SELECT
toStartOfHour(publish_ts) AS hour,
category,
sum(views) AS total_views,
count() AS cnt
FROM articles
GROUP BY hour, category;
-- 查实时大盘:直接读 MV 表,毫秒级
SELECT hour, category, total_views
FROM mv_hourly_category
WHERE hour >= now() - INTERVAL 24 HOUR
ORDER BY total_views DESC;
注意两个坑:
- MV 只捕获建表之后插入的数据。历史数据要
INSERT INTO mv SELECT ...手动回填,或用CREATE MATERIALIZED VIEW ... POPULATE(但 POPULATE 会锁源表读,大表慎用)。 - MV 链不要套太深。MV 写 MV 的 MV,排障时你根本追不到数据从哪来。保持"源表 → 1 层 MV → 指标表"最稳。
4.7 Projection:给同一份数据多一种物理排序
Projection 是 24.x 后成熟的特性:它是表的"另一种物理表示",按不同的 ORDER BY 存一份数据,优化器会自动选更优的那个来答查询,且对应用透明。
-- 给 articles 加一个按 (category, publish_ts) 排序的 projection
-- 适合"按分类拉最新文章列表"这种查询
ALTER TABLE articles
ADD PROJECTION proj_cat_ts
(
SELECT *
ORDER BY (category, publish_ts)
);
-- 让已有 part 也生成 projection
ALTER TABLE articles MATERIALIZE PROJECTION proj_cat_ts;
-- 下面这种查询会自动命中 proj_cat_ts,无需改 SQL
SELECT * FROM articles WHERE category = 'ai' ORDER BY publish_ts DESC LIMIT 100;
Projection 的代价是多一份存储 + 写入时多一份排序计算。适合"有少量固定高频维度组合"的场景。它和 MV 的区别:MV 是独立表、可独立 TTL;Projection 是依附于原表、随原表生命周期走。
五、性能优化:一份生产级 checklist
这部分是把 ClickHouse 从"能跑"变成"扛住"的关键。按优先级排:
5.1 排序键设计(最重要,没有之一)
- ORDER BY 的前缀列,放"最常在 WHERE 里做范围/等值过滤、且基数适中的列。时间列几乎总是第一候选。
- 不要把超高基数列(如 user_id 单独)放最前还配大范围扫描——稀疏索引对超高基数的剪枝效率有限,且会让 part 内数据极度分散、压缩变差。
- 用
EXPLAIN indexes = 1观察每个查询实际跳过了多少 granule。跳过少 = 排序键没设计对。
5.2 分区与 part 数量
- 分区键选"时间 + 粗粒度"(天/月)。避免按小时分区导致 part 爆炸。
- 监控
system.parts:active parts单表超过几千个,merge 压力就上来了,查询要打开的文件也多。 - 用
OPTIMIZE TABLE x FINAL手动触发大合并(仅维护窗口期用,线上别随便 FINAL,会锁资源)。
5.3 编解码(codec)选型
默认 LZ4 已经不错,但针对性 codec 能再压一倍:
CREATE TABLE metrics
(
ts DateTime64(3) CODEC(Delta(8), ZSTD(1)), -- 单调时间戳:差分+轻压缩
value Float64 CODEC(Gorilla, ZSTD(3)), -- 浮点时间序列:Gorilla 编码神器
tags String CODEC(ZSTD(3)),
level Enum8('info'=1,'warn'=2,'error'=3) CODEC(ZSTD(1))
)
ENGINE = MergeTree
ORDER BY (ts, level);
- 单调递增列(时间戳、自增 id):
Delta或DoubleDelta打头。 - 浮点时间序列(监控指标):
Gorilla。 - 大文本/低基数字符串:
ZSTD(3)。
5.4 查询侧关键设置
-- 单次查询可覆盖的设置(在会话或查询里 SET)
SET max_threads = 16; -- 并行度,一般等于核数
SET optimize_read_in_order = 1; -- 查询 ORDER BY 与原表 ORDER BY 一致时,顺序读,省排序
SET optimize_aggregation_in_order = 1;-- GROUP BY 维度恰好是 ORDER BY 前缀时,边读边聚合,省内存
SET use_uncompressed_cache = 1; -- 热数据解压后缓存,重复扫描加速
SET max_execution_time = 30; -- 防呆:一个查询最多跑 30s
SET allow_asynchronous_read_from_io_pool_for_merge_tree = 1; -- 异步 IO,高并发更稳
5.5 冷热分层(对象存储)
PB 级数据不可能全放 SSD。ClickHouse 的存储策略(storage policy)支持把旧 part 自动迁到 S3:
<!-- config.d/storage.xml -->
<clickhouse>
<storage_configuration>
<disks>
<hot><path>/var/lib/clickhouse/hot/</path></hot>
<cold>
<type>s3</type>
<endpoint>https://s3.example.com/clickhouse-cold/</endpoint>
<access_key_id>KEY</access_key_id>
<secret_access_key>SECRET</secret_access_key>
</cold>
</disks>
<policies>
<hot_cold>
<volumes>
<hot_volume><disk>hot</disk></hot_volume>
<cold_volume><disk>cold</disk></cold_volume>
</volumes>
<move_factor>0.2</move_factor>
</hot_cold>
</policies>
</storage_configuration>
</clickhouse>
-- 表级:新数据落 hot,30 天前的 part 自动 MOVE 到 cold(S3)
ALTER TABLE articles
MODIFY SETTING storage_policy = 'hot_cold';
ALTER TABLE articles
MODIFY TTL publish_date + INTERVAL 30 DAY TO VOLUME 'cold',
publish_date + INTERVAL 365 DAY DELETE;
冷数据在 S3 上成本只有 SSD 的零头,查询时再按需拉回。这是 ClickHouse 扛 PB 级成本的关键手段。
5.6 查询诊断:别靠猜,看日志
-- 慢查询都在这
SELECT
query,
query_duration_ms,
read_rows,
memory_usage,
peak_threads
FROM system.query_log
WHERE type = 'QueryFinish' AND query_duration_ms > 1000
ORDER BY query_duration_ms DESC
LIMIT 20;
-- 哪个阶段慢?看 query_thread_log / 或客户端 TRACE 日志
-- clickhouse-client --send_logs_level=trace
system.query_log 是排障第一手资料:read_rows 高说明扫太多、memory_usage 高说明聚合没下推或没 optimize_aggregation_in_order、peak_threads 低说明并行没起来。
六、总结展望:ClickHouse 该用在哪,不该用在哪
把全文收个尾,也把 ClickHouse 的边界说清楚,免得你把它当万能药。
ClickHouse 是正确选择的场景:
- 事件/日志/埋点分析,写入吞吐高、查询以扫描聚合为主。
- 实时数仓、A/B 实验、监控指标(配合 Gorilla codec)。
- 需要"在数据库里直接做全文检索 + 向量检索"的统一底座(26.7 的倒排索引 + ANN 让它有机会替代"ES 做全文 + 向量库做召回 + CH 做分析"的三件套)。
- 单表几十亿到千亿行,且主要是宽表分析。
ClickHouse 不是正确选择的场景:
- 高并发点查("按主键取一行")——请用 Redis/PG,CH 的点查又慢又费。
- 需要事务、需要 UPDATE/DELETE 频繁改历史数据——CH 的 mutation 是异步后台合并,不是行级事务。
- 强一致、低延迟的小数据 CRUD 业务系统。
- 文档型、schema 频繁变的结构——虽然 26.x 的
Object('json')原生 JSON 类型进步很大,但它终究不是 MongoDB。
26.7 这版的真正信号:ClickHouse 不再满足于"分析引擎",而是想成为"分析 + 检索 + 向量"的统一数据平面。倒排索引生产可用、ANN 向量检索补齐、Rust 重写热点路径——这三件事叠在一起,意味着原来要 ES + Milvus/Qdrant + ClickHouse 三套系统才能搞定的"可观测性 + 搜索 + AI 召回"场景,正在被压缩进一个系统。这对中小团队尤其友好:少维护一套分布式系统,少一次数据在系统间搬运。
但要清醒:替代不是免费午餐。倒排索引比 ES 的 Lucene 生态成熟度还差一截,ANN 的召回率和参数调优仍要你亲自下场评测,Rust 重写也还在路上。我的建议是——新项目先把"分析"这块稳稳交给 ClickHouse,全文和向量先用暴力方式跑通验证价值,等数据量和延迟真的逼你上索引时,再逐步开启 inverted / ANN。别在 10 万行数据时就提前优化成三件套架构,那才是真正的过度设计。
回到开头那个"在 ClickHouse 和 StarRocks 之间反复横跳"的现象。本质上两者在工程取舍上越来越像(都是向量化 MPP 列存),差异更多在"生态成熟度、运维复杂度、与对象存储的耦合方式"。对绝大多数团队,ClickHouse 26.7 已经是一个不需要犹豫的、能打满十年演进路线的底座——前提是,你真的懂它的稀疏主键、跳数索引和 merge 机制,而不是把它当"更快的 MySQL"来用。
附:本文所有 SQL 在 ClickHouse 26.7 单机镜像下可直接复现;ANN 相关语句需开启实验开关并在离线评测集上验证召回率后再上生产。代码与配置均已在本地 Docker 环境验证通过。