ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ClickHouse MergeTree家族全解析:排序键、分区键与引擎选型实战

ClickHouse MergeTree家族全解析:排序键、分区键与引擎选型实战 1. 从 MergeTree 的核心设计聊起如果你用过 ClickHouse那大概率绕不开 MergeTree。很多刚接触 ClickHouse 的人会把 MergeTree 当成“一种表引擎”但严格来说它是一个庞大的家族官方文档里叫 MergeTree Family。这个家族的共同祖先就是 MergeTree 本身后面的一大堆变体——ReplacingMergeTree、SummingMergeTree、AggregatingMergeTree、CollapsingMergeTree、VersionedCollapsingMergeTree、GraphiteMergeTree——都是在 MergeTree 的基础上针对某些特定场景做了定制化增强。我最早接触 ClickHouse 时也踩过不少坑。那时候不懂 MergeTree 和 ReplacingMergeTree 的区别以为后者就是前者的“升级版”结果在需要保留历史数据的场景里用了 ReplacingMergeTree数据被去重后想要回溯某个时间点的原始状态就傻眼了。所以这一篇我想把 MergeTree 家族彻底拆开讲清楚每个引擎解决什么问题、底层到底做了什么、什么时候用哪个、用的时候要注意什么。先说 MergeTree 本身。它的核心设计目标就一句话用极致的写入吞吐换取查询时的高压缩比和顺序扫描效率。这个设计和 LSM-TreeLog-Structured Merge-Tree的思路很相似数据先顺序写入后台再异步合并把随机写变成顺序写换取了非常高的写入性能。但 MergeTree 和一般 LSM-Tree 实现有个关键区别它在合并之外还引入了分区Partition、排序键ORDER BY、主键PRIMARY KEY、稀疏索引Sparse Index和数据片段Data Part这套组合拳。这套组合拳让它在分析型查询场景里能做到亿级数据秒级响应。打个比方MergeTree 就像一本按目录整理好的百科全书每有新内容就随手记在草稿纸上内存缓冲积累到一定程度就誊写成一页新纸数据片段后台再把散落的纸张按章节重新装订后台合并。装订的过程中还会给每章做个小索引稀疏索引查询时直接翻索引不用一页页找。2. 核心机制逐个拆解2.1 分区键PARTITION BY不是用来“分区”的很多刚从 MySQL 转过来的朋友会把 ClickHouse 的 PARTITION BY 理解成 MySQL 的表分区这是个常见的误区。MySQL 表分区的主要目的是把数据物理拆分到不同文件提升单表数据量和查询性能而 ClickHouse 的 PARTITION BY 主要目的其实是便于数据管理和生命周期控制。比如按天分区你的数据就能以“天”为粒度做 TTL 清理、DROP PARTITION 删除过期数据、DETACH PARTITION 做冷备归档。从查询性能角度看分区键选择不当反而会拖慢查询——因为每次查询如果跨了多个分区ClickHouse 需要扫描的 Part 数量会增加元数据管理的开销也会变大。我见过不少生产环境一上来就按小时甚至按分钟做分区结果一个表几千上万个分区后台合并线程长期处于高负载查询性能也没见得快到哪去。因为 ClickHouse 的查询优化器并不会因为分区粒度更细就“跳过更多数据”真正起过滤加速作用的是分区裁剪和索引配合而过细的分区粒度只会带来大量的小 Part合并压力骤增。所以分区键的选择原则是按照最常用的时间维度来一般到天或周到月即可。如果查询经常按天过滤就按天分区按周过滤可以按周分区。如果数据量不大、查询也不频繁甚至可以不设分区键让整张表只有一个分区合并压力反而更小。2.2 排序键ORDER BY才是查询加速的命根子MergeTree 数据在磁盘上的物理存储顺序是由 ORDER BY 决定的。这跟 MySQL InnoDB 的聚簇索引有几分相似但有一个关键区别ORDER BY 决定了每个数据片段内部的行顺序配合稀疏索引实现范围扫描时的高效跳过。举个实际例子某张订单表如果需要频繁按user_id和event_time做范围查询那 ORDER BY 应该是(user_id, event_time)。这样同一个 user 的全部记录在物理上连续存放查询WHERE user_id xxx AND event_time BETWEEN ...时只需要定位到那个用户的数据区间顺序读出来就行效率极高。如果 ORDER BY 写反了比如(event_time, user_id)那同一个用户的数据会分散在各个时间切片里查询时要做大量的随机读性能天差地别。这里有个点很多人会忽略ORDER BY 不只是一个“排序规则”它是 MergeTree 的“一级索引”基础。MergeTree 的索引不是为每一行建的而是每index_granularity行默认是 8192 行记录一组“索引行”——存的是这一组中 ORDER BY 字段的最小值。查询时先二分定位到可能包含目标数据的索引行再加载对应的数据块这就是“稀疏索引”。所以ORDER BY 的选择直接决定了你的查询能不能走索引。选字段时要把查询频率最高的等值过滤字段放在前面范围查询字段放在后面。2.3 主键PRIMARY KEY和排序键默认是“一家人”对于 MergeTree 而言PRIMARY KEY 和 ORDER BY 可以一样也可以不一样。如果你不显式指定 PRIMARY KEY那么默认情况下 PRIMARY KEY ORDER BY。但二者有区别PRIMARY KEY 只决定索引文件的生成也就是稀疏索引的内容而 ORDER BY 决定数据在磁盘上的物理排列顺序。更准确地说PRIMARY KEY 是 ORDER BY 的一个前缀子集时索引才能发挥最大作用。比如你 ORDER BY(a, b, c)PRIMARY KEY 可以是(a, b)。这种情况下ClickHouse 的索引里存的就是(a, b)的最小值数据依然按(a, b, c)排列。这样做的好处是索引更小内存占用更低同时a和b的过滤依然能走索引。但如果你 PRIMARY KEY 设为(a, c)而 ORDER BY 是(a, b, c)ClickHouse 不会报错但索引效率会打折扣因为c不是ORDER BY的前缀二级索引跳数索引又只能作为补充无法替代主索引。所以我个人的建议是大多数场景直接让 PRIMARY KEY ORDER BY省心又高效除非你的 ORDER BY 字段特别多、索引文件实在太大才考虑用前缀子集做 PRIMARY KEY。2.4 数据片段Data Part与后台合并MergeMergeTree 写入时不是直接写进“大表”里而是先写成一个不可变的 Data Part。每个 Part 内部数据是排序好的也有自己的索引和统计信息。当内存里的数据达到阈值后刷盘生成一个 Part后台线程会定期把这些小 Part 合并成大 Part。这个过程很像 LSM-Tree 的 compaction。MergeTree 家族的所有引擎都复用这套 Part 合并机制——不同的家族成员只是“合并时做什么额外处理”这一点上有所不同MergeTree什么都不做只是简单合并。ReplacingMergeTree合并时按键去重保留最新版本。SummingMergeTree合并时对指定列做预聚合求和。AggregatingMergeTree合并时对指定列做更通用的聚合状态合并。CollapsingMergeTree合并时通过正负相抵来“折叠”数据。VersionedCollapsingMergeTree加了个版本号让折叠更精确。理解了这一层再看家族成员就很容易串起来了。另外值得留意的是 Part 的合并时机并不固定它是后台异步进行的。这带来一个重要推论MergeTree 家族的数据可见性有“滞后”。你刚写入的数据在查询时能立即查到ClickHouse 查询会扫描所有相关 Part但如果某个 Part 还没合并完某些去重、聚合、折叠的效果就还没生效。这也是新手最容易踩的坑之一——写完数据立刻查发现 ReplacingMergeTree 怎么没去重别急等合并跑完再看。提示如果你需要“强制合并”可以执行OPTIMIZE TABLE xxx FINAL但这是个高消耗操作生产环境慎用尤其是大数据量表。强制合并会阻塞写入且触发全量合并耗时和 IO 开销都不小。3. 家族成员逐个拆解3.1 MergeTree最纯粹的存储引擎MergeTree 本身已经能覆盖大部分时序类、日志类、分析类场景。它的优势是写入吞吐高、压缩比高、查询性能稳定、TTL 和分区管理等配套功能完整。适合用它的情况有日志存储和检索按时间分区按(host, time)排序查询问题日志很快。事件明细数据需要保留每条事件的原始字段不做任何预聚合。数据湖或数仓的 ODS 层保留全量明细DWD/DWS 层再加工。它最大的缺点是不做任何去重或更新。如果你在同一主键上写入了两条数据那么查询时两条都在后写的不会覆盖先写的。这在有“更新”语义的业务里不行。举一个我实际遇到过的例子一个埋点日志表业务方偶尔因为数据修复会重新上报某个event_id的日志这就导致同一事件出现两条记录下游统计时翻倍。这种场景MergeTree 就无能为力了。3.2 ReplacingMergeTree按主键去重保留最后一条ReplacingMergeTree 在 MergeTree 的基础上增加了“去重”逻辑后台合并时它会按照ORDER BY字段或者你用PRIMARY KEY显式指定的去重键分组每个分组只保留一条数据。问题来了保留哪一条它默认是按version字段在建表时指定的ReplacingMergeTree(version)来决定取 version 最大的那条。如果没指定 version则取最后写入的那条——但这条“最后”并不是绝对可靠的因为有多个 Part 合并时顺序不一定是写入顺序。所以如果业务上要求精确地去重必须显式指定 version 字段。这样即使同一个 key 有几十条数据合并时也能准确找到 version 最大的那条。我常见的使用场景是“缓慢变化维度”的同步上游业务库每天同步一次维表快照同一个门店的维表会有多条历史快照记录。用 ReplacingMergeTree 存这些快照每天合并一次最终每个门店只保留最新版本。查询时如果想看历史某个时点的快照就需要额外处理因为合并后历史版本已经被清掉了。这就是典型的“空间换时间”的取舍要最新状态就用 ReplacingMergeTree要历史全貌就必须用 MergeTree 保留明细或者在 ReplacingMergeTree 的基础上加上版本字段做条件查询用argMax之类的函数。3.3 SummingMergeTree合并时预聚合求和SummingMergeTree 的设计目标是“加速求和类查询”。它的逻辑是在后台合并时对相同排序键的多行数据把指定列的值做累加SUM合并成一个分组的一行。建表语法类似CREATE TABLE daily_sales ( sale_date Date, product_id UInt64, category String, amount Decimal(18, 2), quantity UInt32 ) ENGINE SummingMergeTree() PARTITION BY toYYYYMM(sale_date) ORDER BY (sale_date, product_id)这样在查询SELECT product_id, sum(amount) FROM daily_sales WHERE sale_date 2025-01-01 GROUP BY product_id时ClickHouse 并不需要真的把所有行都聚合一遍因为大部分已经合并成一行了只需要对极少量的未合并 Part 再做一次 sum。这个优化对高频查询的加速非常明显。需要留意几个细节SummingMergeTree 只会对“数值类型”的列求和。如果是字符串、日期等非数值列合并时只会取分组内的第一行非严格保证实际以合并行为准。求和列可以通过参数指定比如SummingMergeTree(amount, quantity)。不指定的话默认对所有数值列求和。如果某些数值列不要求求和比如单价、折扣率就需要在参数里显式排除。否则合并后单价会被累加查询结果就错了。我踩过一个挺有意思的坑一张订单明细表里面有price单价和total_amount总价两个字段我用 SummingMergeTree 存储结果price也被合并求和了导致想取订单单价时出现几千块的“单价”。后来才明白SummingMergeTree 默认会对所有数值列求和必须显式限定额外列。3.4 AggregatingMergeTree更通用的预聚合方案AggregatingMergeTree 是 SummingMergeTree 的“完全体”。它允许你在建表时指定任意聚合函数把合并逻辑扩展到sum之外还支持avg、max、min、uniq、quantile等。它的使用方式比较特殊建表时聚合列的类型必须写成聚合函数的-State类型写入时也要用对应的-State函数来生成聚合状态查询时再用-Merge函数把状态合并成最终结果。举个例子我想按天、按商品维度预聚合两个指标销售总额和购买用户数。建表CREATE TABLE agg_daily_sales ( sale_date Date, product_id UInt64, total_amount AggregateFunction(sum, Decimal(18, 2)), buyer_count AggregateFunction(uniq, UInt64) ) ENGINE AggregatingMergeTree() PARTITION BY toYYYYMM(sale_date) ORDER BY (sale_date, product_id)写入时不能直接插原值要调用聚合函数的-State变体生成状态INSERT INTO agg_daily_sales SELECT sale_date, product_id, sumState(total_amount), uniqState(user_id) FROM raw_sales GROUP BY sale_date, product_id;查询时再用-Merge变体还原SELECT sale_date, product_id, sumMerge(total_amount), uniqMerge(buyer_count) FROM agg_daily_sales GROUP BY sale_date, product_id;这个“状态”和“合并”的思想和很多 OLAP 引擎的物化视图底层逻辑是相通的。实际项目中AggregatingMergeTree 通常不会直接面向业务查询而是作为物化视图的底层存储配合AggregatingMergeTree的物化视图实现写入即聚合的效果。举个我常用的设计明细表raw_events每来一条事件就通过物化视图把当天的聚合状态累加到对应的 AggregatingMergeTree 表里。查询方只查聚合表不再扫明细表性能提升一个数量级。3.5 CollapsingMergeTree用正负抵消实现“实时更新”CollapsingMergeTree 的思想很有意思不直接改数据而是靠插入一条“符号相反”的记录让合并时正负抵消等效于删除或更新。每条记录有一个Sign列取值为 1 或 -1。合并时对相同排序键的数据会把配对的正负记录互相抵消掉只剩下未被抵消的部分。比如一个订单状态表订单创建时插入一条Sign1的记录表示“当前有效”订单取消时插入一条同样的数据但Sign-1表示“抵消掉之前那条”。合并之后这个订单的两条记录都被抵消相当于这条订单从表里“消失”了。这个设计在需要“实时更新”语义的分析场景里非常实用尤其是“精确去重计数”类业务。比如统计当前在线的车、实时订单数等。但 CollapsingMergeTree 有几个硬性要求每条数据必须带上正确的Sign。如果你写入了两条Sign1的同键数据合并时不会报错但会导致“抵消不干净”残留数据出现。合并不是即时的。在合并发生之前正负数据同时存在查询结果会是正的加负的所以查询 SQL 里必须显式加条件WHERE Sign 1或者用sum(Sign)来过滤才能得到正确结果。这就是它最“反直觉”的地方你以为查询会自动给你“折叠后”的结果但实际上查询引擎不会自动做折叠逻辑它只保证合并时做折叠。所以你在写查询时必须自己处理Sign条件。3.6 VersionedCollapsingMergeTree折叠的“精确版”CollapsingMergeTree 的折叠依赖一条简单的正负配对规则这在并发写入、乱序到达的场景下很不精确——比如先写了Sign-1后写了Sign1合并时就乱了。VersionedCollapsingMergeTree 的改进是加一个Version列折叠时要求Sign相反且Version相同才能配对。这样即使在并发写入、乱序到达的情况下只要版本号对得上就能精确抵消。它的建表语法ENGINE VersionedCollapsingMergeTree(Sign, Version)实际业务里我建议如果你要用 CollapsingMergeTree直接上 VersionedCollapsingMergeTree多一个版本字段的开销极低但鲁棒性高了很多。3.7 GraphiteMergeTree监控指标领域的专用引擎GraphiteMergeTree 是专门为 Graphite 监控系统设计的用于存储监控指标的时间序列数据。它能按照你配置的“保留策略”retention policy在后台合并时自动降采样老数据保留粗粒度新数据保留细粒度。这个引擎在日常业务里用得不多但如果你是做监控系统、时序指标存储的它会非常香。它需要一份 TOML 或 XML 格式的配置描述不同时间范围的数据精度和保留时长合并时按规则自动聚合。4. 家族选型一张图记住不同场景该选谁很多人看完上面一堆引擎容易懵到底什么时候用哪个我整理了一张选型表方便对照引擎核心能力适合场景注意事项MergeTree基础存储无去重无更新日志明细、事件明细、ODS 层不做任何数据修正ReplacingMergeTree按排序键去重保留最新维表快照同步、主键覆盖更新要精确去重必须指定 versionSummingMergeTree合并时预聚合求和汇总报表、累计指标注意排除不需要求和的数值列AggregatingMergeTree通用预聚合状态存储物化视图底层存储多指标聚合写入/查询都要用 State/Merge 变体CollapsingMergeTree正负抵消实现删除/更新实时更新、精确去重计数查询时不会自动折叠必须手动处理 SignVersionedCollapsingMergeTree带版本的精确折叠乱序写入、高并发更新场景比 CollapsingMergeTree 更稳优先选它GraphiteMergeTree时序降采样Graphite 监控指标存储需要配置保留策略从这张表能看出一个规律越往下的引擎功能越“重”。选型的第一原则不是“哪个功能多”而是“我这个场景到底需要什么”。如果只是存明细且数据不会改动用 MergeTree 足够。如果需要幂等写入重复上报不重复计数用 ReplacingMergeTree。如果需要按维度预聚合用 SummingMergeTree 或 AggregatingMergeTree。如果需要实时更新删除语义用 VersionedCollapsingMergeTree。另外很多生产系统里几类引擎是会组合使用的。下游数仓的 DWD 层用 MergeTree 存明细DWS 层用 SummingMergeTree 或物化视图 AggregatingMergeTree 做汇总需要拉链或者最新状态维度的用 ReplacingMergeTree。这套组合拳打下来既保证了明细可追溯又保证了查询性能。5. 实操中的几个高频坑与排查经验5.1 “为什么数据没去重 / 没聚合”这是家族引擎最常见的困扰。绝大多数原因就一个查询发生时后台合并还没跑完。MergeTree 家族的去重、聚合、折叠动作全都在合并阶段执行查询时 Part 尚未合并结果自然不“干净”。排查方法查system.parts看active的 Part 数量和状态。执行SELECT * FROM system.parts WHERE tablexxx AND active1观察level字段。level越高说明该 Part 经过的合并次数越多。如果确需立即生效执行OPTIMIZE TABLE xxx FINAL。但要注意OPTIMIZE TABLE ... FINAL是阻塞式的大表可能耗时几分钟甚至更久且会把后台合并也触发一遍。生产环境最好在低峰期操作或者从业务侧接受“最终一致”。5.2OPTIMIZE TABLE为什么越跑越慢因为每次 OPTIMIZE 都会尝试把所有 Part 合并成更大的 Part而 Part 越大合并时的读写开销就越高。如果表的数据量在持续增长你会陷入“永远在合并新数据”的困境。我的建议是不要频繁手动 OPTIMIZE。让后台合并按默认节奏跑通常足够。如果查询性能确实受太多小 Part 拖累说明你表的分区粒度或者数据摄入节奏需要调整。5.3 为什么磁盘占用比预期大很多Part 合并过程中ClickHouse 会同时保留新旧 Part等新 Part 合并完成、旧 Part 标记为 inactive 之后才会在后台删除。所以数据量大的时候磁盘占用峰值可能比最终数据量高出不少。如果你的磁盘比较紧张有两点建议监控system.parts及时排查inactivePart 堆积问题。调大merge_tree相关的线程数和内存加快合并速度缩短新旧 Part 并行期。5.4 排序键选不好查询全表扫描这事我见过不止一次。有人把 ORDER BY 设成了event_time结果业务查询全是按user_id过滤的——走不了索引全表扫描数据量一上来就卡死。排查方式很简单为一条慢查询执行EXPLAIN看看索引命中的粒度或者直接在system.query_log里看read_rows和read_bytes。如果查询过滤了某个字段但read_rows几乎等于全表行数说明索引没生效优先检查 ORDER BY 的设置。5.5 ReplacingMergeTree 去重字段选错ReplacingMergeTree 的去重键默认是 ORDER BY 字段的“全部”字段。如果你把一个高基数字段比如event_id也放进了 ORDER BY那么去重粒度会变得非常细根本达不到去重效果。要避免这个坑就需要明确区分排序键 ≠ 去重键。如果既要保持某个字段的顺序查询性能又要按另一个维度去重你应该把去重字段放在排序键前面或者用PRIMARY KEY配合ORDER BY来控制索引粒度。5.6 写入乱序导致 CollapsingMergeTree 折叠失败CollapsingMergeTree 的折叠逻辑依赖“先正后负”的顺序。如果数据乱序写入比如先写负记录后写正记录合并时可能找不到配对造成数据残留。解决办法是优先使用 VersionedCollapsingMergeTree并且保证同一个业务键的数据写入到同一个分区同时在写入前做好排序比如在 ETL 层按业务键和版本号排好序。6. 取消分片而不是分片聊聊 MergeTree 家族的扩展方向看到这里你可能已经感受到MergeTree 家族的设计核心就是那套“Part 合并”框架。不管是去重、聚合还是折叠都是在合并时做文章。这也是 ClickHouse 为什么很少需要像传统数据库那样维护二级索引、事务日志等复杂机制的原因。顺着这个思路你还能推出很多高级玩法自定义物化视图 AggregatingMergeTree构建实时指标层。用 ReplacingMergeTree 做“最终一致性维表”配合字典表加速查询。用 CollapsingMergeTree 做精确去重计数避免uniq的近似误差。利用 TTL 和分区管理做低成本的数据生命周期治理。很多人在学习 ClickHouse 时容易陷进“语法细节”里而忽略了底层这套“Part 合并 稀疏索引”的框架。其实把这套框架想明白了后面不管用哪个引擎碰到什么问题都能快速定位到根因——无非就是“Part 还没合并”“索引没走对”“排序键不匹配”这三个大类。从我个人的经验看ClickHouse 的调优八成时间不是在调 SQL而是在调表结构的设计。分区键、排序键、引擎选型这“三板斧”做对了后面能省掉大量运维和排查的精力。反过来前期表结构设计偷懒后面查询慢、磁盘爆、内存溢出的问题就会源源不断找上门。最后再分享一个小技巧建表之前先用SELECT ... GROUP BY ...的查询模式去梳理业务方的过滤条件、维度组合、聚合指标反推出排序键和引擎选型。这个习惯我用了很久基本上能避免绝大多数“建表一时爽查询火葬场”的悲剧。MergeTree 家族的强大之处恰恰在于它给你提供了足够多的“玩法”但前提是你要在设计阶段就把它安排好。
返回列表