主题
第 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 亿个国家代码,
String占 200 MB,LowCardinality(String)只占 12 MB; - 向量化执行的速度和类型紧凑度强相关:
UInt8一条 SIMD 指令算 64 个,String一条算 1 个; 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 全谱系
| 类型 | 字节数 | 范围 |
|---|---|---|
Int8 | 1 | -128 ~ 127 |
Int16 | 2 | -32,768 ~ 32,767 |
Int32 | 4 | -2.1e9 ~ 2.1e9 |
Int64 | 8 | -9.2e18 ~ 9.2e18 |
Int128 | 16 | ±1.7e38 |
Int256 | 32 | ±5.8e76 |
UInt8 | 1 | 0 ~ 255 |
UInt16 | 2 | 0 ~ 65,535 |
UInt32 | 4 | 0 ~ 4.29e9 |
UInt64 | 8 | 0 ~ 1.84e19 |
UInt128 | 16 | 0 ~ 3.4e38 |
UInt256 | 32 | 0 ~ 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 位有效数字⚠ 金额绝对不要用 Float:
0.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) | ≤ 9 | 4 |
Decimal64(s) | ≤ 18 | 8 |
Decimal128(s) | ≤ 38 | 16 |
Decimal256(s) | ≤ 76 | 32 |
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收益:
- 存储省 5~20 倍;
- 查询快(GROUP BY / 过滤 / JOIN 都基于整数 code,SIMD 友好);
- 字典自动维护,新值会自动加入;
- 字典宽度自动选择 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
| 类型 | 字节数 | 范围 | 精度 |
|---|---|---|---|
Date | 2 | 1970-01-01 ~ 2149-06-06 | 天 |
Date32 | 4 | 1900-01-01 ~ 2299-12-31 | 天 |
DateTime | 4 | 1970-01-01 ~ 2106-02-07 | 秒 |
DateTime 带时区 | 4 | 同上 | 秒(带时区元信息) |
DateTime64(0) | 8 | 1900 ~ 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)代价:
- 存储多 ~12.5%(小列影响更大);
- GROUP BY / 比较 / JOIN 都要先看 null mask,向量化效率下降;
- Nullable 不能作为 ORDER BY / PARTITION BY 表达式;
- 配合 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 累加值即可,查询直接用 SUM3.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(与 MySQLCAST行为不同)。生产代码请显式用toUInt32OrZero/toUInt32OrNull/accurateCastOrNull。
📌 与 MySQL / PostgreSQL 的对比小框
| 类型 | MySQL | PostgreSQL | ClickHouse |
|---|---|---|---|
| 整数家族 | TINYINT / SMALLINT / INT / BIGINT | smallint / integer / bigint | Int8/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) / TEXT | varchar / text | String(无长度上限) |
| 字符串定长 | CHAR(N) | char(N) | FixedString(N)(按字节而非字符) |
| 字符串字典 | ENUM | ❌(要 lookup table) | LowCardinality(T) + Enum |
| JSON | JSON | jsonb(带索引) | JSON / Object(实验)+ Map / Nested |
| 数组 | ❌ | array | Array(T) 原生 |
| 元组 | ❌ | composite type | Tuple(T1, T2, ...) |
| Map | ❌ | hstore / jsonb | Map(K, V) |
| Nested | ❌ | composite + array | Nested(...)(并行数组糖) |
| UUID | CHAR(36) 模拟 | uuid | UUID(16 字节) |
| IP | INET 仅 PG | inet / cidr | IPv4 / 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演示:
- 写入各类型并读出;
- 对比
String/FixedString/LowCardinality(String)在同一份 100 万行数据上的存储大小; - 对比
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)。
收益:
- 存储省 5~20 倍;
- 查询快(GROUP BY、过滤、JOIN 都基于位置整数,SIMD 友好);
- 字典自动维护,新值无成本加入;
- 与 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。
代价:
- 存储多约 12.5%(小类型列影响更大,比如 UInt8 列直接翻倍);
- 查询多读一列:每个算子都要先读 null mask 再判断;
- 不能作为 ORDER BY / PARTITION BY 表达式;
- 破坏 SIMD 紧凑度:算子要先按 mask 跳行;
LowCardinality(Nullable(T))字典里要专门存 NULL 占位符,节省效果打折;- 聚合函数的语义复杂化:
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) 三个怎么选?
考察点:是否真的写过表结构。
标准答案:
| 类型 | 适用 | 优势 | 劣势 |
|---|---|---|---|
String | 99% 场景,默认选它 | 灵活、无长度上限 | 高基数列存储略大 |
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():把多个中间状态合并成新的中间状态(链式聚合)。
为什么物化视图离不开它:
- 普通聚合只能算一次,不能合并 —— 比如「每天的 UV」相加 ≠ 「这周 UV」;
- 中间状态可以跨时间 / 跨分区合并 —— 每分钟物化一次,查询任意天 / 周 / 月 UV 时只需
uniqMerge; 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 DATETIME | MySQL TIMESTAMP | CK DateTime | CK DateTime64(p) |
|---|---|---|---|---|
| 字节数 | 5~8 | 4~7 | 4 | 8 |
| 范围 | 1000-9999 | 1970-2038 | 1970-2106 | 1900-2299 |
| 精度 | 0~6(秒~微秒) | 0~6 | 秒 | 0~9(秒~纳秒) |
| 时区 | 不带,按原值存 | 自动转 UTC 存,按 session 时区显示 | 底层永远 UTC,时区是显示元数据 | 同左 |
| 可指定时区 | ❌ | ❌(全局 session) | ✅ DateTime('Asia/Shanghai') | ✅ |
ClickHouse 的关键设计:
DateTime列底层永远是 UTC unix timestamp(4 字节 UInt32);- 列定义里写的时区只是「显示规则」 —— 不同客户端用不同时区连进来,看到的字符串自动换算;
- 不带时区的
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()