围绕ClickHouse MergeTree系列展开深入探讨,从基本概念出发逐步深入到高级应用。内容涵盖数据库与存储系统、数据仓库与数据湖中的ClickHouse MergeTree系列原理、关键算法、性能特性及优化策略,结合实际案例分析常见问题与解决方案,为工程实践提供系统性指导,适合希望深入理解该领域的技术人员学习参考。
需要学习ClickHouse MergeTree系列,涉及ClickHouse、MergeTree等核心概念。从基本概念出发逐步深入到高级应用。这一知识点在数据仓库与数据湖 > 数据仓库建模中承上启下,连接基础理论与工程实践。
可视化演示
围绕ClickHouse MergeTree系列展开深入探讨,从基本概念出发逐步深入到高级应用。内容涵盖数据库与存储系统、数据仓库与数据湖中的ClickHouse MergeTree系列原理、关键算法、性能特性及优化策略...
存储引擎基础
图书馆开门营业前,要先定规矩:新书一律送到导流台,由工作人员按分类捆成小包,再交给管理员上架。ClickHouse 的世界里,这个「导流台规矩」就是 MergeTree 表引擎——它规定了数据进门后如何被对待:不覆盖旧书、不原地改,而是把新来的数据打包成一个个不可变的「小捆」,也就是 data part。
每个 data part 是磁盘上一个自包含的目录,里面有列数据文件、索引文件和元数据。你可以把它理解为一捆书:里面所有书都按同一套规则排好序,并且这捆书一旦捆好就不再拆开。新增数据永远产生新的小 part,旧 part 原封不动。这一设计的核心奥妙在于:写入变成了「只追加」,省去了随机写和行级锁的开销,天然适合海量日志、指标这类持续涌入的数据。
在存储层面,MergeTree 表的物理布局大致为:表目录下先按分区键拆成多个分区目录,每个分区目录下再挂多个 part 子目录。每次 INSERT 至少生成一个 part,后台再慢慢把这些 part 合并更大、更有序的 part。
一句话理解:MergeTree 表就是一堆不可变数据块(part)的集合;新数据只追加,合并交给后台。
- MergeTree table(表整体)一张 MergeTree 表由若干部分(part)组成,像图书馆由多个书架构成
- 并集符号 ∪物理空间上所有 part 合起来承载表的所有数据,不重复、不折叠
- 不可变交集为空任意两个 part 在物理文件层面互不重叠,保证元数据与数据一致性 回到图书馆:正因为每捆书都独立编号、独立存在,管理员才能轻松地在夜里把几十捆小书合并成一捆大书,而读者白天永远看到的是整洁有序的大书架。理解 MergeTree 的存储结构,是后面所有优化技巧的基石——没有「不可变 part」这个前提,后台合并、稀疏索引、副本同步都无从谈起。
排序键与主键
图书馆最怕的事,是读者要一本「数据库入门」,管理员却要从一楼找到顶楼。MergeTree 的解法很朴素:在把数据捆进 part 时,就按读者最常找的字段排好序。这个「最常找的字段」就是排序键(ORDER BY)。一本书在书架上的位置由它决定,数据行在 part 内部的物理顺序也由它决定。
但数据量动辄上亿行,就算有序,也不能逐行扫描。MergeTree 的做法是画一张「目录卡」:每 8192 行分成一个 granule(数据颗粒),索引文件只记录每个 granule 第一行的排序键值。这就是稀疏索引——它不精确到每一行,而是像一个「每排书架只记第一本书书名」的目录。查询时先在目录卡上二分查找,确定目标大概率落在哪几个 granule,再读取这几个数据块。
主键(PRIMARY KEY)这里要特别注意:它不等于 MySQL 里的主键,不保证唯一性。MergeTree 中主键只是排序键的一个前缀,专门用来构建稀疏索引。实际建表时,ORDER BY 是必备的,主键字段可以省略(默认沿用 ORDER BY)。选对了排序键,等值查询、范围查询都能走索引快速跳过大量数据。
数据在 part 内按排序键有序排列,每 index_granularity 行生成一条稀疏索引记录,指向对应数据块的起始行。
- row_number(行号)数据在 part 内的物理序号,从 0 开始连续计数,表示「这本书在第几个位置」
- index_granularity(索引粒度)每多少行生成一条索引,默认 8192,决定目录卡「每排书架记几本书」
- 向下取整整除取整数,行号映射到它所属的数据颗粒编号,得到「这行属于第几排书架」 回到图书馆:目录卡只记每排第一本书,读者先在目录上锁定某排,再在那一排里快速找到目标——这就是稀疏索引的全部精髓。选排序键的智慧在于:把查询条件中最常用的等值字段放在最前面,让目录卡能最大程度缩小查找范围,千万别把主键当成唯一约束的保险箱。
分区机制
大图书馆还有一个聪明的设计:按楼层分区。一楼是文学,二楼是科技,三楼是历史。新书进来先看类别送到对应楼层,读者要找计算机书时直奔二楼,绝不去一楼瞎逛。MergeTree 的分区(PARTITION BY)就是给数据盖「楼层标签」,按分区键的值将数据拆进不同目录。
在磁盘上,每个分区对应一个形如 202410_1_3_0 的目录名,由四个部分组成:分区 ID、最小块编号、最大块编号和合并层级。这个命名规则像图书馆的「楼层-区域-批次」编码,让人一眼看出这堆数据属于哪个时间范围、经历了多少次合并。分区键可以是日期、整数或任意表达式,最常用的是 toYYYYMM(date) 按月份分区。
分区最大的价值是分区裁剪:查询 WHERE 条件带分区键时,ClickHouse 直接从元数据层跳过无关分区目录,只扫描命中的分区。这相当于读者在图书馆门口先刷楼层门禁,根本不用进那些不相关的楼层。注意:分区和排序键是两个维度——分区决定「数据落在哪层楼」,排序键决定「楼里的书怎么排」。
part 目录名携带分区 ID 与块编号范围,ClickHouse 借此快速定位数据所属分区,并推断合并历史。
- partition_id(分区 ID)由分区键值哈希或格式化后得到的字符串,是楼层编号
- min_block_no 与 max_block_no(块编号范围)记录这个 part 覆盖的数据块编号区间,像楼层里从第 1 排到第 3 排
- merge_level(合并层级)part 被合并的次数,层级越高说明这个 part 经历过越多轮整理 分区是把双刃剑:按天数分区,查询当天数据极快,但如果每天写入的数据不足一个 part 的量,反而会造成大量碎片 part 拖垮合并线程。聪明的馆长会选「月」做楼层,而不是「小时」。分区粒度要结合数据量、TTL 策略和查询模式综合权衡,记住:分区让数据管理有边界,但真正的查询加速引擎还是排序键。
合并与突变
图书馆每天晚上闭馆后,管理员会做一件重要的事:把白天积攒的几十捆小书拆开,按统一排序规则合并成几捆大书,再插回书架。这个动作在 MergeTree 里叫后台合并。合并线程会定期扫描各分区下的 part,把多个小 part 读出、按排序键归并排序、写成一个更大的新 part,旧 part 随后被标记删除。
ClickHouse 的合并策略是分层的:新写入的小 part 属于 L0 层,合并后升到 L1、L2……层级越高 part 越大、数量越少。这有点像图书馆管理层级:先合并到「书籍区」再合并到「书架」。合并的好处显而易见:part 数量减少,查询要打开的文件少了,稀疏索引的覆盖范围更整齐,还能顺带压缩数据。
但「改动现有数据」就麻烦了。MergeTree 的设计哲学是数据不可变,所以 UPDATE 和 DELETE 不是同步逐行改,而是通过 Mutation(突变) 实现:后台把包含目标行的整个 part 重写一份,在重写过程中完成修改,再切换文件引用。这相当于管理员要把某本书的内容改了,必须把那捆书整个重新抄一遍,再换上新书捆。因此突变操作成本很高,尤其当目标行分散在大量 part 中时,写放大可能非常惊人。
合并是把多个小 part 归并排序成一个有序大 part;突变则是重写包含目标行的 part,二者都由后台异步完成。
- sort(归并排序)合并不是简单堆叠,而是把多个 part 的行按排序键重新归并,保证新 part 内部依然有序
- rewrite(重写)突变操作读取旧 part 的所有行,应用修改后写出一份全新 part,替代旧文件
- mutation(突变标记)每次 ALTER UPDATE/DELETE 会生成一个 mutation 版本,各 part 逐步在该版本下异步完成重写 图书馆管理员的智慧在于:白天不动书架,晚上统一整理,读者体验永远平滑。理解「合并是好,突变是贵」的权衡,你就能解释为什么 ClickHouse 适合海量分析型负载,却不适合高频行级更新。实际工程中,宁可把数据按批次重刷,也不要依赖频繁突变来修正历史数据。
性能优化
图书馆经营久了,总有一些「过期书」:去年的旧杂志、三年前的促销传单。馆长会在夜里安排管理员把这些书下架销毁——这就是 MergeTree 的 TTL(生存时间) 机制。建表时可以对某列或整表声明 TTL 表达式,到期后数据会被自动删除或移动到冷存储,既节省空间又让热数据查询更快。
除了 TTL,MergeTree 还提供了丰富的压缩编码:列式存储天然能让同类数据聚在一起,配合 LZ4、ZSTD 等压缩算法,以及 Delta、Gorilla 等专用编码,数值型数据的压缩比可以做到 5~10 倍甚至更高。这相当于把旧书换成了更薄的纸张,同样的书架能装下更多书。物化视图(Materialized View) 则是另一个省心工具:数据写入时自动触发聚合计算,把预聚合结果存成一张隐式表,查询高并发报表时不必现算。
最后一个关键调优点在索引:index_granularity 默认 8192,调大会减少索引体积但增加扫描粒度,调小则反之;还可以通过 INDEX 声明跳过索引(Skip Index),对高基数列做布隆过滤。优化的核心思想是:让「目录卡」更聪明、让「楼层」更合理、让「书页」更薄,三者配合才能榨干硬件性能。
TTL 为数据设定自动过期时间;压缩比衡量列存储的瘦身效果。两者共同目标是降低成本、提升热查询速度。
- now()(当前时间)TTL 计算以服务器当前时间为基准,数据插入时间与 TTL 时长相加即得过期点
- ttl_interval(TTL 间隔)用户在表定义中声明如
INTERVAL 30 DAY,表示 30 天后数据过期 - compression_ratio(压缩比)原始大小与压缩后大小的比值,数值越大说明列式压缩效果越好 馆长视角看优化:TTL 让书架只保留读者需要的书,压缩编码让同样空间装更多内容,物化视图让常用答案提前算好。性能优化的本质不是某一招必胜,而是「少存取、少计算、少空间」的系统工程。每加一层优化,都要用真实查询和数据量做基准验证,避免为了炫技引入过度设计。
副本与分布式
当图书馆规模大到一座楼放不下时,馆长会开分馆。为了不让读者扑空,每个分馆都会存一份完整的藏书——这就是副本。MergeTree 的兄弟引擎 ReplicatedMergeTree 专门干这事:多台机器各存一份相同数据,任何一台挂了,读者还能从其他馆借到书。
副本同步的「总协调员」是 ZooKeeper(ZK)。所有分馆的元数据、part 清单、合并任务、突变版本都登记在 ZK 上。实际上,每个分馆的机器只负责在自己本地执行合并和写入,一旦本地产生了新 part,就向 ZK 注册,其他副本看到后异步拉取。数据文件本身由各副本直接传输,ZK 不扛数据流量——它只是那个「互通有无的公告栏」。
如果数据量大到单机放不下,则需要 分布式表。它本身不存数据,只是一个「查询路由器」:像图书馆的中央检索系统,把请求转发到后端多个本地 MergeTree 表,再汇总结果。注意区分:副本解决的是「可用性」(一馆挂掉还有别馆),分布式解决的是「扩展性」(数据多到需要分馆各存一部分)。两者可以叠加:先按分片拆到不同机器组,每组内部再用 ReplicatedMergeTree 做副本。
假设单副本故障率为 f,有 r 个副本时整体不可用概率约为 f 的 r 次方,副本数越多可用性越高,但 ZK 协调压力也越大。
- f(单副本故障率)一台机器在某段时间内出故障的概率,数值越小表示机器越可靠
- r(副本数)同一份数据保存的份数,每多一份就多一层保险
- 幂运算所有副本同时故障才导致整体不可用,概率指数级下降 回到图书馆:分馆越多读者越安心,但分馆之间同步的「公告栏」负担也越重。所以生产环境通常配 2~3 个副本就足够,盲目加副本反而会让 ZK 变成新瓶颈。理解「副本保可用、分片保扩展、ZK 是协调者」三角关系,是设计高可用 ClickHouse 集群的入门必修课。
AI 应用场景
用户行为特征存储
阶段:特征工程与样本管理
解决的问题:推荐系统需要海量用户行为日志(点击、曝光、转化)作为特征来源。传统方案用 Hive 冷存储延迟高,用 Redis 热存储成本高且难做范围查询。ClickHouse MergeTree 以列式压缩 + 稀疏索引实现亚秒级筛选,按用户 ID 排序后,取最近 N 天行为只需扫少量 granule。
具体案例:某短视频平台的召回特征库。每天新增 50 亿条曝光日志,写入按天分区,排序键设为 (user_id, toYYYYMMDD(event_time))。拉取单个用户近 7 天特征时,分区裁剪 + 稀疏索引让 P99 延迟稳定在 80ms 内,特征拼接脚本可直接查询结果集。
相关角色:特征平台工程师、推荐系统架构师
模型训练样本回放
阶段:训练数据准备 / 离线评估
解决的问题:时序模型训练需要按时间窗口回放历史样本,并排除「未来数据泄漏」。用 MergeTree 的 ORDER BY (sample_id, event_time) + TTL 管理样本生命周期,可按样本 ID 精确定位,配合窗口函数完成正负样本拼接。
具体案例:某广告 CTR 预估团队用 Mutations 批量修正标注错误样本,后台重写 part 后自动生成新版本样本集;物化视图实时维护「用户-广告」统计特征,训练与上线共用一套数据管道。
相关角色:ML 数据工程师、算法工程师
Python 演示
""“模拟 ClickHouse MergeTree 的 part 合并与稀疏索引查找”""import random class SparseIndex: ""“稀疏索引:每 granularity 行记录一个索引条目”"" def init(self, granularity=2): self.granularity = granularity self.index = [] def build(self, sorted_data): self.index = [] for i in range(0, len(sorted_data), self.granularity): self.index.append(sorted_data[i]) return self.index def search(self, sorted_data, key): # 二分定位候选区间,再顺序扫描 lo, hi = 0, len(self.index) - 1 while lo < hi: mid = (lo + hi) // 2 if self.index[mid] <= key: lo = mid + 1 else: hi = mid start = max(0, (lo - 1) * self.granularity) end = min(len(sorted_data), (lo + 1) * self.granularity + 1) return [v for v in sorted_data[start:end] if v == key] class MergeTreeSim: ""“最小 MergeTree 模拟:写入小 part,后台合并为有序大 part”"" def init(self): self.parts = [] # 每个元素是一个有序 list self._count = 0 # 写入总行数 def insert(self, rows): for row in rows: self.parts.append([row]) # 每次写入一个 1 行小 part self._count += 1 self._background_merge() def _background_merge(self): # 模拟后台合并:优先合并两个最小的 part while len(self.parts) >= 2: self.parts.sort(key=len) a = self.parts.pop(0) b = self.parts.pop(0) merged = sorted(a + b) self.parts.append(merged) # 若最大 part 已超过阈值,本轮合并停止(模拟分段合并) if len(self.parts[-1]) >= 8: break def query(self, key): result = [] for part in self.parts: idx = SparseIndex(granularity=2) idx.build(part) result.extend(idx.search(part, key)) return result if name == "main": random.seed(42) mt = MergeTreeSim() for _ in range(10): mt.insert([random.randint(1, 50) for _ in range(3)]) print(“合并后 parts 数量:”, len(mt.parts)) print(“各 part 行数:”, [len(p) for p in mt.parts]) print(“查询 7 的结果:”, mt.query(7)) data = sorted(random.sample(range(1, 100), 10)) idx = SparseIndex(granularity=2) index_entries = idx.build(data) print(“\n排序数据:”, data) print(“稀疏索引条目:”, index_entries) print(“等值查找 42:”, idx.search(data, 42))
常见误区
误区我用过 MySQL,MergeTree 的 PRIMARY KEY 应该也是唯一的,插入重复主键数据应该会报错吧?为什么我插了两遍同样的 user_id 都在表里?
正解MergeTree 的主键只是稀疏索引的排序依据,绝不保证唯一性。它相当于目录卡上的一排标签,同一本书可以重复出现多次。如果需要去重,必须用 ReplacingMergeTree 或专门的聚合去重机制,而不是依赖主键约束。
为何常见:几乎所有从关系型数据库转来的工程师都会踩这个坑,因为「主键 = 唯一」是 SQL 世界的铁律,而 MergeTree 把主键重新定义为「排序键前缀」,语义完全不同。
误区我执行了 `ALTER TABLE ... UPDATE`,应该跟 MySQL 一样立即生效吧?但等了半天查询结果还是旧的,是不是卡住了?
正解MergeTree 的更新删除是异步 Mutation,后台要把所有包含目标行的 part 整个重写一遍再切换。行多、part 多时非常慢,而且会放大 IO 和磁盘占用。正确姿势是用 TTL 或分区迁移来管理淘汰,尽量避免高频突变。
为何常见:「增删改查要实时」是数据库用户的默认预期,但 MergeTree 是为分析设计的,把「改」放在了后台重写路径上,概念错位导致误判。
误区查询经常按天筛选,那我就按天建分区,一天一个目录,这样查询只扫一个目录,比按月分区快 30 倍吧?
正解分区粒度过细会制造大量小 part,后台合并线程被迫频繁工作,ZK 元数据膨胀,写入和合并双双变慢。分区的本质是管理边界,查询加速要靠排序键的稀疏索引。通常按「月」或「周」分区已足够,配合分区裁剪收益最佳。
为何常见:直觉上「切得越细找得越快」,但忽略了每个分区目录里还有多个 part 的调度开销,这是典型的局部最优陷阱。
误区建表时没想好查询模式,先随便定一个 ORDER BY,等上线后慢了再 ALTER 改排序键不就行了?
正解修改排序键等于全表重写(Mutation),数据量上亿时耗时和资源消耗难以承受。排序键一旦定下,part 内物理顺序和稀疏索引就固定了。必须在建模阶段认真分析高频查询的 WHERE 等值条件,将最常查的字段放到排序键最前面。
为何常见:很多工程师带着 OLTP 的「改表成本低」惯性思维来到 OLAP 世界,没有意识到列式存储的物理排序是全局重排。
误区ReplicatedMergeTree 副本越多,读性能肯定越强吧?多搞几个副本,查询并发就能翻几倍。
正解副本解决的是高可用,不是读扩展。普通 MergeTree 查询只在单个副本上全量执行,不会把一部分数据拆到多个副本并行算。读扩展要靠分布式表 + 分片,副本加太多反而给 ZooKeeper 同步带来巨大压力。
为何常见:「分布式系统里副本 = 并行」的朴素认知。实际副本是「冗余备份」,只有配合分片路由才能真正水平扩展读能力。
面试题
Q1. MergeTree 中 PRIMARY KEY 与 ORDER BY 的关系是?
- A.主键是独立唯一约束
- B.主键是排序键的前缀← 正确答案
- C.主键用于强制去重
- D.主键与排序键完全无关
答案:B。MergeTree 的 PRIMARY KEY 只是排序键(ORDER BY)的一个前缀,用来构建稀疏索引,不保证唯一性。就像目录卡上选取的排序项前缀,它描述的是一种组织方式,而不是数据库层面的唯一性约束。
Q2. MergeTree 最适合哪种负载场景?
- A.高频逐行事务更新
- B.海量数据分析查询← 正确答案
- C.实时流式事件处理
- D.文档全文检索服务
答案:B。MergeTree 的不可变 part、后台合并、列式压缩和稀疏索引都是为「海量数据 + 复杂分析查询」设计的。它不支持高频行级更新,也不适合全文检索,因此分析型数据仓库是最佳场景。
Q3. 每次 INSERT 写入 MergeTree 表,直接产生的结果是?
- A.追加一个新的 data part← 正确答案
- B.原地修改已有数据文件
- C.立即触发全表重新排序
- D.删除并重建整个分区
答案:A。MergeTree 是追加写模型,每次 INSERT 至少生成一个新的不可变 part,与旧 part 并行存在。旧数据不会被修改,真正的整理动作由后台合并完成。这是它吞吐高的根本原因。
Q4. 关于稀疏索引,下列说法正确的是?
- A.每一行都对应一条索引项
- B.索引精确记录每个主键值
- C.每 8192 行记录一个索引项← 正确答案
- D.索引需要全表扫描才生效
答案:C。稀疏索引的默认粒度是 8192 行,索引文件只记录每个 granule 的首行主键值,查询时先二分索引定位候选块,再在块内扫描。它比稠密索引体积小得多,非常适合 OLAP 的范围扫描场景。
Q5. MergeTree 中 ALTER UPDATE 的实现机制是?
- A.同步逐行更新记录
- B.后台异步 Mutation 重写 part← 正确答案
- C.直接在索引文件上修改
- D.删除旧数据并重放日志
答案:B。Mutation 不是原地修改,而是后台把包含目标行的 part 整个重写为新 part,再原子切换引用。因此它比普通写入贵得多,频繁使用会产生严重的写放大和磁盘压力。
Q6. 以下哪些是 MergeTree 分区的合理用途?
- A.按日期边界裁剪查询范围← 正确答案
- B.配合 TTL 管理数据生命周期← 正确答案
- C.强制实现主键唯一性
- D.替代排序键加速单行查找
答案:AB。分区让查询元数据层面跳过不相关目录,同时 TTL 可以按分区或按列过期数据。分区与唯一性无关,也不能替代排序键的稀疏索引,单行查找还是要靠索引粒度之内的扫描。
Q7. 后台合并(Merge)主要解决什么问题?
- A.提升写入请求并发数
- B.加密压缩磁盘数据
- C.将小 part 合并为有序大 part← 正确答案
- D.把数据复制到其他节点
答案:C。合并把多个无序小 part 归并排序成一个有序大 part,减少文件数量、优化索引覆盖、提升查询效率。它不负责写入并发,也不承担跨节点复制,那是副本层的职责。
Q8. ReplicatedMergeTree 使用 ZooKeeper 的目的包括?
- A.协调各副本的元数据← 正确答案
- B.存储实际的列数据行
- C.监控副本存活与状态← 正确答案
- D.承担聚合计算任务
答案:AC。ZK 是副本间的协调者,维护 part 清单、mutation 版本和副本健康状态。真实数据通过副本间直连传输,ZK 不存数据也不做计算。它更像图书馆的公告栏,而不是书架本身。
Q9. 下列哪些属于 MergeTree 性能优化手段?
- A.设置 TTL 自动清理过期数据← 正确答案
- B.为高并发读写增加更多分区
- C.按查询模式配置排序键
- D.用主键强制代替稀疏索引
- E.单行更新需重写整个 part,写放大极高
- F.更新操作会触发分布式锁竞争
- G.单行更新无法使用稀疏索引
- H.更新前必须清空分区缓存
答案:A。MergeTree 以不可变 part 为存储单位,任何单行修改都要把目标 part 整体读出、修改、重写。若目标行所在 part 很大,一次更新的实际 IO 远超一行数据。因此高频单行更新场景应选用支持行级更新的数据库,而不是 ClickHouse。