我会把 ClickHouse 的新手误区理解成一组物理层约束的提醒:它很快,但你必须顺着它的写入、排序、合并、协调和内存模型来设计系统。

核心判断

ClickHouse 最容易被误用的地方,不是某个配置项难懂,而是它的很多概念长得像传统数据库里的熟悉词,但实际语义不同。PRIMARY KEY 不是 B-tree 点查入口,LIMIT 不保证提前停止,物化视图不是自动同步的派生表,ReplacingMergeTree 不保证实时唯一,Keeper 也不是可以随手混部的小组件。

所以我更倾向把 ClickHouse 入门理解成一件事:把 OLAP 数据库的物理模型重新装进脑子里。 只有先接受 part、merge、稀疏索引、分片协调和向量化执行这些底层事实,后面的调参才不会变成迷信。

写入形状决定后台压力

clickhouse-13-mistakes 里排第一的 Too many parts 很适合作为入口。ClickHouse 每次 INSERT 都会把数据变成 part,后台再通过 merge 把 part 合并到更适合查询和存储的形态。如果写入端不断制造小 part,或者用高基数 partition key 把数据拆进大量永不互相合并的分区,后台 merge 很快会被压垮。

这解释了几个实践判断:

  • partition key 是数据管理工具,主要服务删除、归档和分区级操作,不应被当成随手加的查询优化器;
  • 小批量 INSERT 会把问题转嫁给 part 合并,客户端攒批或 async inserts 通常是更自然的入口;
  • 物化视图越多,插入时触发的下游写入越多,也会增加目标表 part 压力。

我会把这条原则概括为:ClickHouse 的写入不是只看每秒来了多少行,还要看这些行以什么形状进入 MergeTree

主键是在定义物理顺序

ClickHouse 的主键误区本质上来自 OLTP 经验迁移。传统 B-tree 主键让人自然想到“定位某一行”,但 ClickHouse 的稀疏主键更像是在告诉系统:哪些数据应该在磁盘上相邻,哪些过滤条件最值得提前缩小扫描范围。

因此,ORDER BY 的选择比后补 data skipping index 更根本。主键列应该来自稳定、高频的查询过滤模式,列顺序要同时考虑过滤和压缩。data skipping index 只有在目标列和主键排序存在强相关时才更有意义;如果每个 granule 几乎都可能命中,它就只是额外的写入成本。

这和 sql-indexingquery-shape-and-index-usage 的精神是一致的,只是 ClickHouse 的“索引”更偏向列式 OLAP 的物理排序,而不是 OLTP 的行级定位。

不要让 LIMIT 替你优化查询

LIMIT 很容易给人一种安全感:既然最后只要几行,查询应该便宜。但 ClickHouse 是否能提前停下来,取决于查询是否能流式执行,以及排序或聚合顺序是否能利用主键。

如果查询要按非主键列排序,或者聚合必须读完整个输入才能产出结果,LIMIT 只是在最后裁剪结果。分布式表上还会多一层问题:每个 shard 都可能需要各自计算 Top-N,再交给协调节点汇总。

这对点查尤其危险。ClickHouse 可以通过精心设计的主键和数据布局支持某些点查模式,但它并不是默认擅长大量随机行级 lookup 的系统。把 LIMIT 1 贴在全表扫描式点查后面,只是在语义上减少返回行数,没有自动减少执行成本。

更新和去重要接受最终性

ClickHouse 最舒服的路径仍然是不可变数据。确实需要修改时,可以使用经典 mutations、轻量更新、轻量删除、ReplacingMergeTree 或查询时去重,但这些能力都不是 OLTP 式的“原地改一行”。

经典 mutations 会重写 part,并和 merge 争资源;轻量更新通过 patch part 降低改写成本,但读取和后续 merge 仍要承接这些变化;ReplacingMergeTree 依赖后台 merge,因此只能提供 best effort 去重;FINAL 能强制查询时去重,但会增加查询成本。

oneuptime-replicated-replacingmergetree 给出了 ReplicatedReplacingMergeTree 的正向用法:ORDER BY 是逻辑身份,version 是新旧顺序,FINAL 是读时去重工具。clickhouse-issue-20867 则提醒我,ReplacingMergeTree 的“去重”不能和 replicated insert deduplication 混在一起。前者按排序键折叠行,并可用 version 列决定胜出者;后者在复制表的插入路径上跳过重复 insert block,主要服务重试幂等。

一个周期性全量快照如果每次 payload 都完全相同,业务上可能是在写“新快照”,但复制层可能把它看成重复提交。因此,做每日状态表时要把业务日期、版本时间和插入唯一性拆开建模,而不是指望 FINALOPTIMIZE FINAL 自动理解“最后一次业务状态”。

我的判断是:如果一个系统大量依赖事后修正数据,应该先反问上游事件模型是否设计错了,而不是一开始就把 mutation 当成主要写路径。

物化视图是插入触发器

增量物化视图的触发模型是另一个很容易踩坑的地方。它只处理新插入的 block,不会因为源表发生 mutation、partition drop 或 merge 就自动修正目标表。换句话说,它不是“永远与源表保持一致的视图”,而是一个插入时执行的转换管道。

这带来两个实践结论:

  • 如果目标是实时预聚合或写入时转换,增量物化视图很强;
  • 如果需要周期性全量重算、多表复杂查询或对源表变更重新对齐,refreshable materialized view 或显式重建流程更贴近问题。

物化视图越多,写入路径越重。超过几十个视图时,我会优先怀疑这是建模问题,而不是先调并发参数。

Keeper 不是旁路组件

在复制环境里,Keeper / ZooKeeper 承担复制元数据、插入去重窗口和协调状态。节点一旦失去协调服务连接,就可能进入只读状态。这说明 Keeper 不是部署图上的小方块,而是写入可用性的关键路径。

因此,clickhouse-keeper-vs-zookeeper 里的选型讨论还要再加一层运维直觉:无论选 Keeper 还是 ZooKeeper,都要把它当成生产数据库的一部分来容量规划、独立部署和监控。把协调层和 ClickHouse Server 草率混部,等于把复制系统的脆弱点藏到了最不该脆弱的位置。

内存治理要覆盖查询和用户

ClickHouse 的内存超限常来自高基数聚合、大 join、全局排序和分布式协调节点汇总。可用的技术手段很多:外部 group by、外部 sort、join algorithm、ANY JOINASOF JOIN、过滤前置、右表缩小、quotas、query complexity、memory overcommit。

但这些手段背后的原则只有一个:不要把数据库内存当成无穷缓冲区。查询形状、租户隔离和资源上限必须一起设计。尤其当 ClickHouse 被开放给多人或多个服务使用时,没有配额的自由查询能力,本质上就是允许任意用户制造生产级故障。

不要忽视列的物理开销

clickhouse-query-optimization-guide 里有一条让我重新审视了 “INSERT 时多发几个 NULL 有什么关系” 的惯性思维:每个 Nullable 列都会额外携带一个 UInt8 null mask,这不仅仅是多存一字节,而是从磁盘读取到向量化执行,每一步都要检查 null mask。实践中更常见的陷阱是:表定义里设了 Nullable,但实际没有任何 NULL 值——在没验证前,这个 Nullable 就是白付的存储、内存和 CPU 成本。

建表前的正确姿势是用 countIf(col IS NULL) 扫描一批代表性数据,确认 NULL 确实存在后再决定是否保留 Nullable。能让应用端发默认值就不要让数据库端承载 NULL:空字符串、0、-1、epoch 通常比 NULL 对 ClickHouse 更友好。

这也适用于分区和 skip index。分区数超过一千个时性能严重退化——分区是管理工具,不是性能工具。Skip index 只有在目标列和 ORDER BY 有实质性相关性时才有效;如果每个 granule 几乎都能命中,index 只是多了写入成本而没有读取收益。

一个实用检查表

新建或评审一张 ClickHouse 表时,我会优先问这些问题:

  • partition key 是否低基数,且确实服务数据生命周期管理;
  • INSERT 是否批量化,是否需要 async inserts;
  • ORDER BY 是否贴合最重要的过滤和压缩路径;
  • 是否误把 data skipping index 当成通用二级索引;
  • 是否依赖 LIMIT 掩盖全表扫描、非主键排序或跨 shard Top-N;
  • mutations、轻量更新、去重和 FINAL 是否只是补救,而不是掩盖版本建模缺失;
  • 增量物化视图是否被当成自动同步表误用;
  • Keeper / ZooKeeper 是否独立、稳定、可观测;
  • 高基数聚合、join 和排序是否有内存保护;
  • beta / experimental 功能是否被放进核心生产路径。

对我的启发

ClickHouse 的复杂度不是坏事,它只是把分析数据库真实要处理的物理问题暴露出来了。好的 ClickHouse 设计看起来并不神秘:让写入形成足够大的 part,让主键表达真实访问路径,让分布式拓扑晚一点复杂化,让 Keeper 稳定,让内存有边界。

我真正想避免的是另一种姿势:看到 ClickHouse 很快,就把它当成“什么都能快”的数据库。它更像一台非常强的分析机器,但前提是你别逆着它的机械结构拧。


来源:clickhouse-13-mistakes · clickhouse-issue-20867 · oneuptime-replicated-replacingmergetree · clickhouse-query-optimization-guide

相关页面:clickhouse-deployment-topologies · clickhouse-keeper-vs-zookeeper · clickhouse-replicated-engines-and-conversion · clickhouse-query-optimization · sql-indexing · query-shape-and-index-usage · sql-join-performance · clickhouse