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。

这样做带来的好处是爆炸性的:

  1. 存储空间急剧下降:整数(比如UInt8, UInt16)占用的空间远小于长度不定的字符串。一个中文UTF-8字符串可能占好几个字节,但用一个UInt16(2字节)就能代表65536个不同的值,这对于大部分低基数字段绰绰有余。
  2. 查询速度大幅提升:比较、排序、分组(GROUP BY)这些操作,在整数上进行的速度比在字符串上快得多。CPU处理整数的指令效率极高。
  3. 函数计算下推:很多针对字符串的操作,比如length(), substring(),甚至一些条件判断,可以直接在字典上计算一次,然后将结果映射回所有行,避免了海量数据的重复计算。

我画个简单的示意图帮你理解。假设我们有一个记录用户行为的原始表:

user_idaction
101login
102view_page
101click_button
103login

使用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)

  1. 枚举型状态字段:这是最经典的场景。比如订单状态、支付状态、审核状态、开关状态。这些字段的值通常不超过几十个。
  2. 分类/标签字段:商品类目、文章分类、用户等级、设备型号、APP版本号、国家、省份、城市。这些字段的基数虽然可能成百上千,但相对于动辄上亿的行数来说,依然是极低的。
  3. 离散的业务类型:就像我开头例子里的event_type(事件类型)、log_level(日志级别)、http_method(GET/POST等)、error_code(错误码)。这些值由业务逻辑定义,范围有限。
  4. 作为连接(JOIN)键的维度字段:如果你经常需要用一个字符串字段去关联另一张维度表(比如通过city_name关联城市信息表),把这个字段改成LowCardinality能显著提升JOIN的性能。

注意:这里有个关键点,LowCardinality不仅适用于基数绝对数量小的字段,更适用于基数相对于总行数比例很小的字段。哪怕一个字段有1万个不同的值,但你的表有100亿行,那它的基数依然是“低”的,使用LowCardinality收益会很高。

3.2 需要谨慎或避免使用的场景

  1. 高基数字符串:最典型的就是user_idemail手机号订单号这种唯一标识符。每个值几乎都不一样,基数等于行数。如果硬要用LowCardinality,字典会变得无比巨大,甚至超过原数据大小,查询时字典本身就成了瓶颈。我曾经在测试环境误将一个用户ID字段改为低基数,结果存储空间暴涨了15%,查询速度慢了3倍,这就是反面教材。
  2. 超长文本字段:比如文章内容、商品详情描述。LowCardinality的优化前提是重复值多。长文本几乎不可能重复,字典编码毫无优势。而且字典里存储超长字符串本身也会成为负担。
  3. 频繁更新的字段:虽然ClickHouse的LowCardinality字典是全局的,但频繁插入全新的、不同的值,会导致字典不断扩容和重组,带来一定的开销。对于写入极其频繁且值离散度高的流式数据,需要评估。
  4. 基数在边界徘徊的字段:如果一个字段的基数在几千到几万之间波动,你需要关注一个关键参数: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并不会立即重写所有数据文件,而是会:

  1. 为这个列创建新的字典。
  2. 在后台逐步合并(merge)数据parts时,将旧parts中的数据转换为新的低基数格式。
  3. 新的写入会直接使用低基数格式。

所以你可以几乎无感地在生产表上执行这个操作。我曾在一天内对一个有数十亿行记录的表执行了这样的修改,对线上查询的影响微乎其微。

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字段,建议将其纳入日常监控:

  1. 监控字典大小增长:定期检查字典是否频繁拆分。如果拆分过多,考虑调整max_dictionary_size或重新评估字段选择。
  2. 关注查询计划:对于复杂的查询,可以使用EXPLAIN语句查看低基数优化是否生效。留意是否有“字典解码”成了性能热点(在极高基数误用时可能出现)。
  3. 测试真实负载:任何优化都不能只看微观基准测试。在将字段改为低基数后,一定要用真实的业务查询链路进行压测,确保整体稳定。

我在一个数据分析系统中,将十几个维度字段改为LowCardinality后,整个集群的平均查询延迟下降了35%存储成本降低了约25%。这个收益是实实在在的。当然,过程中也踩过坑,比如曾经试图对一个用户自定义标签字段(基数超过50万)使用低基数,结果适得其反。所以,关键还是那句话:理解原理,认清场景,大胆测试,谨慎上线。LowCardinality不是魔法,但它确实是ClickHouse送给处理海量数据开发者的一把锋利而趁手的利器,用对了地方,效果绝对让你惊喜。

更多推荐