3、ClickHouse深度剖析(下):分布式表与ReplicatedMergeTree、物化视图实战、高可用部署方案、典型应用场景与避坑指南

上一讲我们把 ClickHouse 的存储引擎和 MergeTree 家族聊透了。这一讲,咱们直接上生产环境最关心的东西:分布式表怎么搭、数据怎么复制、物化视图怎么用才不会翻车,以及高可用到底怎么落地。

说实话,我最早接触 ClickHouse 时,觉得它就是个单机版的列存数据库。直到有一次,客户要求单表日增 10 亿行,我才真正开始研究它的分布式能力。嗯,踩了不少坑,今天一并分享给你。

3.1 分布式表与 ReplicatedMergeTree:数据分片与高可用的基石

ClickHouse 的分布式能力,核心就两个东西:Distributed 表引擎和ReplicatedMergeTree。前者负责路由查询,后者负责数据冗余。

3.1.1 ReplicatedMergeTree:数据复制怎么玩?

ReplicatedMergeTree 不是一个新的表引擎,它是在 MergeTree 基础上加了 Zookeeper 协调的复制层。每个分片可以有多个副本,写入一个副本,其他副本自动同步。

核心原理:每个数据部分(part)的变更都会记录为 ZK 上的一个日志条目。其他副本监听 ZK,拉取日志并回放。说白了,就是 ZK 驱动的一个主主复制模型。

创建一张复制表,语法很简单:

CREATE TABLE events_local ON CLUSTER my_cluster (
    event_date Date,
    event_type String,
    user_id UInt64,
    amount Float64
) ENGINE = ReplicatedMergeTree(
    '/clickhouse/tables/{shard}/events_local',  -- ZK 路径
    '{replica}'                                   -- 副本名称
)
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, event_type);

这里有个关键点:ZK 路径必须唯一。我见过有人把两个不同表的 ZK 路径写一样,结果数据混在一起,查了半天才发现。路径里的 {shard} 和 {replica} 是宏变量,需要在配置文件中定义。

我的习惯:ZK 路径格式统一用 /clickhouse/tables/{cluster}/{shard}/{table_name},这样集群扩容时一目了然。

3.1.2 Distributed 表:查询怎么路由?

Distributed 表本身不存数据,它只是一个视图,把查询分发到各个分片,然后汇总结果。创建方式:

CREATE TABLE events_dist ON CLUSTER my_cluster
AS events_local
ENGINE = Distributed(
    'my_cluster',       -- 集群名
    'default',          -- 数据库名
    'events_local',     -- 本地表名
    rand()              -- 分片键
);

分片键决定了数据写到哪个分片。我用 rand() 做随机分发,适合写入均衡的场景。如果按用户 ID 取模,可以保证同一个用户的数据落在同一个分片,方便做 GROUP BY 优化。

注意:Distributed 表写入是异步的。数据先写到本地,后台线程再转发到目标分片。如果写入后立即查询,可能查不到最新数据。我曾经因为这个被业务方追着问……解决方案是设置 insert_distributed_sync=1,但会牺牲写入性能。

3.2 物化视图实战:预聚合的艺术

物化视图是 ClickHouse 最强大的功能之一。它不像普通视图那样只是个查询模板,而是真实存储数据。数据写入源表时,会自动触发物化视图的更新。

3.2.1 物化视图的两种模式

模式 触发时机 适用场景
TO 模式 数据写入源表时,直接写入目标表 数据清洗、字段重排
POPULATE 模式 创建时先加载源表已有数据,之后增量同步 预聚合、降采样

我推荐用 TO 模式。POPULATE 模式有个坑:创建期间写入的数据可能会丢失。为什么呢?因为 POPULATE 只处理创建那一刻之前的数据,创建过程中的新数据可能被跳过。

3.2.2 一个实战例子:实时聚合每分钟的 PV/UV

-- 源表:原始点击流
CREATE TABLE clicks (
    ts DateTime,
    user_id String,
    page String
) ENGINE = MergeTree()
ORDER BY ts;

-- 物化视图目标表:分钟级聚合
CREATE TABLE clicks_agg (
    ts_min DateTime,
    page String,
    pv UInt64,
    uv UInt64
) ENGINE = SummingMergeTree()
ORDER BY (ts_min, page);

-- 物化视图:自动聚合
CREATE MATERIALIZED VIEW clicks_mv TO clicks_agg AS
SELECT
    toStartOfMinute(ts) AS ts_min,
    page,
    count() AS pv,
    uniqExact(user_id) AS uv
FROM clicks
GROUP BY ts_min, page;

这样,每次写入 clicks 表,数据都会自动聚合到 clicks_agg。查询时直接查聚合表,速度能快几百倍。

我的经验:物化视图不要写太复杂的逻辑。嵌套子查询、多表 JOIN 都会导致写入性能急剧下降。我一般只做简单的聚合和过滤,复杂的计算留给查询时做。

3.3 高可用部署方案:不只是搭个集群

高可用不是简单地搭几个节点就完事了。你得考虑:节点挂了怎么办?ZK 挂了怎么办?网络分区了怎么办?

3.3.1 推荐架构:3 分片 2 副本

我习惯用 3 个分片、每个分片 2 个副本的架构。总共 6 个节点,再加 3 个 ZK 节点。这样任意一个节点挂了,数据不丢,查询不中断。

ClickHouse 高可用架构(3分片2副本) ZooKeeper 集群(3节点) 分片1 - 副本A 分片1 - 副本B 分片2 - 副本A 分片2 - 副本B 分片3 - 副本A 分片3 - 副本B 说明: 每个分片有2个副本,副本间通过 ZK 协调数据复制 任意一个节点故障,查询自动切换到同分片其他副本

3.3.2 部署要点

  • ZK 不能和 ClickHouse 混部:ZK 对磁盘 IO 敏感,混部会导致写入抖动。我见过有人把 ZK 和 CH 放一起,结果 ZK 写日志把磁盘 IO 打满,整个集群都卡了。
  • 副本数不要超过 3:副本越多,ZK 压力越大。2 副本足够保证高可用,3 副本有点浪费。
  • 分片数根据数据量定:每个分片建议 2-4TB 数据。分片太多会导致查询开销大,分片太少又存不下。

3.4 典型应用场景与避坑指南

3.4.1 场景一:实时日志分析

这是 ClickHouse 最擅长的领域。每天几十亿条日志,写入毫无压力,查询秒级返回。我做过一个项目,用 6 个节点扛住了日均 50 亿条日志的写入,查询 P99 在 200ms 以内。

优化技巧:日志场景建议按天分区,使用 ORDER BY (timestamp, source)。查询时尽量指定时间范围,避免全表扫描。

3.4.2 场景二:用户行为分析

用户行为数据通常需要做留存分析、漏斗分析。这些查询涉及大量用户去重和路径匹配,用 ClickHouse 的 retention 和 windowFunnel 函数非常方便。

3.4.3 避坑指南

我曾经踩过的坑:

  • 大查询把内存打满:有一次跑了个没加过滤条件的 GROUP BY,直接把 64G 内存吃光,OOM 了。后来我养成了习惯,所有查询都加 WHERE 条件,并且设置 max_memory_usage 限制。
  • ZK 节点挂了导致写入失败:ReplicatedMergeTree 依赖 ZK,ZK 挂了写入就会阻塞。解决方案是部署 3 节点 ZK 集群,并且给 ZK 单独配 SSD。
  • 物化视图数据不一致:如果源表数据被修改(UPDATE/DELETE),物化视图不会自动同步。所以,能用 APPEND 就别用 UPDATE。
  • 分布式表 JOIN 性能差:跨分片 JOIN 会把数据拉到查询节点,网络开销很大。我一般把大表做成分布式表,小表做成本地表,用 GLOBAL JOIN 广播小表。

3.4.4 不适合的场景

ClickHouse 不是万能的。以下场景我建议你别用它:

  • 频繁的 UPDATE/DELETE:ClickHouse 的 Mutation 是异步的,性能很差。
  • 事务性操作:它不支持事务,别拿它当 MySQL 用。
  • 点查场景:按主键查单条记录,ClickHouse 不如 HBase 或 Redis。