编程 ClickHouse 深度实战:当列式存储成为实时分析引擎——从 MergeTree 引擎家族、向量化执行到分布式查询与生产级落地的完整工程指南(2026)

2026-07-20 07:43:40 +0800 CST views 28

ClickHouse 深度实战:当列式存储成为实时分析引擎——从 MergeTree 引擎家族、向量化执行到分布式查询与生产级落地的完整工程指南(2026)

2026 年,OLAP 战场已经分化成两条清晰的路线:一条是 DuckDB 代表的「进程内分析引擎」,一条是 ClickHouse 代表的「分布式实时分析数据库」。前者把数据库塞进一个进程,后者把数据库变成一套能在 PB 级数据上做秒级响应的分析底座。本文不堆参数、不抄文档,而是从一个工程师的视角,把 ClickHouse 的「为什么快」「怎么用对」「如何调优」讲透,并配上能直接跑起来的代码。

一、背景:为什么 2026 年还要认真聊 ClickHouse?

很多人第一次听到 ClickHouse,是在「Yandex 开源的列式数据库」这种介绍里。但到了 2026 年,ClickHouse 已经不是「又一个 OLAP 数据库」了——它背后站着一整套实时可观测性(ClickStack)、云数仓、以及大量互联网公司的日志/指标/事件分析系统。

先说结论:ClickHouse 解决的核心矛盾,是「数据量极大」与「查询要快且要便宜」之间的冲突。

我们做个对比,更直观:

维度PostgreSQLDuckDBClickHouse
定位通用 OLTP/OLAP 混合进程内 OLAP分布式实时 OLAP
存储模型行存为主,可加列存索引列存(单进程)列存(可分布式)
写入模型事务、行级更新追加/批量批量追加、后台合并
典型规模TB 级以内舒服单机内存/磁盘PB 级
实时更新弱(靠合并去重)
典型场景业务库、中小分析本地数据分析、笔记本日志、指标、事件、用户行为

关键点在于:ClickHouse 牺牲了「行级实时更新」和「事务一致性」,换来了「极致的扫描吞吐」和「极低的存储成本」。 它不追求像 PostgreSQL 那样每行可改,而是假设你的数据主要是「不断追加的事件流」,然后通过后台异步合并(Merge)来收敛数据。这个设计取舍,决定了它适合什么、不适合什么。

到 2026 年,ClickHouse 已经迭代到 v26.x(26.3 是 LTS,26.6 是最新稳定版)。v25 到 v26 这一代最大的变化,是它在「易用性」上补齐了大量短板:轻量删除/更新(mutations)成熟、倒排索引支持文本检索、JSON 成为一等公民、物化投影(Projection)普遍可用、以及对 Iceberg / Delta / Unity Catalog 等开放表格式的直接查询支持。换句话说,它正在从「极客的性能怪兽」变成「能直接当实时数仓用的生产系统」。

二、核心概念:列式存储与 MergeTree 引擎家族

2.1 列式存储到底改变了什么?

传统行存数据库(MySQL、PostgreSQL)按行把一整条记录写在一起。当你要 SELECT sum(amount) FROM orders 时,它得把每条订单的整行都读出来,再摘出 amount 字段——大量 IO 被浪费在无关字段上。

ClickHouse 按列存储:同一列的数据物理上挨在一起。算 sum(amount) 时,它只读取 amount 这一列的数据块,IO 量直接缩小到「查询涉及的列 / 全部列」。这就是 OLAP 场景下降本增效的根本来源。

但列存只是第一步,真正让 ClickHouse 起飞的是两件配套设计:

  1. 向量化执行(Vectorized Execution):不再是「一次算一行」,而是「一次对一个数据块(Block,通常 8192 行)做批量运算」。CPU 分支预测、函数调用开销被摊薄,还能用 SIMD 指令加速。
  2. 高压缩比:同一列数据类型相同、取值范围相近,压缩率远高于行存(常见 5~10 倍)。压缩不仅省磁盘,更省 IO——而 IO 往往是分析查询的瓶颈。

2.2 主键不是「唯一约束」,而是「稀疏索引」

这是 ClickHouse 最容易踩的认知坑:它的 PRIMARY KEY 不保证唯一,也不做去重,它是一棵「稀疏索引」(sparse index)。

工作原理:

  • 数据按 ORDER BY 排序后,每 index_granularity(默认 8192)行取一个「标记」(主键列的值 + 该 granule 在磁盘上的偏移)。
  • 查询时,ClickHouse 用主键索引快速定位到可能包含目标数据的 granule 区间,跳过其余 99% 的数据块。
  • 因为索引是「每 8192 行一个标记」,所以索引本身极小,可以全放内存。
-- 注意:PRIMARY KEY 默认等于 ORDER BY。
-- 主键是稀疏索引(用于跳过数据块),ORDER BY 决定物理排序。
CREATE TABLE events
(
    event_date Date,
    user_id    UInt64,
    event_type String,
    amount     Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)          -- 按月分区
ORDER BY (user_id, event_date, event_type) -- 物理排序键
PRIMARY KEY user_id                         -- 主键可以是 ORDER BY 的前缀
SETTINGS index_granularity = 8192;

设计建议:ORDER BY 要选「查询里最常用来过滤 + 高基数的列」。比如用户行为分析里 user_id 高基数、常被范围过滤,放前面能最大化索引跳过效果。PRIMARY KEY 通常设成 ORDER BY 的前缀,用于更粗粒度的裁剪。

2.3 MergeTree 引擎家族:一套底座,多种合并策略

MergeTree 是 ClickHouse 所有表的基石。它在后台做的事情很直白:不断把小数据 parts 合并成更大的 parts,并在合并时应用特定规则。不同引擎的区别,就在于「合并时应用什么规则」。

(1)MergeTree —— 最朴素的追加表

就是上面的例子。写入快、扫描快,但相同主键不会自动去重。如果你重复插入相同数据,查出来就是两份。适合纯追加的事件流。

(2)ReplacingMergeTree —— 合并时按版本去重

CREATE TABLE user_profile
(
    user_id   UInt64,
    name      String,
    updated_at DateTime,
    is_deleted UInt8 DEFAULT 0
)
ENGINE = ReplacingMergeTree(updated_at)   -- 合并时保留 updated_at 最大的一行
ORDER BY user_id;

合并时,相同 ORDER BY(这里是 user_id)的多行只保留 updated_at 最大的那条。注意两个坑:

  • 去重只在合并发生时生效,不是写入即生效。新插入的重复行在合并前查得到。
  • 要立刻看到去重结果,用 SELECT ... FROM user_profile FINAL;,但 FINAL 会强制现场合并,代价高,生产查询别滥用。正确做法是非热点查询依赖后台合并,或定期 OPTIMIZE TABLE user_profile FINAL(在低峰做)。

(3)SummingMergeTree —— 合并时预聚合求和

CREATE TABLE daily_sales
(
    date    Date,
    region  String,
    orders  UInt64,
    revenue Float64
)
ENGINE = SummingMergeTree
ORDER BY (date, region);

相同 (date, region) 的行在合并时被「加」成一行:orders 求和、revenue 求和,非聚合列取遇到的第一行。这对实时指标汇总极友好——写入明细,查询直接读聚合结果,快得离谱。

(4)AggregatingMergeTree —— 存储「聚合中间态」

这是物化视图实时聚合的核心。它存的不是最终值,而是聚合函数的中间状态(比如计算 uniqStatesumState),查询时再用 *-Merge 函数收口。

CREATE TABLE uv_states
(
    date   Date,
    page   String,
    uv     AggregateFunction(uniq, UInt64)   -- 注意类型声明
)
ENGINE = AggregatingMergeTree
ORDER BY (date, page);

-- 写入时用 -State 版本,把中间态存进去
INSERT INTO uv_states
SELECT date, page, uniqState(user_id)
FROM raw_clicks
GROUP BY date, page;

-- 查询时用 -Merge 收口
SELECT date, page, uniqMerge(uv) AS uv_count
FROM uv_states
GROUP BY date, page;

uniqState 存的是 HyperLogLog 的草图,合并时再算近似去重。这是 ClickHouse 能在亿级数据上秒出 UV 的秘诀。

(5)CollapsingMergeTree / VersionedCollapsingMergeTree —— 行级「正负抵消」

+1 / -1 的 sign 列表示「新增 / 撤销」,合并时相互抵消,实现类似「更新」的语义。比 ReplacingMergeTree 更细,适合需要精确表达「这一行被改/被删」的场景,但写入端要自己发成对的正负行,工程复杂度高。

选型清单(背下来):

  • 纯事件流、不怕重复 → MergeTree
  • 需要「按最新版本去重」→ ReplacingMergeTree
  • 需要「明细自动累加成指标」→ SummingMergeTree
  • 需要「任意聚合的实时物化」→ AggregatingMergeTree + 物化视图
  • 需要「精确增删改表达」→ CollapsingMergeTree

三、架构解析:向量化执行引擎与分布式骨架

3.1 向量化执行:Block、IColumn 与「少调用函数」

ClickHouse 的执行引擎核心数据结构是 Block(一组同长度的 Column)和 IColumn(某一列的连续内存)。查询被拆成一串 Processor(处理器),数据以 Block 为单位在 Processor 之间流动:

Source(读列) → Expression(算表达式) → Filter(过滤) → Aggregator(聚合) → Sink(输出)

每个 Processor 一次处理一整个 Block(8192 行),而不是一行。好处:

  • 函数调用次数从「行数」降到「Block 数」(除以 8192)。
  • 内部用模板 + 编译期分派替代运行时虚函数,配合 SIMD 批量算。
  • 列数据是连续内存,CPU cache 命中率高。

你可以用 EXPLAIN PIPELINE 直接看一条查询被拆成了哪些 Processor:

EXPLAIN PIPELINE
SELECT region, sum(revenue) FROM daily_sales GROUP BY region;

输出会是一棵处理器树,你能清楚看到 AggregatingTransform、ResizingTransform 等在怎么协作。做性能分析时,这比看执行计划更贴近真实执行。

3.2 ClickHouse Keeper:ZooKeeper 的 Raft 替代

老版本 ClickHouse 用 ZooKeeper 做副本协调(记录 parts、DDL 锁)。ZooKeeper 是个外部依赖,运维痛。新版本内置 ClickHouse Keeper——一个用 C++ 实现、基于 Raft 共识、协议兼容 ZooKeeper 的组件。部署 3 节点 Keeper 即可替代外部 ZK,副本元数据的可靠性不变,运维却简单一个量级。

3.3 分布式:分片 + 副本 + Distributed 表

ClickHouse 的分布式不是「自动分库分表」,而是「手动声明、显式路由」:

  • 分片(Shard):数据按分片键切到不同节点,负责水平扩展容量和吞吐。
  • 副本(Replica):同一分片的多份拷贝,负责高可用。靠 ReplicatedMergeTree + Keeper 同步。
  • Distributed 表:一张「逻辑表」,本身不存数据,只把查询转发到各分片并把结果合并回来。
<!-- 集群定义在 config 里(简化) -->
<remote_servers>
  <ch_cluster>
    <shard>
      <replica><host>ch-node-1</host></replica>
      <replica><host>ch-node-2</host></replica>
    </shard>
    <shard>
      <replica><host>ch-node-3</host></replica>
      <replica><host>ch-node-4</host></replica>
    </shard>
  </ch_cluster>
</remote_servers>
-- 本地表(每个节点都有一份 ReplicatedMergeTree)
CREATE TABLE events_local ON CLUSTER ch_cluster
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date);

-- 分布式逻辑表,查询打它就自动路由到各分片
CREATE TABLE events AS events_local
ENGINE = Distributed(ch_cluster, default, events_local, rand());

写入 events(分布式表)时,ClickHouse 按分片键(这里 rand() 随机)把数据落到对应分片;查询 events 时,它把 SQL 下推到各分片并行执行,再在发起节点做最终合并。这就是 MPP(大规模并行处理)的朴素实现。

四、代码实战:从零搭一套能跑的生产级 ClickHouse

4.1 Docker 一键起(含 Keeper)

# docker-compose.yml
services:
  keeper:
    image: clickhouse/clickhouse-keeper:25.8
    ports: ["9181:9181"]
    command: ["/etc/clickhouse-keeper/keeper.xml"]
  clickhouse:
    image: clickhouse/clickhouse-server:25.8
    ports: ["8123:8123", "9000:9000"]
    environment:
      CLICKHOUSE_PASSWORD: "secret"
    volumes: ["./config.xml:/etc/clickhouse-server/config.d/cluster.xml:ro"]

启动后用 HTTP 接口就能查:

# 健康检查
curl 'http://localhost:8123/?user=default&password=secret' --data 'SELECT version()'

# 批量插入(TSV)
echo -e "1\talice\t2026-07-01\n2\tbob\t2026-07-02" | \
  curl 'http://localhost:8123/?user=default&password=secret' \
      --data 'INSERT INTO events FORMAT TSV'

4.2 实战:用户行为实时漏斗

假设我们有一张原始点击表,要实时算出「每天每页的 UV 和 PV」。用 AggregatingMergeTree + 物化视图:

-- 原始明细
CREATE TABLE raw_clicks
(
    event_date Date DEFAULT today(),
    user_id    UInt64,
    page       String,
    ts         DateTime DEFAULT now()
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (page, ts);

-- 物化视图:插入 raw_clicks 时自动聚合进 uv_states
CREATE MATERIALIZED VIEW mv_daily_uv
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (day, page)
AS
SELECT
    toDate(ts) AS day,
    page,
    uniqState(user_id) AS uv,
    countState()       AS pv
FROM raw_clicks
GROUP BY day, page;

-- 业务写入明细,视图自动物化
INSERT INTO raw_clicks (user_id, page) VALUES
  (1, '/home'), (1, '/home'), (2, '/home'), (3, '/product');

-- 查询实时指标(秒回)
SELECT
    day, page,
    uniqMerge(uv) AS uv,
    countMerge(pv) AS pv
FROM mv_daily_uv
GROUP BY day, page
ORDER BY pv DESC;

关键认知:ClickHouse 的物化视图不是「查询时的视图」,而是「写入时的触发器」。 数据进 raw_clicks 的瞬间,就会被增量聚合写入目标表,之后查询只读已算好的聚合结果——这是实时看板的性能根基。

4.3 Python 客户端(clickhouse-connect)

官方推荐的现代 Python 驱动是 clickhouse-connect(比老 clickhouse-driver 更快、更省内存):

import clickhouse_connect

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

# 建表
client.command("""
CREATE TABLE IF NOT EXISTS events_py
(
    event_date Date,
    user_id UInt64,
    event_type String,
    amount Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date)
""")

# 批量插入(用列格式,避免逐行 INSERT)
rows = 1_000_000
client.insert(
    'events_py',
    [
        [20260701 + (i % 30) for i in range(rows)],   # event_date
        [i % 500_000 for i in range(rows)],           # user_id
        [f"type_{i % 5}" for i in range(rows)],       # event_type
        [float(i % 100) for i in range(rows)],        # amount
    ],
    column_names=['event_date', 'user_id', 'event_type', 'amount']
)

# 查询(返回 Arrow Table,零拷贝给 pandas/分析栈)
result = client.query("SELECT event_type, sum(amount) FROM events_py GROUP BY event_type")
print(result.result_rows)

clickhouse-connect 默认走 HTTP,返回 Apache Arrow 格式,能零拷贝喂给 Pandas/Polars/DuckDB,整个 Python 分析链路打通。

4.4 Go 客户端(clickhouse-go)

Go 服务里高频写 ClickHouse,用 clickhouse-go(原生 TCP 协议,支持批量):

package main

import (
    "context"
    "time"

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

func main() {
    conn, err := clickhouse.Open(&clickhouse.Options{
        Addr: []string{"localhost:9000"},
        Auth: clickhouse.Auth{Database: "default", Username: "default", Password: "secret"},
    })
    if err != nil { panic(err) }
    defer conn.Close()

    ctx := context.Background()
    batch, err := conn.PrepareBatch(ctx, "INSERT INTO events_py")
    if err != nil { panic(err) }

    // 用 Append 攒一个 batch 再一次性发,吞吐远高于逐条 INSERT
    for i := 0; i < 100_000; i++ {
        if err := batch.Append(
            time.Now(),           // event_date
            uint64(i),            // user_id
            "type_0",             // event_type
            float64(i%100),       // amount
        ); err != nil { panic(err) }
    }
    if err := batch.Send(); err != nil { panic(err) }
}

要点:永远批量写(batch),别在循环里逐条 INSERT。逐条 INSERT 每次都要建连接、建 parts,吞吐能差两个数量级。ClickHouse 的设计哲学就是「攒一批、一次落」。

五、性能优化:索引、分区与查询调优

5.1 跳数索引(二级索引):主键之外的第二把刀

主键索引只能按 ORDER BY 前缀裁剪。如果你的过滤条件不在主键前缀上(比如按 event_type 过滤),主键就帮不上忙。这时用跳数索引(skip index)

CREATE TABLE logs
(
    ts         DateTime,
    level      LowCardinality(String),
    service    String,
    trace_id   String,
    msg        String,
    INDEX idx_service service TYPE bloom_filter(0.01) GRANULARITY 4,
    INDEX idx_trace  trace_id TYPE tokenbf_v1(1024, 2, 0) GRANULARITY 4,
    INDEX idx_level   level    TYPE set(100) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (ts, service);

四种常用类型:

  • minmax:记录 granule 内列的最大最小值,范围查询高效。
  • set(N):记录 granule 内出现的去重值集合,等值过滤高效。
  • bloom_filter(p):布隆过滤器,适合高基数列的等值/IN 查询。
  • tokenbf_v1 / ngrambf_v1:分词/ngram 布隆,适合 LIKE 和文本包含查询。

GRANULARITY 4 表示「每 4 个 granule(8192×4 行)建一个索引项」。索引会占用存储和写入开销,只在「高频且无法靠主键裁剪的过滤列」上加。

5.2 分区策略:按月,别按天也别按年

PARTITION BY toYYYYMM(date) 是经验最优解:

  • 按月分区,单分区数据量适中,合并效率好。
  • 按天分区会让 parts 数量爆炸,合并压力大、元数据膨胀。
  • 按年分区会让单分区过大,删除/迁移一个分区代价太高。

分区还带来一个隐藏能力:分区级 TTL 和 DROP PARTITION。冷数据直接 ALTER TABLE x DROP PARTITION '202501' 秒删,比 DELETE 便宜一万倍。

5.3 关键 settings:先会看,再会调

影响最大的几个:

setting作用建议
max_threads查询并行线程数默认等于 CPU 核数,复杂查询可适当上调
max_memory_usage单查询内存上限集群里设成节点内存的 70%~80%
use_uncompressed_cache是否缓存解压后的块反复扫描同列时开,提升缓存命中
max_insert_block_size单次插入 Block 大小批量写时调到 1e6 量级
send_logs_level日志级别排查时设 trace 看执行细节

5.4 用 query_log 做「性能法医」

ClickHouse 自带 system.query_log,每条查询的资源消耗都被记下来,这是它比很多数据库好调优的地方:

SELECT
    query,
    query_duration_ms,
    read_rows,
    memory_usage,
    formatReadableSize(memory_usage) AS mem
FROM system.query_log
WHERE event_date = today() AND type = 'QueryFinish'
ORDER BY query_duration_ms DESC
LIMIT 10;

配合 EXPLAIN PLAN / EXPLAIN PIPELINE,你能精确知道一条慢查询是「读太多行」「内存爆了」还是「某个 join 算法选错」。这套自带的 observability,是生产运维的救命绳。

5.5 投影(Projection):让一张表「换种排序」被自动选中

有时候同一张表既要按 (user_id, ts) 查,又要按 (event_type, ts) 查。建第二个 ORDER BY 的物理表太浪费。用 Projection

ALTER TABLE events ADD PROJECTION proj_by_type
(
    SELECT * ORDER BY (event_type, ts)
);

插入数据后执行 ALTER TABLE events MATERIALIZE PROJECTION proj_by_type(回填已有数据)。之后按 event_type 过滤的查询,ClickHouse 会自动选用这个投影,无需改 SQL。投影本质是「同一份数据的另一种物理排序」,查询优化器会自动挑最优的那份。

六、总结与展望:2026 年的 ClickHouse 该怎么用

6.1 v25 → v26 的关键进化

把前面提到的点收一下,这一代 ClickHouse 最值得关注的:

  1. 轻量删除/更新(mutations)成熟DELETE / UPDATE 不再需要重写整分区,后台异步、影响可控,让「需要偶尔改数」的业务也能上车。
  2. 倒排索引(Inverted Index):原生支持文本检索,日志场景可以直接用 ClickHouse 做全文搜索,少养一套 ES。
  3. JSON 一等公民JSON 类型 + schema 推断,半结构化日志不用先拍平成固定列。
  4. 开放表格式直读:直接 SELECT Iceberg / Delta / Hudi / Unity Catalog 上的数据,ClickHouse 当「查询引擎」贴在湖上。
  5. 投影与查询条件缓存普遍可用:同表多维度加速、重复子查询缓存,进一步压榨性能。

6.2 选型边界(最重要的一节)

用 ClickHouse,如果你:

  • 数据主要是追加型事件流(日志、指标、埋点、IoT)。
  • 查询以「大范围扫描 + 聚合」为主,对单行点查、事务一致性要求低。
  • 数据量大(GB 起步,到 PB),且希望存储成本可控。
  • 需要实时看板、实时聚合。

别用 ClickHouse,如果你:

  • 业务系统需要事务、行级强一致更新(用 PostgreSQL / MySQL)。
  • 数据是笔记本上的小文件、临时分析(用 DuckDB 更顺手)。
  • 高并发单行点查、低延迟 KV 访问(用 Redis / Valkey)。
  • 需要复杂 join 的 TP 型业务。

一句话:ClickHouse 是「分析型实时底座」,不是「万能数据库」。 它和 PostgreSQL、DuckDB、Valkey 是互补关系——一个现代数据栈里,它们经常同时存在,各管一段。

6.3 给工程师的三条建议

  1. 先想 ORDER BY,再想建表。 排序键决定了索引裁剪效率和 80% 的查询性能,是 ClickHouse 设计的「第一性原理」。
  2. 批量写、攒批次。 无论哪个客户端,都走 batch 插入,这是吞吐的生命线。
  3. 物化视图做实时,投影做多维。 实时聚合交给 AggregatingMergeTree + MV;同一张表的多维加速交给 Projection。两者配合,能在 PB 级数据上稳定给出亚秒级响应。

ClickHouse 的魅力,不在于它「快」这一个字,而在于它用一套清晰的设计取舍(列存 + 向量化 + 后台合并 + 稀疏索引),把「大规模实时分析」这件原本很贵的事,做得既快又便宜。理解它为什么这么设计,比背会任何配置项都重要——这也是 2026 年还值得认真聊它的原因。


本文代码示例基于 ClickHouse v25/v26 系列,客户端使用 clickhouse-connect(Python)与 clickhouse-go(Go)的当前主流版本。生产环境请结合官方文档核对具体版本的行为差异。

推荐文章

Linux查看系统配置常用命令
2024-11-17 18:20:42 +0800 CST
Nginx 反向代理 Redis 服务
2024-11-19 09:41:21 +0800 CST
Python Invoke:强大的自动化任务库
2024-11-18 14:05:40 +0800 CST
55个常用的JavaScript代码段
2024-11-18 22:38:45 +0800 CST
Gai:AI 原生的 Go Web 全栈框架
2026-05-21 16:19:43 +0800 CST
最全面的 `history` 命令指南
2024-11-18 21:32:45 +0800 CST
程序员茄子在线接单