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 节点。这样任意一个节点挂了,数据不丢,查询不中断。
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。