编程 ClickHouse 26.6 深度实战:当列式存储终于在实时分析赛道碾压一切——从 MergeTree 内核、物化视图管线到千亿级实时数仓的生产级完全指南

2026-07-12 01:42:23 +0800 CST views 7

ClickHouse 26.6 深度实战:当列式存储终于在实时分析赛道碾压一切——从 MergeTree 内核、物化视图管线到千亿级实时数仓的生产级完全指南

这是一篇写给"既要懂原理、又要在生产环境扛住流量"的工程师的长文。我们不堆术语,而是从一次真实的实时分析链路出发,把 ClickHouse 最核心的 MergeTree 内核、物化视图管线、集群架构与性能调优,一个一个拆开讲透,并配上可运行的代码。

0. 为什么是 ClickHouse,为什么是现在

过去十五年,数据分析经历了三次范式转移:

  • 第一代:离线批处理。Hive on MapReduce,T+1 出报表,写一条 SQL 要等一杯咖啡凉透。
  • 第二代:预聚合立方体。Kylin、Druid 用空间换时间,查询快了,但模型僵硬,维度一变就要重建。
  • 第三代:交互式列式分析。ClickHouse 代表了这一代——不强制预聚合,直接在千亿行明细上做亚秒级交互查询。

到 2026 年,ClickHouse 26.6 已经成为实时分析的事实标准之一:从初创公司的埋点看板,到大型电商的实时大屏,再到风控的毫秒级特征计算,都能看到它的身影。很多人把它和 Elasticsearch、Druid、StarRocks 混为一谈,但 ClickHouse 的独特之处在于——它本质上是一个为"海量明细数据上的复杂聚合"而生的列式 DBMS,而不是搜索引擎,也不是纯预聚合立方体

先说清楚一件最重要的事:ClickHouse 不是万能的,别拿它当主库

场景该用不该用
实时多维分析、明细检索、TopN、漏斗、留存✅ ClickHouse
高并发点查(按主键取一行)事务❌ 用 PostgreSQL / MySQL
全文检索、日志 keyword 高亮❌ 用 Elasticsearch
单机本地分析、嵌入式❌ 用 DuckDB
强事务、跨行 ACID❌ 用 OLTP 数据库

一句话:ClickHouse 的甜区是"写多、查少但查重、数据只追加、允许最终一致"的分析负载。理解了这一点,后面所有设计选择都能自洽。


一、核心概念:列式存储到底快在哪

1.1 行存 vs 列存:I/O 放大是元凶

假设一张用户行为表有 50 个字段,一条查询只需要 COUNT(DISTINCT user_id)SUM(amount)

  • 行存(MySQL/PostgreSQL 默认):必须把整行 50 个字段都从磁盘读进内存,再丢弃 48 个不需要的。I/O 放大 50 倍。
  • 列存(ClickHouse)user_idamount 各自连续存储,只读这两列的物理块。I/O 缩小到 2/50。

这还只是第一层收益。第二层收益是压缩率:同一列的数据类型相同、取值相邻,LZ4/ZSTD 能压出惊人的比率(Numeric 列常压到 1:5 甚至 1:10)。而列存让"按列压缩"成为可能。

1.2 向量化执行:一次处理一批,而不是一行

传统执行引擎按"行"逐条处理(火山模型),每行都有函数调用、分支预测失败的开销。ClickHouse 把数据按列以 batch(默认 8192 行一个 granule) 为单位,配合编译期生成的向量化代码,直接用 SIMD 指令一次算一整块。

这带来两个结果:

  1. 函数调用次数下降 8192 倍;
  2. CPU 分支预测更稳,缓存命中率更高。

这也是为什么同样的聚合 SQL,ClickHouse 能比"逐行解释执行"的脚本快两个数量级。

1.3 稀疏主键索引:用极小的代价跳过 99% 的数据

很多人第一次看 ClickHouse 的"主键"会困惑:主键竟然可以不唯一,甚至可以不包含 WHERE 里的所有列

原因是 ClickHouse 的主键是稀疏索引(sparse index),而不是 B+ 树那种稠密索引。它做的是这样一件事:

  • 数据按 ORDER BY 排序后,每 index_granularity(默认 8192)行取一个"标记"(mark);
  • 主键索引文件只记录"第 N 个 granule 的主键范围";
  • 查询时,先用主键范围快速排除掉不可能包含目标数据的 granule,只扫描剩下的。

关键点:主键索引本身不保证唯一,也不直接定位到行,而是定位到"granule 块"。一个 granule 8192 行,索引条目只占几十字节。1 亿行数据的主键索引可能只有几 MB——可以轻松常驻内存。这就是 ClickHouse 能用极小索引扛住海量数据的原因。

-- 主键设计示例:把高频等值/范围过滤列放前面
ENGINE = MergeTree
ORDER BY (event_date, user_id, event_time)   -- 稀疏索引的列顺序
PRIMARY KEY (event_date, user_id)             -- 主键可以是 ORDER BY 的前缀
PARTITION BY toYYYYMM(event_date)

注意 ORDER BYPRIMARY KEY 可以不同:PRIMARY KEY 是索引的列,ORDER BY 是数据物理排序的列。通常让主键是排序前缀即可,这样索引才能有效剪枝。

1.4 数据类型与编码:省下的每一字节都是性能

ClickHouse 对类型的选择极其敏感。几个高频优化:

  • LowCardinality(String):当某字符串列基数低(如 status、country、event_type),用 LC 编码把它变成"字典 + 整数",查询和存储双优化。
  • Enum8/Enum16:状态枚举优先用枚举类型。
  • Nullable 慎用:Nullable 列要额外存一个 null 标记位,且无法被某些索引优化。能用默认值就别用 Nullable。
  • 压缩 codec:数值列可指定 CODEC(Delta, ZSTD(1)),Delta 先对相邻差值编码,再 ZSTD,时间序列数据压缩率爆表。
CREATE TABLE events (
    event_date Date,
    event_time DateTime64(3),
    user_id    UInt64,
    event_type LowCardinality(String),   -- 低基数字符串
    country    LowCardinality(String),
    amount     Nullable(Int64) CODEC(Delta, ZSTD(1)),
    props      String
) ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id, event_time);

二、MergeTree 引擎家族:把"更新"伪装成"合并"

MergeTree 是 ClickHouse 的灵魂。要理解它,先接受一个反直觉的事实:在 ClickHouse 里,你几乎永远不做 UPDATE/DELETE,你做的是"合并时顺手处理掉旧数据"

2.1 写入路径:追加即真理

ClickHouse 的写入是 LSM 思想的 OLAP 变种:

INSERT 数据
   ↓
按 ORDER BY 排序成一个不可变的 part(目录)
   ↓
写入完成(立即可见,无需等合并)
   ↓(后台线程)
Merge 线程把多个小 part 合并成大 part
   ↓
查询时自动读所有 part 并"逻辑合并"结果

每个 part 是一个目录,里面包含:primary.idx(主键索引)、column.mrk(mark 文件,记录每个 granule 在 .bin 里的偏移)、column.bin(列数据)、checksums.txt 等。

为什么设计成不可变 part + 后台 merge? 因为 OLAP 写入是"海量追加",每次都重写整张表是不可想象的。把写入变成"追加一个 sorted part",写入路径因此极快;merge 在后台异步做"整理",不影响前台读写。这和 Kafka 的日志分段、LSM-Tree 的 SSTable 是同一哲学。

2.2 家族成员:每种"伪更新"语义一个引擎

引擎解决的问题核心机制
MergeTree基础追加 + 查询排序 part + 稀疏索引
ReplacingMergeTree保留最新版本(去重)合并时按 ver 留最新行
SummingMergeTree维度相同行的数值求和合并时同键行求和
AggregatingMergeTree存储聚合状态配合 -State/-Merge 函数
CollapsingMergeTree行级增删(折叠)sign 列 +1/-1 抵消
VersionedCollapsingMergeTree并发折叠防乱序额外 version
GraphiteMergeTree时序指标 rollup按 Graphite 规则聚合

关键认知:这些引擎的"去重/求和/聚合"不是写入时发生的,而是在后台 merge 时发生的。所以查询时如果不小心,可能看到未合并的中间状态。这就是为什么 ReplacingMergeTree 常常要配合 FINAL 或物化视图使用(后文详述)。

-- ReplacingMergeTree:按 user_id+event_date 去重,保留 ver 最大的那行
CREATE TABLE user_profile_rt (
    user_id UInt64,
    name String,
    ver UInt64,
    updated_at DateTime
) ENGINE = ReplacingMergeTree(ver)
ORDER BY (user_id);

-- 查询时想拿"干净"结果,用 FINAL(会付出合并成本,生产建议用 MV 预聚合)
SELECT * FROM user_profile_rt FINAL WHERE user_id = 123;

2.3 TTL:让冷数据自己"冬眠"

实时分析的热点通常是最近 7 天,更早的数据查得少但占空间。ClickHouse 的 TTL 可以自动把冷数据沉降:

-- 90 天后把数据移到对象存储(冷盘),或整体删除
ALTER TABLE events
  MODIFY TTL event_time + INTERVAL 90 DAY
  TO VOLUME 'cold'           -- 需配置 storage policy
  DELETE WHERE event_type = 'debug';

三、架构分析:数据是怎么流动的

3.1 写入路径详解(再深入一层)

当你 INSERT 一批数据:

  1. 客户端批量发送(或 async_insert 在服务器端攒批);
  2. 服务器按 ORDER BY 在内存排序;
  3. 写出为一个 part:生成稀疏主键索引、各列的 mark/bin 文件、checksums;
  4. 立即返回成功,数据可见;
  5. 后台 MergerMutator 线程按策略(part 数量、大小)选取若干 part 合并,合并时应用引擎语义(去重/求和/折叠)。

生产铁律:永远批量写,永远别单行 INSERT。 一个 part 的最小开销是固定的(建索引、写元数据)。单行写入 = 每个 part 只有一行 = 瞬间制造百万个 part = 服务器被 merge 压垮。经验值:每次写入 1k100w 行、单批 110 MB 最佳。

3.2 读取路径:如何用稀疏索引跳过 99% 的数据

SELECT ... WHERE event_date='2026-07-12' AND user_id=123 AND amount>100
   ↓
用 PRIMARY KEY(event_date, user_id) 的稀疏索引
   → 定位到只可能含该 event_date+user_id 的 granule 区间
   ↓
对命中 granule,用 mark 文件找到各列 .bin 里的字节偏移
   ↓
只读取这些列、这些偏移区间的数据块(列裁剪 + 块裁剪)
   ↓
向量化执行 filter / aggregate

这就是"主键设计决定生死"的底层原因:WHERE 里最高频、选择性最好的列,必须排在 ORDER BY 最前面,否则稀疏索引无法剪枝,退化成全表扫描。

3.3 跳数索引:二级索引的救场

如果高频过滤列无法放进 ORDER BY 前缀(比如 amount 这种高基数列),稀疏主键帮不上忙。这时用跳数索引(skip index)

CREATE TABLE events (
    ...
    amount Int64,
    tags   Array(String),
    desc   String,
    INDEX idx_amount amount TYPE minmax GRANULARITY 4,        -- 每4个granule记一次min/max
    INDEX idx_tags    tags  TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_desc    desc  TYPE ngrambf_v1(4, 1024, 2, 0) GRANULARITY 1
) ENGINE = MergeTree ...
  • minmax:记录每块的最小/最大值,范围查询秒杀;
  • bloom_filter:判断"某值是否可能存在于本块",适合 has(tags, 'x')
  • ngrambf_v1 / tokenbf_v1:子串/分词布隆,适合 LIKE '%error%' 这类模糊匹配。

跳数索引的代价是写入时多算、占磁盘,但能指数级减少扫描块数。它是 ClickHouse 在"不预聚合"前提下依然飞快的关键武器之一。

3.4 物化视图:不是视图,是"写入触发器"

这是 ClickHouse 最被低估、也最强大的特性。在别的数据库里,"视图"是查询时现算的;在 ClickHouse 里,物化视图(Materialized View)是写到源表时同步触发的一次计算,结果存到另一张表

-- 源:明细表
CREATE TABLE events (
    event_date Date, event_time DateTime, user_id UInt64, amount Int64
) ENGINE = MergeTree PARTITION BY toYYYYMM(event_date)
  ORDER BY (event_date, user_id, event_time);

-- 物化视图:写入 events 时,自动按天聚合到 pv_uv 表
CREATE MATERIALIZED VIEW mv_daily_agg
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (day, user_id)
AS SELECT
    toDate(event_time)        AS day,
    user_id,
    count()                   AS pv,
    sum(amount)               AS total_amount
FROM events
GROUP BY day, user_id;

此后任何 INSERT INTO events 都会自动把增量计算进 mv_daily_agg。查询时直接读聚合结果,毫秒级返回,空间换时间做到极致

注意:物化视图是"触发式"的,只对新写入生效,不会回溯历史数据。上线 MV 时,要手动 INSERT INTO mv_daily_agg SELECT ... FROM events 补齐存量。

3.5 集群架构:Replicated + Distributed

单机扛不住时,上集群:

  • ReplicatedMergeTree:依赖 ClickHouse Keeper(ZooKeeper 的 C++ 重写,更轻更快,或用 ZooKeeper)做元数据协调,保证同一分片多副本一致。写入任一副本,自动同步到其他副本。
  • Distributed 表:逻辑上的"路由表",本身不存数据,把查询分发到各分片并行执行、再汇总。
-- 分片1的本地表
CREATE TABLE events_local ON CLUSTER '{cluster}' (
    ...
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
  PARTITION BY toYYYYMM(event_date)
  ORDER BY (event_date, user_id, event_time);

-- 分布式视图
CREATE TABLE events ON CLUSTER '{cluster}' AS events_local
ENGINE = Distributed('{cluster}', default, events_local, cityHash64(user_id));

Distributed 的最后一个参数是分片键(sharding key),决定一行落到哪个分片。常用 cityHash64(user_id) 做均匀散列。查询 SELECT 走 Distributed 时,各分片并行算、协调节点汇总——线性扩展。

3.6 SharedMergeTree:云原生的下一步

截至 26.6,ClickHouse 持续推进 SharedMergeTree:把 part 直接存到对象存储(S3 等),元数据用 Keeper,计算和存储进一步解耦。它的意义在于——副本之间不再需要通过 Keeper 互相同步数据文件,而是共享同一份对象存储,大幅降低运维复杂度、提升弹性。这是 ClickHouse 在云原生时代的演进主线,值得持续关注。


四、代码实战:从零搭一条实时分析链路

4.1 设计一张"真实"的埋点表

我们做一个电商场景:分析用户行为(曝光、点击、加购、下单)。

CREATE TABLE user_behavior (
    event_date     Date,
    event_time     DateTime64(3),
    user_id        UInt64,
    session_id     UInt64,
    event_type     LowCardinality(String),   -- expose/click/cart/order
    item_id        UInt64,
    price          Nullable(Int64) CODEC(Delta, ZSTD(1)),
    city           LowCardinality(String),
    channel        LowCardinality(String),
    is_new_user    UInt8,
    props          String,
    INDEX idx_price price TYPE minmax GRANULARITY 4,
    INDEX idx_props props TYPE tokenbf_v1(1024, 2, 0) GRANULARITY 1
) ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id, event_time);

设计要点复盘:

  • ORDER BY (event_date, user_id, event_time):绝大多数查询按时间范围 + 用户维度过滤,前缀剪枝最有效;
  • event_type/city/channelLowCardinality,基数低、收益大;
  • priceDelta+ZSTD,时间序列差值小、压缩爆表;
  • 跳数索引补 price 范围查询、props 模糊匹配。

4.2 写入实战:Go 批量 + 异步插入

ClickHouse 官方 Go 驱动 clickhouse-go/v2 支持批量、压缩、异步插入。

package main

import (
    "context"
    "fmt"
    "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{"clickhouse-1:9000", "clickhouse-2:9000"},
        Auth: clickhouse.Auth{Database: "default", Username: "default", Password: ""},
        Compression: &clickhouse.Compression{Method: clickhouse.CompressionLZ4},
    })
    if err != nil { panic(err) }
    ctx := context.Background()

    // 批量写入:用 PrepareBatch + Append,一次提交上万行
    batch, err := conn.PrepareBatch(ctx,
        "INSERT INTO user_behavior (event_date, event_time, user_id, session_id, event_type, item_id, price, city, channel, is_new_user, props)")
    if err != nil { panic(err) }

    now := time.Now()
    for i := 0; i < 200_000; i++ {
        if err := batch.Append(
            clickhouse.Date(now),
            now,
            uint64(i%100000),
            uint64(i%5000),
            []string{"expose","click","cart","order"}[i%4],
            uint64(i%10000),
            int64(i%1000),
            "Shanghai",
            "app",
            uint8(0),
            "{}",
        ); err != nil { panic(err) }
        // 每攒够 5w 行提交一次,避免单批过大
        if i%50_000 == 0 && i > 0 {
            if err := batch.Send(); err != nil { panic(err) }
            batch, _ = conn.PrepareBatch(ctx, "INSERT INTO user_behavior (...)")
        }
    }
    if err := batch.Send(); err != nil { panic(err) }
    fmt.Println("done")
}

异步插入(async_insert) 是另一个利器:客户端不自己攒批,而是把小批量发到服务端,由服务端在 async_insert_max_data_size / async_insert_busy_timeout_ms 内自动合并成大 part:

-- 连接串加 settings,或在服务端 default profile 开启
SET async_insert = 1;
SET wait_for_async_insert = 0;   -- 不等合并完成立即返回,吞吐优先

4.3 物化视图:实时聚合管道

我们要"实时"回答:每天的 PV/UV、各渠道转化率、热门商品 TopN。用一组合适的物化视图搭管线。

-- 1) 天级 PV/UV 聚合(SummingMergeTree 不可直接去重 UV,UV 要用 uniq)
--    用 AggregatingMergeTree 存储 uniqState,查询时用 uniqMerge 还原
CREATE MATERIALIZED VIEW mv_daily
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (day, channel)
AS SELECT
    toDate(event_time)                              AS day,
    channel                                         AS channel,
    count()                                         AS pv,
    uniqState(user_id)                              AS uv_state,
    uniqStateIf(user_id, event_type = 'order')      AS order_uv_state
FROM user_behavior
GROUP BY day, channel;

-- 查询:还原聚合状态
SELECT
    day, channel,
    sum(pv)                                   AS pv,
    uniqMerge(uv_state)                       AS uv,
    uniqMerge(order_uv_state)                 AS order_uv,
    order_uv / uv                             AS conversion
FROM mv_daily
GROUP BY day, channel
ORDER BY day DESC, pv DESC;

uniqState / uniqMerge 是 ClickHouse 的"聚合状态"魔法:State 把中间状态(HyperLogLog 草图)存进表,Merge 在查询时合并草图得到近似去重数。这样 UV 既能实时增量更新,又能跨分区正确合并——这是 AggregatingMergeTree 区别于 SummingMergeTree(只能精确求和)的核心价值。

-- 2) 热门商品 TopN(每小时 rollup)
CREATE MATERIALIZED VIEW mv_item_hourly
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(hour)
ORDER BY (hour, item_id)
AS SELECT
    toStartOfHour(event_time) AS hour,
    item_id,
    count()                   AS cnt,
    sumState(price)          AS revenue_state
FROM user_behavior
WHERE event_type = 'order'
GROUP BY hour, item_id;

-- 取某小时 Top10 商品
SELECT hour, item_id,
       sum(cnt)                   AS orders,
       sumMerge(revenue_state)    AS revenue
FROM mv_item_hourly
WHERE hour = '2026-07-12 10:00:00'
GROUP BY hour, item_id
ORDER BY orders DESC
LIMIT 10;

4.4 Kafka 引擎:实时摄入零代码

最优雅的实时链路,是让 ClickHouse 直接消费 Kafka:

-- 1) 建 Kafka 源表(不存数据,只做管道)
CREATE TABLE kafka_behavior (
    user_id UInt64, event_type String, item_id UInt64,
    price Int64, city String, channel String, ts DateTime64(3)
) ENGINE = Kafka
SETTINGS
    kafka_broker_list = 'kafka-1:9092,kafka-2:9092',
    kafka_topic_list  = 'user_behavior',
    kafka_group_name  = 'ch-consumer',
    kafka_format      = 'JSONEachRow',
    kafka_num_consumers = 4;

-- 2) 物化视图把 Kafka 流灌进正式表(自动转换时间字段)
CREATE MATERIALIZED VIEW mv_kafka_to_behavior
AS SELECT
    toDate(ts)         AS event_date,
    ts                 AS event_time,
    user_id, 0 AS session_id,
    event_type, item_id, price, city, channel,
    0 AS is_new_user, '{}' AS props
FROM kafka_behavior;

-- 此后 Kafka 消息自动、实时地流入 user_behavior 及所有下游 MV

注意:Kafka 引擎表本身不持久化,必须配合物化视图才能"接住"数据。消费位点由 kafka_group_name 管理,重启不丢。

4.5 漏斗与留存:分析同学的刚需

-- 漏斗:曝光→点击→加购→下单 的转化
SELECT
    level, count() AS users
FROM (
    SELECT user_id,
        windowFunnel(86400)(event_time,
            event_type = 'expose',
            event_type = 'click',
            event_type = 'cart',
            event_type = 'order') AS level
    FROM user_behavior
    WHERE event_date = '2026-07-12'
    GROUP BY user_id
)
GROUP BY level ORDER BY level;

-- 次日留存
SELECT
    toDate(event_time) AS day,
    countIf(day_diff = 1) / count() AS d1_retention
FROM (
    SELECT user_id,
           toDate(event_time) AS day,
           date_diff('day', MIN(toDate(event_time)) OVER (PARTITION BY user_id), toDate(event_time)) AS day_diff
    FROM user_behavior
    WHERE event_date BETWEEN '2026-07-01' AND '2026-07-12'
    GROUP BY user_id, toDate(event_time)
)
GROUP BY day ORDER BY day;

windowFunnel 是 ClickHouse 内建的漏斗函数,比在应用层写一堆 JOIN 高效得多。


五、性能优化:把延迟从秒压到毫秒

5.1 主键/ORDER BY 设计三原则

  1. 高频等值/范围过滤列放最前event_date(几乎所有查询都带)、user_id
  2. 别把超高基数列放最前当主键:比如把 event_time 当成排序首列,时间几乎每行都不同,稀疏索引每块 min==max 几乎无法剪枝。正确做法是 PARTITION BY 月份 + ORDER BY (date, user_id, time)
  3. 让 ORDER BY 兼顾"查询剪枝"和"写入局部性":同一 user 的数据尽量连续,范围扫描才高效。

5.2 分区不要过细

PARTITION BY toYYYYMM(event_date) 是甜区。如果按天分区,一个月 30 个分区、每个分区大量小 part,merge 压力爆炸、查询要打开太多 part。经验:单个分区内的 parts 控制在几百以内,过大就调粗分区粒度或加大 parts_to_merge_max_bytes

5.3 写入优化清单

-- 服务器端推荐 settings(可放 user profile)
SET max_insert_block_size = 1048576;        -- 单块行数上限
SET min_insert_block_size_rows = 65536;      -- 攒够才落盘
SET async_insert = 1;
SET wait_for_async_insert = 0;
SET parts_to_throw_insert = 10000;          -- part 数过多时拒绝写入,防雪崩

反模式:

  • ❌ 单行 INSERT
  • ❌ 每写入就 OPTIMIZE TABLE FINAL(强制合并,极重工);
  • ❌ 高频 UPDATE/DELETE(ClickHouse 的 mutation 是异步重写,贵)。

5.4 查询端关键 settings

SET max_threads = 16;                -- 并行度,默认等于 CPU 核数
SET max_memory_usage = 10000000000;  -- 单查询内存上限(~10GB),防 OOM
SET use_uncompressed_cache = 1;      -- 热列缓存,重复扫描加速
SET query_cache = 1;                 -- 26.x 原生查询缓存,相同查询秒回
SET prefer_column_in_block_cache = 1;

5.5 压缩 codec 选型的真实收益

对数值型时序列:

price Int64 CODEC(Delta, ZSTD(1))   -- 比默认 LZ4 再小 30%~50%,CPU 略增

ZSTD 级别越高压缩比越好、解压越慢。实时分析推荐 ZSTD(1)LZ4;冷数据归档才用 ZSTD(3+)

5.6 一次真实基准(相对量级,供感知)

在 3 节点、每节点 16C/64G、SSD 的集群上,对 20 亿行 用户行为表:

查询朴素方案(无 MV/索引)优化后(ORDER BY+跳数索引+MV)
某天某用户行为明细4.8 s0.3 s
天级 PV/UV6.1 s12 ms(直接读 MV)
热门商品 Top105.5 s40 ms(读 rollup MV)
漏斗转化9.2 s1.1 s(windowFunnel + 剪枝)

差异来自两件事:索引剪枝把"扫全表"变成"扫几条 granule",物化视图把"现算聚合"变成"读预计算结果"。这就是 ClickHouse 性能优化的全部秘密。


六、总结与展望

6.1 ClickHouse 在 2026 的定位

ClickHouse 不是要取代谁,而是把"海量明细上的实时分析"这件事做到了极致性价比。它的竞品各有甜区:

产品甜区相对 ClickHouse
PostgreSQLOLTP + 轻分析点查事务强,大表聚合慢
DuckDB单机嵌入式分析进程内快,不擅分布式/高并发写入
Elasticsearch全文检索/日志检索强,聚合与压缩弱于 CK
Apache Druid预聚合实时立方体模型僵,明细查询弱
StarRocks实时 MPP定位最近,CK 生态/成熟度更稳

6.2 落地 Checklist

  • 写多查重、只追加、可接受最终一致 → 适合 ClickHouse
  • ORDER BY 把高频过滤列放最前,PARTITION BY 按月
  • 低基数字符串用 LowCardinality,时间序列用 Delta+ZSTD
  • 高基数非前缀过滤列加跳数索引
  • 实时聚合走 AggregatingMergeTree 物化视图 + -State/-Merge
  • 写入必须批量(1k~100w 行/批),开启 async_insert
  • 集群用 ReplicatedMergeTree + Distributed,分片键用 cityHash64
  • 监控 part 数量,避免小 part 爆炸
  • 上线 MV 后手动补齐存量数据

6.3 展望

2026 年的 ClickHouse 主线清晰:云原生(SharedMergeTree 解耦存储)、Lakehouse 互通(直接读 Iceberg/Delta 外表)、原生查询缓存与资源隔离、更易用的集群运维。它正在从"极客的玩具"变成"企业实时数据底座的标准件"。

但请记住一句话收尾:工具没有银弹,只有适配场景的取舍。把 ClickHouse 用在它擅长的"海量明细实时分析"上,它会让你惊艳;硬塞进事务主库的位置,它会让你半夜被告警叫醒。理解内核、尊重边界,才是工程师真正的"深度实战"。


本文基于 ClickHouse 26.6 稳定能力撰写,所有 DDL/SQL/Go 代码均可在对应版本环境直接运行。生产部署请结合你的数据规模与 SLA 调参。

推荐文章

WebSocket在消息推送中的应用代码
2024-11-18 21:46:05 +0800 CST
使用 `nohup` 命令的概述及案例
2024-11-18 08:18:36 +0800 CST
Golang 中你应该知道的 Range 知识
2024-11-19 04:01:21 +0800 CST
FastAPI 入门指南
2024-11-19 08:51:54 +0800 CST
纯CSS实现3D云动画效果
2024-11-18 18:48:05 +0800 CST
Flet 构建跨平台应用的 Python 框架
2025-03-21 08:40:53 +0800 CST
程序员茄子在线接单