深入理解 ClickHouse:设计哲学、列式存储与索引奥秘
在我们日常的开发中,经常会遇到这样的场景:在一张十亿级别的日志表上执行 SELECT COUNT(*) FROM logs WHERE date = '2025-09-10'。在 MySQL 中,这类查询可能会从秒级延迟骤降至分钟甚至小时级别。其瓶颈并非硬件,而是架构与场景的错配。
ClickHouse 的诞生,正是为了彻底解决海量数据下的实时分析问题。它不属于全能型数据库,而是一款专为 OLAP(在线分析处理)场景设计的武器。它的核心哲学是:放弃通用性,换取在“大范围数据扫描与聚合”上的极致性能。
一、为什么行式存储(如MySQL)在分析场景中失灵?
要理解 ClickHouse 的强大,必须先明白传统数据库的局限。MySQL 这类行式数据库将一行的所有数据紧密地存储在一起。这种结构非常适合事务处理(OLTP),例如更新某个用户的个人信息,数据库只需定位并修改那一行数据即可。
然而,一旦进入分析场景(OLAP),行式存储的弊端暴露无遗:
- I/O 瓶颈:一个简单的查询,如
SELECT AVG(duration) FROM logs,即使只关心一个字段,数据库也必须从磁盘读取每一行的所有字段(如id, user_id, city, action...),造成了巨大的 I/O 浪费。 - 缓存效率低下:宝贵的内存缓存被大量无关数据占据,有效数据缓存命中率低。
- 压缩率低:不同类型的数据(如整型、字符串、时间戳)混杂在一起存储,难以获得高效的压缩比。
因此,问题的根源在于存储模型。ClickHouse 选择了另一条路:列式存储。
二、列式存储:为分析而生的核心设计
ClickHouse 的核心革新在于列式存储。它将表中的每一列数据分别独立存储在不同的文件中。例如,duration 列的所有值连续存储在 duration.bin 文件中,city 列的值存储在 city.bin 文件中。
这一改变带来了三大决定性优势:
- 极致 I/O 效率:前述的
AVG(duration)查询,现在只需读取duration.bin这一个文件,I/O 量减少了十倍甚至百倍。 - 超高压缩比:同一列的数据类型相同,数值重复性和局部相关性更高(例如城市名、枚举值、连续的时间戳),使得 LZ4 或 ZSTD 等压缩算法能发挥最大效力,压缩比可达 5:1 乃至更高,进一步减少了 I/O。
- 向量化执行:列式数据在内存中以数组形式排列,CPU 可以利用 SIMD(单指令多数据流)指令,一次性对上百个数据执行同一操作,极大加速了聚合计算。
然而,这引入了一个关键问题:同一行的数据被物理拆散了,查询时如何正确地“拼凑”回来? 例如,如何确保 id=2 的 name 和 age 值被正确匹配?
答案是:ClickHouse 并不在查询时进行复杂的关联,而是在写入时就通过确定的流程保证了“行对齐”。
三、写入与行对齐:确定性流程的魅力
虽然数据最终按列存储,但写入过程仍以“行”为单位处理。关键在于一个强制的排序步骤。
- 接收行式数据:
INSERT INTO users VALUES (3, 'Charlie', 35), (1, 'Alice', 25), (2, 'Bob', 30)
- 按 ORDER BY 键排序: 假设表引擎定义为
ORDER BY (id),ClickHouse 会在内存中将整个数据块按id排序。注意,排序是以“行”为单位移动的。
排序前在内存中的数据块:
| id | name | age |
|---|---|---|
| 3 | Charlie | 35 |
| 1 | Alice | 25 |
| 2 | Bob | 30 |
排序后在内存中的数据块:
| id | name | age |
|---|---|---|
| 1 | Alice | 25 |
| 2 | Bob | 30 |
| 3 | Charlie | 35 |
-
转置并写入列文件: 排序完成后,ClickHouse 将有序的数据按列拆分,写入磁盘:
id.bin:[1, 2, 3]name.bin:['Alice', 'Bob', 'Charlie']age.bin:[25, 30, 35]
至此,行对齐的奥秘揭晓: 所有列文件中的第 i 个值,天然就属于同一行。查询 id=2 时,系统在 id.bin 的数组中找到值为 2 的索引位置 i=1,那么它只需直接取出 name.bin[1] 和 age.bin[1],即可得到正确结果 ('Bob', 30)。这种对齐关系是由写入时的排序过程保证的,查询时只是简单地进行数组下标寻址。
四、索引机制:稀疏索引与最左前缀原则
仅仅保证行对齐还不够,快速定位数据所在的范围同样关键。ClickHouse 采用了一种极其高效且节省空间的索引方案——稀疏索引(Sparse Index)。
- Granule(颗粒):数据被划分为多个 Granule,每个 Granule 是数据读取的最小单位,默认包含 8192 行。
- 稀疏索引(primary.idx):ClickHouse 不会为每一行创建索引,而是为每个 Granule 的第一行数据创建一条索引记录。这个索引值就是该表
ORDER BY键的值。因此,稀疏索引本质上是排序键的一个“采样快照”。
例如,ORDER BY (city, timestamp) 的表,其索引文件可能如下:
| Granule 起始行 | city | timestamp |
|---|---|---|
| 0 | Beijing | 2025-09-10 00:00:00 |
| 8192 | Beijing | 2025-09-10 00:02:15 |
| 16384 | Shanghai | 2025-09-10 00:05:30 |
查询过程:当执行 WHERE city = 'Shanghai' AND timestamp > '2025-09-10 00:04:00' 时,ClickHouse 使用稀疏索引进行二分查找,迅速跳过前两个 Granule(因为它们的 city 是 Beijing),直接定位到从第三个 Granule 开始扫描,极大地减少了数据读取量。
.mrk2 标记文件:索引文件只知道逻辑上的 Granule 编号,.mrk2 文件则记录了每个 Granule 在对应列的 .bin 文件中的物理偏移量,如同一张地图,将逻辑索引与物理数据连接起来。
最重要的限制:最左前缀匹配 ClickHouse 的索引遵循最左前缀匹配原则。只有查询条件能匹配到 ORDER BY 子句的最左字段,才能有效利用索引跳过数据。
| 查询条件 | 能否利用索引跳过? | 原因 |
|---|---|---|
WHERE city = 'Beijing' | ✅ 能 | 匹配最左字段 city |
WHERE city = 'Beijing' AND timestamp > ... | ✅ 能 | 匹配前缀 (city, timestamp) |
WHERE timestamp > '2025-09-10 00:04:00' | ❌ 不能 | 缺少 city,必须全表扫描 |
这是 ClickHouse 的一种设计权衡:通过强制要求良好的表结构设计(将高频过滤字段放在 ORDER BY 最左),来换取极致的查询性能。
五、Part 机制:不可变的数据单元与合并艺术
在 ClickHouse 中,Part 是数据存储的核心物理单元。每次插入数据,ClickHouse 都会在磁盘上创建一个新的 Part 目录(例如 20240917_123_123_0),其内部包含了该批次数据完整的列文件、索引和标记文件。
“ClickHouse 不支持原地更新。UPDATE 和 DELETE 操作本质上是异步的数据重组任务,它们会标记旧数据为‘待淘汰’,并在后续合并过程中将其移除。”
Part 为何被设计为不可变?
不可变性(Immutability) 是 ClickHouse 写入模型的基石。一个 Part 一旦落盘,就永不再变。即便是更新(UPDATE)或删除(DELETE)操作,也并非直接修改数据,而是通过创建新的小 Part来标记旧数据失效。
这种设计哲学带来了三个决定性优势:
- 无锁写入,吞吐极高 传统数据库需要复杂的锁机制来协调并发写入,避免数据混乱。而 ClickHouse 的写入流程极为纯粹:在内存中完成排序、压缩和索引构建,然后将整个数据块以顺序写的方式一次性刷盘,生成新 Part。这个过程几乎不与现有 Part 发生任何交互,彻底解放了写入性能,尤其适合海量数据的批量导入。
- 无锁读取,性能稳定 正因为数据不可变,一个正在被查询的 Part 绝不会在查询过程中被修改。这意味着查询引擎无需为读取操作加任何锁,彻底避免了读写锁竞争。查询性能因此变得异常稳定和可预测,不会因后台的数据变动而产生性能抖动或延迟尖峰。
- 简化故障恢复与复制 每个 Part 都是自包含的、带有校验和的独立单元。在分布式集群中,Part 可以安全地在节点间传输和同步,因为其内容恒定不变。若写入中途发生故障,只需删除那个不完整的 Part 目录即可,绝不会影响现有数据的完整性,简化了容错逻辑。
写入与合并:用异步成本换取即时收益
一个自然的疑问是:每次写入都生成新 Part,不会产生大量碎片吗?成本是否太高?
答案是:短期确实有成本,但 ClickHouse 通过一个名为 Merge 的后台进程,将这种成本转化为长期的巨大收益。
-
写入:生成新 Part 每次插入都会创建新的 Part。若频繁进行小批量插入(如每次一行),就会产生大量微小 Part。
-
合并:化零为整的优化过程 ClickHouse 的后台合并线程会持续自动地将多个小 Part 合并(Merge) 成一个更大的、有序的 Part。
-
过程:合并线程选取数个相邻的 Part,将它们的数据读出,按排序键进行完整的重新排序,然后写入一个新的、更大的 Part。完成后,旧 Part 被标记并异步删除。
-
优势:
- 减少文件数:将大量小文件合并为少量大文件,极大减少了查询时需要打开的文件描述符数量,提升查询效率。
- 优化全局有序性:单个 Part 内部有序,但 Part 间数据可能重叠。合并后,数据在更大范围内有序,使得稀疏索引能跳过更多无关数据块,查询效率再次飞跃。
-
这个过程的本质是一场精妙的权衡: 将排序和整理的计算成本(CPU & I/O)从急迫的写入请求中剥离,转移到后台异步执行。以此换来前台的极速写入吞吐和无锁的稳定查询。
为何这种代价是值得的?
这种“写入-合并”模式(源自 LSM-Tree 思想)的优势完美契合 OLAP 场景的需求:
- 吞吐重于延迟:分析型系统更关注批量写入的吞吐量(每秒吞入 GB 级数据),而非单次插入的微秒级延迟。ClickHouse 为此而生。
- 一切为了查询:所有设计的终极目标都是为了加速分析查询。不可变 Part 是实现无锁读取和高效索引的基础,合并过程则进一步优化了数据布局,一切都是为这个终极目标服务。
- 资源调度更合理:将重计算任务卸载到后台,让写入链路保持轻量和高速,使系统能更平稳地应对写入洪峰。
最佳实践启示:理解此机制后,就会明白为何必须避免高频次、单条插入。应积攒足够数据后进行批量写入,以减少 Part 数量,减轻合并压力,让系统始终保持在最佳状态。