clickhouse(以下简称CH)是互联网企业常见的OLAP引擎。因此我抱着好奇的态度开始学习CH。这篇笔记的知识来源于CH的官网和AI对话,如果大家想系统性地学习CH,那从CH官方文档开始是最合适的,免费且系统(我尝试过找CH的书籍,没有找到很权威的),甚至还有中文版,还有详尽的示意图。如果对某个特定的知识点有疑问,那可以用AI进行深入的追问。
当我希望开始接触CH时,脑海里大概是这些问题,希望大家也可以有自己的问题,带着问题去学习CH
- CH在数据系统中的地位是什么?计算有spark/flink,存储有parquet/表格式等,那CH服务于哪一环?
- 经典面试题:CH为什么快?尤其想了解的是,存储结构上有何特别之处?大数据领域的快通常和存储模型有极大的关系,如果不能快速跳过无关数据,那计算再快也没用
- CH的分布式模型是什么样的?
- CH有哪些竞品,与竞品有何区别,是相互取代还是互补关系?
这篇笔记结构分为四部分:CH 架构/存储模型、计算模型、使用技巧、QA。
CH的定位
从传统数仓链路说起
以下如果说到数仓,可能指的是数仓团队/数仓链路(当说到数仓由多个部分组成时),也可能指的是数仓产品(比如snowflake/CH),需要按语境仔细甄别
一个传统数仓系统,通常有ETL(也可能ELT,以下只称ETL)步骤。ETL的存在是因为传统数仓只能处理结构化数据,支持的数据源类型也有限,而外界数据不仅来源多样、格式多样,甚至可能有非结构化数据。为了将五花八门的原始数据转换为数仓可接受的结构化数据格式,需要有一套ETL清洗pipeline,完成从数据源加载(Extract)、转换(Transform,或者叫清洗),然后将清洗后的结果存到数仓中(Load,数仓载入数据)。最后数仓为数据分析师等分析/算法/报表团队提供查询服务,他们可以跑临时SQL来探索数据;也可以从已有表出发做计算,得到另一张表;还可以搭建报表、看板等。大致链路如下图所示
数据源 -> 加载&清洗 -> 数仓 -> 各种查询服务这边的第一个问题就是,为啥非得用数仓来提供查询服务,不能直接从源数据查吗,省的这么多数据工程师的人力劳动了?答案是不行。一方面很多用数仓的用户并不太懂技术,或者只会使用SQL进行数据分析和处理,所以数仓提供给他们一个统一的数据处理环境,不需要太多额外的知识;另一方面数仓只用考虑结构化数据,通常查询性能比较高,通常可以在秒级从数十亿行的表里算出某个指标(这听起来像魔法,实则利用了很多方式跳过无关数据)。因此不管从性能还是易用性上,数仓系统都是必须的。
第二个问题是,ETL和ELT的差别是什么?随着数仓系统的多年发展,数仓不是只能导入结构化数据了,很多数仓系统拥有从各类数据源接数据的功能。因此转换,或者说清洗步骤,就可以后移到数仓里执行。这里的差别是,字母L所在的位置界定了T是发生在进数仓前还是进数仓后。这更像是云厂商的广告文字游戏,个人认为大部分时候不用特别区别ETL/ELT两者的区别
数仓和OLAP数据库的差别?我觉得二者的界限很模糊,几乎可以划等号。数仓肯定是为了支持OLAP负载而生的。如果非要咬文嚼字一下,那传统数仓系统算是OLAP系统中的一种架构实现,OLAP系统还可以是数据湖架构等。不过当提到“我们是做数仓的”,其实通常指的也是搭建数仓链路的,具体实现可以是数仓,也可以是数据湖。
CH处于哪里?
CH可以是数仓,也可以只是查询服务层。这是因为CH主要特点是极快的查询速度,而对调度、复杂计算的支持有限,所以它经常可以在一些日志查询系统里见到。比如如下架构
日志源 -> Flink(可选) -> CH -> 快速查询CH 架构
存储模型
ClickHouse 更像是面向 OLAP 的列式查询引擎,而不是完整的数据仓库平台。它擅长大宽表、明细查询、聚合分析和维度表关联,但不适合复杂大表 Join、多层依赖 Pipeline、Graph Transaction、CDC 主链路,也没有内置 DAG 调度器。
因此,在数据平台里,ClickHouse 通常更适合作为“存储 + 查询”层:上游用 Airbyte、Debezium、Flink、Glue、Spark 或 dbt 完成接入和转换,再把查询友好的结果写入 ClickHouse。
这一章的核心问题:
- Merge Tree引擎和LSM Tree的异同?如果了解过BigTable/HBase架构,那理解MergeTree会更快速
- 底层数据是如何组织的,索引如何构建,有哪些索引?
- 各种“键”有何差别?对性能和行为各有什么影响?
1.1 MergeTree 是核心引擎
ClickHouse 最核心的表引擎是 MergeTree 家族。它被设计用来处理海量数据:高吞吐写入、大规模列式存储、快速扫描和聚合。
MergeTree 的写入模型和 LSM Tree 有相似之处:数据先批量进入内存,排序后写成一个不可变的 part,后台再异步合并多个 part。不同的是,ClickHouse 不维护传统意义上的 memTable,也不优先优化点写入、点更新或事务恢复,而是围绕 OLAP 的批式写入和批式查询做取舍。
不维护 memTable 的原因主要是架构复杂性和负载模型:
- ClickHouse 的主场景是 OLAP,写入通常是批式或微批。
- 如果引入 memTable,会带来 WAL、崩溃恢复、更多内存数据结构、更多写放大和一致性处理。
- 对分析型系统来说,简化写入路径、提升批量吞吐通常比优化单行写入更重要。
1.2 Part、Partition、Granule、Mark
ClickHouse 的 MergeTree 表可以从这几个层次理解:
partitionKey:决定数据分区,可以近似理解为数据目录,常用于生命周期管理,比如按天、月分区后直接 drop 旧分区。part:每次批量写入后形成的物理数据片段,后台 merge 会不断合并 part。granule:ClickHouse 的基本读取粒度,一组连续行,默认index_granularity = 8192。mark:每个 granule 的索引标记,记录该 granule 的主键值和列文件中的位置。
因此物理存储如下图所示:
partition | - parts | - primary.idx (granule file): what's the primary key of start row of each granule? used to skip irrelevent data - mark file (index, column level) - data file (column level)ClickHouse 不会为每一行建立索引,而是为每个 granule 建立一条稀疏索引记录。默认情况下,primary index 存储每 8192 行中第一行的主键值。查询时,ClickHouse 先用主键稀疏索引定位可能相关的 granule,再通过每列自己的 mark file 找到对应列数据在磁盘上的物理位置。注意这里,每个part里,每一列数据会单独存放,但是每个granule的数据并不会分开存。只是逻辑上用mark文件可以把每一列的数据切成多个granule分开读取,跳过无关数据。
这种多层索引设计虽然读/写放大不小,但是它的关键收益是:primary index 可以足够小,并且完整加载到内存。代价是 ClickHouse 的读取粒度不是单行,而是一段 granule,所以它更适合扫描、过滤和聚合,而不是点查和高频小更新。尤其是高频小更新会造成生成太多part,merge不过来。
Q: 如果多次写入后有多个part,那内存里的primary index是如何组织的?是每个part都有自己的primary index,还是所有part的primary index合并成一个?
A: 在 ClickHouse 中,每个 part 都有自己的 primary index。每个 part 的 primary index 是独立的,记录了该 part 中每个 granule 的主键值和位置。当查询时,ClickHouse 会加载所有相关 part 的 primary index 到内存中,但它们并不会合并成一个单一的索引结构。相反,查询引擎会在内存中维护一个列表或集合,包含所有相关 part 的 primary index,然后根据查询条件逐个检查这些索引,以确定哪些 granule 需要被读取。这种设计允许 ClickHouse 高效地处理多个 part,同时保持索引的稀疏性和内存占用的低成本。
1.3 列式存储和 Wide Format
ClickHouse 是列式存储。MergeTree 的数据在磁盘上按 part 组织,每个 part 是一个目录;在 wide format 下,每一列都有自己的数据文件和 mark 文件。
列式存储带来的直接收益是:查询只读取需要的列。如果 SQL 只 select 3 列,就没有必要读取整行里的其他几十列或几百列。
同时,列式存储也更容易压缩。相同类型、相似分布的数据集中放在一起,压缩率通常比行存更高。如果排序键让相似值更接近,压缩效果还会进一步提升。
1.4 主键、排序键、分片键
ClickHouse 里几个 key 的含义容易混淆:
shard key:分片方式,类似 Elasticsearch 的 routing key,用来决定数据落到哪个 shard。只在分布式模式中才生效partitionKey:分区键,主要服务生命周期管理和分区裁剪。primary key:主键稀疏索引的 key,用于加速过滤。sorting key/ORDER BY:part 内的数据排序方式,影响过滤效率和压缩率。
ClickHouse 的 primary key 不等同于传统数据库里的唯一约束。ClickHouse 允许多行拥有相同的 primary key 值。它的 primary key 更接近“用于跳过数据的索引”。
如果同时指定 primary key 和 sorting key,primary key 必须是 sorting key 的前缀。排序键和主键可以不完全一样,但二者共同影响过滤效率、数据局部性和压缩率。
1.5 Data Skipping Index
ClickHouse 支持 data skipping index,可以理解为二级跳数索引,用来在主键之外进一步跳过不相关数据。常见的data-skipping index有min-max(范围比较聚集),布隆过滤器(user_id等高基数字段)等
联想:看到这里,如果之前对parquet/iceberg有了解的同学应该会感到很熟悉,因为这些存储格式也都提供了这些二级索引,方便用户跳过无关数据加速查询。大数据领域能减少IO的方式不多,可用的索引也不多,常见的就是排序(但是排序没法作为二级索引)、布隆过滤器、minmax等。
1.6 Projection、物化视图
如果希望针对多种访问模式建立“多个索引视角”,通常需要额外的数据组织方式:
- 手动复制一份表,并使用不同排序键。
- 创建物化视图,把数据按另一个查询模式重新写入。
- 使用 projection,让 ClickHouse 在同一张表内维护额外的数据布局。
本质上,这些方案都是用额外存储换查询性能。
CH为什么快?
ClickHouse 快,不是来自单一技巧,而是存储、执行、索引、压缩、并行化一起配合的结果,以下小章节会分点说明。
延伸:和spark这类离线计算引擎相比,CH是常驻服务,没有启动时间开销,计算也发生在存储节点上,没有网络传输开销,因此才可能做到亚秒级的响应。这些虽然不是面经里会提到的,但我觉得是计算能做到秒级以内的关键因素。因为什么向量化、计算优化,spark肯定没少做;什么列存、skip index,parquet/iceberg也都有。但是Spark冷启动执行一条SQL是绝不可能做到秒级的,因为几秒内spark甚至都还没启动。
2.1 列存优势:不读无关列
列式存储让 ClickHouse 只读取查询需要的列。对宽表分析来说,这是非常关键的优化:很多查询虽然基于一张大表,但实际只用少量字段。
2.2 少读行组
MergeTree 的 primary index 是稀疏索引。查询会先根据主键范围排除不相关 granule,再读取剩下的数据块。它不是精确定位单行,而是尽量减少扫描范围。
Data skipping index、partition pruning、projection 也都服务于同一个目标:尽可能少读数据。
2.3 顺序读取和并行扫描
ClickHouse 读取的是 granule,而不是单行。虽然这牺牲了点查粒度,但换来了顺序 IO、批量处理和并行扫描能力。多个列文件、多个 part、多个 shard 都可以并行参与查询。
2.4 压缩减少 IO
列式数据通常压缩率更好。排序键把相似数据放到一起后,压缩效果更好,磁盘读取量也更小。对分析查询来说,少读磁盘往往比 CPU 多做一点解压更划算。
2.5 向量化和近似计算
ClickHouse 的执行模型适合批量处理数据块。它还提供大量近似计算能力,比如近似去重、分位数、采样等。在业务可以接受近似结果时,可以用精度换更低延迟和更少资源。
例如:
- 近似 distinct count。
- 分位数计算。
- 按部分 key 或采样数据做聚合估算。
使用技巧
3.1 批量写入,不要单行高频写入
ClickHouse 不怕大吞吐写入,怕小数据量高频写入。每次 insert 都可能产生新的 part,如果一行一行写,会造成 part 数量过多,影响查询和后台 merge。
如果上游是流式数据,更推荐在上游攒批后微批写入。例如每次至少几千到几万行,常见经验是每批 10k 行左右起步,再根据数据宽度、延迟要求和集群资源调整。
小知识:如何解决流式系统的小文件问题?流式系统写入时如果配置不当,很容易造成小文件问题,而大多数OLAP引擎对小文件的支持都不好,因为小文件更偏向OLTP负载
- 上游攒一批数据再写入OLAP系统,比如Flink每10秒写入一次
- 定期压缩合并小文件(这也是merge后台线程在干的事情)
- 选择合适的接收系统,比如有的系统专门为流式负载设计,对并发写入(例如表格式中的hudi)或者小文件支持较好
3.2 控制 Part 数量和大小
Part 数量过多会让查询和 merge 压力变大。一个粗略经验:
- 单分区 parts 小于 10 通常比较健康。
- 100 以下通常还能接受。
- 500 左右需要关注。
- 大于 1000 通常需要介入。
但也不是 part 越大越好。过大的 part 会给后续 merge 带来压力。很多场景下,把 part 控制在数百 MB 是比较合适的起点。
3.3 合理设计 Partition
Partition 主要用于生命周期管理,而不是越细越好。常见做法是按日期、月份或业务生命周期分区。这样可以快速 drop 旧数据,也能让查询做 partition pruning。
如果 partition 过细,会制造大量小 part 和元数据压力。
3.4 排序键服务高频过滤和压缩
排序键应该优先考虑高频过滤条件、数据局部性和压缩效果。相似数据排在一起,可以提升压缩率;过滤字段排在前面,可以提升跳数效果。
如果一张表有多种完全不同的查询路径,不要指望一个排序键同时服务所有场景。可以考虑 projection、物化视图或复制表。
什么是物化视图?就是名义上是个view,实际上会将view的逻辑执行一遍存成物理数据集。它和单独计算生成物理数据的差别在于,物化视图可以随着数据源的改变、逻辑的改变自动重新生成物理数据集,省去了人工管理/定期运行成本
3.5 慎用 Nullable
尽量选择紧凑的数据类型,减少不必要的Nullable。
Nullable(T)会额外创建一列UInt8类型的 null 标记。每次处理这个 Nullable 字段时,ClickHouse 都需要处理额外列,这会增加存储和计算开销。因此,如果业务上能用默认值、空字符串、特殊枚举或外部语义表达缺失值,可以避免滥用 Nullable。
3.6 理解不同 MergeTree 引擎的语义
MergeTree 家族的不同引擎,核心差异在于 merge 时如何处理相同主键的数据:
- 普通
MergeTree:只合并 part,不对相同主键做业务语义处理。 ReplacingMergeTree:可在 merge 时按版本保留新值。SummingMergeTree:可在 merge 时对数值列求和。AggregatingMergeTree:可在 merge 时合并聚合状态。
这些语义通常发生在后台 merge 阶段,不应假设写入后立即完成。
3.7 谨慎使用 FINAL
FINAL会在查询时强制按照引擎语义处理重复或可合并数据。对于普通MergeTree,使用FINAL通常没有意义;对于ReplacingMergeTree等表,它可以保证查询时看到去重或替换后的结果。
但FINAL会影响性能,尤其可能破坏局部聚合优化。例如计算COUNT时,原本可以把多个 part 的 count 相加;用了FINAL后,需要先做去重或合并,再聚合。
如果必须保证结果正确,可以选择:
- 查询时使用
FINAL。 - 使用
OPTIMIZE FINAL强制触发合并。 - 通过业务语义过滤,例如保留
version = max(version)的记录。
4. QA:杂项问题
Q1:ClickHouse 支持物化视图吗?
支持。ClickHouse 的物化视图常用于把写入数据同步转换到另一张表中,也可以用来维护不同排序键、预聚合结果或查询加速表。
Q2:ClickHouse 适合做数仓吗?
它可以承担数仓中的存储和查询层,但不是完整数仓平台。它缺少内置 DAG、调度、复杂数据开发治理等能力,也不擅长复杂大表 Join 和多层依赖 Pipeline。更常见的方式是让 Spark、dbt、Flink 等负责转换和编排,ClickHouse 负责高性能查询。
Q3:ClickHouse 为什么不适合大表 Join?
ClickHouse 的优势是列式扫描、过滤、聚合和局部并行。大表 Join 往往需要大量 shuffle、内存构建 hash table、跨节点数据移动和复杂执行计划,这不是它最擅长的模型。它更适合事实宽表加小维表 join,或者提前通过建模把常用字段打宽。
Q4:ClickHouse 适合 CDC 吗?
不适合作为 CDC 的原始状态存储或事务型更新系统。ClickHouse 可以接收 CDC 结果,但更适合接收已经规整成批或微批的数据。如果要表达更新语义,通常需要使用ReplacingMergeTree、版本字段、物化视图或上游合并逻辑。
Q5:Primary Key 是唯一约束吗?
不是。ClickHouse 允许相同 primary key 的多行存在。它的 primary key 主要是稀疏索引(即排序索引),用于加速数据跳过,不负责唯一性约束。
Q6:为什么 primary index 可以放进内存?
因为它不是每行一条,而是每个 granule 一条。默认一个 granule 是 8192 行,所以 primary index 的规模远小于总行数。它只记录主键值和定位 granule 所需的信息,具体列数据位置再通过每列 mark file 查找。
Q7:Merge 一定会很快完成吗?
不一定。ClickHouse 后台 merge 更关注系统整体吞吐,不保证某次去重、替换或聚合在固定时间内完成。如果业务依赖 merge 后语义,需要使用FINAL、OPTIMIZE FINAL或业务版本过滤保证结果。