ClickHouse——Data Types




2021-02-21

blog_main_img

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

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

官方文档把类型系统拆得非常细,从整数、浮点、时间、字符串,到 MapJSONDynamicLowCardinalityNullable,每一类都有自己的脾气。Data Types in ClickHouse

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

类型总览图

先别急着建表,先知道 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

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

SELECT *
FROM system.data_type_families
ORDER BY name;

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

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

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

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

例如:

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

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

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

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

Float32 / Float64 适合:

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

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

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

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

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

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

一个常见例子:

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

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

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

官方文档里也列出了 BooleanData Types in ClickHouse

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

  • pending / running / success / failed
  • draft / published / archived

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

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

String:最通用,但不是最省

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

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

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

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

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

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

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

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

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

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

比如:

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

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

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

官方文档对 LowCardinality(T) 的描述很直接:它把其他类型的内部表示改成字典编码(dictionary-encoded)。LowCardinality(T)

这东西特别适合:

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

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

  • 如果字典里的不同值少于 10,000,通常收益更明显
  • 如果不同值超过 100,000,可能反而不如普通类型
    LowCardinality(T)

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

另外,官方文档还建议:处理字符串时,可以优先考虑 LowCardinality 而不是 Enum,因为它更灵活,而且经常有相近甚至更好的效率。LowCardinality(T)

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

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

ClickHouse 的时间类型并不少:

  • Date
  • Date32
  • Time
  • Time64
  • DateTime
  • DateTime64

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

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

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

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

DateTime:到秒就够

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

DateTime64:需要更高精度时再上

如果你要记录:

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

DateTime64 会更自然。

一个很常见的做法是:

event_time DateTime64(3, 'Asia/Shanghai')

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

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

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

官方文档列出了 UUIDIPv4IPv6 这些专门类型。Data Types in ClickHouse

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

原因很直白:

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

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

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

Array:同类型列表

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

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

示例:

tags Array(String)

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

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

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

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

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

  • ClickHouse 的 map 允许重复 key
  • m[k] 的读取是线性扫描,运行时复杂度跟 map 大小成线性关系
    Map(K, V)

这两个点非常值得记住。

也就是说:

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

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

  • Map 的 key 不能是 Nullable
  • 读取不存在的 key 时,返回的是值类型的默认值,而不是报错
    Map(K, V)

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

Nested:适合成组出现的列

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

Nullable:能不用就别乱用

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

首先,Nullable 会额外存一份 NULL mask 文件;其次,官方文档明确写了:使用 Nullable 几乎总会对性能产生负面影响Nullable(T)

这个提醒非常重要。

另外,官方文档还指出:

  • Nullable 列不能包含在表索引里
  • Array / Map 不能直接作为 Nullable(T)T
  • 但你可以写成 Array(Nullable(Int8)) 这种形式
    Nullable(T)

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

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

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

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

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

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

官方文档说明,JSON 类型可以把 JSON 文档存进单列里,而且能按路径读子列。JSON Data Type

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

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

官方文档还展示了通过子列读取的方式,这也是它比“单纯 String 存 JSON”更有意思的地方。JSON Data Type

如果你的数据天然就是:

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

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

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

官方文档对 Dynamic 的描述很直接:它允许在事先不知道所有类型的情况下,存储任意类型的值。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

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

选型图

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

官方 Python 集成页给的是 clickhouse-connect,安装方式也很直接:pip install clickhouse-connectIntegrating ClickHouse with 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 建一张涵盖多类类型的表

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

再插一批数据感受一下

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)

查一下几类类型的效果

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 与类型系统