Skip to content

第 3 章 数据类型详解

学习目标:能背出 ClickHouse 9 大类数据类型的存储字节数;能向同事解释 String / FixedString / LowCardinality(String) 的差别和适用场景;能讲清 Nullable 为什么「在 ClickHouse 里是奢侈品」;能用 Array / Tuple / Map / Nested 把半结构化数据塞进 OLAP;对 AggregateFunction 有初步认识,为后面的物化视图(第 10 章)打底。


3.0 写在前面:为什么数据类型在 ClickHouse 这么重要

在 MySQL 里,INT 还是 BIGINT 影响的可能只是几个字节;在 ClickHouse 里,类型选错可能让你的存储多花 5~10 倍、查询慢 10 倍。原因有三:

  1. 列存的压缩比和类型强相关:同样 1 亿个国家代码,String 占 200 MB,LowCardinality(String) 只占 12 MB;
  2. 向量化执行的速度和类型紧凑度强相关UInt8 一条 SIMD 指令算 64 个,String 一条算 1 个;
  3. Nullable 的额外 null 掩码列:让一列变两列,压缩 / 计算 / 索引全部受影响。

所以这一章必须慢慢读、动手试


3.1 类型大全速览

                 ClickHouse 数据类型家族(24.x 版本)
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  数值        Int8  Int16  Int32  Int64  Int128  Int256       │
│             UInt8 UInt16 UInt32 UInt64 UInt128 UInt256      │
│             Float32  Float64                                │
│             Decimal32  Decimal64  Decimal128  Decimal256    │
│                                                             │
│  布尔        Bool(本质是 UInt8 别名)                       │
│                                                             │
│  字符串      String(变长)                                  │
│             FixedString(N)(定长 N 字节)                    │
│             LowCardinality(T)(字典编码包装)                │
│                                                             │
│  时间        Date                                            │
│             Date32                                           │
│             DateTime('tz' 时区可选)                        │
│             DateTime64(precision, 'tz')                     │
│                                                             │
│  其他原子    UUID                                            │
│             Enum8  Enum16                                    │
│             IPv4  IPv6                                       │
│                                                             │
│  复合        Array(T)                                        │
│             Tuple(T1, T2, ...)                              │
│             Map(K, V)                                        │
│             Nested(...)                                      │
│             Variant(T1, T2, ...)(新型 union)              │
│             JSON / Object(实验,22+)                       │
│                                                             │
│  聚合        AggregateFunction(name, types...)              │
│             SimpleAggregateFunction(name, T)                │
│                                                             │
│  特殊        Nullable(T)(包装别的类型)                     │
│             Nothing                                          │
│             Geo: Point / Polygon / Ring / MultiPolygon      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3.2 数值类型:永远选最小够用的

3.2.1 整数:Int / UInt 全谱系

类型字节数范围
Int81-128 ~ 127
Int162-32,768 ~ 32,767
Int324-2.1e9 ~ 2.1e9
Int648-9.2e18 ~ 9.2e18
Int12816±1.7e38
Int25632±5.8e76
UInt810 ~ 255
UInt1620 ~ 65,535
UInt3240 ~ 4.29e9
UInt6480 ~ 1.84e19
UInt128160 ~ 3.4e38
UInt256320 ~ 1.16e77

选型口诀

  • 性别 / 是否 / 状态码 → UInt8
  • 国家编号 / 应用 ID → UInt16
  • 用户 ID / 订单 ID(< 40 亿) → UInt32
  • 全局唯一 ID(雪花算法 64 位) → UInt64
  • 加密哈希 → UInt128 / UInt256

📌 小妙招:列编码 CODEC(T64, LZ4) 能把 UInt64按实际位宽紧凑存放。如果列里实际值都 < 1000,T64 自动按 10 位存,硬把 8 字节压成 ~1 字节。

3.2.2 浮点:Float32 / Float64

Float32  4 字节  ~7 位有效数字
Float64  8 字节  ~15 位有效数字

金额绝对不要用 Float0.1 + 0.2 ≠ 0.3,IEEE 754 浮点的天坑在 OLAP 里照样存在。金额请用 Decimal

3.2.3 定点:Decimal32 / 64 / 128 / 256

sql
amount  Decimal(18, 4)    -- 18 位精度,4 位小数
ratio   Decimal(9, 6)     -- 9 位精度,6 位小数
别名总位数字节数
Decimal32(s)≤ 94
Decimal64(s)≤ 188
Decimal128(s)≤ 3816
Decimal256(s)≤ 7632

s 是小数位数,最大不能超过总位数。

3.2.4 Bool(CK 22+)

sql
is_paid Bool   -- 等价于 UInt8,只是查询 / 显示更友好

3.3 字符串:String / FixedString / LowCardinality

这是面试必考三件套,请理解到「能给同事讲清」的程度。

3.3.1 String:变长 UTF-8 字节流

sql
url String                              -- 默认就行
url String CODEC(ZSTD(3))               -- 高基数字符串建议加 ZSTD
  • 内部就是 (length: VarInt) + 字节数据
  • 没有「长度上限」(理论上 1 GB);
  • 没有 charset 概念,全部当 UTF-8 字节流处理。

3.3.2 FixedString(N):定长 N 字节

sql
country FixedString(2)     -- 国家代码 'CN' / 'US'
phone   FixedString(11)    -- 手机号
hash    FixedString(32)    -- MD5
  • 永远占 N 字节,不足补 \0
  • 比 String 略快(无需读长度),但 N 偏大就是浪费;
  • 使用条件:所有值长度严格相等。

3.3.3 LowCardinality(T):字典编码包装

这是 ClickHouse「对 OLAP 字符串列的核武器」。它不是新类型,而是包装:你可以把它套在 String / FixedString / Date / Number 等绝大多数类型上。

sql
country LowCardinality(String)
status  LowCardinality(String)

底层做了什么

原列:           1 亿行 country
"BJ", "SH", "GZ", "BJ", "SH", "BJ", "SH", "GZ", "GZ", "BJ", ...
存储:每行 2~10 字节字符串 → 共约 200 MB

LowCardinality 后:
┌──────────────┐         ┌────────────┐
│ 字典 dict    │         │ codes      │
├──────────────┤         ├────────────┤
│ 0 → "BJ"     │         │ 0,1,2,0,1, │  ← 每行 1 字节 (UInt8)
│ 1 → "SH"     │         │ 0,1,2,2,0  │
│ 2 → "GZ"     │         │ ...        │
└──────────────┘         └────────────┘
存储:字典 30 字节 + 1 亿 × 1 字节 ≈ 100 MB → 再 LZ4 压缩 → ~12 MB

收益

  1. 存储省 5~20 倍;
  2. 查询快(GROUP BY / 过滤 / JOIN 都基于整数 code,SIMD 友好);
  3. 字典自动维护,新值会自动加入;
  4. 字典宽度自动选择 UInt8 / UInt16 / UInt32(CK 自动判断)。

适用条件:取值集合 < 10000,且重复值多。

反模式

  • ❌ 套在 user_id 这种几乎全唯一的列上 → 字典反而比原值大;
  • ❌ 套在 Int32 上 → 几乎无收益(且 22+ 默认禁止,需 allow_suspicious_low_cardinality_types);
  • ❌ 配合 Nullable 用 → null mask 会破坏字典紧凑度。

📌 生产经验:把所有「枚举级字符串」都套上 LowCardinality —— country、city、province、status、device_type、os、browser、HTTP method、ad_slot、product_category…… 这一刀下去,整张表存储能缩 30~70%。


3.4 时间类型:Date / Date32 / DateTime / DateTime64

类型字节数范围精度
Date21970-01-01 ~ 2149-06-06
Date3241900-01-01 ~ 2299-12-31
DateTime41970-01-01 ~ 2106-02-07
DateTime 带时区4同上秒(带时区元信息)
DateTime64(0)81900 ~ 2299
DateTime64(3)8同上毫秒
DateTime64(6)8同上微秒
DateTime64(9)8同上纳秒
sql
create_date  Date                                -- 生日 / 注册日
ts           DateTime                            -- 大多数事件时间戳
event_ts     DateTime('Asia/Shanghai')           -- 带时区元数据
trace_ts     DateTime64(3, 'UTC')                -- 毫秒级日志

📌 时区怎么处理:ClickHouse 的 DateTime底层永远存 UTC 秒,时区只是「显示元数据」。也就是说同一列在不同时区客户端连进来看到的会自动换算。这套机制比 MySQL 的「TIMESTAMP 自动时区 / DATETIME 不带时区」更清晰。

sql
-- 示例
SELECT now('UTC'), now('Asia/Shanghai'), now('America/New_York');

时序列强烈推荐编码:CODEC(DoubleDelta, ZSTD(3)) —— DoubleDelta 利用相邻时间差异极小的特性,配合 ZSTD 压缩比通常 30+ 倍。


3.5 其他原子类型

3.5.1 UUID

sql
trace_id UUID    -- 16 字节,自动支持 generateUUIDv4()
sql
INSERT INTO logs VALUES (generateUUIDv4(), 'event-x', now());

3.5.2 Enum8 / Enum16

sql
status Enum8('pending'=1, 'paid'=2, 'shipped'=3, 'cancel'=4)
  • 物理存 1 / 2 字节整数;
  • 写入时校验 —— 不在枚举值内会报错拒绝(比 LowCardinality 严格);
  • 查询时自动显示成字符串。

📌 Enum vs LowCardinality:Enum 新增值要 ALTER(Mutation 重写),适合「真正闭集合」;LowCardinality 新增值零成本,适合「相对稳定但可能加值」。

3.5.3 IPv4 / IPv6

sql
client_ip4  IPv4    -- 4 字节
client_ip6  IPv6    -- 16 字节

带专属函数:IPv4StringToNum / IPv4NumToString / toIPv6 / IPv6CIDRToRange(按 CIDR 段过滤超快)。


3.6 复合类型:Array / Tuple / Map / Nested

3.6.1 Array(T)

sql
tags Array(String)      -- ['vip','black-friday']
scores Array(Float32)   -- [1.1, 2.2, 3.3]

底层布局是「两列」:

tags 实际存储:
  ┌─ tags.size: UInt64  各行数组长度
  └─ tags.data: String  各行数组元素扁平串起来

查询:

sql
-- 用下标
SELECT tags[1] FROM t;

-- 高阶函数
SELECT arrayMap(x -> x * 2, scores) FROM t;
SELECT arrayFilter(x -> x > 0.5, scores) FROM t;

-- 数组炸开
SELECT user_id, tag FROM t ARRAY JOIN tags AS tag;

-- 长度
SELECT length(tags) FROM t;

3.6.2 Tuple(T1, T2, ...)

sql
geo Tuple(Float64, Float64)        -- (lng, lat)

底层:每个字段独立一列存(真正打开了「Tuple = 多列封装」的盖子)。

sql
SELECT geo.1 AS lng, geo.2 AS lat FROM t;

3.6.3 Map(K, V)

sql
attrs Map(String, String)   -- {"src":"app","ver":"1.2"}

底层等价 Tuple(Array(K), Array(V))不要拿来当通用 KV 存储:取一个 key 是 O(N) 扫描。少量、固定的 key 才用 Map,否则不如展平成多列。

3.6.4 Nested(...)

轻量化的子表」 —— 一组同长度的并行数组。

sql
CREATE TABLE orders (
    order_id UInt64,
    items Nested(
        sku    UInt64,
        price  Decimal(18,2),
        qty    UInt16
    )
) ENGINE = MergeTree ORDER BY order_id;

底层是 3 个并行数组列:items.sku Array(UInt64)items.price Array(Decimal(...))items.qty Array(UInt16)

写入:

sql
INSERT INTO orders VALUES
  (1, [1001, 1002, 1003], [10.00, 20.50, 5.00], [2, 1, 3]);

查询(炸开成多行):

sql
SELECT order_id, items.sku, items.price, items.qty
FROM orders
ARRAY JOIN items;

3.7 Nullable(T):在 ClickHouse 里是奢侈品

3.7.1 为什么 Nullable 有代价

Nullable(T) 会让一列额外多出一列 null mask(每行 1 字节,标记是否为 null):

列 amount Nullable(Decimal(18,2)):

实际存储:
  ┌─ amount.bin           (Decimal 8 字节 × N)
  └─ amount.null.bin      (UInt8 1 字节 × N,0=有值 1=null)

代价:

  1. 存储多 ~12.5%(小列影响更大);
  2. GROUP BY / 比较 / JOIN 都要先看 null mask,向量化效率下降;
  3. Nullable 不能作为 ORDER BY / PARTITION BY 表达式
  4. 配合 LowCardinality 使用时LowCardinality(Nullable(String)) 字典里要专门存一个 NULL 占位符,效率不如「用空串 '' 代替 NULL」。

3.7.2 推荐姿势

  • 数值列:用「业务侧无意义值」代替 NULL(-1 / 0 / 2147483647);
  • 字符串列:用 '' 代替 NULL;
  • 真的需要区分「未知」和「值」:才上 Nullable。

📌 黄金法则:在 ClickHouse 里,「有 Nullable 是默认错」,需要明确收益才该用。这与 MySQL/PG「字段默认 NULL」是反的。


3.8 AggregateFunction:物化视图的弹药库

AggregateFunction(name, types...) 是 ClickHouse 一个奇特但极强的类型:它存的不是「最终聚合结果」,而是「聚合的中间状态」(intermediate state)。

sql
CREATE TABLE agg_demo (
    day Date,
    uv_state AggregateFunction(uniq, UInt32)
)
ENGINE = AggregatingMergeTree
ORDER BY day;

写入要用 *State 后缀:

sql
INSERT INTO agg_demo
SELECT toDate(ts), uniqState(user_id)
FROM events
GROUP BY toDate(ts);

查询要用 *Merge 后缀:

sql
SELECT day, uniqMerge(uv_state) AS uv FROM agg_demo GROUP BY day;

为什么需要中间状态

  • 普通聚合一次性算完后不能再合并
  • 聚合状态可以 跨时间窗口、跨分区合并。例:每分钟物化一次 uniqState,最终查任意天 / 周 / 月 UV 时只要 uniqMerge 上千个状态就行;
  • 第 10 章物化视图实时聚合的根基,就是 AggregateFunction。

SimpleAggregateFunction(name, T) 是它的「轻量版」,仅适用于聚合结果与原值类型相同的简单聚合(sum / min / max / any),不需要 State / Merge 函数

sql
CREATE TABLE simple_agg (
    user_id UInt32,
    pv      SimpleAggregateFunction(sum, UInt64)
) ENGINE = AggregatingMergeTree ORDER BY user_id;

-- 直接 INSERT 累加值即可,查询直接用 SUM

3.9 类型转换:CAST / toXxx / accurateCast

sql
-- CAST:标准 SQL 风格
SELECT CAST('42' AS UInt32);           -- 42
SELECT CAST(3.14 AS Decimal(10, 2));   -- 3.14

-- toXxx 系列:函数风格(更常用)
SELECT toUInt32('42');
SELECT toDate('2026-04-17');
SELECT toDateTime('2026-04-17 09:30:00', 'Asia/Shanghai');
SELECT toString(42);

-- 严格转换(失败则报错而不是返回 0)
SELECT accurateCast('abc', 'UInt32');  -- ERROR

-- 安全转换(失败返回 NULL)
SELECT accurateCastOrNull('abc', 'UInt32');     -- NULL
SELECT toUInt32OrZero('abc');                   -- 0
SELECT toUInt32OrDefault('abc', 99);            -- 99 (24+)

📌 常踩坑toUInt32('abc') 在 ClickHouse 里会报错而不是返回 0(与 MySQL CAST 行为不同)。生产代码请显式用 toUInt32OrZero / toUInt32OrNull / accurateCastOrNull


📌 与 MySQL / PostgreSQL 的对比小框

类型MySQLPostgreSQLClickHouse
整数家族TINYINT / SMALLINT / INT / BIGINTsmallint / integer / bigintInt8/16/32/64/128/256 + UInt 系列
无符号整数UNSIGNED 修饰符❌ 无原生 UInt8/16/32/64(更常用)
256 位整数✅ Int256 / UInt256(区块链友好)
金额DECIMAL(p,s)numeric(p,s)Decimal32/64/128/256(s)
字符串变长VARCHAR(N) / TEXTvarchar / textString(无长度上限)
字符串定长CHAR(N)char(N)FixedString(N)(按字节而非字符)
字符串字典ENUM❌(要 lookup table)LowCardinality(T) + Enum
JSONJSONjsonb(带索引)JSON / Object(实验)+ Map / Nested
数组arrayArray(T) 原生
元组composite typeTuple(T1, T2, ...)
Maphstore / jsonbMap(K, V)
Nestedcomposite + arrayNested(...)(并行数组糖)
UUIDCHAR(36) 模拟uuidUUID(16 字节)
IPINET 仅 PGinet / cidrIPv4 / IPv6
时间精度DATETIME(6) 微秒timestamp(6)DateTime64(0~9) 纳秒
Nullable默认允许默认允许默认 NOT NULL,Nullable 是奢侈品
聚合中间状态AggregateFunction(name, ...)

3.10 实操演练

3.10.1 SQL 演练(03_data_types/init.sql

bash
clickhouse-client --multiquery < 03_data_types/init.sql

会建一张 type_demo 表,覆盖 9 大类典型类型,并灌 100 行示例数据。

3.10.2 Python 演练(03_data_types/code/types_play.py

bash
python 03_data_types/code/types_play.py

演示:

  1. 写入各类型并读出;
  2. 对比 String / FixedString / LowCardinality(String) 在同一份 100 万行数据上的存储大小;
  3. 对比 Nullable(Int32)Int32 的存储大小。

3.10.3 浏览器演示(03_data_types/demo.html

  • LowCardinality 字典编码可视化 —— 输入一组重复字符串,看字典 + codes 怎么压;
  • Nullable 额外存储成本演示 —— 滑动控制 null 比例,看双列布局;
  • Array / Map / Nested 三种复合类型 —— JSON 输入 → 列存物理布局可视化。

3.11 本章小结

┌──────────────────────────────────────────────────────────┐
│                    本章核心要点                          │
├──────────────────────────────────────────────────────────┤
│                                                          │
│ ① 数值:永远选「最小够用」                              │
│   - 状态码 → UInt8                                       │
│   - ID < 40 亿 → UInt32                                  │
│   - 雪花 ID → UInt64                                     │
│   - 金额绝对不要 Float,用 Decimal                       │
│                                                          │
│ ② 字符串三件套:                                        │
│   - String:变长,默认                                  │
│   - FixedString(N):定长场景                            │
│   - LowCardinality(T):枚举级字符串的核武器              │
│                                                          │
│ ③ 时间:DateTime 默认 UTC,时区是显示元数据             │
│   - 时序列强烈推荐 CODEC(DoubleDelta, ZSTD(3))          │
│                                                          │
│ ④ 复合:Array / Tuple / Map / Nested                    │
│   - Map 的 key 查找是 O(N),不要乱用                    │
│   - Nested 本质是并行数组糖                             │
│                                                          │
│ ⑤ Nullable 在 CK 是奢侈品                                │
│   - 多一列 null mask,影响存储 / 查询 / 索引             │
│   - 默认用 -1 / '' 代替,需要再上 Nullable              │
│                                                          │
│ ⑥ AggregateFunction:物化视图的弹药库                    │
│   - State / Merge 双函数,跨窗口可合并                   │
│   - SimpleAggregateFunction 轻量版                       │
│                                                          │
│ ⑦ 转换:toXxxOrZero / accurateCastOrNull 才安全          │
│                                                          │
└──────────────────────────────────────────────────────────┘

3.12 面试高频题

Q1:LowCardinality 是什么?什么时候该用?什么时候不该用?

考察点:OLAP 数据建模选型,CK 面试最高频题之一。

标准答案

LowCardinality(T) 是 ClickHouse 给「取值集合较小(通常 < 10000)的列」准备的字典编码包装。它把列拆成两部分:

  • 字典(dictionary):去重后的取值列表;
  • 位置编码(codes):每行存的不是原值,而是字典里的下标(自动 UInt8 / UInt16 / UInt32)。

收益

  1. 存储省 5~20 倍;
  2. 查询快(GROUP BY、过滤、JOIN 都基于位置整数,SIMD 友好);
  3. 字典自动维护,新值无成本加入;
  4. 与 LZ4 / ZSTD 压缩正交,可叠加。

该用

  • 国家、城市、状态、性别、HTTP method、客户端类型、广告位 …… 任何重复值多、基数 < 10000 的字符串列。

不该用

  • user_id、URL、订单号这类几乎全唯一的字段 —— 字典反而比原值大;
  • 数值列 —— 收益甚微(CK 22+ 默认禁止套在 Int / Float 上,需 allow_suspicious_low_cardinality_types);
  • 配合 Nullable 用 —— 字典里要存 NULL 占位,效率打折。

加分项:能说出「实际 codes 类型自动选最小(UInt8 → UInt16 → UInt32)」、「字典在 Part 内是局部的,跨 Part Merge 时会合并字典」。

易错点:把 LowCardinality 当成「压缩」—— 它本质是字典编码,与 LZ4/ZSTD 是两层不同的能力。

与 MySQL/PG 对比:MySQL 有 ENUM 但不能动态加值;PG 没有原生等价物,要靠 lookup table + JOIN。LowCardinality = 「免运维 ENUM」。


Q2:Nullable 在 ClickHouse 的代价是什么?为什么默认建议不用?

考察点:对存储格式与向量化执行的体感。

标准答案

Nullable(T) 在 ClickHouse 里不只是一个标志位 —— 它会让一列额外多出一列 null mask

  • <col>.bin 仍然存原值;
  • <col>.null.bin 每行 1 字节,0 表示有值,1 表示 NULL。

代价

  1. 存储多约 12.5%(小类型列影响更大,比如 UInt8 列直接翻倍);
  2. 查询多读一列:每个算子都要先读 null mask 再判断;
  3. 不能作为 ORDER BY / PARTITION BY 表达式;
  4. 破坏 SIMD 紧凑度:算子要先按 mask 跳行;
  5. LowCardinality(Nullable(T)) 字典里要专门存 NULL 占位符,节省效果打折;
  6. 聚合函数的语义复杂化avg(x) 默认忽略 NULL 行,但有些函数没那么直观。

推荐姿势

  • 数值列:用业务侧无意义值(-1 / 0 / 极大值);
  • 字符串列:用 ''
  • 真的需要明确区分「未知」和「值」:才上 Nullable。

加分项:能补「SELECT count(col) 在 Nullable 列上是『非 NULL 行数』,与 count(*) 不同」、「Mutation 改类型在 Nullable ↔ NOT NULL 之间转换可能要重写整列」。

易错点:把 ClickHouse 的 NULL 当成 MySQL/PG 那种「免费的、随便用」 —— 在 OLAP 里它是奢侈品。

与 MySQL/PG 对比:MySQL/PG 字段默认 NULL,开销也低(一个标志位);ClickHouse 明确「NULL 有代价、默认 NOT NULL」,是 OLAP 设计哲学。


Q3:String / FixedString(N) / LowCardinality(String) 三个怎么选?

考察点:是否真的写过表结构。

标准答案

类型适用优势劣势
String99% 场景,默认选它灵活、无长度上限高基数列存储略大
FixedString(N)长度严格相等的字段(MD5、手机号、IP 字符串、国家码)稍快(无长度字段)N 偏大就浪费、不足补 \0
LowCardinality(String)重复值多、基数 < 10000存储省 5~20×、查询快高基数反而变大

选型决策树

列里值长度都一样吗?
  ├ 是 → 是国家码 / IP / 哈希这种?→ FixedString(N)
  └ 否 → 重复值多吗?
          ├ 是(基数 < 10000) → LowCardinality(String)
          └ 否 → String + ZSTD CODEC(如果要进一步压)

加分项

  • 能补「LowCardinality(FixedString(2)) 也能用」;
  • 能讲出 FixedString 的字节长度而非字符长度(多字节 UTF-8 字符要小心算)。

易错点:把 FixedString(N) 当成 VARCHAR(N) —— 它是定长且按字节计。

与 MySQL/PG 对比:CK 没有 VARCHAR(N) 这种「带长度上限的变长字符串」 —— 因为列存里长度上限毫无意义,String 就够。


Q4:什么是 AggregateFunction?为什么物化视图离不开它?

考察点:对实时聚合机制的理解,第 10 章铺垫题。

标准答案

AggregateFunction(name, types...) 存的不是「最终结果」,而是「聚合中间状态」 —— 例如 uniq 的 HyperLogLog 草图、avg 的 (sum, count) 元组、quantile 的样本数组。

它有三个伴随后缀:

  • <agg>State():把原始值变成中间状态,写入 AggregateFunction 列;
  • <agg>Merge():把多个中间状态合并成最终结果;
  • <agg>MergeState():把多个中间状态合并成新的中间状态(链式聚合)。

为什么物化视图离不开它

  1. 普通聚合只能算一次,不能合并 —— 比如「每天的 UV」相加 ≠ 「这周 UV」;
  2. 中间状态可以跨时间 / 跨分区合并 —— 每分钟物化一次,查询任意天 / 周 / 月 UV 时只需 uniqMerge
  3. AggregatingMergeTree + MaterializedView 是 ClickHouse 实时聚合的标准范式,第 10 章会反复用到。

加分项

  • 能解释 SimpleAggregateFunction(sum, T) 是轻量版,仅适用于聚合结果与原值类型相同的简单聚合(sum / min / max / any),无需 State / Merge;
  • 能说出 quantilesTDigestState 这种基于近似算法的状态可序列化、可合并的特性。

易错点:把 AggregateFunction 列当普通列直接 SELECT —— 看到的是二进制状态而非数值,必须用 xxxMerge

与 MySQL/PG 对比:MySQL 没有等价物;PG 里 partial aggregate(最近版本支持)思路类似但生态远不如 CK。这套机制是 ClickHouse 实时数仓核心竞争力之一。


Q5:ClickHouse 的时间类型 DateTime / DateTime64 与 MySQL 的 DATETIME / TIMESTAMP 有什么区别?

考察点:时区与精度,跨数据库迁移高频踩坑题。

标准答案

维度MySQL DATETIMEMySQL TIMESTAMPCK DateTimeCK DateTime64(p)
字节数5~84~748
范围1000-99991970-20381970-21061900-2299
精度0~6(秒~微秒)0~60~9(秒~纳秒)
时区不带,按原值存自动转 UTC 存,按 session 时区显示底层永远 UTC,时区是显示元数据同左
可指定时区❌(全局 session)DateTime('Asia/Shanghai')

ClickHouse 的关键设计

  1. DateTime底层永远是 UTC unix timestamp(4 字节 UInt32);
  2. 列定义里写的时区只是「显示规则」 —— 不同客户端用不同时区连进来,看到的字符串自动换算;
  3. 不带时区的 DateTime 在显示时使用服务器配置的时区。

实操示例

sql
SELECT
  now('UTC')                AS utc,
  now('Asia/Shanghai')      AS bj,
  now('America/New_York')   AS ny;
-- 三个值底层是同一个 UInt32,只是显示时区不同

加分项

  • 能补「时序列建议加 CODEC(DoubleDelta, ZSTD(3)),时间戳列存压缩比 30+ 倍」;
  • 能解释为什么 DateTime64(3) 内部用 8 字节而 DateTime 用 4 字节(精度 ↑ 字节数 ↑)。

易错点:在不同时区客户端跑同样的 WHERE ts = '2026-04-17 10:00:00',结果可能不同 —— 必须明确写时区或用时间戳。

与 MySQL/PG 对比:CK 的「底层永远 UTC + 显示时区是元数据」机制比 MySQL DATETIME vs TIMESTAMP 的混乱设计要清晰得多,更接近 PG 的 timestamptz 思路。


📌 下一章预告:第 4 章我们进入「表引擎全景图」 —— MergeTree 家族、Log 家族、Memory、File、URL、MySQL / PostgreSQL / Kafka / S3 / Distributed / Materialized 引擎,以及如何根据场景选择。这是 ClickHouse 用得好不好的「分水岭」。

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

python
"""
types_play.py —— 第 3 章配套代码:数据类型实战

涵盖:
  1) 写入 / 查询各种典型类型(含 Array / Tuple / Map / Nested / Nullable);
  2) String vs FixedString vs LowCardinality(String) 在 100 万行同数据上的
     存储大小对比 —— 直观感受 LowCardinality 的「字典魔法」;
  3) Nullable(Int32) vs Int32 的存储大小对比;
  4) AggregateFunction 演示:uniqState + uniqMerge 跨分组合并 UV。

运行前提:
  - ClickHouse 已在 127.0.0.1:8123 启动
  - pip install -r requirements.txt
  - (可选)执行过 03_data_types/init.sql 看 type_demo 表

运行:
  python 03_data_types/code/types_play.py
"""

from __future__ import annotations

import sys
import uuid
from datetime import datetime, date

try:
    import clickhouse_connect
except ImportError:
    sys.stderr.write("❌ 缺少 clickhouse-connect,pip install -r requirements.txt\n")
    sys.exit(1)


CK = dict(host="127.0.0.1", port=8123, username="default", password="", database="learn_ck")


def hr(title):
    print("\n" + "=" * 72)
    print(f"  {title}")
    print("=" * 72)


def fmt_bytes(b):
    if b >= 1 << 30: return f"{b / (1 << 30):.2f} GB"
    if b >= 1 << 20: return f"{b / (1 << 20):.2f} MB"
    if b >= 1 << 10: return f"{b / (1 << 10):.2f} KB"
    return f"{b} B"


def demo_1_basic_types(client):
    hr("DEMO 1: 写入各种类型并读出")
    client.command("DROP TABLE IF EXISTS learn_ck.tt1")
    client.command("""
        CREATE TABLE learn_ck.tt1 (
            id           UInt64,
            nick         String,
            tag          LowCardinality(String),
            score        Decimal(10, 2),
            ts           DateTime,
            trace_id     UUID,
            tags         Array(String),
            geo          Tuple(Float64, Float64),
            attrs        Map(String, String),
            middle_name  Nullable(String)
        ) ENGINE = MergeTree ORDER BY id
    """)
    client.insert(
        "learn_ck.tt1",
        [
            (1, "Alice", "vip", 99.50, datetime(2026, 4, 17, 9, 30),
             uuid.uuid4(), ["a", "b", "c"], (116.4, 39.9),
             {"src": "app", "ver": "1.2"}, None),
            (2, "Bob", "normal", 12.00, datetime(2026, 4, 17, 9, 31),
             uuid.uuid4(), ["x"], (121.5, 31.2),
             {"src": "web"}, "Robert"),
        ],
        column_names=["id", "nick", "tag", "score", "ts", "trace_id",
                       "tags", "geo", "attrs", "middle_name"],
    )

    rows = client.query("SELECT * FROM learn_ck.tt1 ORDER BY id").result_rows
    for r in rows:
        print(" ", r)


def demo_2_str_compare(client, n_rows=1_000_000):
    hr(f"DEMO 2: String vs FixedString vs LowCardinality 存储对比 ({n_rows:,} 行)")
    for tbl in ("ts_string", "ts_fixed", "ts_lc"):
        client.command(f"DROP TABLE IF EXISTS learn_ck.{tbl}")

    client.command(f"""
        CREATE TABLE learn_ck.ts_string (country String) ENGINE = MergeTree ORDER BY tuple()
    """)
    client.command(f"""
        CREATE TABLE learn_ck.ts_fixed (country FixedString(2)) ENGINE = MergeTree ORDER BY tuple()
    """)
    client.command(f"""
        CREATE TABLE learn_ck.ts_lc (country LowCardinality(String)) ENGINE = MergeTree ORDER BY tuple()
    """)

    base = """
        SELECT arrayElement(['CN','US','JP','UK','DE','FR','RU','BR','IN','AU'],
                            toUInt32(1 + (number % 10))) AS country
        FROM numbers({n})
    """
    print(f"灌入 {n_rows:,} 行...")
    for tbl in ("ts_string", "ts_fixed", "ts_lc"):
        client.command(f"INSERT INTO learn_ck.{tbl} {base.format(n=n_rows)}")

    client.command("OPTIMIZE TABLE learn_ck.ts_string FINAL")
    client.command("OPTIMIZE TABLE learn_ck.ts_fixed FINAL")
    client.command("OPTIMIZE TABLE learn_ck.ts_lc FINAL")

    rows = client.query("""
        SELECT
            table,
            sum(data_compressed_bytes)   AS compressed,
            sum(data_uncompressed_bytes) AS uncompressed
        FROM system.parts
        WHERE database = 'learn_ck'
          AND table IN ('ts_string','ts_fixed','ts_lc')
          AND active
        GROUP BY table
        ORDER BY compressed DESC
    """).result_rows

    print(f"\n  {'table':<14} {'压缩后':>14} {'压缩前':>14}")
    print("  " + "-" * 46)
    for table, c, u in rows:
        print(f"  {table:<14} {fmt_bytes(int(c)):>14} {fmt_bytes(int(u)):>14}")
    print("\n  → 同样 100 万行国家代码,LowCardinality 的字典化让存储显著缩小。")


def demo_3_nullable_compare(client, n_rows=1_000_000):
    hr(f"DEMO 3: Nullable(Int32) vs Int32 存储对比 ({n_rows:,} 行)")
    for tbl in ("nn_plain", "nn_null"):
        client.command(f"DROP TABLE IF EXISTS learn_ck.{tbl}")
    client.command("CREATE TABLE learn_ck.nn_plain (v Int32) ENGINE = MergeTree ORDER BY tuple()")
    client.command("CREATE TABLE learn_ck.nn_null  (v Nullable(Int32)) ENGINE = MergeTree ORDER BY tuple()")

    print("灌入数据(30% 行 NULL)...")
    client.command(f"INSERT INTO learn_ck.nn_plain SELECT toInt32(rand() % 1000) FROM numbers({n_rows})")
    client.command(f"""
        INSERT INTO learn_ck.nn_null
        SELECT if(rand() % 10 < 3, NULL, toInt32(rand() % 1000)) FROM numbers({n_rows})
    """)
    client.command("OPTIMIZE TABLE learn_ck.nn_plain FINAL")
    client.command("OPTIMIZE TABLE learn_ck.nn_null FINAL")

    rows = client.query("""
        SELECT
            table,
            sum(data_compressed_bytes)   AS compressed,
            sum(data_uncompressed_bytes) AS uncompressed
        FROM system.parts
        WHERE database = 'learn_ck' AND table IN ('nn_plain','nn_null') AND active
        GROUP BY table
        ORDER BY table
    """).result_rows

    print(f"\n  {'table':<12} {'压缩后':>14} {'压缩前':>14}")
    print("  " + "-" * 44)
    for table, c, u in rows:
        print(f"  {table:<12} {fmt_bytes(int(c)):>14} {fmt_bytes(int(u)):>14}")
    print("\n  → Nullable 多出一列 null mask;NULL 比例越高、原列越窄,开销占比越显著。")


def demo_4_aggregate_function(client):
    hr("DEMO 4: AggregateFunction —— uniqState + uniqMerge 跨分组合并 UV")
    client.command("DROP TABLE IF EXISTS learn_ck.uv_state")
    client.command("""
        CREATE TABLE learn_ck.uv_state (
            day Date,
            uv_state AggregateFunction(uniq, UInt32)
        ) ENGINE = AggregatingMergeTree ORDER BY day
    """)
    # 模拟 5 天的事件
    client.command("""
        INSERT INTO learn_ck.uv_state
        SELECT toDate('2026-04-17') + toIntervalDay(number % 5) AS day,
               uniqState(toUInt32(rand() % 1000)) AS uv_state
        FROM numbers(100000)
        GROUP BY day
    """)

    print("\n按天 UV:")
    for day, uv in client.query("""
        SELECT day, uniqMerge(uv_state) AS uv FROM learn_ck.uv_state GROUP BY day ORDER BY day
    """).result_rows:
        print(f"  {day}  uv={uv}")

    print("\n直接 SELECT uv_state 列(看到的是中间状态二进制):")
    res = client.query("SELECT day, length(uv_state) AS state_size FROM learn_ck.uv_state ORDER BY day").result_rows
    for day, sz in res:
        print(f"  {day}  state_size={sz} bytes(hyperloglog 草图大小)")

    print("\n跨天合并出「整 5 天」UV:")
    rows = client.query("SELECT uniqMerge(uv_state) AS total_uv FROM learn_ck.uv_state").result_rows
    print(f"  total_uv = {rows[0][0]}")
    print("  → 中间状态可跨分组合并,是物化视图实时聚合的根基(详见第 10 章)")


def main():
    try:
        client = clickhouse_connect.get_client(**CK)
    except Exception as e:
        sys.stderr.write(f"❌ 连接失败:{e}\n")
        sys.exit(1)

    demo_1_basic_types(client)
    demo_2_str_compare(client)
    demo_3_nullable_compare(client)
    demo_4_aggregate_function(client)

    print("\n🎉 第 3 章实操完成。打开 03_data_types/demo.html 看可视化演示。")


if __name__ == "__main__":
    main()

types_play.py ↗