码到成功

ClickHouse——Data Types


在 `ClickHouse` 里,类型系统不是附属品,它几乎直接影响:

- 存储体积
- 查询速度
- 压缩效果
- 索引与跳数效果
- Python 侧写入和读取时的心智负担

官方文档把类型系统拆得非常细,从整数、浮点、时间、字符串,到 `Map`、`JSON`、`Dynamic`、`LowCardinality`、`Nullable`,每一类都有自己的脾气。[Data Types in ClickHouse](https://clickhouse.com/docs/sql-reference/data-types)

这篇就围绕 `data-types` 这条线来收一遍,不追求把所有类型背成目录,而是尽量讲清楚:**常见类型怎么选、哪些包装类型特别值钱、哪些地方最容易踩坑。**

![类型总览图](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/clickhouse-datatypes-map-zqfd.svg)

## 先别急着建表,先知道 ClickHouse 到底有哪些类型

官方文档的类型页列出了当前支持的一大串类型,除了常见的:

- `Int / UInt`
- `Float32 / Float64 / BFloat16`
- `Decimal`
- `String / FixedString`
- `Date / Date32 / Time / Time64 / DateTime / DateTime64`
- `Boolean`
- `Array / Tuple / Map / Nested`

还有一批更偏分析场景的类型:

- `UUID`
- `IPv4 / IPv6`
- `Enum`
- `Variant`
- `LowCardinality`
- `Nullable`
- `JSON`
- `Dynamic`

官方文档还特别提到一个很实用的系统表:`system.data_type_families`。它能帮助你查看当前有哪些类型、哪些名字其实是别名、以及名称是否区分大小写。[Data Types in ClickHouse](https://clickhouse.com/docs/sql-reference/data-types)

这张表很适合在你不确定某个类型名到底怎么写的时候先查一眼:

```sql
SELECT *
FROM system.data_type_families
ORDER BY name;
```

这个动作看起来不起眼,但在你开始接触 `Bool`、`UUID`、`DateTime64`、`LowCardinality` 这些类型时,会非常省脑子。

## 数值类型:先把“能装下”和“算得准”分开想

### Int / UInt:最基础,也最常用

这类类型最容易理解,也最容易被滥用。

例如:

- 设备计数、用户 ID、状态码,常见用 `UInt32 / UInt64`
- 会出现负值的指标,比如增量差值、偏移量,才更适合 `Int32 / Int64`

一个很实用的习惯是:**别默认上 `Int64`,先看业务值域到底多大。**

在列式数据库里,类型越贴近真实值域,通常越容易拿到更好的压缩和存储效率。

### Float:算得快,但别拿来装钱

`Float32 / Float64` 适合:

- 指标计算
- 比例、分数
- 带小数的近似值

但如果你在乎精确小数,比如:

- 金额
- 结算单价
- 折扣值

那就别偷懒用 `Float64`,直接考虑 `Decimal`。

### Decimal:给精确小数一个正式工位

`Decimal` 的存在,就是为了避免浮点在精度上的那些小脾气。

一个常见例子:

```sql
CREATE TABLE order_amount
(
    order_id UInt64,
    amount Decimal(18, 2)
)
ENGINE = MergeTree
ORDER BY order_id;
```

在分析型数据库里,钱这类字段如果一开始就用错,后面补救成本很高。这里不需要赌运气,直接用 `Decimal`。

### Boolean:有,但别把所有状态都塞成 Bool

官方文档里也列出了 `Boolean`。[Data Types in ClickHouse](https://clickhouse.com/docs/sql-reference/data-types)

但一个工程上的小建议是:如果状态天然就不止两类,比如:

- `pending / running / success / failed`
- `draft / published / archived`

那就不要硬拆成多个布尔列,直接上枚举或字符串会更自然。

## 字符串和枚举:别一股脑全都 String

### String:最通用,但不是最省

`String` 非常灵活,所以很多人建表时会本能地往它身上靠。

问题在于,它过于宽泛了。

如果你的列有明显的模式,比如:

- 状态值很少
- 国家码重复很多
- 渠道名固定集合

那 `String` 虽然能装,但通常不是最优解。

### FixedString(N):定长字段才值得用

`FixedString(N)` 不是拿来代替所有 `String` 的。它更适合那些长度天然固定的字段,比如固定编码或二进制片段。

如果字符串长度变化很大,硬上 `FixedString` 反而会别扭。

### Enum:适合小而稳定的离散集合

如果一列的取值非常少,而且变化频率低,`Enum` 是个很利落的选择。

比如:

```sql
status Enum8(
    'new' = 1,
    'paid' = 2,
    'done' = 3,
    'cancelled' = 4
)
```

但它有一个现实问题:后续要改枚举成员时,没有 `String` 或 `LowCardinality(String)` 那么松快。

### LowCardinality:ClickHouse 里特别值得认识的包装类型

官方文档对 `LowCardinality(T)` 的描述很直接:它把其他类型的内部表示改成字典编码(dictionary-encoded)。[LowCardinality(T)](https://clickhouse.com/docs/sql-reference/data-types/lowcardinality)

这东西特别适合:

- 城市名
- 国家码
- 渠道名
- 类别标签
- 重复度很高的字符串列

官方文档还给了一个很有参考价值的经验区间:

- 如果字典里的不同值少于 `10,000`,通常收益更明显
- 如果不同值超过 `100,000`,可能反而不如普通类型  
[LowCardinality(T)](https://clickhouse.com/docs/sql-reference/data-types/lowcardinality)

这条经验很实用,别背成教条,但值得记在脑子里。

另外,官方文档还建议:处理字符串时,可以优先考虑 `LowCardinality` 而不是 `Enum`,因为它更灵活,而且经常有相近甚至更好的效率。[LowCardinality(T)](https://clickhouse.com/docs/sql-reference/data-types/lowcardinality)

这句话在工程里非常有分量。

## 时间类型:别让时间精度和时区混成一团

ClickHouse 的时间类型并不少:

- `Date`
- `Date32`
- `Time`
- `Time64`
- `DateTime`
- `DateTime64`

实际建表时,可以先按这个思路判断:

### Date / Date32:只关心日期,不关心具体时刻

如果你的分析只是按天聚合,比如订单日、注册日、账单日,优先考虑 `Date` 或 `Date32`。

你不需要“时分秒”的时候,就别多背一个更重的时间类型。

### DateTime:到秒就够

很多埋点、日志、订单创建时间,用 `DateTime` 就足够了。

### DateTime64:需要更高精度时再上

如果你要记录:

- 毫秒级日志
- 链路事件
- 更细粒度的操作时间

那 `DateTime64` 会更自然。

一个很常见的做法是:

```sql
event_time DateTime64(3, 'Asia/Shanghai')
```

它表达得就非常清楚:毫秒精度,按指定时区解释。

这里最怕的不是类型太少,而是“明明只按天分析,却全表都上了高精度时间列”,最后存了很多自己根本不会用到的信息。

## UUID、IP、网络类字段:别把它们都塞成字符串

官方文档列出了 `UUID`、`IPv4`、`IPv6` 这些专门类型。[Data Types in ClickHouse](https://clickhouse.com/docs/sql-reference/data-types)

如果你本来就知道一列语义明确,比如它天然就是 UUID 或 IP 地址,那直接用对应类型通常比 `String` 更稳。

原因很直白:

- 语义更清楚
- 查询和函数适配更自然
- 后续转换和校验也更方便

这类列如果一开始被你偷懒塞成了字符串,之后经常会在过滤、转换、函数使用上多出很多补丁。

## 容器类型:Array、Tuple、Map、Nested 怎么分工

### Array:同类型列表

`Array(T)` 很适合表示一列里的一组同类型值,比如:

- tag 列表
- 维度 ID 列表
- 时间序列片段

示例:

```sql
tags Array(String)
```

### Tuple:一行里绑在一起的定长结构

`Tuple` 更像是“几种类型的固定打包”,适合轻量结构,但不一定适合作为特别复杂的业务对象主载体。

### Map:看起来像字典,但别把它想得太像传统哈希表

`Map(K, V)` 很容易让人产生一个误解:它是不是和很多数据库里的 map 一样,按 key 查非常自然?

官方文档专门提醒了两个关键点:

- ClickHouse 的 map **允许重复 key**
- `m[k]` 的读取是**线性扫描**,运行时复杂度跟 map 大小成线性关系  
[Map(K, V)](https://clickhouse.com/docs/sql-reference/data-types/map)

这两个点非常值得记住。

也就是说:

- `Map` 更像“成对存储的一组 key-value”
- 它内部实现接近 `Array(Tuple(K, V))`
- 它不是为“超高频、随机 key 查找”量身做的

官方文档还说了一个细节:

- `Map` 的 key 不能是 `Nullable`
- 读取不存在的 key 时,返回的是值类型的默认值,而不是报错  
[Map(K, V)](https://clickhouse.com/docs/sql-reference/data-types/map)

如果你真要判断 key 存不存在,记得用 `mapContains`,不要只看 `m[k]` 的结果。

### Nested:适合成组出现的列

`Nested` 适合那种“一组字段一起重复”的结构,比如事件属性列表、商品明细片段。它和 JSON 的感觉有点像,但更偏结构化、预定义。

## Nullable:能不用就别乱用

`Nullable(T)` 很常见,但官方文档对它的提醒也非常直接。

首先,`Nullable` 会额外存一份 `NULL` mask 文件;其次,官方文档明确写了:**使用 `Nullable` 几乎总会对性能产生负面影响**。[Nullable(T)](https://clickhouse.com/docs/sql-reference/data-types/nullable)

这个提醒非常重要。

另外,官方文档还指出:

- `Nullable` 列不能包含在表索引里
- `Array / Map` 不能直接作为 `Nullable(T)` 的 `T`
- 但你可以写成 `Array(Nullable(Int8))` 这种形式  
[Nullable(T)](https://clickhouse.com/docs/sql-reference/data-types/nullable)

实际建模时,一个很实用的原则是:

- 如果业务上真有“缺失值”,再用 `Nullable`
- 如果只是“空字符串”“0”“unknown”就能表达,那可以先别上 `Nullable`

尤其在高频查询列上,不要图省事一把梭全设成 `Nullable`。

## JSON、Dynamic、Variant:半结构化数据别再只会塞 String

ClickHouse 这块现在已经不是“只能把 JSON 当字符串存着”的阶段了。

### JSON:把 JSON 文档作为单列存储

官方文档说明,`JSON` 类型可以把 JSON 文档存进单列里,而且能按路径读子列。[JSON Data Type](https://clickhouse.com/docs/sql-reference/data-types/json)

你可以像这样访问子路径:

```sql
SELECT json.a.b, json.c FROM events;
```

官方文档还展示了通过子列读取的方式,这也是它比“单纯 String 存 JSON”更有意思的地方。[JSON Data Type](https://clickhouse.com/docs/sql-reference/data-types/json)

如果你的数据天然就是:

- 属性很多
- 字段会缓慢演化
- 并不想为每个字段立刻建显式列

那 `JSON` 会比裸 `String` 更像一等公民。

### Dynamic:我现在还不知道它到底会来什么类型

官方文档对 `Dynamic` 的描述很直接:它允许在事先不知道所有类型的情况下,存储任意类型的值。[Dynamic](https://clickhouse.com/docs/sql-reference/data-types/dynamic)

这类类型适合那些模式不完全稳定、又不想一开始就过度建模的场景。

### Variant:有限集合里的多种可能类型

如果一列只会在几种已知类型之间切换,`Variant(T1, T2, ...)` 比完全放飞的 `Dynamic` 更可控。

简单说:

- `Variant`:类型集合是已知的
- `Dynamic`:类型集合不一定预先知道
- `JSON`:重点在文档结构和路径读取

## 一个很实用的选型顺序

如果你现在要设计一张 ClickHouse 表,类型选择可以按这个顺序想:

1. 它是不是纯数值?如果要精确小数,优先 `Decimal`
2. 它是不是日期或时间?如果只按天分析,就别上高精度时间
3. 它是不是重复值很多的字符串?优先想想 `LowCardinality(String)`
4. 它是不是真的需要 `NULL`?不要默认 `Nullable`
5. 它是不是 key-value 结构?先确认 `Map` 的读取代价能不能接受
6. 它是不是半结构化文档?优先评估 `JSON / Dynamic / Variant`

这套判断比死记类型目录更有用。

![选型图](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/clickhouse-datatypes-choose-zqfd.svg)

## 用 Python 看类型系统,会更有手感

官方 Python 集成页给的是 `clickhouse-connect`,安装方式也很直接:`pip install clickhouse-connect`。[Integrating ClickHouse with Python](https://clickhouse.com/jp/integrations/python)

先来一个最基础的例子,直接查系统表:

```python
import clickhouse_connect

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

result = client.query("""
    SELECT name, case_insensitive, alias_to
    FROM system.data_type_families
    ORDER BY name
    LIMIT 20
""")

for row in result.result_rows:
    print(row)
```

这个小查询很适合作为“类型系统体检入口”。

### 用 Python 建一张涵盖多类类型的表

```python
client.command("""
CREATE TABLE IF NOT EXISTS demo_data_types
(
    id UInt64,
    city LowCardinality(String),
    price Decimal(18, 2),
    created_at DateTime64(3, 'Asia/Shanghai'),
    is_active Boolean,
    tags Array(String),
    attrs Map(String, String),
    ext_json JSON,
    note Nullable(String)
)
ENGINE = MergeTree
ORDER BY (city, id)
""")
```

这个结构里其实就把几类常用选择放在一起了:

- 主键 ID 用 `UInt64`
- 城市名用 `LowCardinality(String)`
- 金额用 `Decimal`
- 时间用 `DateTime64`
- tag 用 `Array`
- 属性包用 `Map`
- 扩展字段用 `JSON`
- 可缺失文案才用 `Nullable`

### 再插一批数据感受一下

```python
from decimal import Decimal
from datetime import datetime

rows = [
    [
        1,
        'Shanghai',
        Decimal('19.90'),
        datetime.now(),
        True,
        ['new', 'vip'],
        {'channel': 'app', 'campaign': 'spring'},
        {'device': {'os': 'android'}, 'score': 98},
        None,
    ],
    [
        2,
        'Shanghai',
        Decimal('29.50'),
        datetime.now(),
        False,
        ['promo'],
        {'channel': 'web'},
        {'device': {'os': 'ios'}, 'score': 88},
        'manual review',
    ],
]

client.insert('demo_data_types', rows)
```

### 查一下几类类型的效果

```python
result = client.query("""
    SELECT
        city,
        sum(price) AS total_price,
        count() AS cnt,
        groupArray(tags) AS tag_groups,
        ext_json.device.os AS os
    FROM demo_data_types
    GROUP BY city, os
    ORDER BY city, os
""")

print(result.result_rows)
```

这里有几个特别直观的点:

- `LowCardinality(String)` 在分组场景里通常很香
- `Decimal` 聚合时比浮点更让人安心
- `JSON` 子路径能直接读
- `Nullable` 仍然要有意识地控制使用范围

![Python 与类型系统](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/clickhouse-datatypes-python-zqfd.svg)