ClickHouse 入门:从原理到实战

一、ClickHouse 是什么,解决什么问题

ClickHouse 是 Yandex 开源的列式存储数据库(DBMS),主打 OLAP(在线分析处理)场景。核心命题只有一个:海量数据下,如何让聚合查询(sum、count、group by)快到毫秒级

要理解它为什么快,得先理解它和 MySQL 这类 OLTP 数据库的根本分歧——存储方式。

列式存储 vs 行式存储

假设有一张表:

Id Name Age
1 张三 18
2 李四 22
3 王五 34

行式存储在磁盘上是这样组织的:

1 张三 18 | 2 李四 22 | 3 王五 34

想查"张三的所有信息",一次磁盘寻址+顺序读就搞定。但想算"所有人的平均年龄",需要跳着读,或者全表扫描——大量不需要的字段(Name)也被迫一起读进来了。

列式存储反过来:

1 2 3 | 张三 李四 王五 | 18 22 34

算平均年龄,只需要把 Age 这一列整体读出来。这个差异在数据量小的时候不明显,但数据量到千万级、亿级时,I/O 差距会被放大到数量级级别。

这也是为什么 ClickHouse 单表查询能做到 presto、Hive 的几十倍甚至上百倍(PDF 里的 benchmark 数据:单表查询总耗时,ClickHouse 85.9,而 Hive 是 4096——差了近 48 倍)。

列式存储还带来一个副产品:压缩率暴涨。 同一列数据类型高度一致,压缩算法能针对性优化,压缩比远超行式存储。压缩比高了,磁盘 I/O 和 cache 命中率都跟着受益。

但它不是万能的

单条 Query 能利用整机所有 CPU 核心并行处理——这是靠"分区(partition)→索引粒度(index granularity)→多核并行"这套机制实现的。代价是:单条查询独占多核,不适合高并发的高 QPS 查询。如果你的场景是"C 端用户高频小查询",ClickHouse 不是对的选择;如果是"内部报表、离线分析、大批量聚合",这正是它的主场。


二、表引擎:ClickHouse 的灵魂

MySQL 里存储引擎(InnoDB/MyISAM)是可选项,大多数人一辈子只用 InnoDB。但在 ClickHouse 里,表引擎决定了数据怎么存、支持什么查询、能不能并发、要不要索引——选错引擎,等于用错了整个数据库。

引擎大致分四类:合并树系列(MergeTree family)、日志类、接口类、其他。日常开发 99% 精力都在 MergeTree 系列上,其他两类了解即可:

  • TinyLog:列文件存磁盘,不支持索引,没并发控制。适合练手,生产环境基本不用。
  • Memory:数据存内存,重启即丢失。读写不阻塞,不支持索引,简单查询能跑到 10G/s 以上。适合"数据量不大(上限约1亿行)+ 要求超高性能"的场景。

真正的重头戏是 MergeTree。


三、MergeTree:三个参数决定一切

建表语句长这样:

create table t_order_mt(
    id UInt32,
    sku_id String,
    total_amount Decimal(16,2),
    create_time Datetime
) engine = MergeTree
  partition by toYYYYMMDD(create_time)
  primary key (id)
  order by (id, sku_id);

三个关键字理解透了,MergeTree 就理解了一半:

partition by(分区,可选)

作用和 Hive 一样:缩小扫描范围。不填就只有一个分区,所有数据混一起。

容易踩的坑:每一批 insert 进来的数据,都会先落地成一个临时分区,不会立即合并进已有分区。ClickHouse 会在后台(大概 10-15 分钟后)自动 merge,你也可以手动触发:

optimize table t_order_mt final;

这意味着什么?如果你的 Flink 任务频繁地一条条 insert 写入 ClickHouse,会疯狂产生临时分区,后台合并压力剧增,严重时直接拖垮整个集群的写入性能。这是生产环境的高发事故点,对应到物流数仓场景:Flink Sink 到 ClickHouse 必须走批量写入(攒够一批,用 JDBC batch 或者官方 connector 的 flush 机制),不能来一条写一条。

primary key(主键,可选)

这里要打破一个直觉:ClickHouse 的主键不是唯一约束,它只是一级索引,允许重复值存在。

它的作用是配合 order by 字段做二分查找,定位到某个 index granularity(索引粒度) 区间,避免全表扫描。这就是所谓的"稀疏索引"——不像 MySQL 的 B+ 树精确定位到某一行,ClickHouse 只定位到"大概在这个区间",然后在区间内做一点扫描。

默认粒度是 8192 行,官方不建议改,除非某列存在大量重复值。

order by(排序键,唯一必填项)

这是 MergeTree 里唯一必须填的字段,甚至比 primary key 更重要。原因是:如果你没显式设置 primary key,ClickHouse 会隐式用 order by 做主键;更关键的是,后面讲的去重(ReplacingMergeTree)、预聚合(SummingMergeTree)都是**依照 order by 字段来判定"哪些行算同一组"**的。

有个硬约束要记住:primary key 必须是 order by 字段的前缀。比如 order by (id, sku_id),那 primary key 只能是 id 或 (id, sku_id),不能是 sku_id 单独拎出来。


四、MergeTree 的变种:按场景选型

这部分是重点中的重点——三个引擎对应三种业务场景,选错了等于埋雷。

ReplacingMergeTree:去重,但不是你想的那种去重

create table t_order_rmt(
    id UInt32,
    sku_id String,
    total_amount Decimal(16,2),
    create_time Datetime
) engine = ReplacingMergeTree(create_time)
  partition by toYYYYMMDD(create_time)
  primary key (id)
  order by (id, sku_id);

ReplacingMergeTree() 括号里填的是"版本字段"——重复数据保留版本字段值最大的那一条;不填的话,默认按插入顺序保留最后一条。

这里有三个反直觉的坑:

  1. 去重只在后台 merge 时发生,不是实时的。你 insert 完立刻 select,大概率还能看到重复数据。
  2. 去重不能跨分区。如果表按天分区,同一个 id 的数据分别落在两天的分区里,永远不会被去重。
  3. "重复"的判定标准是 order by 字段,不是你自以为的主键

这意味着:如果你的场景需要"CDC 同步过来的订单状态变化,只保留最新状态"这种强一致去重需求,ReplacingMergeTree 不能完全兜底,它只是"后台悄悄清理重复数据以节省空间",不保证查询时没有重复。真正要精确拿最新状态,查询时还得加 FINAL 关键字或者自己写 argMax 聚合,但 FINAL 会牺牲性能。

SummingMergeTree:预聚合,专治只看汇总不看明细

create table t_order_smt(
    id UInt32,
    sku_id String,
    total_amount Decimal(16,2),
    create_time Datetime
) engine = SummingMergeTree(total_amount)
  partition by toYYYYMMDD(create_time)
  primary key (id)
  order by (id, sku_id);

如果一张表你只关心"按维度汇总的结果",不关心明细,用普通 MergeTree 会导致存储空间和查询时聚合的双重浪费。SummingMergeTree 在后台 merge 时,会把 order by 字段相同的行自动做 sum 合并。

规则:

  • 只有 SummingMergeTree() 括号里指定的列(必须是数字列)会被求和,不填就默认所有非维度的数字列都参与求和
  • order by 的字段是维度列,合并时保留
  • 其他非数字、非维度的列,只保留合并中第一行的值
  • 同样不能跨分区聚合,同样是"后台 merge 时才生效",查询时可能读到未合并的临时明细

一个常见的认知误区:很多人以为设了 SummingMergeTree 之后, select sum(total_amount) from t where ... 可以省略,直接 select total_amount from t where ... 就能拿到汇总值。这是错的——因为可能存在还没来得及合并的临时分区数据。查汇总值,永远老老实实用 sum() 聚合,只是效率会比普通 MergeTree 高一些(但因为列式存储本身效率已经很高,提升不会特别明显)。

怎么选:一张表选型速查

场景 选型
明细表,不去重不聚合 普通 MergeTree
需要按最新状态去重展示(弱一致可接受) ReplacingMergeTree
只关心汇总指标,不看明细 SummingMergeTree
强一致去重/精确更新 Mutation(alter update/delete),但代价很高,见下节

五、Update / Delete:能做,但很"重"

ClickHouse 支持 Update/Delete,官方称为 Mutation 查询,本质上是 Alter 的一种:

alter table t_order_smt delete where sku_id = 'sku_001';
alter table t_order_smt update total_amount = toDecimal32(2000.00, 2) where id = 102;

"重"体现在:每次 Mutation 都会导致原分区被放弃、数据重建新分区。而且不支持事务。执行时分两步——先标记旧分区逻辑失效并写新分区,真正物理删除旧数据要等到分区合并触发。

结论:能不做 Update/Delete 就不做,要做也尽量批量做,不要频繁对小数据做变更。


六、SQL 层面与 MySQL 的差异

ClickHouse 基本兼容标准 SQL,以下是几个值得注意的不同点:

  • 支持子查询、CTE(with 子句)、各种 JOIN,但 JOIN 不能使用缓存,即使两次一模一样的 JOIN 语句也会被当成两条新 SQL 重新执行
  • 不支持自定义函数
  • GROUP BY 支持 with rollup(从右至左逐级去掉维度做小计)、with cube(双向去掉维度做小计)、with totals(只算总计),用来快速做多维度汇总,不用手写多条 union
  • 窗口函数在官方测试阶段(以 PDF 版本为准,后续版本可能已转正)

七、副本与分片:高可用怎么做

  • 副本(Replication):解决数据可用性问题。一台节点宕机,其他节点还有全量数据。依赖 ZooKeeper 协调写入日志,写入流程是:client 写入 A 节点 → A 提交写入日志到 ZK 集群 → 目标副本节点 B 从 ZK 收到通知 → B 从 A 拉取新数据。副本只能同步数据,不能同步表结构,每台机器要手动建表(引擎用 ReplicatedMergeTree)。
  • 分片(Sharding):解决横向扩容问题。副本只能保证"每台机器都有全量数据",但单机容量到顶就没辙了。分片把数据切开分布到不同节点,再通过 Distributed 表引擎把各分片拼接成一张逻辑表对外提供查询。Distributed 引擎本身不存数据,只是一层路由中间件(类似 MySQL 生态里的 MyCat)。

一个业内常见的现实是:大部分企业只做副本,不做分片——因为分片会显著增加查询复杂度(尤其是跨分片的 JOIN、去重、聚合),在数据量没有大到必须水平扩展之前,复杂度收益不成正比。

更多推荐