编程 ClickHouse 深度实战:从列式存储、MergeTree 稀疏索引到向量检索与物化投影的工程全解(2026)

2026-07-22 05:12:57 +0800 CST views 13

ClickHouse 深度实战:从列式存储、MergeTree 稀疏索引到向量检索与物化投影的工程全解(2026)

当你面对每天 200 亿条记录的查询、需要从 PostgreSQL 迁移来把平均延迟压到 250ms 以内,或者想用一套 SQL 同时解决实时分析与向量检索时,ClickHouse 几乎是那个「答案」。本文从工程师视角,把它的底层存储、稀疏索引、表引擎家族、分布式架构,到物化视图、投影、JSON 类型、向量检索,再到 Go/Python 实战与性能优化,一次性讲透。

一、背景介绍:为什么是 ClickHouse,而不是再写一条 Postgres 慢查询

1.1 OLTP 与 OLAP 的「物理鸿沟」

很多人第一次栽在 ClickHouse 上,不是因为它难,而是因为用错了场景。我们得先厘清一个基本事实:为事务设计的数据库,天生不适合做分析;为分析设计的数据库,天生不适合做事务。

关系型数据库(MySQL、PostgreSQL)采用行式存储(row-oriented):一行记录的所有字段在磁盘上紧挨着存放。这在「取一条订单的完整信息」时效率极高,因为一次磁盘 IO 就能拿到整行。但在分析场景里,典型的查询是:

SELECT toStartOfHour(event_time) AS hour, count()
FROM user_events
WHERE app_id = 1024 AND event_time >= now() - INTERVAL 7 DAY
GROUP BY hour;

这个查询只用到 event_timeapp_id 两列,却要扫描几十亿行。行式存储被迫把每行不相关的字段(用户名、头像 URL、设备信息……)一起读进内存,CPU 和 IO 大量浪费在「读了我根本用不上的数据」上。

ClickHouse 选择列式存储(columnar storage):同一列的数据在物理上连续存放。上面的查询只需要把 event_timeapp_id 两列的数据块读进内存,其余几十列完全不用碰。这一条,就让分析查询的 IO 量从「行数 × 行宽」骤降到「行数 × 用到的列数」。

1.2 Yandex 的「军火库」出身

ClickHouse 由俄罗斯搜索巨头 Yandex 为其网页分析产品 Metrica 研发,2016 年开源,C++ 编写。它的设计目标从一开始就非常纯粹:在海量数据上做极快的实时聚合查询。Metrica 是世界上第二大的网页分析平台,要处理 PB 级数据、每秒数百万事件写入、亚秒级交互式查询——这种「写多、读聚合、几乎不更新」的负载,恰好是 ClickHouse 的甜区。

对比一下同期的几个选择:

  • PostgreSQL:事务强、生态好,但行式存储 + B 树索引,在十亿级聚合查询上会迅速「上探」到全表扫描。
  • MySQL:类似,且分析函数(窗口函数、CTE)长期偏弱。
  • Elasticsearch:全文检索强,但聚合查询成本高、存储膨胀严重。
  • DuckDB(本站已有专文):进程内(in-process)OLAP,单机 embedded 场景无敌,但分布式与高并发服务化能力不是它的主战场。
  • ClickHouse:分布式、高吞吐写入、列存 + 向量化 + 稀疏索引,专为「大规模 + 实时 + 服务化」的分析而生。

一句话定位:DuckDB 是你笔记本里那个快到飞起的本地分析引擎;ClickHouse 是你把分析能力当成一项高并发在线服务对外提供时的最终形态。

1.3 2026 年的 ClickHouse 在往哪走

今天的 ClickHouse 早已不只是「快一点的 MySQL 替代品」。几个 2024–2026 年的关键演进让它的边界继续扩张:

  • 原生 JSON 数据类型(2024 引入,持续增强):不再需要把 JSON 拍平或塞进 String,半结构化数据有了「一等公民」。
  • 向量检索能力:内置 L2DistancecosineDistance 等距离函数,以及近似最近邻(ANN)索引,让 ClickHouse 能直接用 SQL 做 embedding 检索,RAG / 记忆系统不再必须外接向量库。
  • SharedMergeTree:云原生存算分离引擎,Serverless 弹性 + 共享对象存储,把运维复杂度进一步压低。
  • ClickStack:基于 ClickHouse 的开源可观测性技术栈,用一套引擎统一日志、指标、追踪。

这些让 ClickHouse 从「OLAP 数据库」逐渐变成「实时数据平台」。本文会逐步拆解它们。


二、核心概念:列式、向量化、稀疏索引

2.1 列式存储不只是「按列存」

列式的收益有三层,常被初学者忽略:

  1. IO 剪枝:只读用到的列(上一节已讲)。
  2. 压缩率暴增:同一列的数据类型相同、取值范围相近(比如全是状态码 0–3、全是城市名),压缩算法(LZ4、ZSTD)能压到行式的数倍甚至数十倍。压缩不仅省磁盘,更省 IO——读 1GB 压缩数据可能只需从磁盘读 200MB。
  3. 向量化执行友好:同一列连续存放,CPU 可以一次性把一整块(batch)同类型数据喂给 SIMD 指令并行处理,而不是逐行解释执行。

2.2 向量化执行引擎:不是「一行一行算」,而是「一块一块算」

传统解释型执行器对每一行调用一次函数,分支预测差、函数调用开销大。ClickHouse 把数据按 block(默认 65536 行左右的一块)组织在内存里,每个列是一个连续的、定宽的数组。聚合、过滤、表达式求值都针对整个数组批量执行,内部大量使用 SIMD 指令。

举个直觉例子,计算 sum(x * 2 + 1)

  • 解释执行:循环 N 次,每次 acc += x[i]*2 + 1,每次都有分支和函数调用。
  • 向量化执行:先对整个 x 列做 col * 2(一条 SIMD 指令处理 8/16/32 个元素),再做 + 1,最后一次向量求和。CPU 的乱序执行和流水线被喂得满满当当。

这也是为什么 ClickHouse 在 SELECT count(*)GROUP BY 这类聚合上能甩开传统数据库一个数量级:它把「逐行」的重活,变成了「逐块」的机器级并行。

2.3 稀疏主键索引:为什么 ClickHouse 不用 B 树

这是 ClickHouse 最反直觉、也最关键的设。PostgreSQL 的主键是 B 树,稠密索引——每一行都在索引里有一条记录,能精确定位到某一行。代价是索引本身巨大、写入要维护树结构、且不擅长「范围聚合」。

ClickHouse 的 MergeTree 用的是稀疏索引(sparse index):它不为每一行建索引,而是每 index_granularity(默认 8192)行记录一个「标记」(mark),指向这一数据颗粒(granule)在磁盘上的位置。查询时:

  1. primary.idx 上做二分查找,定位到可能包含目标键值的若干 granule;
  2. 只读这些 granule 对应的数据块;
  3. 在一个 granule 内部(8192 行)再做线性扫描。

为什么敢「稀疏」?因为分析查询本来就是扫大量行,少读几百个 granule 已经足够,精确到单行反而拖慢写入、膨胀索引。稀疏索引用极小的索引体积,换来了「范围裁剪」的高效——这是 ClickHouse 能在百亿数据上秒级响应的底层密码。

工程直觉:稠密索引(B 树)适合「点查单行」;稀疏索引(MergeTree)适合「范围聚合 + 跳过大量无关数据」。这再次印证了「场景决定架构」。


三、架构分析:MergeTree 是怎么「长」出来的

3.1 表引擎家族全景

ClickHouse 的「表引擎」决定了数据怎么存、怎么读、支持什么操作。核心是 MergeTree 家族:

引擎用途关键语义
MergeTree通用基线主键稀疏索引、分区、TTL、可加副本
ReplicatedMergeTree高可用在 MergeTree 之上加 ZK/Keeper 协调的副本同步
ReplacingMergeTree去重最新值ORDER BY + 版本列,合并时保留最新版本
SummingMergeTree预聚合求和合并时对数值列求和,适合指标累加
AggregatingMergeTree预聚合复杂指标配合 -State/-Merge 函数做任意聚合预计算
CollapsingMergeTree行级增删用 sign 列抵消旧行,实现「删除」
VersionedCollapsingMergeTree带版本增删解决并发写入的顺序问题
SharedMergeTree云原生存算分离,共享对象存储,Serverless 弹性

注意一个常见误解:ORDER BY 不等于 PRIMARY KEY。在 MergeTree 里,ORDER BY 决定了数据在磁盘上的物理排序(这是性能的根本),PRIMARY KEY 默认等于 ORDER BY 但可以取它的前缀,用来控制稀疏索引的列——当你想按 A、B、C 排序存,但只想用 A、B 建索引时能用上。

3.2 写入流程:从内存到 part 到 merge

理解「为什么 ClickHouse 写入飞快、删除/更新很别扭」,关键在这一节。

写入链路:

  1. 数据进入内存:INSERT 的数据先在内存里攒成列存格式。
  2. 落盘成 part:达到一定阈值(或 async_insert 攒批),一次性写入磁盘,形成一个 part(目录,如 202601_1_1_0)。
  3. 后台 merge:ClickHouse 的后台线程会把多个小 part 合并成更大的 part(类似 LSM-Tree 的 compaction)。所有「去重、聚合、替换」语义,都是在 merge 阶段发生的,而不是在写入时。

这条链路的启示:

  • 写入极快:因为只是追加式落盘 + 后台异步合并,没有 B 树的随机写。
  • 「UPDATE/DELETE 很贵」:ClickHouse 早期几乎不支持原地更新。所谓更新,往往是用 ReplacingMergeTree 插入一条新版本,等后台 merge 时把旧版本「折叠」掉——也就是说,你查到的可能是合并前的中间状态,除非用 FINAL 或特殊的投影。
  • FINAL 是双刃剑SELECT ... FROM t FINAL 会强制触发合并视图,保证看到去重结果,但代价是当场做合并,慢。生产上更推荐用物化视图把结果预先算好。

3.3 数据的物理结构(这部分值得背下来)

一个 part 目录里长这样:

20260101_0_0_0/
├── checksums.txt      # 校验和,保证数据完整性
├── columns.txt        # 列定义
├── count.txt          # 行数
├── primary.idx        # 稀疏主键索引(每 granule 一个标记值)
├── [column].bin       # 该列压缩后的二进制数据
├── [column].mrk2      # 该列的标记文件,把 primary.idx 的标记映射到 .bin 的偏移
└── partition.dat      # 分区信息(若需)

查询时:用 primary.idx 二分定位 granule → 通过 .mrk2 找到 .bin 中对应的压缩块偏移 → 解压 → 在 granule 内线性扫描。整个过程几乎零随机 IO,全是顺序读 + 解压 + 向量化计算。

3.4 分区裁剪:最容易拿到的免费性能

PARTITION BY 把数据按某个表达式(通常是时间 toYYYYMM(event_time))切成不同的目录。查询带上分区键条件时,ClickHouse 直接跳过无关分区目录——这叫分区裁剪(partition pruning)

-- 只扫 2026 年 1 月这一个分区目录
SELECT count() FROM events
WHERE event_date = '2026-01-15';

对时间序列数据,分区键几乎必选「时间」。但要注意:分区太细(如按天、数据量小)会制造海量小 part,反而拖慢 merge;分区太粗(如按年)又剪不掉多少。 经验值:单个分区原始数据量在「单分区几 GB 到几十 GB」比较舒服。

3.5 分布式架构:分片、副本与 Keeper

单机的 ClickHouse 再快也有上限。生产集群靠两样东西横向扩展:

  • 分片(shard):把数据水平拆分到不同节点,查询时并行扫所有分片再汇总。靠 Distributed 表引擎做路由——它本身不存数据,只是个「逻辑视图」,把请求转发到各分片的本地表。
  • 副本(replica):同一份数据存多份,防单机宕机。副本同步靠 ClickHouse Keeper(ZooKeeper 的 C++ 重写版,更轻、更稳),替代老式的 ZooKeeper 依赖。

典型部署:N 个 shard,每个 shard 有 2 个 replica,用 ReplicatedMergeTree 家族 + Distributed 表。写请求落到任意副本,Keeper 协调同步到其他副本;读请求经 Distributed 表扇出到所有 shard 并行执行。

SharedMergeTree(企业版/云)进一步把 part 存到共享对象存储(S3),计算节点无状态、可随时扩缩容,实现真正的 Serverless 弹性——这是 ClickHouse 对抗「存算耦合」运维痛点的答案。


四、代码实战:从建表到向量检索

理论够了,上代码。下面所有示例都是可直接落地的工程写法。

4.1 建一张「对」的表:ORDER BY 是灵魂

建表最容易犯的错,是随便抄一个 ORDER BY id。记住:ORDER BY 决定物理排序,直接决定哪些查询能命中稀疏索引。

CREATE TABLE events
(
    event_date Date,
    app_id      UInt32,
    user_id     UInt64,
    event_type  LowCardinality(String),   -- 低基数字符串用 LowCardinality,省空间提速度
    country     FixedString(2),           -- 固定长度(国家码)用 FixedString
    payload     String,
    duration_ms UInt32,
    event_time DateTime
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)         -- 时间分区,天然支持裁剪
ORDER BY (app_id, event_time, user_id)    -- 高频查询的过滤/排序列放前面
TTL event_time + INTERVAL 90 DAY           -- 90 天后自动清理,控制存储成本
SETTINGS index_granularity = 8192;

设计 ORDER BY 的口诀:把「等值过滤列」和「范围/排序列」按查询频率从左到右排。比如上面 (app_id, event_time, user_id):先按 app 等值过滤,再按时间范围,最后用 user 细化。这样绝大多数查询都能顺着稀疏索引跳过大量 granule。

4.2 写入:异步插入是生产必修课

直接 INSERT INTO ... VALUES 逐行写,ClickHouse 会被海量小 part 淹没。生产正确姿势是攒批 + 异步插入

-- 服务端攒批:客户端只管发,ClickHouse 在后台按条数/时间合并成 part
SET async_insert = 1;
SET wait_for_async_insert = 1;   -- 1=等确认落盘;压测时可设 0 追求吞吐
SET async_insert_max_data_size = '1M';
SET async_insert_busy_timeout_ms = 1000;

INSERT INTO events VALUES
  ('2026-01-15', 1024, 88123, 'click', 'CN', '{"x":1}', 12, '2026-01-15 10:00:00'),
  ('2026-01-15', 1024, 88124, 'view',  'CN', '{"x":2}', 8,  '2026-01-15 10:00:01');

客户端侧也要攒批(比如每 1000 行或每 200ms 发一次),双管齐下。

4.3 查询实战:聚合、窗口函数与 JOIN 避坑

-- 漏斗式留存:每小时事件量(向量化聚合,飞快)
SELECT
    toStartOfHour(event_time) AS hour,
    count()                    AS events,
    uniqExact(user_id)         AS uv,
    quantile(0.95)(duration_ms) AS p95_ms
FROM events
WHERE app_id = 1024
  AND event_time >= '2026-01-15 00:00:00'
GROUP BY hour
ORDER BY hour;

-- 窗口函数:每个 app 内按时间排的事件序号
SELECT
    app_id, user_id, event_time,
    row_number() OVER (PARTITION BY app_id ORDER BY event_time) AS seq
FROM events
WHERE event_date = '2026-01-15'
LIMIT 10;

JOIN 的「隐秘陷阱」(很多人集群崩在这里):ClickHouse 默认把右表全量拉到各分片内存做 hash join。如果右表是「百亿轨迹表 JOIN 千万维表」,内存直接炸。正确姿势:

  1. 小表在右:把维表放右,事实表放左。
  2. join_algorithm = 'grace_hash':Grace Hash Join 把数据分桶落盘,避免单点内存爆炸。
  3. 用字典(Dictionary)替代维表 JOIN:维度数据(国家码→名称)注册成 Dictionary,查询时 dictGet 直接查,零 JOIN 开销。
-- 用字典替代 JOIN,性能与稳定性双提升
SELECT
    e.user_id,
    dictGetString('countries', 'name', e.country_code) AS country_name
FROM events e
WHERE event_date = '2026-01-15';

4.4 物化视图:把「实时预聚合」当主菜

物化视图(Materialized View)在 ClickHouse 里不是「视图」——它是一张真实存在的表,源表 INSERT 时,MV 自动把数据按定义再算一份存下来。最适合做「高频聚合查询加速」。

-- 原始明细表
CREATE TABLE hits
(
    event_date Date,
    app_id UInt32,
    user_id UInt64,
    duration_ms UInt32
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (app_id, event_date);

-- 物化视图:按 (app_id, 天) 预聚合 PV / 平均时长
CREATE MATERIALIZED VIEW hits_daily_mv
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (app_id, day)
AS SELECT
    app_id,
    toDate(event_time) AS day,
    count()            AS pv,
    sum(duration_ms)   AS total_ms
FROM hits
GROUP BY app_id, day;

写入 hits 时,hits_daily_mv 同步更新。SummingMergeTree 会在后台 merge 时把相同 (app_id, day) 的行合并求和。查询直接读 MV,毫秒级:

SELECT app_id, day, pv, total_ms / pv AS avg_ms
FROM hits_daily_mv
WHERE day = '2026-01-15';

注意:官方不推荐建 MV 时加 POPULATE(会把历史数据一次性灌入,但灌入期间的新写入会丢)。正确做法是无 POPULATE 建空 MV,再用 INSERT INTO mv SELECT ... FROM source 手动回填历史。

4.5 投影(Projection):应用无感知的「第二种排序」

MergeHouse 的 ORDER BY 只有一种物理排序,但查询模式可能多样。物化视图要额外存一份数据;而 Projection(投影) 是「挂」在原始表上的另一种预排序/预聚合,查询时 ClickHouse 自动判断是否命中投影,对应用完全透明

ALTER TABLE events
ADD PROJECTION p_user
(
    SELECT *
    ORDER BY (user_id, event_time)   -- 针对「按用户查历史」的查询模式
);

-- 触发投影物化(对存量数据)
ALTER TABLE events MATERIALIZE PROJECTION p_user;

-- 这个查询会自动走 p_user 投影,而不是全表扫
SELECT * FROM events WHERE user_id = 88123 ORDER BY event_time DESC LIMIT 20;

投影相比物化视图的优势:不重复建表、不占额外命名空间、查询优化器自动路由。代价是写入时要多算一份。高基维度上的「另一种排序」场景,投影是首选。

4.6 原生 JSON 类型:半结构化数据的一等公民

过去要在 ClickHouse 存 JSON,要么 String 存了再 JSONExtract,要么用 Nested 拍平——都别扭。2024 起官方 JSON 类型让 JSON 成为原生列:

CREATE TABLE logs
(
    ts   DateTime,
    app  String,
    body JSON
)
ENGINE = MergeTree
ORDER BY (app, ts);

INSERT INTO logs VALUES
  ('2026-01-15 10:00:00', 'web',
   '{"user":{"id":1,"name":"alice"},"tags":["vip","cn"],"amount":99.5}');

-- 直接按 JSON 路径查询,类型自动推断
SELECT
    body.user.name,
    body.amount,
    body.tags[1]
FROM logs
WHERE body.user.id = 1;

ClickHouse 会为 JSON 里出现过的路径自动创建隐藏的子列(列式存储),既保留灵活性,又不丢失列存性能。对日志、埋点、IoT 这种 schema 漂移频繁的场景,体验拉满。

4.7 向量检索:用 SQL 做 embedding 搜索(RAG / 记忆系统)

这是 2024–2026 年最让人兴奋的能力。ClickHouse 内置距离函数 + ANN 索引,可以直接当向量库用:

CREATE TABLE memories
(
    id        UInt64,
    user_id   UInt64,
    embedding Array(Float32),        -- 向量
    content   String,
    created   DateTime
)
ENGINE = MergeTree
ORDER BY id
-- ANN 索引:用 HNSW 做近似最近邻,distance 函数指定度量
SETTINGS index_granularity = 8192;

-- 写入时用向量(这里用占位,实际来自 embedding 模型)
INSERT INTO memories VALUES
  (1, 88123, [0.1,0.2,0.3, ...], '用户喜欢 Rust', now()),
  (2, 88123, [0.3,0.1,0.4, ...], '关注分布式系统', now());

-- 语义检索:找与给定向量最相似的 top-5(L2 距离)
SELECT
    id, content,
    L2Distance(embedding, [0.12,0.18,0.33, ...]) AS dist
FROM memories
WHERE user_id = 88123
ORDER BY dist
LIMIT 5;

配合 cosineDistance(余弦相似度)与 ANN 索引,ClickHouse 能在数十亿向量上做低延迟语义检索。这意味着 RAG 应用的「知识库 + 记忆」可以不必再外接一个独立的向量数据库——一套 ClickHouse 同时搞定结构化分析、日志与向量检索,架构大幅简化,成本也更好控制。

4.8 Go 客户端实战(clickhouse-go)

服务端用 Go 的常见,官方 clickhouse-go 驱动很好用:

package main

import (
    "context"
    "fmt"
    "time"

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

func main() {
    conn, err := clickhouse.Open(&clickhouse.Options{
        Addr: []string{"clickhouse:9000"},
        Auth: clickhouse.Auth{
            Database: "default",
            Username: "default",
            Password: "",
        },
        // 压缩:减少网络传输
        Compression: &clickhouse.Compression{
            Method: clickhouse.CompressionLZ4,
        },
    })
    if err != nil {
        panic(err)
    }
    ctx := context.Background()

    // 批量写入:用 Batch 而不是逐条 Exec
    batch, err := conn.PrepareBatch(ctx, "INSERT INTO events")
    if err != nil {
        panic(err)
    }
    for i := 0; i < 1000; i++ {
        err := batch.Append(
            time.Now(),            // event_date
            uint32(1024),          // app_id
            uint64(i),             // user_id
            "click",               // event_type
            "CN",                  // country
            "{}",                  // payload
            uint32(10),            // duration_ms
            time.Now(),            // event_time
        )
        if err != nil {
            panic(err)
        }
    }
    if err := batch.Send(); err != nil { // 一次网络发送整批
        panic(err)
    }

    // 查询
    var (
        hour  time.Time
        events uint64
    )
    rows := conn.QueryRow(ctx,
        "SELECT toStartOfHour(event_time), count() FROM events WHERE app_id=? GROUP BY 1",
        uint32(1024))
    for rows.Next() {
        if err := rows.Scan(&hour, &events); err != nil {
            panic(err)
        }
        fmt.Printf("%s -> %d events\n", hour, events)
    }
}

要点:PrepareBatch + Append + Send批量写入的标准姿势,比逐条 Exec 快一个量级;开启 CompressionLZ4 省带宽。

4.9 Python 客户端实战(clickhouse-connect)

数据分析/后端用 Python 的,clickhouse-connect 是官方推荐:

import clickhouse_connect

client = clickhouse_connect.get_client(
    host='clickhouse', port=8123,
    username='default', password='', database='default',
)

# 批量插入(用列字典,client 自动攒批)
rows = [
    {"event_date": "2026-01-15", "app_id": 1024, "user_id": 1, "duration_ms": 12},
    {"event_date": "2026-01-15", "app_id": 1024, "user_id": 2, "duration_ms": 8},
]
client.insert("events", rows)

# 查询
result = client.query(
    "SELECT app_id, count() AS c FROM events GROUP BY app_id"
)
print(result.result_rows)        # [[1024, 2]]
print(result.column_names)       # ['app_id', 'c']

# 直接读成 pandas / polars 做后续分析
df = client.query_df("SELECT * FROM events LIMIT 1000")

clickhouse-connect 默认用 HTTP 接口(8123),对云环境和代理友好;query_df 直接对接 pandas,数据科学链路丝滑。


五、性能优化:把 ClickHouse 真正「调」出来

5.1 第一性原则:ORDER BY 设计好,性能就赢了 80%

重申:ORDER BY 是 ClickHouse 性能的总开关。所有优化里,先把高频查询的过滤/排序/分组列排进 ORDER BY 前缀。一个糟糕的 ORDER BY (id) 会让几乎所有分析查询退化成全表扫;一个贴合查询模式的 ORDER BY (app_id, event_time, user_id) 能让稀疏索引大显身手。

5.2 数据类型:能小则小,能定则定

  • 整数用最小的:状态码用 UInt8(0–255)而不是 Int64,列存体积和向量化速度都受益。
  • 低基数用 LowCardinality(String):性别、国家、事件类型这种取值有限的字符串,包一层 LowCardinality,ClickHouse 会用字典编码,省空间、提比较速度。
  • 固定长度用 FixedString(N):国家码 FixedString(2)、MD5 FixedString(32)String 更高效。
  • 避免 NullableNullable 列实际存成「值 + 空标记」两列,且无法进某些索引优化。能用默认值(如 0'')代替就用默认值。
  • DateTime64 指定精度:不需要微秒就别用 DateTime64(6),精度越高存储越贵。

5.3 分区与 TTL:控制「数据的体积」

  • 分区键用时间,粒度让单分区落在 GB 级。
  • TTL 自动清理冷数据,避免存储无限膨胀。也可以做 TTL 移动到冷存储(对象存储):
    TTL event_time + INTERVAL 30 DAY TO VOLUME 'cold',
        event_time + INTERVAL 90 DAY DELETE;
    

5.4 异步插入与攒批:写入吞吐的命门

前面 4.2 讲过。async_insert + 客户端攒批,是扛住高写入吞吐的基础。压测时 wait_for_async_insert=0 能进一步提吞吐,但要接受「极端情况下可能丢一点」的代价。

5.5 物化视图 / 投影:用空间换时间

高频聚合查询(dashboard、监控指标)一定要预计算。规则:

  • 需要对外暴露成独立表、或聚合逻辑复杂 → 物化视图(SummingMergeTree / AggregatingMergeTree)。
  • 只是想给原表加「另一种排序/预聚合」且不想多建表 → 投影。
  • 维度 JOIN → 字典(dictGet),别用大表 JOIN。

5.6 关键 Settings 调优

几个最能影响查询体验的 session 级设置:

SET max_threads = 16;             -- 并行线程数,通常等于 CPU 核数
SET max_memory_usage = '32G';     -- 单查询内存上限,防 OOM 拖垮集群
SET use_uncompressed_cache = 1;   -- 热数据解压后缓存,重复扫同列时加速
SET max_insert_block_size = 1048576; -- 写入块大小
SET join_algorithm = 'grace_hash';   -- 大表 JOIN 防内存爆炸
SET optimize_read_in_order = 1;   -- 按 ORDER BY 顺序读,跳过无关 granule

system.settings 里可查全部参数;生产上建议把这些写进 users.xml 或按需 per-query 设。

5.7 查询诊断:别靠猜,看 plan 和日志

-- 看执行计划,确认是否走了索引/分区裁剪
EXPLAIN PLAN SELECT count() FROM events
WHERE app_id = 1024 AND event_time >= '2026-01-15';

-- 看实际读了多少 granule、用了哪些索引
EXPLAIN indexes = 1 SELECT ...;

-- 慢查询分析:从系统表里捞真实执行数据
SELECT
    query, query_duration_ms, read_rows, memory_usage
FROM system.query_log
WHERE type = 'QueryFinish'
  AND query_duration_ms > 1000
ORDER BY query_duration_ms DESC
LIMIT 20;

system.query_logsystem.mergessystem.parts 是排障三件套:query_log 看慢查询,merges 看后台合并是否堆积,parts 看 part 数量是否过多(过多说明写入太碎,要调 async_insertparts_to_throw_insert)。

5.8 常见坑位清单

  1. SELECT * 在大宽表上极慢:列存优势是「只读要的列」,你全选就废了。
  2. Nullable 列进不了部分优化:见 5.2。
  3. 大表 JOIN 右表是事实表:内存炸,改字典或 grace_hash
  4. 频繁小 INSERT:part 爆炸,务必攒批 + 异步。
  5. 滥用 FINAL:实时去重要用物化视图预先算,别在线上查 FINAL
  6. 分区过细:按天分区但每天数据只有几 MB,part 成千上万,merge 跟不上。

六、总结与展望:ClickHouse 的边界与未来

6.1 它擅长什么,不擅长什么

先用一句话收口:

  • 擅长:海量数据的实时聚合分析、高吞吐写入、时序/日志/埋点/指标、服务化在线分析、以及(现在)向量检索与半结构化 JSON。
  • 不擅长:频繁单行 UPDATE/DELETE、强事务(跨行 ACID)、点查单行、作为业务主库。

它和本站前文讲过的 DuckDB(进程内 OLAP)、PostgreSQL 18(事务 + 新异步 IO)、Valkey(内存 KV 缓存)是互补关系,不是替代。一个现代数据栈的画像往往是:PostgreSQL 扛事务、Valkey 扛缓存、ClickHouse 扛分析、DuckDB 在本地做即席探索。

6.2 2026 的三个确定性方向

  1. 分析 + AI 融合:向量检索原生支持,让 ClickHouse 直接进入 RAG / 智能体记忆系统的技术栈,少一个外部依赖、少一份同步一致性负担。
  2. 可观测性一体化:ClickStack 用一套引擎统一 logs/metrics/traces,按资源(而非按事件数/主机数)计费,正在重塑可观测性的成本模型——这也是本站读者做监控架构时值得重点关注的范式。
  3. 云原生存算分离:SharedMergeTree + 对象存储,让「弹性」从口号变成默认形态,中小企业也能用上 PB 级分析而不必养一支专职运维。

6.3 给工程师的上手建议

如果你今天想认真把 ClickHouse 用起来,我的路径建议是:

  1. 先用 Docker 起一个单机实例,按本文 4.1 建表,灌一批自己的数据(哪怕几百万行),跑 4.3 的聚合查询,感受「秒级」是什么体验。
  2. 刻意练习 ORDER BY 设计:故意设计一个糟糕的 ORDER BY,再用 EXPLAIN indexes=1 看扫描了多少 granule,对比优化后的差异——这一课比看十篇文档都管用。
  3. 上手物化视图和投影:把你的高频 dashboard 查询改写成 MV,体会毫秒级返回的爽感。
  4. 再上集群:理解了 part / merge / Keeper 之后,再碰分片副本,才不会在半夜被「part 堆积」「ZK 抖动」叫醒。

ClickHouse 不是银弹,但它是「当分析变成一项高并发在线服务」时,工程师手里最锋利的那把刀。理解它「为什么是列存、为什么是稀疏索引、为什么写入快更新慢、为什么 ORDER BY 是灵魂」,你就不再是一个「会写 SQL 的人」,而是一个真正懂数据库物理代价的人——而这,恰恰是区分「会用工具」和「懂系统」的分水岭。


参考资料与延伸阅读:ClickHouse 官方文档(clickhouse.com/docs)、阿里云 ClickHouse 企业版 SharedMergeTree 技术解析、ClickHouse Inc. 关于 JSON 类型与向量检索的官方博客、Momentic 从 PostgreSQL 迁移到 ClickHouse 的工程复盘(2026-07)。本文所有 SQL/Go/Python 示例均经过工程化简化,可直接作为生产模板的起点。

推荐文章

MySQL用命令行复制表的方法
2024-11-17 05:03:46 +0800 CST
Vue3中如何处理异步操作?
2024-11-19 04:06:07 +0800 CST
程序员茄子在线接单