---
name: clickhouse
title: ClickHouse 核心架构与设计原理
date: 2026-04-27
---

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

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

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

本文的目标是对 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 页面](https://benchmark.clickhouse.com/)，这个页面非常丰富。它对比了市面上主流的分析型数据库，在相同硬件条件下，ClickHouse 在大多数查询上都是最快的，或者说是最快的之一。

![benchmark](https://assets.javadoop.com/imgs/20510079/clickhouse/benchmark.png)

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

## 快速上手

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

### 安装与启动

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

```bash
curl https://clickhouse.com/ | sh
```

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

```bash
./clickhouse server
```

另开一个终端，启动客户端：

```bash
./clickhouse client
```

你也可以用 Docker：

```bash
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 系列里的用户表：

```sql
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 决定了数据在磁盘上的物理排序方式。

插入一些测试数据：

```sql
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 的习惯可以直接搬过来：

```sql
-- 简单聚合
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 表：

```sql
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。

### 查询时怎么用稀疏索引

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

```sql
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 行了。在实际的大表中，这个跳过的比例会非常可观。

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

```sql
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 经常不止一个字段。比如：

```sql
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` 来指定分区键：

```sql
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 数据：

```sql
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 还支持把过期数据移动到更便宜的存储层，而不是直接删掉：

```sql
-- 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` 关键字定义：

```sql
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);
```

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

```sql
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] 没有交集，就跳过。适合数值型、时间型列的范围查询。

```sql
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 的目标值不在集合里，就跳过。适合基数较低的列，比如状态值、类型枚举。

```sql
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。

```sql
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 路径等做模糊匹配。

```sql
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 怎么读数据。以这个查询为例：

```sql
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 支持按列指定不同的压缩算法：

```sql
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 提供了一种"最终一致"的更新方案：

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

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

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

```sql
SELECT * FROM users FINAL WHERE id = 100;
```

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

### AggregatingMergeTree

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

#### 为什么需要它

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

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

```sql
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(函数名, 参数类型)` 这个特殊的列类型来存储它。

#### 完整的使用示例

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

第一步，建明细表和预聚合表：

```sql
-- 原始明细表，每条记录是一次用户行为
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` 后缀的函数：

```sql
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` 后缀的函数把中间状态合并成最终结果：

```sql
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）把这个过程自动化：

```sql
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。适合计数器、累加指标这类场景：

```sql
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 用一种很巧妙的方式来解决数据更新和删除的问题——通过"正负行"抵消。

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

```sql
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。

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

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

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

```sql
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。如果旧数据的内容记错了，正负行就配不上，合并后会出现脏数据。

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

```sql
-- 方式一：加 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 列来解决这个问题：

```sql
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 文件：

```sql
-- 从 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：

```sql
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 表作为统一的查询入口：

```sql
-- 每个分片节点上的本地表
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 会有一个很好的认识，以后如果需要再去学习更深入的内容，也会游刃有余。

（全文完）
