本节摘要:ClickHouse 的数据类型比传统数据库丰富得多。用对类型能省存储、提速查询;用错类型则浪费空间、拖慢扫描。本节讲常规类型、LowCardinality、复合类型、日期类型的选用。
阅读完本节,你应当能够:
ClickHouse 的常规类型和别家数据库类似,但有几个细节要注意。
整数:Int8/Int16/Int32/Int64/Int128/UInt8...UInt256,按数值范围选最小的够用就行。一个 user_id 如果不超过 40 亿用 UInt32 就够,用 UInt64 白翻一倍空间。列存下,每列翻倍就是整列翻倍,影响明显。
浮点:Float32/Float64。注意浮点有精度问题,金额别用浮点。
定点数:ClickHouse 有 Decimal(P, S),适合存金额。Decimal(18, 2) 能存 18 位有效数字、2 位小数,足够大多数金额场景。
字符串:String(定长无,就是任意字节串)、FixedString(N)(定长,适合 UUID、MD5 这种固定长度的)。FixedString 比 String 省空间且访问快,但长度必须固定。
| 需求 | 选型 | 说明 |
|---|---|---|
| 用户 id < 40 亿 | UInt32 | 别无脑 UInt64 |
| 金额 | Decimal(18,2) | 别用 Float |
| UUID | FixedString(16) 或 UUID 类型 | 定长省空间 |
| 任意文本 | String | 默认选择 |
| 枚举值少 | LowCardinality(String) | 见下 |
LowCardinality(String) 是 ClickHouse 一个很实用的类型。它对基数(不同值的数量)低的字符串列做字典编码:把字符串映射成整数编号存储,查询时再还原。适合城市名、事件类型、状态这类枚举值少的列。
收益有两块:存储上,存编号比存字符串省很多;查询上,整数比较比字符串快,且能走向量化。代价是字典本身占点空间,且基数太高(比如超过几万)时收益下降甚至变负。
CREATE TABLE events ( event_time DateTime, city LowCardinality(String), -- 几十个城市 event_type LowCardinality(String), -- 几种事件 user_id UInt64 ) ENGINE = MergeTree ORDER BY (event_time, user_id);
经验值:基数 < 1 万用 LowCardinality 基本都赚,1 万到 10 万看情况,超过 10 万别用。
💡 关键直觉:日志表里的城市、渠道、状态这种"枚举型字符串"列,无脑上 LowCardinality(String),几乎是稳赚不赔的优化。
ClickHouse 的复合类型很适合存半结构化数据,这是它区别于传统 OLAP 库的一个亮点。
Array(T):数组。比如一篇文章的标签 Array(String)、一次会话的页面序列 Array(String)。查询用 arrayJoin 把数组展开成行。Tuple(T1, T2, ...):元组,固定结构。比如 Tuple(name String, age UInt8)。Map(K, V):键值映射。Nested:嵌套表,相当于数组的结构体,每个字段都是数组。JSON:实验性类型,存动态 JSON。CREATE TABLE user_sessions ( session_id UInt64, user_id UInt64, pages Array(String), -- 访问过的页面序列 durations Array(UInt32), -- 每页停留秒数 props Map(String, String) -- 自定义属性 ) ENGINE = MergeTree ORDER BY (user_id, session_id);
查"访问过首页的会话":
SELECT session_id FROM user_sessions WHERE has(pages, 'home');
把数组展开成行做分析:
SELECT session_id, page, duration FROM user_sessions ARRAY JOIN pages AS page, durations AS duration;
复合类型让 ClickHouse 能存"一个会话有多页"这种一对多关系,不用拆成两张表 JOIN,宽表化正是它的强项。
日期类型有 Date(天)、DateTime(秒)、DateTime64(亚秒)。按粒度选:
Date:只到天,2 字节。分区键常用它,省空间。DateTime:到秒,4 字节。事件时间常用。DateTime64:到亚秒(可指定精度),8 字节。高精度监控用。注意时区。DateTime 可以带时区 DateTime('Asia/Shanghai'),不带则用服务器时区。跨时区业务要明确指定,避免歧义。
CREATE TABLE events ( event_date Date, -- 分区用,到天 event_time DateTime('Asia/Shanghai') -- 精确到秒 ) ENGINE = MergeTree PARTITION BY event_date ORDER BY event_time;
把 Date 当分区键、DateTime 当精确时间是常见做法——分区用粗粒度裁剪,精确时间用细粒度查询。
| 场景 | 推荐类型 |
|---|---|
| 自增主键/计数 | UInt32 或 UInt64 |
| 金额 | Decimal(18,2) |
| 枚举字符串(城市/状态) | LowCardinality(String) |
| 固定长度(UUID) | UUID 或 FixedString(16) |
| 标签列表 | Array(String) |
| 键值属性 | Map(String, String) |
| 分区键 | Date |
| 事件精确时间 | DateTime |
⚠️ 常见坑:有人把所有整数都声明成
UInt64、所有字符串都String,结果存储膨胀、扫描变慢。ClickHouse 是列存,每列类型选得越紧致,整体越快越省。建表时花几分钟想清楚每列类型,比事后调优省事得多。
第 3 章结束。你已经掌握引擎选型、MergeTree 家族、索引、类型——这是用 ClickHouse 最核心的能力。下一章讲查询怎么跑、怎么优化。
类型选择的收益,可以在同一份数据上用不同声明方式做对比。比如分别用 String 和 LowCardinality(String) 存城市名,看压缩后的大小:
-- 建两张结构相同、仅城市列类型不同的表 CREATE TABLE city_string (city String) ENGINE = MergeTree ORDER BY city; CREATE TABLE city_low (city LowCardinality(String)) ENGINE = MergeTree ORDER BY city; -- 灌入重复值较多的城市数据后,对比磁盘占用 SELECT table, sum(bytes_on_disk) AS bytes FROM system.parts WHERE table IN ('city_string', 'city_low') GROUP BY table;
city_low 的磁盘占用通常会小很多,因为 LowCardinality 把字符串映射成整数编号存储,重复值越多收益越大。查询时整数比较也比字符串快。这就是"类型选得紧致,整体越快越省"的直观证据。
日期类型的选择影响分区裁剪的粒度。toYYYYMMDD(event_time) 按天分区支持"只查某天"的裁剪,粒度更细的分区裁剪更准,但分区数也会变多、part 管理开销变大:
-- 按天分区:适合大多数日志分析 CREATE TABLE log_daily ( event_time DateTime, message String ) ENGINE = MergeTree PARTITION BY toYYYYMMDD(event_time) ORDER BY event_time; -- 按小时分区:适合查询集中在小时内的场景 CREATE TABLE log_hourly ( event_time DateTime, message String ) ENGINE = MergeTree PARTITION BY toYYYYMMDDhh(event_time) ORDER BY event_time;
分区的选择本质是"裁剪粒度"和"管理开销"的权衡:查询经常按天过滤就用天级分区,经常按小时过滤才考虑小时级。别为了"整齐"做无意义的分区,分区是为查询模式服务的。