编程 ClickHouse 深度拆解:当关系型数据库学会「按列思考」——从 MergeTree 引擎、向量化执行到稀疏索引与生产级 OLAP 完全指南(2026)

2026-07-17 02:44:42 +0800 CST views 7

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 / PostgreSQLClickHouseDuckDB
存储模型行存为主列存列存(进程内)
主战场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 一批数据:

  1. 数据按 ORDER BY 在内存中排序;
  2. 排序后写入一个新的 part 目录/store/xxx/yyy/,里面每列一个 .bin 压缩文件、每列一个 .mrk marks 文件、主键索引 primary.idx、分区 minmax 信息、跳数索引等);
  3. 这个 part 立即可查(无需 merge 就能被读到);
  4. 后台线程在「合适的时机」(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 的流程是:

  1. PARTITION BY 的 minmax 信息,直接跳过无关分区;
  2. primary.idx 稀疏索引做二分,定位候选 granule 区间;
  3. .mrk marks 跳到对应 .bin 的字节偏移,只读需要的列、需要的 granule;
  4. 对每个 granule 应用向量化算子,并行推进,最后汇总。

这就是为什么「选对 ORDER BY」是 ClickHouse 表设计里最致命的一件事:它决定了稀疏索引能否有效裁剪、数据在磁盘上是否聚簇、merge 是否高效。

3.4 跳数索引与 ClickHouse Keeper

除了主键稀疏索引,ClickHouse 还支持 跳数索引(skipping index),用于主键之外的列做粗粒度裁剪,比如 bloom_filterminmaxsetngrambf_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 不是免费的午餐,它有三个工程直觉值得记住:

  1. part 越多,merge 越累。每次写入生成一个 part,小 part 海会让 merge 线程疲于奔命,也会拖慢启动和元数据加载。所以「批量写、缓冲写」不只是吞吐问题,更是健康度问题。经验上,单表稳定状态下的 part 数应控制在几千以内,而不是几十万。
  2. merge 是「写放大」的来源。一次逻辑写入可能在后台被反复合并多次,总写入量可能是原始数据的数倍。这正是用「写入放大」换「读取极速」的代价兑现时刻。
  3. 分区粒度要匹配查询。分区只在「按分区键裁剪」时有效。如果你的查询从不按分区键过滤,分得太细只会增加 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_dailySELECT,聚合结果写进 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 PIPELINEEXPLAIN indexes = 1 看执行计划和索引命中,别靠猜;
  • max_threads(并行度,默认等于核数)、max_memory_usage(单查询内存上限)、max_bytes_before_external_group_by(聚合内存不够时落盘)等设置,避免大查询 OOM 或被杀。

5.3 一张「诊断速查表」

现象可能原因抓手
查询扫了大量 granuleORDER 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 抠字段);倒排索引 让文本检索不再只能靠 LIKELightweight Delete 让删除不再是重写整个 part 的重操作;Projections 允许一张表有「另一种排序视图」而无需冗余;云原生侧 SharedMergeTree / Shared 数据库 把存储与计算分离,拥抱对象存储。这些都在让 ClickHouse 从「纯 OLAP」缓慢但坚定地走向「更通用的分析平台」。

5. 云原生与多模是主旋律。 ClickHouse Cloud、SharedMergeTree、对 S3 对象存储的原生支持,标志着它正从「单机猛兽」进化成「弹性分析云」。对团队来说,这意味着你可以用更低的运维成本,撑起过去要养一整套 Hadoop/Spark 才能扛的负载。

最后回到工程师视角:ClickHouse 最迷人的地方,不在于它「快」这个结果的结论,而在于它把「为什么快」写进了每一寸架构——列存、向量化、稀疏索引、part 合并、最终一致。当你真正理解了这些齿轮如何咬合,你就不只是「会写一条 SELECT」,而是拥有了一套在任意数据分析系统里都能复用的心智模型。这,才是「深度拆解」真正想交给你的东西。


本文基于 ClickHouse v25.8 LTS 线撰写,代码示例均可在该版本直接运行。版本特性持续演进,落地前请以官方文档与你的实际环境为准。

推荐文章

使用Python提取图片中的GPS信息
2024-11-18 13:46:22 +0800 CST
Vue3中的v-for指令有什么新特性?
2024-11-18 12:34:09 +0800 CST
程序员茄子在线接单