返回博客

为分析而生:ClickHouse 的设计哲学与实践模式

第一次用 ClickHouse 的人,往往会带着 MySQL 的直觉去用它,然后撞墙。为什么频繁的单行 INSERT 会让它变慢?为什么 SELECT * 是大忌?为什么官方文档反复强调「批量」?

这些问题指向同一个答案:ClickHouse 是为分析而生的,它的每一个取舍,都是为了让聚合查询在 TB 级数据上跑出毫秒级的响应。理解这个前提,很多「反直觉」的行为就变得理所当然。

列存储:为什么查询快

传统行存数据库(MySQL、PostgreSQL)把一行的所有列连续存在磁盘上。分析查询通常只关心几列,但读盘时却不得不把整行都搬出来,然后丢掉不需要的部分。

ClickHouse 把每一列单独存储。一个宽表有 50 列,一个分析查询只用到其中 3 列,那它实际读取的 I/O 大概只有行存的 6%。加上列内数据类型一致、重复率高,压缩比也远优于行存。

这个物理结构决定了两件事:

  • SELECT * 是反模式。显式指定你需要的列,是使用 ClickHouse 最基本的性能礼仪。
  • 数据类型要精打细算。UInt32 vs UInt64LowCardinality(String) vs String,选错了不只是存储浪费,还影响压缩效率和查询速度。

写入策略:批量是第一原则

ClickHouse 的写入机制会把每次 INSERT 的数据先落成一个独立的「分片」(part),后台异步 merge 成更大的分片。这个架构让批量写入极高效,但也意味着:如果你用循环逐条写入,每条数据都会触发一次磁盘写、一次 merge 调度,很快就会把后台 merge 线程打爆。

正确做法是在应用层攒批,一次性提交:

// 攒够 1000 条(或超过 N 秒)再一次性写入
async function bulkInsert(records: Record[]) {
  const rows = records.map(r =>
    `('${r.id}', '${r.type}', ${r.value}, '${r.ts}')`
  ).join(',')
 
  await clickhouse.query(`
    INSERT INTO events (id, type, value, ts) VALUES ${rows}
  `).toPromise()
}

生产环境里我会在应用层维护一个内存队列,定时刷入(比如每秒或每 500 条触发一次)。这个「缓冲层」不复杂,但少了它,写入性能会差一到两个数量级。

写入和查询的性能逻辑是对称的

ClickHouse 的查询快是因为列存储减少了 I/O;写入的正确姿势也是减少触发次数——批量合并后一次落盘。两个方向都在遵循同一个原则:减少不必要的 I/O 和调度开销。

表引擎选型:不只是 MergeTree

MergeTree 是最基础的引擎,但它有几个变体,对应不同的业务场景:

ReplacingMergeTree:数据有重复(比如 CDC 或多源同步),想按主键去重。注意它的去重是异步的,查询时加 FINAL 才能保证拿到最新版本(但 FINAL 有性能代价,高频场景要权衡)。

AggregatingMergeTree:把聚合函数的「中间状态」持久化下来。配合物化视图使用时,写入数据的同时自动维护各维度的累计汇总,查询时只需要 sumMerge()uniqMerge() 完成最终合并,不需要扫原始明细表。这是实现实时看板低延迟的核心手段。

物化视图:预聚合的正确打开方式

如果你有一张事件明细表,每次查询都要做 GROUP BY,在几亿行上跑这个聚合会很慢。物化视图的思路是:让写入的代价换来查询的速度

-- 写入 events 表时,自动维护小时级聚合
CREATE MATERIALIZED VIEW events_hourly_mv
TO events_hourly
AS SELECT
    toStartOfHour(ts) AS hour,
    type,
    sumState(value) AS total_value,
    countState() AS cnt,
    uniqState(user_id) AS unique_users
FROM events
GROUP BY hour, type;

查询时直接走 events_hourly,不碰明细表。对于时序型分析(日活、漏斗、留存),这个模式几乎是标配。

查询优化:从 ORDER BY 设计开始

ClickHouse 的索引是稀疏的,和 B-Tree 索引逻辑完全不同。它依赖 ORDER BY 排序键做数据跳过(granule skipping)。规则很简单:过滤条件里的列,要和排序键的顺序保持一致,才能最大化跳过无关数据块

-- 建表时 ORDER BY (date, market_id)
-- 查询时先过滤 date,再过滤 market_id,走得动索引
WHERE date >= '2025-01-01' AND market_id = 'mkt-001'
 
-- 反过来,先过滤 market_id 再 date,效果大打折扣
WHERE market_id = 'mkt-001' AND volume > 500

另外,system.query_log 是性能调优的起点——它记录了每条查询的执行时长、读了多少行、扫了多少字节:

SELECT query, query_duration_ms, read_rows, read_bytes
FROM system.query_log
WHERE type = 'QueryFinish' AND query_duration_ms > 500
ORDER BY query_duration_ms DESC
LIMIT 10;

慢查询诊断时,我习惯先看 read_bytes。扫了几十 GB 但只返回几百行,说明分区或排序键没命中,是最值得优化的方向。

一句话心法

ClickHouse 的性能不是调出来的,是设计进去的——表结构的分区键、排序键、引擎选型,在建表那一刻就决定了八成的上限。