2021-02-21
在 ClickHouse 里,类型系统不是附属品,它几乎直接影响:
官方文档把类型系统拆得非常细,从整数、浮点、时间、字符串,到 Map、JSON、Dynamic、LowCardinality、Nullable,每一类都有自己的脾气。Data Types in ClickHouse
这篇就围绕 data-types 这条线来收一遍,不追求把所有类型背成目录,而是尽量讲清楚:常见类型怎么选、哪些包装类型特别值钱、哪些地方最容易踩坑。
官方文档的类型页列出了当前支持的一大串类型,除了常见的:
Int / UIntFloat32 / Float64 / BFloat16DecimalString / FixedStringDate / Date32 / Time / Time64 / DateTime / DateTime64BooleanArray / Tuple / Map / Nested还有一批更偏分析场景的类型:
UUIDIPv4 / IPv6EnumVariantLowCardinalityNullableJSONDynamic官方文档还特别提到一个很实用的系统表:system.data_type_families。它能帮助你查看当前有哪些类型、哪些名字其实是别名、以及名称是否区分大小写。Data Types in ClickHouse
这张表很适合在你不确定某个类型名到底怎么写的时候先查一眼:
SELECT *
FROM system.data_type_families
ORDER BY name;
这个动作看起来不起眼,但在你开始接触 Bool、UUID、DateTime64、LowCardinality 这些类型时,会非常省脑子。
这类类型最容易理解,也最容易被滥用。
例如:
UInt32 / UInt64Int32 / Int64一个很实用的习惯是:别默认上 Int64,先看业务值域到底多大。
在列式数据库里,类型越贴近真实值域,通常越容易拿到更好的压缩和存储效率。
Float32 / Float64 适合:
但如果你在乎精确小数,比如:
那就别偷懒用 Float64,直接考虑 Decimal。
Decimal 的存在,就是为了避免浮点在精度上的那些小脾气。
一个常见例子:
CREATE TABLE order_amount
(
order_id UInt64,
amount Decimal(18, 2)
)
ENGINE = MergeTree
ORDER BY order_id;
在分析型数据库里,钱这类字段如果一开始就用错,后面补救成本很高。这里不需要赌运气,直接用 Decimal。
官方文档里也列出了 Boolean。Data Types in ClickHouse
但一个工程上的小建议是:如果状态天然就不止两类,比如:
pending / running / success / faileddraft / published / archived那就不要硬拆成多个布尔列,直接上枚举或字符串会更自然。
String 非常灵活,所以很多人建表时会本能地往它身上靠。
问题在于,它过于宽泛了。
如果你的列有明显的模式,比如:
那 String 虽然能装,但通常不是最优解。
FixedString(N) 不是拿来代替所有 String 的。它更适合那些长度天然固定的字段,比如固定编码或二进制片段。
如果字符串长度变化很大,硬上 FixedString 反而会别扭。
如果一列的取值非常少,而且变化频率低,Enum 是个很利落的选择。
比如:
status Enum8(
'new' = 1,
'paid' = 2,
'done' = 3,
'cancelled' = 4
)
但它有一个现实问题:后续要改枚举成员时,没有 String 或 LowCardinality(String) 那么松快。
官方文档对 LowCardinality(T) 的描述很直接:它把其他类型的内部表示改成字典编码(dictionary-encoded)。LowCardinality(T)
这东西特别适合:
官方文档还给了一个很有参考价值的经验区间:
10,000,通常收益更明显100,000,可能反而不如普通类型这条经验很实用,别背成教条,但值得记在脑子里。
另外,官方文档还建议:处理字符串时,可以优先考虑 LowCardinality 而不是 Enum,因为它更灵活,而且经常有相近甚至更好的效率。LowCardinality(T)
这句话在工程里非常有分量。
ClickHouse 的时间类型并不少:
DateDate32TimeTime64DateTimeDateTime64实际建表时,可以先按这个思路判断:
如果你的分析只是按天聚合,比如订单日、注册日、账单日,优先考虑 Date 或 Date32。
你不需要“时分秒”的时候,就别多背一个更重的时间类型。
很多埋点、日志、订单创建时间,用 DateTime 就足够了。
如果你要记录:
那 DateTime64 会更自然。
一个很常见的做法是:
event_time DateTime64(3, 'Asia/Shanghai')
它表达得就非常清楚:毫秒精度,按指定时区解释。
这里最怕的不是类型太少,而是“明明只按天分析,却全表都上了高精度时间列”,最后存了很多自己根本不会用到的信息。
官方文档列出了 UUID、IPv4、IPv6 这些专门类型。Data Types in ClickHouse
如果你本来就知道一列语义明确,比如它天然就是 UUID 或 IP 地址,那直接用对应类型通常比 String 更稳。
原因很直白:
这类列如果一开始被你偷懒塞成了字符串,之后经常会在过滤、转换、函数使用上多出很多补丁。
Array(T) 很适合表示一列里的一组同类型值,比如:
示例:
tags Array(String)
Tuple 更像是“几种类型的固定打包”,适合轻量结构,但不一定适合作为特别复杂的业务对象主载体。
Map(K, V) 很容易让人产生一个误解:它是不是和很多数据库里的 map 一样,按 key 查非常自然?
官方文档专门提醒了两个关键点:
m[k] 的读取是线性扫描,运行时复杂度跟 map 大小成线性关系这两个点非常值得记住。
也就是说:
Map 更像“成对存储的一组 key-value”Array(Tuple(K, V))官方文档还说了一个细节:
Map 的 key 不能是 Nullable如果你真要判断 key 存不存在,记得用 mapContains,不要只看 m[k] 的结果。
Nested 适合那种“一组字段一起重复”的结构,比如事件属性列表、商品明细片段。它和 JSON 的感觉有点像,但更偏结构化、预定义。
Nullable(T) 很常见,但官方文档对它的提醒也非常直接。
首先,Nullable 会额外存一份 NULL mask 文件;其次,官方文档明确写了:使用 Nullable 几乎总会对性能产生负面影响。Nullable(T)
这个提醒非常重要。
另外,官方文档还指出:
Nullable 列不能包含在表索引里Array / Map 不能直接作为 Nullable(T) 的 TArray(Nullable(Int8)) 这种形式实际建模时,一个很实用的原则是:
NullableNullable尤其在高频查询列上,不要图省事一把梭全设成 Nullable。
ClickHouse 这块现在已经不是“只能把 JSON 当字符串存着”的阶段了。
官方文档说明,JSON 类型可以把 JSON 文档存进单列里,而且能按路径读子列。JSON Data Type
你可以像这样访问子路径:
SELECT json.a.b, json.c FROM events;
官方文档还展示了通过子列读取的方式,这也是它比“单纯 String 存 JSON”更有意思的地方。JSON Data Type
如果你的数据天然就是:
那 JSON 会比裸 String 更像一等公民。
官方文档对 Dynamic 的描述很直接:它允许在事先不知道所有类型的情况下,存储任意类型的值。Dynamic
这类类型适合那些模式不完全稳定、又不想一开始就过度建模的场景。
如果一列只会在几种已知类型之间切换,Variant(T1, T2, ...) 比完全放飞的 Dynamic 更可控。
简单说:
Variant:类型集合是已知的Dynamic:类型集合不一定预先知道JSON:重点在文档结构和路径读取如果你现在要设计一张 ClickHouse 表,类型选择可以按这个顺序想:
DecimalLowCardinality(String)NULL?不要默认 NullableMap 的读取代价能不能接受JSON / Dynamic / Variant这套判断比死记类型目录更有用。
官方 Python 集成页给的是 clickhouse-connect,安装方式也很直接:pip install clickhouse-connect。Integrating 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)
这个小查询很适合作为“类型系统体检入口”。
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)
""")
这个结构里其实就把几类常用选择放在一起了:
UInt64LowCardinality(String)DecimalDateTime64ArrayMapJSONNullablefrom 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 仍然要有意识地控制使用范围