深入解析ClickHouse中的LowCardinality类型:优化字符串存储的利器
1. 为什么我们需要LowCardinality类型?
如果你用过ClickHouse处理过日志、用户行为或者任何带有标签的数据,肯定对一种字段又爱又恨:字符串。爱它是因为它太灵活了,什么都能往里放;恨它是因为它太“吃”资源了,存储空间大,查询速度慢,简直就是性能的“吞金兽”。我自己在早期做用户行为分析平台时,就深受其苦,一张表动辄几十亿行,里面有几个String类型的标签字段,磁盘空间蹭蹭涨,一个简单的GROUP BY查询能跑上好几分钟。
ClickHouse其实早就意识到了这个问题,并且给出了几种解决方案。比如FixedString,用来存固定长度的字符串,像MD5值、IP地址这种就很合适。还有Enum类型,把字符串映射成数字枚举值。但说实话,这两种方案在实际中用起来都挺别扭的。FixedString要求长度固定,而我们业务里绝大部分字符串,比如商品名称、城市名、事件类型,长度都是参差不齐的。用FixedString就得按最长的来定义,会造成大量的空间浪费。Enum类型更麻烦,它需要预先定义好所有可能的值,每次业务新增一个标签或者一个事件类型,你都得去改表结构,这在快速迭代的业务里几乎是不可行的。
所以,ClickHouse的工程师们给出了第三条路,也是我们今天要深入聊的LowCardinality类型,中文可以叫“低基数”类型。这个名字起得非常直白,它就是为了解决那种“长度不固定、值可能会变,但总体上不同的值(也就是基数)并不算特别多”的字符串字段而设计的。举个例子,你的用户表里有个“省份”字段,全中国也就34个省级行政区,这就是一个典型的低基数字段。再比如,一个电商平台的“订单状态”,无非就是“待付款”、“待发货”、“已发货”、“已完成”、“已取消”等有限的几种状态。把这些字段继续用普通的String类型来存,就有点“杀鸡用牛刀”了,不仅浪费,还拖慢整个系统的速度。
我第一次意识到它的威力,是在一次存储成本审计中。我们发现一个存储用户行为事件的大表,其中一个叫event_name的String字段,竟然占用了整个表超过40%的存储空间。而这个字段虽然有几亿行数据,但不同的事件名其实只有不到200种。把心一横,尝试把它改成LowCardinality(String),结果你猜怎么着?那个字段的存储空间直接缩小了将近10倍!查询速度的提升更是立竿见影。从那以后,只要遇到基数不高的字符串字段,我的第一反应就是:能不能上LowCardinality?
2. LowCardinality是如何工作的?揭秘字典编码
光知道它好用还不够,我们得弄明白它为什么这么快、这么省空间。这就要深入到它的实现原理了。LowCardinality的核心思想其实并不新鲜,在计算机科学里,这叫做字典编码,是一种非常经典且高效的无损压缩技术。
你可以把它想象成我们小时候用的“成语词典”。假设你需要记录全班同学最喜欢的运动,如果直接记录字符串,你会写下:“篮球”、“足球”、“篮球”、“乒乓球”、“足球”、“篮球”…… 这样写很长,也很占地方。更聪明的办法是,你先在旁边建立一个小字典: 1: 篮球 2: 足球 3: 乒乓球
然后,你只需要记录一列数字:1, 2, 1, 3, 2, 1。这样一来,信息一点没少,但记录的空间却小了很多。LowCardinality类型干的就是这个事。
具体到ClickHouse内部,当你定义一个LowCardinality(String)的列时,它会为这个列自动维护一个全局字典和一个数据文件。
- 全局字典:里面按顺序存储了这个列所有出现过的、不重复的字符串值。每个字符串都有一个唯一的整数ID(通常是从0或1开始的自增数字)。
- 数据文件:实际存储的不再是原始的字符串,而是对应字符串在字典里的整数ID。
这样做带来的好处是爆炸性的:
- 存储空间急剧下降:整数(比如UInt8, UInt16)占用的空间远小于长度不定的字符串。一个中文UTF-8字符串可能占好几个字节,但用一个UInt16(2字节)就能代表65536个不同的值,这对于大部分低基数字段绰绰有余。
- 查询速度大幅提升:比较、排序、分组(GROUP BY)这些操作,在整数上进行的速度比在字符串上快得多。CPU处理整数的指令效率极高。
- 函数计算下推:很多针对字符串的操作,比如
length(),substring(),甚至一些条件判断,可以直接在字典上计算一次,然后将结果映射回所有行,避免了海量数据的重复计算。
我画个简单的示意图帮你理解。假设我们有一个记录用户行为的原始表:
| user_id | action |
|---|---|
| 101 | login |
| 102 | view_page |
| 101 | click_button |
| 103 | login |
使用LowCardinality后,在磁盘上实际是这样的:
- 字典:
[0: 'login', 1: 'view_page', 2: 'click_button'] - 数据:
[(101, 0), (102, 1), (101, 2), (103, 0)]
你看,原本需要存储四遍的login字符串,现在只在字典里存了一次,数据部分只用小小的数字0来指代。当查询SELECT action, count() FROM table GROUP BY action时,ClickHouse直接对数字0,1,2进行分组聚合,速度自然飞快。
3. 什么场景该用,什么场景不该用?
了解了原理,我们就能更理智地判断,到底哪些字段适合用LowCardinality。这不是银弹,用错了地方反而可能适得其反。
3.1 强烈推荐使用的场景
根据我多年的实战经验,下面这几类字段,你几乎可以闭着眼睛用LowCardinality(String):
- 枚举型状态字段:这是最经典的场景。比如订单状态、支付状态、审核状态、开关状态。这些字段的值通常不超过几十个。
- 分类/标签字段:商品类目、文章分类、用户等级、设备型号、APP版本号、国家、省份、城市。这些字段的基数虽然可能成百上千,但相对于动辄上亿的行数来说,依然是极低的。
- 离散的业务类型:就像我开头例子里的
event_type(事件类型)、log_level(日志级别)、http_method(GET/POST等)、error_code(错误码)。这些值由业务逻辑定义,范围有限。 - 作为连接(JOIN)键的维度字段:如果你经常需要用一个字符串字段去关联另一张维度表(比如通过
city_name关联城市信息表),把这个字段改成LowCardinality能显著提升JOIN的性能。
注意:这里有个关键点,
LowCardinality不仅适用于基数绝对数量小的字段,更适用于基数相对于总行数比例很小的字段。哪怕一个字段有1万个不同的值,但你的表有100亿行,那它的基数依然是“低”的,使用LowCardinality收益会很高。
3.2 需要谨慎或避免使用的场景
- 高基数字符串:最典型的就是
user_id、email、手机号、订单号这种唯一标识符。每个值几乎都不一样,基数等于行数。如果硬要用LowCardinality,字典会变得无比巨大,甚至超过原数据大小,查询时字典本身就成了瓶颈。我曾经在测试环境误将一个用户ID字段改为低基数,结果存储空间暴涨了15%,查询速度慢了3倍,这就是反面教材。 - 超长文本字段:比如文章内容、商品详情描述。
LowCardinality的优化前提是重复值多。长文本几乎不可能重复,字典编码毫无优势。而且字典里存储超长字符串本身也会成为负担。 - 频繁更新的字段:虽然ClickHouse的
LowCardinality字典是全局的,但频繁插入全新的、不同的值,会导致字典不断扩容和重组,带来一定的开销。对于写入极其频繁且值离散度高的流式数据,需要评估。 - 基数在边界徘徊的字段:如果一个字段的基数在几千到几万之间波动,你需要关注一个关键参数:
low_cardinality_max_dictionary_size(默认8192)。当基数超过这个阈值,ClickHouse会拆分字典。多个字典的管理会带来一些额外的元数据开销。虽然Altinity的博客说1000万以下都可以,但我的经验是,对于基数在10万级别的字段,收益依然明显,但你需要监控字典拆分的情况。
4. 手把手实战:性能对比与操作指南
光说不练假把式,我们直接上代码,通过一个完整的例子来看看LowCardinality到底能带来多少提升。我会模拟一个电商场景,记录用户的浏览行为。
4.1 创建测试表并导入数据
首先,我们创建两张结构相同、仅字段类型不同的MergeTree表。
-- 创建使用普通String类型的表
CREATE TABLE default.user_behavior_common
(
user_id Int64,
page_url String,
category String, -- 商品类目,我们假设基数较低
ts DateTime
)
ENGINE = MergeTree()
ORDER BY (user_id, ts);
-- 创建使用LowCardinality(String)类型的表
CREATE TABLE default.user_behavior_lowcard
(
user_id Int64,
page_url String, -- 页面URL基数高,保持普通String
category LowCardinality(String), -- 类目字段使用低基数
ts DateTime
)
ENGINE = MergeTree()
ORDER BY (user_id, ts);
接下来,我们模拟插入1000万行数据。这里我用一个生成函数来模拟,category字段从有限的10个类目中随机选取。
INSERT INTO default.user_behavior_common
SELECT
number % 1000000 as user_id, -- 100万用户
concat('https://example.com/product/', toString(rand64() % 100000)) as page_url, -- 10万个不同URL
['手机', '电脑', '服饰', '家居', '食品', '图书', '美妆', '运动', '电器', '数码'][rand64() % 10 + 1] as category,
now() - (rand64() % 31536000) -- 随机分布在一年内的时间
FROM numbers(10000000);
-- 将同样的数据插入低基数表
INSERT INTO default.user_behavior_lowcard
SELECT * FROM default.user_behavior_common;
4.2 存储空间对比
数据导入后,我们第一时间查看存储占用。这是最直观的收益。
SELECT
table,
formatReadableSize(sum(bytes_on_disk)) as disk_size,
formatReadableSize(sum(data_uncompressed_bytes)) as uncompressed_size
FROM system.parts
WHERE table IN ('user_behavior_common', 'user_behavior_lowcard') AND active
GROUP BY table;
在我的测试环境中,结果大致如下(具体数值因压缩率会有波动):
| 表名 | 磁盘占用 | 未压缩大小 |
|---|---|---|
| user_behavior_common | ~450 MB | ~1.8 GB |
| user_behavior_lowcard | ~380 MB | ~1.2 GB |
可以看到,仅仅是将一个基数仅为10的category字段改为LowCardinality,整张表的未压缩数据量就减少了约600MB,磁盘占用也减少了约70MB。如果这个字段的字符串平均长度更长,或者基数更大一些(比如1000个城市名),节省的空间将更加惊人。
4.3 查询性能对比
现在我们来跑几个典型的查询,看看速度差异。
查询1:按类目分组统计浏览量
-- 在低基数表上查询
SELECT category, count() as pv
FROM default.user_behavior_lowcard
GROUP BY category
ORDER BY pv DESC
FORMAT `Null`; -- 不输出结果,只测量时间
-- 在普通表上执行相同查询
SELECT category, count() as pv
FROM default.user_behavior_common
GROUP BY category
ORDER BY pv DESC
FORMAT `Null`;
执行后,查看ClickHouse日志或使用clickhouse-benchmark工具,你会发现低基数表的查询耗时通常只有普通表的 1/3 到 1/5。因为GROUP BY操作直接在紧凑的整数ID上进行,比比较字符串快得多。
查询2:带字符串过滤条件的查询
-- 查询‘手机’类目的所有记录
SELECT count()
FROM default.user_behavior_lowcard
WHERE category = '手机';
SELECT count()
FROM default.user_behavior_common
WHERE category = '手机';
这个查询的差距可能更大。对于LowCardinality列,WHERE category = '手机'这个条件会先被转换成WHERE category_id = 字典中‘手机’的ID,然后直接在整数列上进行等值过滤,效率极高。而对于普通String列,需要对每一行的字符串值进行全量比较。
4.4 如何修改现有表字段
如果你已经有一张存量的大表,发现其中某个String字段非常适合用LowCardinality,别担心,不需要重建表。ClickHouse的ALTER TABLE语句可以非常高效地完成这种类型修改。
ALTER TABLE default.user_behavior_common
MODIFY COLUMN category LowCardinality(String);
这条语句执行起来非常快,因为它本质上是一个“元数据操作”。ClickHouse并不会立即重写所有数据文件,而是会:
- 为这个列创建新的字典。
- 在后台逐步合并(merge)数据parts时,将旧parts中的数据转换为新的低基数格式。
- 新的写入会直接使用低基数格式。
所以你可以几乎无感地在生产表上执行这个操作。我曾在一天内对一个有数十亿行记录的表执行了这样的修改,对线上查询的影响微乎其微。
5. 深入调优与避坑指南
用好LowCardinality,除了选对场景,还需要了解一些内部机制和参数,这样才能避免踩坑,发挥最大效能。
5.1 理解字典拆分与参数配置
前面提到,当唯一值的数量超过low_cardinality_max_dictionary_size(默认8192)时,ClickHouse会拆分字典。这意味着什么?它会把数据块(granule)分组,每个组使用一个独立的字典。这样做的好处是避免了单个字典过大,内存放不下,但坏处是:
- 跨字典的相同字符串会被编码成不同的ID,失去了全局唯一性。
- 在某些查询(如跨多块的GROUP BY)中,可能需要合并多个字典的结果,带来轻微开销。
你可以通过查询system.columns表来观察字典情况:
SELECT
name,
type,
numeric_scale, -- 对于LowCardinality类型,这个字段表示实际存储用的整数类型(如2代表UInt16)
is_in_partition_key,
is_in_sorting_key
FROM system.columns
WHERE table = 'user_behavior_lowcard' AND database = 'default';
如果发现某个低基数列的numeric_scale很大(比如4代表UInt32),说明它的基数很高,ClickHouse被迫使用了更大的整数来编码,这时你就需要重新评估是否真的适合用此类型。
主要的调优参数有:
low_cardinality_max_dictionary_size: 单个字典的最大大小。如果你的字段基数稳定在1万左右,可以适当调大此值(如设置为20000),以避免不必要的字典拆分。low_cardinality_use_single_dictionary_for_part: 默认为1,表示一个数据部分(part)内尽量使用单个字典。通常保持默认即可。low_cardinality_allow_in_native_format: 控制在Native格式中如何使用低基数,涉及客户端通信,一般无需改动。
5.2 注意与Nullable类型的结合使用
LowCardinality可以和Nullable一起使用,即LowCardinality(Nullable(String))。但这会引入额外的复杂度。空值(NULL)在字典中通常会被分配一个特殊的ID。你需要留意,这可能会稍微增加字典的大小和查询的复杂度。如果业务上允许,尽量保证字段非空,直接使用LowCardinality(String)性能会更优。
5.3 监控与维护
对于核心业务表上的LowCardinality字段,建议将其纳入日常监控:
- 监控字典大小增长:定期检查字典是否频繁拆分。如果拆分过多,考虑调整
max_dictionary_size或重新评估字段选择。 - 关注查询计划:对于复杂的查询,可以使用
EXPLAIN语句查看低基数优化是否生效。留意是否有“字典解码”成了性能热点(在极高基数误用时可能出现)。 - 测试真实负载:任何优化都不能只看微观基准测试。在将字段改为低基数后,一定要用真实的业务查询链路进行压测,确保整体稳定。
我在一个数据分析系统中,将十几个维度字段改为LowCardinality后,整个集群的平均查询延迟下降了35%,存储成本降低了约25%。这个收益是实实在在的。当然,过程中也踩过坑,比如曾经试图对一个用户自定义标签字段(基数超过50万)使用低基数,结果适得其反。所以,关键还是那句话:理解原理,认清场景,大胆测试,谨慎上线。LowCardinality不是魔法,但它确实是ClickHouse送给处理海量数据开发者的一把锋利而趁手的利器,用对了地方,效果绝对让你惊喜。
更多推荐
所有评论(0)