编程 ClickHouse 深度实战:把 PB 级实时分析塞进一台服务器——从 MergeTree 存储引擎、稀疏索引到物化视图流的完整指南(2026)

2026-07-08 05:41:58 +0800 CST views 7

ClickHouse 深度实战:把 PB 级实时分析塞进一台服务器——从 MergeTree 存储引擎、稀疏索引到物化视图流的完整指南(2026)

如果你已经在用 PostgreSQL 扛报表、用 DuckDB 做本地分析,那你大概率正站在 ClickHouse 的门口——只是还没推门进去。本文不讲"列式存储很快"这种正确的废话,我们从 MergeTree 的磁盘布局、稀疏索引的数学、物化视图的"流式触发器"语义,一路打到生产级写入、更新删除与性能调优,目标是让你读完能直接在集群上落一张经得起每秒百万行写入的表。

一、背景:为什么 OLAP 世界需要一个"反常规"的数据库

先说一个被反复验证的事实:进入 2026 年,一家中等规模互联网公司的可观测性 + 用户行为 + 业务指标三类数据,每天轻松产生数十亿行。传统做法是用 PostgreSQL 这类 OLTP 数据库顺手扛分析,结果就是——一张 20 亿行的 events 表,一个 GROUP BY day, country 要扫 40 秒,索引越建越多,写入越来越慢,DBA 开始整夜睡不着。

问题不在 PostgreSQL,而在于它生来就不是干这个的。OLTP 的世界观是:行是原子单位,事务、点查、UPDATE/DELETE 要强一致;而 OLAP 的世界观是:列才是单位,范围扫描、聚合、批量写入才是日常。两套世界观在同一引擎里硬凑,必然两头不讨好。

于是出现了三条清晰的技术路线:

  • 进程内 OLAP(如 DuckDB):把分析引擎塞进你的 Python 进程,零部署、单文件。适合"我本地有 50GB Parquet 要即席查一下"。本站已经拆解过 DuckDB——它极致于"轻",但不解决"分布式、每秒百万行持续写入、多团队共享一个分析服务"的问题。
  • 扩展型 OLTP(如 PostgreSQL + 列存/向量扩展):PostgreSQL 18 已经能原生做向量检索(本站也写过),但让它扛 PB 级明细分析,依然是让短跑选手去跑马拉松。
  • 专职 OLAP(ClickHouse 是其中工程完成度最高、社区最活跃的一个):它从第一行代码起就只为一件事优化——在海量数据上做极快的范围聚合查询,并且写入吞吐高到离谱。

ClickHouse 出身于 Yandex.Metrica(俄罗斯版 Google Analytics)的刚需:要实时统计全球网站的点击流,数据量以 PB 计,查询延迟要求亚秒级。它不是某个大学的论文玩具,而是被真实流量暴打出来的工业级系统。理解了这点,你就能接受它那些"反常规"设计了——比如没有传统二级索引、没有行级事务、主键竟然可以重复。

本文的立场很明确:ClickHouse 不是"更快的 MySQL",而是一个为聚合而生的列式装配线。你用装配线的思维去建模,它就快得离谱;你用关系数据库的直觉去用,它就会用一堆坑教你做人。

二、核心概念:列式存储到底赢在哪(不是你想的那个原因)

所有人都听过"列式存储快",但大多数解释停在了"只读需要的列,所以 IO 少"。这没错,但只说了一半。完整的收益链是这样的:

2.1 减少 IO 只是表象,压缩率才是放大器

行式存储里,一行同时有 UInt8 年龄String 姓名DateTime 时间戳Float64 金额。不同类型混在一起,压缩算法几乎无从下手。列式存储把同一列连续存放:年龄这一整列都是小整数,容易出现大量重复值和连续区间——LZ4 能压到原尺寸的 10%~30%,ZSTD 在更慢的档位上甚至能到 5%~10%。

这意味着:

  • 磁盘空间降一个数量级,存储成本直接腰斩;
  • 同样的数据,从磁盘读到内存的字节数少一个数量级,IO 时间同比例下降;
  • 压缩后的数据在内存里也能保持压缩态(ClickHouse 的 compressed vs uncompressed 内存池),同样内存能装下更多数据。

换句话说,列式存储的"快",很大一部分来自"我要搬的数据本来就没那么多"。

2.2 向量化执行:一次不是处理一行,而是一个 Block

ClickHouse 不会"取出一行 → 算一下 → 取出下一行"。它的执行单位是 Block(数据块,默认约 65536 行)。每个 Block 里每一列是一个连续的、类型确定的数组。这样:

  1. 可以用 SIMD 指令 一次性对 8/16/32 个值做同样的加法或比较,CPU 流水线不打断;
  2. 虚函数调用次数从"每行一次"降到"每块一次",分支预测命中率飙升;
  3. 多核并行是天然的:一个查询会被切成多个流,每个流处理不同的数据范围(granule),最后合并。

你写的 SQL 长这样:

SELECT
    toDate(timestamp) AS day,
    country,
    count()           AS pv,
    uniqExact(user_id) AS uv
FROM events
WHERE timestamp >= '2026-07-01' AND app_id = 42
GROUP BY day, country
ORDER BY pv DESC;

ClickHouse 在内部把它变成一个流水线:从磁盘按列读出 Block → 向量化做 WHERE 过滤 → 向量化做 GROUP BY 哈希聚合 → 跨线程合并局部哈希表 → 排序输出。整个过程几乎没有"逐行解释执行"的开销。这也是为什么相同硬件下,ClickHouse 的聚合查询常常比"通用 SQL 引擎 + 行存"快 10~100 倍。

2.3 一个关键心智模型:ClickHouse 是"装配线",不是"图书馆"

图书馆的思维:你把书(行)整齐摆好,读者(查询)来借某一本(点查)。ClickHouse 的思维:原料(写入)从一头源源不断灌进来,经过几道工序(分区、排序、压缩、建索引、合并)变成半成品,查询从另一头按需取走聚合结果。

这个模型直接解释了后面所有"反常规"设计。接受装配线思维,是玩转 ClickHouse 的第一步。

三、架构分析:MergeTree —— ClickHouse 的心脏

所有 MergeTree 家族引擎(Replacing、Summing、Aggregating、Collapsing、VersionedCollapsing、Replicated…)都建立在一个核心数据结构上:LSM-Tree 的一个变体。理解它,你就理解了 ClickHouse 80% 的行为。

3.1 写入:追加,不修改

数据写入时,先进入内存缓冲区,攒够一个批次后整体刷盘成一个不可变的 Part(数据部分)。每个 Part 内部按 ORDER BY 排序,并生成自己的稀疏主键索引。注意:写入从不原地修改已有 Part。这就是为什么 ClickHouse 的写入吞吐能到每秒百万行——它本质在做顺序追加,避开了随机写和行锁。

后台有一个异步线程池,不断把多个小 Part Merge(合并) 成更大的、依然有序的 Part。这跟 LSM 的 compaction 神似,但 ClickHouse 的 Merge 还顺手干了一件大事:在合并时执行引擎语义——ReplacingMergeTree 在合并时去重,SummingMergeTree 在合并时求和,CollapsingMergeTree 在合并时抵消。也就是说,"计算"被巧妙地推进到了后台合并阶段,查询时拿到的是已经被预处理过的数据。

3.2 最容易误解的一点:ORDER BY 就是主键,且主键可以重复

在 MySQL/PostgreSQL 里,"主键"意味着唯一约束 + B+Tree 索引。ClickHouse 里没有这种东西。PRIMARY KEY 只是稀疏索引的列,而且默认等于 ORDER BY。你可以写:

CREATE TABLE events (
    event_date Date,
    user_id    UInt64,
    app_id     UInt32,
    country    LowCardinality(String),
    action     LowCardinality(String),
    value      Float64,
    timestamp  DateTime
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (app_id, user_id, timestamp);   -- 排序键,同时是主键

几个反直觉但必须记住的真相:

  1. 主键不保证唯一ORDER BY (app_id, user_id, timestamp) 只决定磁盘上的物理排列,重复行完全允许存在。
  2. 主键不是用来点查的。它是一个稀疏索引,目的是在范围扫描时跳过无关数据块,而不是像 B+Tree 那样精确定位某一行。
  3. 排序键的顺序=查询性能的顺序。Put 最高频的等值过滤列在前,高基数列在后。比如 (app_id, user_id, timestamp):先按 app 过滤,再按 user 范围,最后按时间——三级索引层层裁剪,效率最高。

3.3 稀疏索引的数学:为什么 8192 行一个标记就够了

这是 ClickHouse 最精妙的设计之一。它不为每一行建索引,而是把数据切成 granule(粒度,默认 8192 行),每个 granule 在 primary.idx 里只占一条索引项(mark)。假设一张表有 80 亿行,那么主索引只有 8e9 / 8192 ≈ 97.6 万 条 mark——完全可以常驻内存。

查询时发生了什么?比如 WHERE app_id = 42 AND timestamp BETWEEN '2026-07-01' AND '2026-07-07'

  1. 用内存里的稀疏索引,快速定位到哪些 granule 的 [min, max] 区间与查询条件可能相交
  2. 只把命中的 granule 对应的列数据从磁盘读出来;
  3. 在 granule 内部再做精确过滤。

如果排序键设计得好,一次查询可能只需要读 0.1% 的 granule。这就是"稀疏索引 + 有序存储"组合拳的威力:索引极小、但裁剪极准

工程经验:如果你想让某个非排序键的列也能被高效过滤,别去加"二级索引"(ClickHouse 没有传统二级索引),而是用 跳数索引(skipping index),见第五节。

四、代码实战:从建表到实时大屏

下面用一个"电商用户行为分析"的完整例子,把上面所有概念落到可运行代码上。

4.1 建模:一张经得起暴打的明细表

CREATE TABLE events_local (
    event_date  Date,
    event_time  DateTime64(3),
    user_id     UInt64,
    app_id      UInt32,
    session_id  UInt64,
    country     LowCardinality(FixedString(2)),
    device      LowCardinality(String),
    action      LowCardinality(String),
    item_id     UInt64,
    price       Nullable(Float64),
    -- 用 Codec 进一步压榨列存:时间列差值编码,计数器 Gorilla 压缩
    _sign       Int8  DEFAULT 1
)
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/events', '{replica}', event_time)
PARTITION BY toYYYYMMDD(event_date)
ORDER BY (app_id, country, user_id, event_time, session_id)
TTL event_date + INTERVAL 180 DAY DELETE   -- 热数据 180 天后自动冷删
SETTINGS
    index_granularity = 8192,
    index_granularity_bytes = '10Mi',       -- 2026 默认按字节兜底,避免超大行导致 granule 过肥
    min_bytes_for_wide_part = 0;            -- 小表也用 wide part,列独立文件便于裁剪

几个刻意的设计点:

  • LowCardinality(...):对 country/device/action 这种低基数字符串,ClickHouse 会自动做字典编码,查询和存储都省。这是性价比极高的一招。
  • ORDER BY (app_id, country, user_id, event_time, session_id):把等值过滤的 app_idcountry 放最前,时间范围放中间偏后,符合"高选择性、等值优先"原则。
  • TTL:分析数据天然有冷热,180 天前的明细自动删除,用空间换长期的查询稳定性。
  • ReplicatedReplacingMergeTree:既做副本高可用,又在合并时按 event_time 去重(处理至少一次投递产生的重复事件)。

4.2 写入:批量、异步、Native 协议

第一个大坑:千万别逐行 INSERT。每行一次 INSERT 会产生海量小 Part,后台 Merge 永远追不上,查询时打开的文件句柄爆炸。正确姿势是大批次:

-- 业务层攒批,一次插几千到几十万行
INSERT INTO events_local
    (event_date, event_time, user_id, app_id, session_id, country, device, action, item_id, price)
VALUES
    ('2026-07-08', now64(3), 1001, 42, 9001, 'CN', 'ios',   'view',   555, 0.0),
    ('2026-07-08', now64(3), 1002, 42, 9002, 'US', 'android','click', 555, 0.0),
    ('2026-07-08', now64(3), 1001, 42, 9001, 'CN', 'ios',   'buy',    555, 299.0);

更激进也更省连接数的做法是 Async Insert(异步插入):客户端把小批量发到服务端,服务端在内存里攒成大片再落盘。

-- 建表级别开启,或会话级 SET
SET async_insert = 1;
SET wait_for_async_insert = 0;   -- 不等落盘,立刻返回,吞吐拉满

生产环境用官方客户端走 Native TCP 协议(比 HTTP 省序列化开销)。Go 示例:

package main

import (
	"context"
	"log"
	"time"

	"github.com/ClickHouse/clickhouse-go/v2"
	"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)

func main() {
	conn, err := clickhouse.Open(&clickhouse.Options{
		Addr: []string{"ch1.prod:9000", "ch2.prod:9000"},
		Auth: clickhouse.Auth{Database: "default", Username: "writer", Password: "***"},
		// 关键:开启压缩 + 大批量写入
		Compression: &clickhouse.Compression{Method: clickhouse.CompressionLZ4},
	})
	if err != nil {
		log.Fatal(err)
	}

	batch, err := conn.PrepareBatch(context.Background(),
		"INSERT INTO events_local (event_date, event_time, user_id, app_id, session_id, country, device, action, item_id, price)")
	if err != nil {
		log.Fatal(err)
	}

	// 真实业务里这是从 Kafka 消费攒出来的批
	for i := 0; i < 100_000; i++ {
		if err := batch.Append(
			time.Now(), time.Now(), uint64(i), uint32(42), uint64(i+9000),
			"CN", "ios", "view", uint64(i%1000), 0.0,
		); err != nil {
			log.Fatal(err)
		}
	}
	// 一次网络往返,十万行进库
	if err := batch.Send(); err != nil {
		log.Fatal(err)
	}
	log.Println("batch sent")
}

Python 侧用 clickhouse-connect 做批量也类似,记得 max_insert_block_size 别太小。

4.3 查询实战:PREWHERE 与跳数索引

ClickHouse 会自动把 WHERE 里能下推的列条件转成 PREWHERE(先只读过滤列,过滤完再读其余列),你通常不用手写。但你可以显式用跳数索引加速非排序键的过滤——比如按 item_id 找某商品的所有行为:

ALTER TABLE events_local
ADD INDEX idx_item_id item_id TYPE bloom_filter(0.01) GRANULARITY 4;

-- 查询:即使 item_id 不在 ORDER BY 里,也能借 bloom filter 跳过大量 granule
SELECT count()
FROM events_local
WHERE app_id = 42 AND item_id = 555;

跳数索引家族还包括 minmax(适合数值/时间范围)、set(适合低基数列的 IN 过滤)、tokenbf_v1(适合 LIKE 文本)。选型口诀:范围用 minmax,等值集合用 set/bloom,模糊文本用 tokenbf

4.4 物化视图:被严重低估的"流式触发器"

这是 ClickHouse 最独特、也最容易被当普通视图误用的能力。物化视图不是查询缓存,而是一个挂在表上的触发器:每当源表 INSERT 一批数据,物化视图就会用你定义的 SELECT 把这批新数据算一遍,并把结果写进它自己的目标表。

经典场景:实时 PV/UV 大屏。明细表每秒百万行,但你的大屏只想看"每 1 分钟、每 app、每国家的 PV/UV"。用物化视图做滚动预聚合:

-- 目标表:按分钟滚动的聚合结果
CREATE TABLE events_minute_agg (
    app_id    UInt32,
    country   LowCardinality(FixedString(2)),
    minute    DateTime,
    pv        UInt64,
    uv        AggregateFunction(uniqExact, UInt64)   -- 状态类型,可继续合并
) ENGINE = SummingMergeTree
ORDER BY (app_id, country, minute);

-- 物化视图:源表每来一批,就按分钟聚合后写进上面的目标表
CREATE MATERIALIZED VIEW mv_events_minute
TO events_minute_agg
AS SELECT
    app_id,
    country,
    toStartOfMinute(event_time) AS minute,
    count()                      AS pv,
    uniqExactState(user_id)      AS uv          -- 注意是 State,保存聚合中间态
FROM events_local
GROUP BY app_id, country, minute;

查询时直接读聚合表,再 uniqExactMerge(uv) 出最终结果——秒回,且成本与数据总量无关,只和聚合后的小表有关:

SELECT
    app_id, country, minute,
    pv,
    uniqExactMerge(uv) AS uv
FROM events_minute_agg
WHERE app_id = 42
GROUP BY app_id, country, minute
ORDER BY minute DESC
LIMIT 100;

物化视图的三个大坑(踩过的人都哭过):

  1. POPULATE 是陷阱:建视图时加 POPULATE 会用历史数据回填,但回填期间新写入的数据会丢失。生产环境不要POPULATE,而是用"建空视图 + 用 INSERT INTO 目标表 SELECT ... FROM 源表 WHERE 时间范围"手动补历史。
  2. NULL 与多表:物化视图里的 SELECT 如果引用了源表没有的列,会填 NULL;视图目标表只能有一个,要做多路聚合就建多个视图。
  3. 它只响应 INSERT:对 ALTER ... DELETE/UPDATE 这种 mutation,物化视图不会重算。所以物化视图适合"只追加"的流,不适合频繁变更的维表。

4.5 更新与删除:异步 mutation 与轻量删除

ClickHouse 的 UPDATE/DELETE 跟 OLTP 数据库完全不同——它们是异步 mutation,会被丢进一个队列,由后台线程对涉及到的 Part 做整块重写。所以:

-- 这是 mutation,不是即时事务!执行后可能几十秒才生效
ALTER TABLE events_local DELETE WHERE app_id = 42 AND event_time < '2026-01-01';
ALTER TABLE events_local UPDATE price = price * 0.9 WHERE country = 'US';

这对"删除少量脏数据"是 OK 的,但绝不能用它做高并发的点删点改——那会制造 mutation 风暴,拖垮集群。2026 年的现代做法是用 Lightweight Delete(轻量删除):用一条隐藏的删除位图标记删除,查询时自动跳过,几乎零重写开销:

-- 开启轻量删除后,DELETE 走位图而不是整块 mutation
SET allow_lightweight_deleted_part_bits = 1;
DELETE FROM events_local WHERE event_id = 123456;   -- 秒级生效,不重写 Part

而真正的高频"更新/去重"场景,正确姿势是建模时用 Replacing/Aggregating/Collapsing 引擎,把"变更"表达成"追加新版本",让后台 Merge 在合并时自然处理——把随机写变成顺序写,这正是 ClickHouse 的强项。

五、性能优化清单(可直接照着抄)

按投入产出比排序:

  1. 排序键设计是第一生产力。把 WHERE 里最常出现的等值列放最前,时间范围放中间,超高基数列(如 user_id)放后面。一个烂的 ORDER BY 会让所有稀疏索引失效。
  2. 低基数字符串一律 LowCardinality。country/device/status/action 这类,加上后存储和查询通常能再省 30%~50%。
  3. 跳数索引补非主键过滤。排序键覆盖不到的高频过滤列,用 bloom_filter/minmax/set 建跳数索引,granularity 调到 1~4。
  4. 物化视图做预聚合,而不是让大屏直查明细。明细表只负责接数据,聚合表负责被查。二者解耦,查询延迟与数据量解绑。
  5. 写入永远大批次 + 异步插入。单批几万到几十万行,关掉 wait_for_async_insert 提吞吐;监控 Parts 数量,超过几千就要警惕小 Part 问题。
  6. 慎用 SELECT *final。列存的优势就是只读需要的列;SELECT * 直接废掉列存红利。... FINAL(强制合并去重)是重型操作,能用物化视图/聚合表替代就别用。
  7. 分布式注意本地优先。跨分片查询时设置 distributed_product_modeoptimize_skip_unused_shards,让 ClickHouse 尽量把计算下推到各分片本地,只传聚合结果,而不是拉全量。
  8. 2026 值得关注的新特性SharedMergeTree(云原生共享存储,存算分离下降低副本开销)、原生 JSON 类型(不再需要把 JSON 拍平成列或塞进 String)、Lightweight Delete 位图化、以及持续改进的 JOIN 引擎(已能更稳定地做大表 JOIN)。这些不是噱头,是实打实能降成本、提性能的工程改进。

六、总结与展望

回到开头的那张图。ClickHouse 的定位非常清晰,也很有边界:

  • 它擅长:PB 级明细的实时范围聚合、高吞吐持续写入、多团队共享的分析服务、用物化视图构建实时指标管线。
  • 它不擅长:高并发点查、强事务、频繁单行更新、当作应用的主数据库。硬把它当 MySQL 用,你会很痛苦。

所以 2026 年的务实架构往往是这样的分工:PostgreSQL 做 OLTP 与业务主数据,DuckDB 做本地/嵌入式即席分析,ClickHouse 做集中的、海量的、实时的分析底座。三者不是替代关系,而是各司其职。

最后给一句忠告:ClickHouse 的"快"不是玄学,它来自一套自洽的工程取舍——用顺序追加换写入、用稀疏索引换点查、用后台合并换实时计算、用列存+压缩换 IO。你顺着这套取舍去建模(排序键即索引、追加即更新、物化视图即流处理),它就能把 PB 级分析稳稳塞进一台服务器;你逆着它(逐行写、SELECT *、拿它当 OLTP),它就还你一堆坑。

工具永远在进化,但"理解它为什么这么设计"这件事,永远比"记住几个参数"值钱。点开 clickhouse-client,建一张表,灌一亿行,自己 EXPLAIN 一下查询计划——比读十篇科普都管用。

推荐文章

pycm:一个强大的混淆矩阵库
2024-11-18 16:17:54 +0800 CST
底部导航栏
2024-11-19 01:12:32 +0800 CST
Vue3中如何进行异步组件的加载?
2024-11-17 04:29:53 +0800 CST
解决python “No module named pip”
2024-11-18 11:49:18 +0800 CST
Vue3 中提供了哪些新的指令
2024-11-19 01:48:20 +0800 CST
程序员茄子在线接单