之前写了 Parquet 系列介绍列式存储的概念和文件格式。如果你读过那个系列,想继续深入列式存储这个方向,ClickHouse 是一个非常好的学习材料——很多核心思路是相通的,列式存储、编码压缩、数据分块,这些东西在 ClickHouse 里又出现了,只不过它不再是一个文件格式,而是一个完整的数据库。

另外,我们之前开发的 APM 系统,使用了 ClickHouse 来做 metrics 的数据存储与计算,我们用了好几年了,这玩意真的非常稳,几乎不需要运维。

这篇文章会从 ClickHouse 是什么开始,把它的核心存储结构、索引机制、写入读取流程、以及它高性能的几个关键设计讲清楚。之前完全没接触过 ClickHouse 也没关系,有基本的 SQL 知识,对列式存储有初步了解就够了(如果没有,建议先看一下理解列式存储这篇)。

本文的目标是对 ClickHouse 进行一定的科普,并不是什么很深入的文章,目的是让大家对它有一个基本的了解,知道在什么场景下它是一个大杀器,可以作为架构选型的参考。

ClickHouse 是什么

简单来说,ClickHouse 是一个开源的列式 OLAP 数据库管理系统。它由 Yandex(俄罗斯最大的搜索引擎公司)开发,最初是为了支撑 Yandex.Metrica(一个类似 Google Analytics 的网站分析平台)的实时分析需求。2016 年开源后,在全球范围内迅速流行起来。

它的数据按列组织,不是按行。这个在 Parquet 系列里已经聊过了,同样的思路,ClickHouse 在磁盘上也是把同一列的数据放在一起。

所以它擅长的是"从海量数据中做聚合分析",而不是"对单行数据做增删改查"。你让它跑一个 SELECT city, AVG(salary) FROM users GROUP BY city,它飞快;你让它跑一个 UPDATE users SET name='xxx' WHERE id=123,它不是干这个的。

ClickHouse 到底有多快?大家可以参考下官方维护的 benchmark 页面,这个页面非常丰富。它对比了市面上主流的分析型数据库,在相同硬件条件下,ClickHouse 在大多数查询上都是最快的,或者说是最快的之一。

benchmark

介绍一些典型的数字(单机,普通 SSD,十亿行数据),简单聚合查询(COUNT、SUM、AVG)可以做到几十毫秒到几百毫秒,带 GROUP BY 的聚合可以做到几百毫秒到几秒,而复杂的多表 JOIN 也可以在几秒到几十秒完成。

快速上手

废话不多说,我们先来实际跑一下 ClickHouse,建立一个直观感受。

安装与启动

macOS 上最简单的方式是用官方提供的安装脚本:

curl https://clickhouse.com/ | sh

这会下载一个单独的 clickhouse 二进制文件。然后启动 server:

./clickhouse server

另开一个终端,启动客户端:

./clickhouse client

你也可以用 Docker:

docker run -d --name clickhouse-server -p 8123:8123 -p 9000:9000 clickhouse/clickhouse-server
docker exec -it clickhouse-server clickhouse-client

连上之后,你会看到一个类似 MySQL 的交互式命令行,可以直接写 SQL。

建表与插入数据

我们来建一个简单的表,就用前面 Parquet 系列里的用户表:

CREATE TABLE users
(
    id          UInt64,
    name        String,
    age         UInt8,
    city        String,
    salary      Decimal(10, 2),
    create_time DateTime
)
ENGINE = MergeTree()
ORDER BY id;

这里面有两个东西:ENGINE = MergeTree()ORDER BY id。这两个非常关键,后面会详细说。现在先记住:MergeTree 是 ClickHouse 最核心的表引擎,就跟 InnoDB 是 MySQL 的核心引擎一个意思,ORDER BY 决定了数据在磁盘上的物理排序方式。

插入一些测试数据:

INSERT INTO users VALUES
    (1, '张三', 28, '北京', 15000.00, '2023-01-01 10:00:00'),
    (2, '李四', 35, '上海', 25000.00, '2023-01-02 11:00:00'),
    (3, '王五', 42, '北京', 30000.00, '2023-01-03 12:00:00'),
    (4, '赵六', 31, '广州', 20000.00, '2023-01-04 09:00:00'),
    (5, '钱七', 27, '深圳', 22000.00, '2023-01-05 14:00:00');

查询

ClickHouse 的 SQL 和标准 SQL 很接近,大多数情况下你写 MySQL 的习惯可以直接搬过来:

-- 简单聚合
SELECT city, AVG(salary) AS avg_salary
FROM users
GROUP BY city
ORDER BY avg_salary DESC;

-- 过滤 + 聚合
SELECT city, COUNT(*) AS cnt, AVG(salary) AS avg_salary
FROM users
WHERE age > 30
GROUP BY city;

到这里,ClickHouse 的基本使用已经过了一遍,和 MySQL 没有特别大的差别。接下来我们进入核心部分,看看它底层到底是怎么组织数据的。

MergeTree 引擎:ClickHouse 的心脏

ClickHouse 支持很多种表引擎(Engine),但最核心、最常用的就是 MergeTree 家族。可以这么说,理解了 MergeTree,就理解了 ClickHouse 存储层的 80%。

为什么叫 MergeTree

我想 MergeTree 这个名字应该来自 LSM-Tree(Log-Structured Merge-Tree)的思想。核心是这样一种模式:每次写入会生成一个新的数据分片(Part),后台再不断把小 Part 合并(Merge)成大 Part——"分层写入 + 后台合并"。这个设计的核心目标是优化分析查询,面向大批量写入 + 海量数据聚合的场景。MergeTree 内部是列式存储,每列单独存文件。

在深入细节之前,我们先对 MergeTree 的存储层有一个全局的认识:

Table
 ├── Partition (按分区键划分,比如按月)
 │    ├── Part (每次 INSERT 生成一个,后台会合并)
 │    │    ├── Granule 0 (默认 8192 行,读取的最小单位)
 │    │    ├── Granule 1
 │    │    └── ...
 │    │    每个 Granule 内,各列独立存储:
 │    │         ├── column_a.bin  (压缩后的列数据)
 │    │         ├── column_a.mrk2 (Granule 偏移量索引)
 │    │         ├── column_b.bin
 │    │         ├── column_b.mrk2
 │    │         └── ...
 │    │    以及:
 │    │         └── primary.idx   (稀疏索引,记录每个 Granule 的主键起始值)
 │    ├── Part
 │    └── ...
 ├── Partition
 └── ...

大家先对这个层次结构有个印象,后面每个概念展开讲的时候可以回来看这张图。

好了,我们来看 MergeTree 到底是怎么存数据的。

Part:数据的物理存储单元

每次向 MergeTree 表写入一批数据(一个 INSERT 语句),ClickHouse 会在磁盘上生成一个 Part。你可以把 Part 理解为"一批数据的物理存储目录"。

我们来看看一个 Part 在磁盘上长什么样。假设我们刚才的 users 表写入了一批数据,ClickHouse 的数据目录下大概会出现这样的结构:

users/
└── all_1_1_0/                    # 一个 Part
    ├── id.bin                    # id 列的数据(压缩后)
    ├── id.mrk2                   # id 列的 mark 文件(索引辅助)
    ├── name.bin                  # name 列的数据
    ├── name.mrk2                 # name 列的 mark 文件
    ├── age.bin
    ├── age.mrk2
    ├── city.bin
    ├── city.mrk2
    ├── salary.bin
    ├── salary.mrk2
    ├── create_time.bin
    ├── create_time.mrk2
    ├── primary.idx               # 主键索引(稀疏索引)
    ├── count.txt                 # 这个 Part 有多少行
    ├── columns.txt               # 列名和类型信息
    └── checksums.txt             # 校验和

几个关键的观察:

  1. 每一列都有自己独立的 .bin 文件。这就是列式存储在 ClickHouse 中的具体体现——id 的数据和 name 的数据物理上就不在一起。查询时如果只需要 city 和 salary 两列,ClickHouse 只需要读这两列的 .bin 文件,其他列的文件碰都不碰。这和 Parquet 的列裁剪是同一个思路。

  2. 每列还有一个 .mrk2 文件(mark 文件)。这个非常重要,它是稀疏索引能够工作的关键,后面会详细说。

  3. 有一个 primary.idx 文件,存的是主键的稀疏索引。

  4. Part 的目录名 all_1_1_0 包含了分区信息和版本信息。格式大致是 {partition}_{min_block}_{max_block}_{level},level 表示这个 Part 经历了多少次合并。

数据在 Part 内部的组织方式

Part 内部的数据组织有一个很重要的概念:Granule(颗粒)。

ClickHouse 不是把一列的所有数据一股脑存成一整块,而是把数据按行切成一个个 Granule。默认情况下,每 8192 行为一个 Granule。

假设这个 Part 有 32768 行数据(4 个 Granule):

Granule 0:  第 0 ~ 8191 行
Granule 1:  第 8192 ~ 16383 行
Granule 2:  第 16384 ~ 24575 行
Granule 3:  第 24576 ~ 32767 行

每个 Granule 是 ClickHouse 读取数据的最小单位。当查询需要读取某些行时,ClickHouse 不会精确到某一行,而是定位到包含这些行的 Granule,然后把整个 Granule 读出来。

为什么要这么设计?因为逐行读取太碎了,磁盘 I/O 的开销会很大。按 Granule 批量读取,既能利用磁盘的顺序读优势,又不至于每次都把整个 Part 全读出来。这个思路和 Parquet 里面把数据按 Row Group → Column Chunk → Page 分层组织是一样的——都是在"粒度太粗"和"粒度太细"之间找平衡。

.bin 文件和 .mrk2 文件里面具体存了什么呢?

.bin 文件:存的就是这一列的实际数据,按 Granule 的粒度来组织数据,每个 Granule 的数据会单独压缩成一个压缩块。压缩算法默认是 LZ4,也支持 ZSTD 等。

.mrk2 文件(mark 文件):存的是每个 Granule 在 .bin 文件中的物理偏移量。简单说就是一个索引表,告诉 ClickHouse:"第 N 个 Granule 的数据,在 .bin 文件的第几个字节开始"。

两者配合起来的读取流程是这样的:

1. 通过稀疏索引确定需要读哪些 Granule(比如 Granule 2 和 Granule 3)
2. 从 .mrk2 文件中查到 Granule 2 在 .bin 文件中的偏移量
3. 直接 seek 到那个位置,读取并解压数据
4. 同样处理 Granule 3

这样就跳过了 Granule 0 和 Granule 1 的数据,既不读也不解压,节省了大量 I/O 和 CPU。

稀疏索引:ClickHouse 跳过数据的秘密

稀疏索引(Sparse Index)是 ClickHouse 查询性能的核心机制之一。如果你一直用的是 MySQL,可能会下意识地拿 B+ 树索引来对比,但 ClickHouse 的索引和 B+ 树完全不是一回事。

B+ 树索引 vs 稀疏索引

先快速对比一下,大家心里有个概念:

B+ 树索引(MySQL InnoDB):

  • 为每一行数据建立索引条目
  • 索引本身就是一棵很大的树
  • 一亿行数据,索引可能有几个 GB
  • 能精确定位到某一行
  • 面向点查优化

稀疏索引(ClickHouse MergeTree):

  • 不是每一行都有索引条目,而是每隔 N 行记一个
  • 索引非常小,通常只有几 KB 到几 MB
  • 一亿行数据,索引可能只有几十 KB
  • 不能精确定位到某一行,只能定位到某个 Granule
  • 面向范围扫描和聚合优化

这个区别非常重要。ClickHouse 的稀疏索引之所以叫"稀疏",就是因为它只在 Granule 的边界上打标记,而不是每一行都记。

稀疏索引的结构

我们回到前面建的那张 users 表:

CREATE TABLE users (...)
ENGINE = MergeTree()
ORDER BY id;

这里的 ORDER BY id 做了两件事:

  1. 数据在 Part 内部按 id 排序
  2. ClickHouse 为 id 列建立稀疏索引

假设我们有一个 Part,包含 32768 行数据,id 从 1 到 32768。按默认的 8192 行一个 Granule 来划分:

Granule 0:  id 范围 [1, 8192]
Granule 1:  id 范围 [8193, 16384]
Granule 2:  id 范围 [16385, 24576]
Granule 3:  id 范围 [24577, 32768]

稀疏索引(primary.idx)里存的就是每个 Granule 的第一行的主键值:

primary.idx 内容(示意):
  index[0] = 1         -> Granule 0
  index[1] = 8193      -> Granule 1
  index[2] = 16385     -> Granule 2
  index[3] = 24577     -> Granule 3

就这么简单。整个索引就 4 个值。如果是一亿行数据,索引也只有 100000000 / 8192 ≈ 12207 个值,对于 UInt64 来说就是 12207 * 8 ≈ 95KB。这个索引小到可以常驻内存,查询时完全不需要磁盘 I/O。

查询时怎么用稀疏索引

假设我们执行这样一个查询:

SELECT * FROM users WHERE id = 20000;

ClickHouse 的查找过程:

  1. 在稀疏索引中做二分查找:20000 落在 index[2]=16385 和 index[3]=24577 之间,所以目标在 Granule 2
  2. .mrk2 文件中拿到 Granule 2 在各列 .bin 文件中的偏移量
  3. 只读取 Granule 2 的数据(8192 行),跳过其他所有 Granule
  4. 在这 8192 行中找到 id=20000 的那一行

注意,ClickHouse 不能直接定位到 id=20000 所在的确切行,它只能定位到 Granule 级别。但这已经把数据量从 32768 行缩小到 8192 行了。在实际的大表中,这个跳过的比例会非常可观。

再来一个范围查询的例子:

SELECT city, AVG(salary) FROM users WHERE id BETWEEN 10000 AND 20000;
  1. 二分查找:10000 在 Granule 1(8193-16384),20000 在 Granule 2(16385-24576)
  2. 所以需要读取 Granule 1 和 Granule 2,跳过 Granule 0 和 Granule 3
  3. 又因为查询只涉及 city 和 salary 两列,所以 id、name、age、create_time 的 .bin 文件碰都不碰

列裁剪 + Granule 跳过,两层优化叠加起来,最终读取的数据量可能只有原始数据的百分之几。

组合主键

实际业务中,ORDER BY 经常不止一个字段。比如:

CREATE TABLE events
(
    user_id    UInt64,
    event_date Date,
    event_type String,
    duration   UInt32
)
ENGINE = MergeTree()
ORDER BY (user_id, event_date);

这时候稀疏索引里存的是 (user_id, event_date) 的组合值:

primary.idx 内容(示意):
  index[0] = (1, 2023-01-01)
  index[1] = (100, 2023-03-15)
  index[2] = (250, 2023-06-20)
  index[3] = (400, 2023-09-01)

查询时的规则和 MySQL 的最左前缀原则很类似:

  • WHERE user_id = 100:能用上索引,二分查找定位到对应的 Granule 范围
  • WHERE user_id = 100 AND event_date = '2023-05-01':能用上索引,定位更精确
  • WHERE event_date = '2023-05-01':用不上索引,因为 event_date 不是第一列。ClickHouse 只能全表扫描(扫描所有 Granule)

所以 ORDER BY 的字段顺序很重要。一般来说,把查询中最常用于过滤的字段放在前面,基数(不同值的数量)从低到高排列,但具体还是要看实际的查询模式来决定。

数据分区(Partition)

分区是 MergeTree 的另一个重要特性。建表时可以通过 PARTITION BY 来指定分区键:

CREATE TABLE events
(
    user_id    UInt64,
    event_date Date,
    event_type String,
    duration   UInt32
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date);

这里 PARTITION BY toYYYYMM(event_date) 表示按月分区。2023 年 1 月的数据在一个分区,2 月的在另一个分区。

分区和 Part 的关系:一个分区里可以有多个 Part(每次 INSERT 产生一个 Part),但一个 Part 只属于一个分区。后台合并时,也只会把同一个分区内的 Part 合并在一起,不会跨分区合并。

分区最直接的好处:查询如果带了分区键的过滤条件,ClickHouse 可以直接跳过整个分区。比如查询条件是 WHERE event_date BETWEEN '2023-03-01' AND '2023-03-31',那其他月份的分区直接不看。这个粒度比 Granule 级别的跳过还要粗,效果也更明显。

但分区不宜太细。如果你按天分区,一年就是 365 个分区;如果按小时分区,一年就是 8760 个分区。分区太多会导致 Part 碎片化、元数据膨胀、后台合并压力增大。官方建议分区数控制在几十到几百的量级。

TTL:自动清理过期数据

分区还有一个很实用的搭档——TTL(Time To Live)。在日志、监控这类场景下,历史数据的价值会随时间快速衰减,不需要永久保留。TTL 可以让 ClickHouse 自动清理过期数据,不用你写定时任务去手动删。

我们的 APM 系统就用了这个,只保留最近 14 天的 metrics 数据:

CREATE TABLE metrics
(
    date       Date,
    service    String,
    metric     String,
    value      Float64
)
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(date)
ORDER BY (service, metric, date)
TTL date + INTERVAL 14 DAY;

TTL date + INTERVAL 14 DAY 表示当 date 列的值超过 14 天后,这些行就过期了。ClickHouse 会在后台合并时自动删掉过期数据。

这里有个细节值得注意:TTL 的删除粒度。如果表设了分区(比如上面按天分区),ClickHouse 发现整个分区的数据都过期了,会直接 drop 整个分区——这是一个非常轻量的操作,几乎瞬间完成。但如果一个分区里既有过期数据又有未过期数据,就需要在合并时逐行判断,开销会大一些。所以 TTL 和分区配合使用效果最好,分区粒度和 TTL 周期对齐,让过期数据能按分区整体删除。

除了删除,TTL 还支持把过期数据移动到更便宜的存储层,而不是直接删掉:

-- 7 天后从 SSD 移到机械盘,30 天后删除
TTL date + INTERVAL 7 DAY TO VOLUME 'cold',
    date + INTERVAL 30 DAY DELETE

这种"热数据在快盘、冷数据在慢盘、超期数据直接删"的分层策略,在数据量大的场景下可以有效控制存储成本。

跳数索引(Data Skipping Index)

前面介绍的稀疏索引是基于 ORDER BY 键的,只有查询条件命中了 ORDER BY 的前缀列,才能利用索引跳过 Granule。那如果我经常按 event_type 过滤,但 event_type 不在 ORDER BY 里怎么办?全表扫描?

ClickHouse 提供了跳数索引(Data Skipping Index)来解决这个问题。它和主键稀疏索引的思路类似——不是精确定位到某一行,而是快速排除掉"肯定不包含目标数据"的 Granule。

基本用法

跳数索引在建表时通过 INDEX 关键字定义:

CREATE TABLE events
(
    user_id    UInt64,
    event_date Date,
    event_type String,
    city       String,
    duration   UInt32,

    INDEX idx_event_type event_type TYPE set(100) GRANULARITY 4,
    INDEX idx_city city TYPE bloom_filter(0.01) GRANULARITY 2
)
ENGINE = MergeTree()
ORDER BY (user_id, event_date);

也可以对已有的表追加索引:

ALTER TABLE events ADD INDEX idx_event_type event_type TYPE set(100) GRANULARITY 4;
-- 追加索引后,需要对已有数据重建索引
ALTER TABLE events MATERIALIZE INDEX idx_event_type;

这里有两个关键参数:

  1. TYPE:索引类型,决定了用什么数据结构来记录每个 Granule 块的摘要信息
  2. GRANULARITY:索引的粒度。注意这里的 GRANULARITY 和主键稀疏索引的 Granule 不是一个概念——这里的 GRANULARITY 4 表示每 4 个 Granule 组成一个索引块。也就是说,如果每个 Granule 是 8192 行,那一个索引块就覆盖 4 × 8192 = 32768 行

工作原理

跳数索引的核心逻辑非常简单:对每个索引块,记录一份"摘要信息"。查询时,先用 WHERE 条件和摘要信息做一次粗筛——如果根据摘要信息就能判断"这个块里肯定没有满足条件的数据",那整个块就跳过不读。

假设一个 Part 有 16 个 Granule,索引 GRANULARITY = 4:

索引块 0:  Granule 0-3   -> 摘要:{event_type 的可能值集合}
索引块 1:  Granule 4-7   -> 摘要:{event_type 的可能值集合}
索引块 2:  Granule 8-11  -> 摘要:{event_type 的可能值集合}
索引块 3:  Granule 12-15 -> 摘要:{event_type 的可能值集合}

查询 WHERE event_type = 'purchase':
  索引块 0 的摘要中没有 'purchase' -> 跳过 Granule 0-3
  索引块 1 的摘要中有 'purchase'   -> 需要读取 Granule 4-7
  索引块 2 的摘要中没有 'purchase' -> 跳过 Granule 8-11
  索引块 3 的摘要中有 'purchase'   -> 需要读取 Granule 12-15

这样,查询只需要读一半的数据。在实际场景中,如果数据分布比较集中(比如同一种 event_type 的数据写入时间接近),跳过的比例可以非常高。

常用的索引类型

ClickHouse 提供了好几种跳数索引类型,适用于不同的场景:

minmax:记录每个索引块内的最小值和最大值。查询时如果 WHERE 条件的范围和 [min, max] 没有交集,就跳过。适合数值型、时间型列的范围查询。

INDEX idx_duration duration TYPE minmax GRANULARITY 4
-- WHERE duration > 1000 时,如果某个块的 max(duration) <= 1000,直接跳过

这个其实就是 Parquet 里面 Column Chunk 和 Page 上的 min/max 统计信息,思路完全一样。

set(max_size):记录每个索引块内出现过的所有不同值(最多记 max_size 个)。查询时如果 WHERE 的目标值不在集合里,就跳过。适合基数较低的列,比如状态值、类型枚举。

INDEX idx_event_type event_type TYPE set(100) GRANULARITY 4
-- WHERE event_type = 'click' 时,如果某个块的值集合里没有 'click',直接跳过
-- 如果这一列的不同值超过 100 个,索引退化为不起作用,所以 max_size 要根据实际基数来设

bloom_filter(false_positive_rate):布隆过滤器,用概率型数据结构判断"某个值是否可能存在"。它可能误判"存在"(false positive),但不会误判"不存在"。适合高基数列的等值查询,比如用户 ID、URL。

INDEX idx_city city TYPE bloom_filter(0.01) GRANULARITY 2
-- 0.01 表示误判率 1%,误判率越低,索引占用空间越大
-- WHERE city = '北京' 时,布隆过滤器说"不存在"的块一定不存在,可以放心跳过

tokenbf_v1(size, hashes, seed)ngrambf_v1(n, size, hashes, seed):专门用于文本搜索的布隆过滤器变体。tokenbf 按空格等分隔符分词,ngrambf 按 n-gram 切分。适合对日志消息、URL 路径等做模糊匹配。

INDEX idx_message message TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 2
-- WHERE message LIKE '%error%' 或者 WHERE hasToken(message, 'error') 时可以利用

使用建议

跳数索引不是银弹,有几个点需要注意:

  1. 跳数索引只能排除 Granule 块,不能精确定位到行。它的作用是"少读数据",不是"精确查找"
  2. 索引的效果取决于数据的物理分布。如果目标值均匀分散在所有 Granule 块中,那每个块的摘要都会包含目标值,跳不掉任何块,索引就白建了。所以,ORDER BY 的排序顺序会影响跳数索引的效果——如果数据按某个维度排序后,相同值更集中,跳数索引的跳过率就更高
  3. GRANULARITY 不是越小越好。太小的话索引本身会变大,维护成本也更高。一般设成 2-8 比较合理
  4. bloom_filter 有误判率,set 有大小限制。根据列的基数和查询模式选择合适的类型

说白了,跳数索引就是在主键稀疏索引之外,再给非主键列提供一种"粗粒度过滤"的能力。主键索引负责主要的过滤,跳数索引查漏补缺,两者配合起来覆盖更多的查询模式。

写入流程

了解了存储结构之后,我们来看 ClickHouse 的写入是怎么走的。

一次 INSERT 的流程

INSERT INTO users VALUES (...)
         |
         v
  1. 将数据按分区键分组
         |
         v
  2. 每个分区的数据按 ORDER BY 排序
         |
         v
  3. 按列拆分,每列单独编码压缩
         |
         v
  4. 生成 primary.idx(稀疏索引)
         |
         v
  5. 生成 .mrk2 文件(mark 文件)
         |
         v
  6. 写入磁盘,形成一个新的 Part

注意几个点:

  1. 数据是直接落盘的,没有经过内存缓冲区。ClickHouse 传统上不依赖 WAL,每次 INSERT 直接生成一个 Part 目录写到磁盘上。这也是为什么 ClickHouse 更适合批量写入——如果你每次 INSERT 只写一行,就会产生大量的小 Part,性能会非常差。

  2. 写入时数据会立刻排序。ORDER BY 不只是查询时的排序依据,它决定了数据在磁盘上的物理顺序。这意味着写入时有额外的排序开销,但换来的是查询时的巨大收益。

  3. 写入是原子的。一个 Part 要么完整写入成功,要么不存在。不会出现写了一半的 Part。

后台合并(Merge)

随着不断写入,表中的 Part 会越来越多。太多的 Part 会影响查询性能——因为每个查询都需要扫描所有 Part,Part 越多,查询越慢。

所以 ClickHouse 会在后台不断地把小的 Part 合并成大的 Part。这就是 MergeTree 名字中 Merge 的由来。

写入产生的 Part:
  all_1_1_0  (1000 行)
  all_2_2_0  (500 行)
  all_3_3_0  (2000 行)

       | 后台合并
       v

合并后的 Part:
  all_1_3_1  (3500 行)

合并过程中,ClickHouse 会:

  1. 读取所有要合并的 Part 的数据
  2. 做一次归并排序(因为每个 Part 内部已经有序,所以归并排序非常高效)
  3. 重新生成稀疏索引和 mark 文件
  4. 写成一个新的大 Part
  5. 标记旧的 Part 为待删除(实际删除会延迟一段时间)

合并的主要目的是减少 Part 数量、提高查询性能。Part 越少,查询时需要扫描和归并的数据源就越少,效率就越高。

批量写入的最佳实践

因为每次 INSERT 都会生成一个新的 Part,所以 ClickHouse 非常不适合高频小批量写入。官方的建议是:

  • 每次 INSERT 至少几千到几万行
  • 写入频率不超过每秒一次
  • 如果数据来源是流式的,建议在应用层攒一个 buffer,达到一定量再批量写入

如果你确实需要高频写入,可以用 Buffer 表引擎做一层缓冲,或者在客户端做 batch。说到底,ClickHouse 的设计哲学就是"写入可以慢一点、粗一点,但查询一定要快"。

读取流程

写入流程搞清楚了,我们来看查询时 ClickHouse 怎么读数据。以这个查询为例:

SELECT city, AVG(salary)
FROM users
WHERE id BETWEEN 10000 AND 50000
GROUP BY city;

整体流程如下:

1. 分区裁剪(Partition Pruning)
   -> 如果表有分区,先根据 WHERE 条件排除不相关的分区

2. 主键索引过滤(Primary Key Analysis)
   -> 在 primary.idx 中二分查找,确定哪些 Granule 的范围和 [10000, 50000] 有交集
   -> 标记这些 Granule 为"需要读取",其他的跳过

3. 跳数索引过滤(Data Skipping Index)
   -> 如果 WHERE 条件涉及的列上建了跳数索引,再用索引的摘要信息做一轮过滤
   -> 进一步排除掉主键索引没能跳过的 Granule 块

4. 列裁剪
   -> 查询只涉及 id、city、salary 三列
   -> 只读取这三列的 .bin 和 .mrk2 文件

5. 数据读取
   -> 根据 .mrk2 中的偏移量,从 .bin 文件中读取需要的 Granule
   -> 解压数据

6. 过滤
   -> 在读出的数据上精确过滤 id BETWEEN 10000 AND 50000
   -> 因为 Granule 的粒度是 8192 行,所以会读入一些不满足条件的行,这里再过滤掉

7. 聚合执行
   -> 对过滤后的数据按 city 做 GROUP BY,计算 AVG(salary)

8. 返回结果

你会发现,ClickHouse 的读取路径上到处都在做"跳过":分区级别跳过、主键索引和跳数索引的 Granule 级别跳过、列级别跳过。数据从磁盘到最终结果,经过层层过滤,实际需要读取和处理的数据量被大幅缩小。

这也是为什么 ClickHouse 在分析查询上这么快的核心原因之一——不是因为它读得特别快,而是因为它读得特别少。

列式存储与压缩

我们在 Parquet 系列里已经聊过列式存储的压缩优势,这些优势在 ClickHouse 中同样成立。简单回顾一下:

  • 同一列的数据类型相同,重复度高,天然适合压缩
  • 排序后的数据重复度更高,压缩效果更好
  • 数值型数据可以用 Delta 编码,字符串可以用字典编码

ClickHouse 在压缩这块做得更进一步。它的 .bin 文件内部,每个压缩块的结构大致是这样的:

┌──────────────────────────┐
│ Compression Method (1B)  │
├──────────────────────────┤
│ Compressed Size (4B)     │
├──────────────────────────┤
│ Uncompressed Size (4B)   │
├──────────────────────────┤
│ Compressed Data          │
└──────────────────────────┘

默认使用 LZ4 压缩,兼顾压缩比和解压速度。在分析场景下,解压速度往往比压缩比更重要——因为数据要频繁读出来做计算,解压快意味着查询响应快。LZ4 的解压速度可以达到几 GB/s,基本上不会成为瓶颈。

如果你的场景对存储空间更敏感,可以换成 ZSTD,压缩比更高但解压稍慢。ClickHouse 支持按列指定不同的压缩算法:

CREATE TABLE events
(
    user_id    UInt64 CODEC(Delta, LZ4),
    event_date Date CODEC(Delta, ZSTD),
    event_type String CODEC(LZ4),
    payload    String CODEC(ZSTD(3))
)
ENGINE = MergeTree()
ORDER BY (user_id, event_date);

这里 CODEC(Delta, LZ4) 表示先做 Delta 编码(存差值),再用 LZ4 压缩。对于单调递增的数值列(比如自增 ID、时间戳),Delta 编码后差值都很小甚至为 0,再压缩就能压得非常小。

这个思路和 Parquet 里面用 Delta Encoding 处理时间戳列是一样的——先用领域特定的编码把数据变得更"规整",再用通用压缩算法兜底。

实际效果可以非常惊人。在典型的日志分析场景下,ClickHouse 的压缩比可以达到 10:1 到 20:1,也就是说 1TB 的原始数据可能只占 50-100GB 的磁盘空间。

向量化执行引擎

列式存储和稀疏索引解决的是"少读数据",而向量化执行解决的是"读出来以后怎么算得快"。这是 ClickHouse 快的另一个关键原因。

传统的逐行执行

传统数据库(包括 MySQL)的查询执行通常是逐行的:从存储层取一行 → 过滤 → 计算 → 输出 → 取下一行。用伪代码表示:

for each row in table:
    if row.age > 30:
        result.add(row.salary)

这种方式的问题是:每处理一行都有一次函数调用开销,CPU 的分支预测、指令缓存、数据缓存都利用不充分。当数据量上亿时,这些"微小"的开销累积起来就非常可观了。

ClickHouse 的向量化执行

ClickHouse 不是逐行处理,而是一次处理一批数据(一个列的一段)。还是上面那个例子:

// 取出 age 列的一个 batch(比如 8192 个值)
age_batch = [28, 35, 42, 31, 27, ...]

// 一次性对整个 batch 做过滤,生成一个 bitmap
filter = age_batch > 30
// filter = [0, 1, 1, 1, 0, ...]

// 取出 salary 列的对应 batch
salary_batch = [15000, 25000, 30000, 20000, 22000, ...]

// 用 filter 筛选出需要的值
result = salary_batch[filter]
// result = [25000, 30000, 20000, ...]

这种方式的好处:

  1. 一次函数调用处理 8192 个值,函数调用开销被平摊到极低
  2. 同一列的数据在内存中连续排列,CPU 缓存命中率极高
  3. 批量处理可以利用 CPU 的 SIMD 指令(Single Instruction Multiple Data),一条 CPU 指令同时处理多个数据

SIMD 是什么?简单说,现代 CPU 有一些特殊的寄存器(比如 SSE 的 128 位寄存器、AVX2 的 256 位寄存器、AVX-512 的 512 位寄存器),可以把多个小数据打包放进去,然后一条指令同时对它们做运算。

比如要对 8 个 32 位整数做加法:

普通方式:  8 次加法操作
SIMD (AVX2):把 8 个整数装进一个 256 位寄存器,1 次操作搞定

理论上快 8 倍。当然实际效果没有那么理想,但 2-4 倍的加速是很常见的。

ClickHouse 在很多关键路径上都手写了 SIMD 优化的代码,包括过滤、聚合、排序、哈希计算等。这也是它比很多同类产品快的原因之一——不是算法上有多大差异,而是在工程实现上把 CPU 的能力压榨到了极限。

为什么列式存储和向量化执行天然配合

这里多说一句,因为这个点很容易被忽略。

列式存储意味着同一列的数据在内存中是连续的。这恰好满足 SIMD 指令的要求——SIMD 需要操作的数据在内存中连续排列才能高效工作。如果是行式存储,同一列的数据分散在不同行的不同偏移位置,要用 SIMD 的话还得先把数据收集到一起,反而增加开销。

所以列式存储 + 向量化执行,不是"两个独立的优化凑在一起",而是互相成就的关系。前者为后者提供了理想的数据布局,后者把前者的布局优势转化成了实际的计算性能。

MergeTree 家族的其他成员

前面一直在说的是最基础的 MergeTree 引擎。在实际使用中,ClickHouse 还提供了一系列 MergeTree 的变体,解决不同场景的问题。我们简单认识一下,知道有哪些选择就好。

ReplacingMergeTree

我们知道 ClickHouse 是面向分析的,不擅长数据更新。但很多业务场景确实需要更新数据,比如用户修改了个人信息、订单状态发生了变化。ReplacingMergeTree 提供了一种"最终一致"的更新方案:

CREATE TABLE users
(
    id   UInt64,
    name String,
    age  UInt8,
    _version UInt64
)
ENGINE = ReplacingMergeTree(_version)
ORDER BY id;

它的逻辑是:在后台合并时,对于 ORDER BY 相同的多行数据,只保留 _version 最大的那一行,其他的丢弃。

注意:这个"去重"只在合并时发生,不是实时的。在合并之前,查询可能会看到同一个 id 的多条记录。如果要确保查询时只看到最新版本,需要在查询时加 FINAL 关键字:

SELECT * FROM users FINAL WHERE id = 100;

FINAL 会在查询时强制做一次去重,保证结果正确,但代价是性能会变差。这个 trade-off 需要根据业务场景来权衡。

AggregatingMergeTree

这个引擎稍微有点复杂,我们慢慢来。

为什么需要它

先说一个实际场景。假设你有一张用户行为明细表,每天写入几亿条记录,然后有一个实时报表需要展示"每天每个城市的页面浏览量和独立用户数"。

最直觉的做法是每次打开报表时,直接在明细表上跑聚合查询:

SELECT date, city, sum(views), uniq(user_id)
FROM events_raw
GROUP BY date, city;

数据量小的时候这么干没问题,ClickHouse 跑得很快。但当明细表积累到几十亿甚至几百亿行的时候,即使是 ClickHouse,每次查询都要扫描全量数据做聚合也是扛不住的——每次打开报表都要等十几秒甚至几十秒,用户体验很差。

一个自然的想法是:能不能提前把聚合结果算好存起来?报表查询的时候直接读预计算的结果,不用每次都从明细数据重新算。

这就是 AggregatingMergeTree 要解决的问题——在写入阶段做预聚合,把聚合的中间状态持久化下来,查询时只需要合并这些中间状态就能得到最终结果。

什么是"聚合的中间状态"

这是理解 AggregatingMergeTree 的关键概念,我们先把它搞清楚。

对于 sum() 来说,中间状态很好理解——就是一个部分和。比如你有 100 万行数据,先对前 50 万行算一个 sum 得到 S1,再对后 50 万行算一个 sum 得到 S2,最后 S1 + S2 就是最终结果。S1 和 S2 就是中间状态。

uniq()(计算去重后的独立值数量)就没这么简单了。你不能把两个"部分去重数"直接加起来,因为两部分可能有重叠的值。ClickHouse 用的是 HyperLogLog 这种概率算法,它的中间状态是一个特殊的数据结构(一个 bitmap),多个 HyperLogLog 状态可以合并成一个,合并后的结果等价于对所有原始数据做一次去重计数。

所以"中间状态"不一定是一个简单的数字,它是一个和具体聚合函数绑定的数据结构。ClickHouse 用 AggregateFunction(函数名, 参数类型) 这个特殊的列类型来存储它。

完整的使用示例

好了,概念说完了,我们来看怎么用。假设我们要统计每天每个城市的页面浏览量和独立用户数。

第一步,建明细表和预聚合表:

-- 原始明细表,每条记录是一次用户行为
CREATE TABLE events_raw
(
    date       Date,
    city       String,
    user_id    UInt64,
    views      UInt64
)
ENGINE = MergeTree()
ORDER BY (date, city);

-- 预聚合表,存储聚合的中间状态
CREATE TABLE daily_stats
(
    date       Date,
    city       String,
    views      AggregateFunction(sum, UInt64),
    users      AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree()
ORDER BY (date, city);

注意预聚合表的 views 列类型不是 UInt64,而是 AggregateFunction(sum, UInt64)。它存的不是一个具体的数值,而是 sum 函数的中间状态。同理 users 列存的是 uniq 函数的中间状态。

第二步,写入数据。写入预聚合表时,要用 -State 后缀的函数:

INSERT INTO daily_stats
SELECT
    date,
    city,
    sumState(views),       -- 不是 sum(),而是 sumState(),产出中间状态
    uniqState(user_id)     -- 不是 uniq(),而是 uniqState()
FROM events_raw
GROUP BY date, city;

sumState(views) 不会直接算出最终的 sum 值,而是把中间状态存下来。后面有新数据到来时,对新数据同样执行这个 INSERT ... SELECT,新的中间状态会作为新的行写入预聚合表。

这里的关键是:预聚合表里同一个 (date, city) 可能会有多行(因为每次 INSERT 都可能产生一行)。没关系,后台合并的时候,AggregatingMergeTree 会自动把 ORDER BY 键相同的多行合并成一行——sum 的中间状态相加,uniq 的 HyperLogLog 状态合并。

第三步,查询时用 -Merge 后缀的函数把中间状态合并成最终结果:

SELECT
    date,
    city,
    sumMerge(views) AS total_views,      -- 合并所有中间状态,得到最终的 sum
    uniqMerge(users) AS unique_users     -- 合并所有中间状态,得到最终的 uniq
FROM daily_stats
GROUP BY date, city;

为什么查询时还需要 GROUP BY?因为前面说了,后台合并不是实时的,合并之前同一个 (date, city) 可能有多行中间状态。-Merge 函数加上 GROUP BY 保证了不管后台有没有合并完,查询结果都是正确的。

这个查询扫描的数据量比直接查明细表少了几个数量级——明细表可能有几十亿行,预聚合表可能只有几万行(每天 × 每个城市 = 一行)。查询时间从十几秒变成毫秒级。

搭配物化视图:最佳实践

上面的例子中,我们是手动执行 INSERT ... SELECT 来把明细数据聚合到预聚合表的。在实际使用中,没有人会这么干——数据是持续写入的,你不可能每次都手动跑一遍。

正确的做法是用物化视图(Materialized View)把这个过程自动化:

CREATE MATERIALIZED VIEW daily_stats_mv
TO daily_stats                           -- 结果写入 daily_stats 表
AS
SELECT
    date,
    city,
    sumState(views) AS views,
    uniqState(user_id) AS users
FROM events_raw
GROUP BY date, city;

有了这个物化视图之后,每次往 events_raw 写入数据,ClickHouse 会自动对这批新数据执行物化视图里定义的聚合查询,把结果写入 daily_stats 表。业务端只需要往明细表写数据,预聚合完全自动完成。

这是 ClickHouse 中非常经典的架构模式:明细表(MergeTree)+ 物化视图 + 预聚合表(AggregatingMergeTree)。我们之前的 APM 系统用的就是这个模式,效果非常好。

整个数据流是这样的:

业务写入 events_raw(明细表)
         |
         v
物化视图自动触发,对这批数据执行聚合
         |
         v
聚合的中间状态写入 daily_stats(预聚合表)
         |
         v
后台合并时,AggregatingMergeTree 自动合并同一个 (date, city) 的中间状态
         |
         v
查询时用 sumMerge() / uniqMerge() 得到最终结果(毫秒级)

注意事项

  1. 物化视图只对写入之后的新数据生效。如果建物化视图之前 events_raw 里已经有数据了,那些旧数据不会自动聚合到预聚合表。需要手动执行一次 INSERT ... SELECT 来补历史数据

  2. AggregateFunction 类型的列不能直接 SELECT 查看,因为它存的是二进制的中间状态,不是人类可读的值。必须通过 -Merge 函数才能得到可读的结果

  3. 预聚合表的 ORDER BY 键决定了聚合的维度。上面的例子按 (date, city) 聚合,如果你还想按小时维度查询,那预聚合表里就得不到小时级别的数据了。所以预聚合的维度设计要提前想好,它决定了你能查到什么粒度的数据

  4. 一张明细表可以挂多个物化视图,输出到不同维度的预聚合表。比如一个按 (date, city) 聚合,另一个按 (date, event_type) 聚合,满足不同的报表需求

SummingMergeTree

AggregatingMergeTree 的简化版,后台合并时自动对数值列做 SUM。适合计数器、累加指标这类场景:

CREATE TABLE page_views
(
    date   Date,
    page   String,
    views  UInt64,
    clicks UInt64
)
ENGINE = SummingMergeTree()
ORDER BY (date, page);

同一个 (date, page) 的多行数据,合并后 views 和 clicks 会自动累加成一行。

有几个细节值得注意:

  1. SummingMergeTree 默认会对所有数值列做累加。如果你只想对某些列求和,可以在引擎参数里指定:SummingMergeTree((views, clicks)),这样只有 views 和 clicks 会被累加,其他数值列保持不变
  2. 非数值列(比如 String 类型)不会被累加,合并时保留第一条记录的值
  3. 和 ReplacingMergeTree 一样,合并不是实时的。查询时如果需要准确的累加结果,要么加 FINAL,要么在 SELECT 里自己再做一次 GROUP BY + SUM

SummingMergeTree 相比 AggregatingMergeTree 的优势是简单——不需要处理 -State/-Merge 这些特殊函数,写入就是普通的 INSERT,查询也是普通的 SELECT。代价是它只能做 SUM,不支持其他聚合函数。如果你的需求就是累加计数器,用 SummingMergeTree 就够了,没必要上 AggregatingMergeTree。

CollapsingMergeTree

CollapsingMergeTree 用一种很巧妙的方式来解决数据更新和删除的问题——通过"正负行"抵消。

我们来看一个具体的例子:

CREATE TABLE user_actions
(
    user_id UInt64,
    action  String,
    count   UInt64,
    sign    Int8        -- 1 表示"有效行",-1 表示"取消行"
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY user_id;

建表时需要指定一个 sign 列,类型是 Int8,值只能是 1 或 -1。

假设我们先写入一条记录:

-- 用户 1001 点击了 5 次
INSERT INTO user_actions VALUES (1001, 'click', 5, 1);

现在想把 count 从 5 改成 8。在 ClickHouse 里不能直接 UPDATE,CollapsingMergeTree 的做法是:先写一行 sign=-1 把旧数据"取消"掉,再写一行 sign=1 表示新数据:

INSERT INTO user_actions VALUES
    (1001, 'click', 5, -1),    -- 取消旧的那条(内容要和原来完全一样,sign 改为 -1)
    (1001, 'click', 8, 1);     -- 写入新的

后台合并时,ClickHouse 会把 sign=1 和 sign=-1 的配对行抵消掉,最终只剩下 (1001, 'click', 8, 1) 这一行。

删除也是同样的套路——只写一行 sign=-1 的取消行,不写新的 sign=1 行,合并后这条数据就消失了。

这个设计的麻烦在于:业务端必须自己维护"旧数据的完整内容"。你要取消一行,就得把那一行的所有字段原样写一遍,只把 sign 改成 -1。如果旧数据的内容记错了,正负行就配不上,合并后会出现脏数据。

和前面几个引擎一样,抵消只在后台合并时发生。合并之前查询会看到正负行同时存在。如果要在查询时得到正确结果,有两种方式:

-- 方式一:加 FINAL(简单但慢)
SELECT * FROM user_actions FINAL;

-- 方式二:手动用 GROUP BY + SUM 处理 sign(推荐,性能更好)
SELECT
    user_id,
    action,
    sum(count * sign) AS count
FROM user_actions
GROUP BY user_id, action
HAVING sum(sign) > 0;

方式二的思路是:把 count 乘以 sign 再求和,正负行的值会互相抵消。HAVING sum(sign) > 0 过滤掉已经被完全取消的数据。这种写法虽然麻烦一点,但查询性能比 FINAL 好很多。

VersionedCollapsingMergeTree

CollapsingMergeTree 有一个隐含的要求:正负行的写入顺序必须正确——先写 sign=1,再写 sign=-1。如果因为并发写入或者网络乱序,sign=-1 的行先到了,抵消就会出问题。

VersionedCollapsingMergeTree 在 CollapsingMergeTree 的基础上加了一个 version 列来解决这个问题:

CREATE TABLE user_actions
(
    user_id  UInt64,
    action   String,
    count    UInt64,
    sign     Int8,
    version  UInt64      -- 版本号
)
ENGINE = VersionedCollapsingMergeTree(sign, version)
ORDER BY user_id;

合并时,ClickHouse 会根据 version 来配对正负行,而不是依赖写入顺序。同一个 version 下的 sign=1 和 sign=-1 会互相抵消。这样即使写入顺序是乱的,最终结果也是正确的。

在实际生产环境中,如果你需要用 Collapsing 的方式来处理更新和删除,建议直接用 VersionedCollapsingMergeTree,省去很多顺序相关的麻烦。

怎么选

说了这么多变体,最后简单总结一下选型思路:

  • 只需要基础的列式分析,不涉及更新去重 → MergeTree
  • 需要按主键去重,保留最新版本 → ReplacingMergeTree
  • 需要预聚合,支持多种聚合函数 → AggregatingMergeTree
  • 只需要预聚合 SUM → SummingMergeTree
  • 需要支持删除和更新,能接受正负行的复杂度 → CollapsingMergeTree / VersionedCollapsingMergeTree

大多数场景下,MergeTree 和 ReplacingMergeTree 就够用了。其他几个是在特定需求下的优化选择,用到的时候再深入了解也不迟。

ClickHouse 对 Parquet 格式的支持

Parquet 在一定意义上,是作为列式数据的标准,所以几乎各种数据平台都支持将其作为数据交换格式,Clickhouse 也不例外,它可以直接读写 Parquet 文件:

-- 从 Parquet 文件导入数据
INSERT INTO users
SELECT * FROM file('users.parquet', Parquet);

-- 把查询结果导出为 Parquet 文件
SELECT * FROM users
INTO OUTFILE 'result.parquet' FORMAT Parquet;

这也从侧面说明了,Parquet 作为数据交换格式的通用性。

ClickHouse 的局限性

说了这么多优点,最后我们说说 ClickHouse 不适合做什么。任何技术都有它的适用边界,了解局限性和了解优势一样重要。

  1. 不适合高频小批量写入。前面已经说过了,每次 INSERT 生成一个 Part,高频写入会产生大量小 Part,合并压力大,性能急剧下降。

  2. 不支持事务。ClickHouse 没有传统数据库意义上的事务(ACID),没有 BEGIN/COMMIT/ROLLBACK。如果你的业务需要事务保证,ClickHouse 不是正确的选择。

  3. 不擅长点查。虽然稀疏索引可以做点查,但效率远不如 B+ 树索引。如果你的查询模式主要是 WHERE id = xxx 这种精确查找,MySQL 或 Redis 更合适。

  4. 不擅长 UPDATE/DELETE。ClickHouse 虽然支持 ALTER TABLE ... UPDATE/DELETE,但这是一个重量级操作(叫做 Mutation),它本质上是在后台重写整个 Part,不是原地修改。频繁的 UPDATE/DELETE 会导致严重的性能问题。

  5. JOIN 的能力有限。ClickHouse 对 JOIN 的支持远不如 MySQL 或 PostgreSQL 丰富和高效。大表 JOIN 大表在 ClickHouse 中通常不是一个好的选择。实际使用中,更推荐通过宽表(大的扁平表)来避免 JOIN。

了解到这里我觉得就够了。这些局限性不是"缺点",而是设计上的取舍——ClickHouse 选择在分析查询这个赛道做到极致,代价就是放弃了 OLTP 场景的一些能力。

副本与分片

前面所有内容讲的都是单机层面的设计。但生产环境中,我们通常要面对两个问题:一是机器挂了数据怎么办,二是数据量大到单机放不下怎么办。ClickHouse 给出的答案就是副本(Replica)和分片(Shard)。

简单来说,副本解决的是高可用——同一份数据存多个节点,挂一个不影响服务;分片解决的是容量——把数据切成多份,分散到不同的节点上。

不过在讲副本和分片之前,我们先认识一个基础组件——ClickHouse Keeper,因为后面副本的协调全靠它。

ClickHouse Keeper

多个节点之间要同步数据,就需要有个地方来协调状态:谁是主副本、哪些数据需要同步、同步到哪一步了。ClickHouse 早期用的是 Apache ZooKeeper 来做这件事,但 ZooKeeper 是 Java 写的,运维起来比较重,而且在大规模集群下容易成为瓶颈。

所以 ClickHouse 后来自己用 C++ 实现了一个兼容 ZooKeeper 协议的组件——ClickHouse Keeper。它可以直接替换 ZooKeeper,不需要改任何配置(因为协议兼容),但更轻量、性能更好。现在新部署的集群基本都推荐用 ClickHouse Keeper。

知道它是干什么的就行了,我们继续往下看。

副本:ReplicatedMergeTree

副本的用法很简单,就是把引擎名从 MergeTree 换成 ReplicatedMergeTree:

CREATE TABLE events ON CLUSTER my_cluster
(
    event_date Date,
    user_id UInt64,
    event_type String
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
ORDER BY (event_date, user_id);

两个参数分别是 ClickHouse Keeper 中的路径和副本名。

工作原理也不复杂:你往任意一个副本写入数据,ClickHouse 会通过 Keeper 通知其他副本来拉取这些数据。注意是"拉取"而不是"推送"——写入节点只是把操作记录写到 Keeper 的日志里,其他副本看到日志后主动下载对应的 Part 文件。这个设计的好处是写入节点不需要等所有副本都同步完就能返回,写入延迟不会因为副本数增加而显著增大。

每种 MergeTree 变体都有对应的 Replicated 版本:ReplicatedReplacingMergeTree、ReplicatedAggregatingMergeTree、ReplicatedSummingMergeTree 等等,用法上就是在原来的引擎名前加 Replicated 前缀。

这里要注意一点,副本解决的是数据冗余和高可用,每个副本都持有完整的一份数据。如果你的数据量大到单机放不下,光靠副本是不够的,就需要分片了。

分片:Distributed 表引擎

分片的思路很直接:把数据分散到多台机器上,每台机器只存一部分。

ClickHouse 的做法是分两层:底层是每个分片节点上的本地表,上层是一张 Distributed 表作为统一的查询入口:

-- 每个分片节点上的本地表
CREATE TABLE events_local ON CLUSTER my_cluster
(
    event_date Date,
    user_id UInt64,
    event_type String
)
ENGINE = MergeTree()
ORDER BY (event_date, user_id);

-- Distributed 表,作为查询入口
CREATE TABLE events_dist ON CLUSTER my_cluster AS events_local
ENGINE = Distributed(my_cluster, default, events_local, rand());

这里本地表用的是普通 MergeTree,分片本身不要求副本。如果你还需要高可用,把本地表的引擎换成 ReplicatedMergeTree 就行了,分片和副本是两个独立的能力,可以按需组合。

Distributed 表本身不存任何数据,它只是一个路由层。查询 events_dist 时,它会把请求发给所有分片,每个分片在本地执行查询,最后汇总结果返回。写入 events_dist 时,它会根据分片键(这里是 rand(),即随机分配)决定数据发往哪个分片。

副本 + 分片:典型的集群架构

实际生产中,副本和分片通常是一起用的。一个典型的集群长这样:

集群: 2 个分片 × 2 个副本 = 4 个节点

分片 1: node1 (副本 A) ←→ node2 (副本 B)    -- 互为副本,数据相同
分片 2: node3 (副本 A) ←→ node4 (副本 B)    -- 互为副本,数据相同

Distributed 表 → 查询时同时访问分片1和分片2,各取一个副本

每个分片存一半数据(分片解决容量问题),每个分片内两个副本互相同步(副本解决高可用问题)。查询时 Distributed 表从每个分片选一个副本来执行,最后合并结果。

最后说一句,对于大多数中小规模的场景(日数据量在 100GB 以内),单机 ClickHouse 就够用了,不需要折腾集群。真到了需要的时候,记住核心就三件事:ClickHouse Keeper 做协调、ReplicatedMergeTree 做副本、Distributed 表做分片路由。

总结

本文从存储结构到查询优化,再到副本与分片,过了一遍 ClickHouse 的核心设计。几个关键要点:

  1. ClickHouse 是一个列式 OLAP 数据库,擅长海量数据的聚合分析,不擅长事务处理和高频小写入
  2. MergeTree 是 ClickHouse 的核心引擎,数据按 Part 组织,每列独立存储为 .bin 文件
  3. 稀疏索引不同于 B+ 树索引,它只在每 8192 行(Granule)的边界打标记,索引极小可以常驻内存
  4. 跳数索引(Data Skipping Index)为非主键列提供粗粒度过滤能力,通过 minmax、set、bloom_filter 等摘要信息跳过不相关的 Granule 块
  5. 查询时通过分区裁剪、主键索引过滤、跳数索引过滤、列裁剪多层过滤,大幅减少实际读取的数据量
  6. 向量化执行引擎利用 SIMD 指令批量处理数据,和列式存储的内存布局天然配合
  7. 写入要批量、不要太频繁,每次 INSERT 会生成一个 Part,后台会持续合并小 Part
  8. 生产环境中通过 ReplicatedMergeTree 实现数据复制和高可用,通过 Distributed 表实现数据分片,中小规模场景单机就够用

如果你之前读过 Parquet 系列,会发现很多概念在 ClickHouse 中又出现了——列式存储的编码压缩、数据分块、统计信息辅助跳过。这些核心思想在不同的系统中反复出现,只是根据各自的使用场景做了不同的取舍。把这些共通的设计思路抓住,后面再去看其他数据系统,都会觉得似曾相识。

本文不复杂,其实就是带大家入了个门,相信通过本文的介绍,大家对 ClickHouse 会有一个很好的认识,以后如果需要再去学习更深入的内容,也会游刃有余。

(全文完)