Langfuse 中的 ClickHouse LowCardinality 最佳实践:为重复字符串选择正确的数据类型
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
本文基于 Langfuse 仓库内嵌的 ClickHouse 最佳实践技能(.agents/skills/clickhouse-best-practices)中schema-types-lowcardinality规则展开,系统讲解在 LLM 可观测性场景下如何用LowCardinality(String)替代普通String存储重复值,并通过仓库真实的 ClickHouse 迁移脚本(如observations、traces表)印证其落地方式。读完本文,你将掌握字典编码的适用边界、基数(cardinality)判定方法、LowCardinality与FixedString/Enum的选型原则,以及在 Langfuse 事件表中正确运用该类型的实战写法。
规则背景:为什么重复字符串需要特殊处理
在 Langfuse 这类 LLM 可观测平台中,ClickHouse 承载着海量的 trace、observation、score 事件数据。这些表中存在大量取值集合非常有限、但出现次数极高的字符串列——例如观测类型(type)、日志级别(level)、环境名(environment)、SDK 来源(ingestion_sdk_name)等。
普通String类型对每个值都做全量存储:"United States"存储 5 亿次、"Chrome"存储 3 亿次、"page_view"存储 8 亿次,磁盘与内存开销被无限放大。LowCardinality通过**字典编码(dictionary encoding)**解决该问题:为去重后的唯一值建立字典,数据列中仅保存字典索引,从而在唯一值数量较少时获得显著的存储缩减与查询加速。
该规则在技能库中被标记为Impact: HIGH,属于建表时必须检查的核心约束之一(对应 SKILL.md 中 Schema Reviews 的第 6 步)。
反模式:普通 String 存储低基数字符串
以下建表方式虽然语法正确,却会造成严重的空间浪费:
CREATE TABLE events ( country String, -- "United States" stored 500M times browser String, -- "Chrome" stored 300M times event_type String -- "page_view" stored 800M times )当某一列的取值集合远小于行数时,普通String会一遍又一遍重复写入相同的字节串。列式存储虽然只读取需要的列,但每一列的原始数据量仍然随行数线性膨胀,且压缩效率也远低于字典索引方案。
正确姿势:LowCardinality 包装低基数字符串
CREATE TABLE events ( country LowCardinality(String), -- ~200 unique values browser LowCardinality(String), -- ~50 unique values event_type LowCardinality(String) -- ~100 unique values )LowCardinality(String)是String的一种包装类型:对外仍然表现为字符串,支持全部字符串函数与过滤、分组、排序操作,但底层按字典索引存储。唯一值数量越低,字典编码收益越大。
何时使用 LowCardinality:以 10K 为界
规则给出了明确的决策表:
| 唯一值数量 | 建议 |
|---|---|
| < 10,000 | 使用 LowCardinality |
| > 10,000 | 使用普通 String |
阈值背后的原因:字典编码有固定的字典构建与维护开销。当唯一值超过约 1 万后,字典本身越来越大,索引指向的收益被稀释,甚至可能出现“字典≈数据”的极端情况——Langfuse 的 Parquet 导出代码中也对此有明确注释(见 packages/shared/src/server/repositories/clickhouse.ts):0 disables dictionary encoding. Near-unique LLM payloads fall back to plain anyway (dictionary ≈ data),即近乎全唯一的 LLM 载荷本就该回退为明文存储。
建表前先检查基数
在决定列类型之前,用聚合函数确认实际基数:
-- Check cardinality before deciding SELECT uniq(column_name) FROM table_name;uniq()返回去重后的近似计数,足以支撑类型选型判断。Langfuse 的迁移流程中,新增低基数列时同样先基于业务语义判断(如environment、type、level),再以LowCardinality(String)落地。
LowCardinality vs FixedString:各司其职
FixedString(N)与LowCardinality(String)常被混淆,规则明确了两者的分工:
Reserve
FixedStringfor strictly fixed-length data (e.g., 2-char country codes). For most low-cardinality text,LowCardinality(String)outperformsFixedString.
FixedString仅适合长度严格固定的数据,例如两位国家码"US"、"DE"、"JP"。长度不足时 ClickHouse 会补零,长度超出则直接报错,灵活性极差;LowCardinality适合长度可变但取值集合小的字符串,例如国家名"United States"、"Germany"。它不需要关心每个值的长短,字典只存一份完整值。
-- FixedString: Only for truly fixed-length data country_code FixedString(2), -- "US", "DE", "JP" - always 2 chars -- LowCardinality: For variable-length low-cardinality strings country_name LowCardinality(String), -- "United States", "Germany"LowCardinality vs Enum:可变集合 vs 固定枚举
技能库中另一条规则 schema-types-enum 提供了互补的选型视角:
| 场景 | 使用 |
|---|---|
| 建表时值集合固定且已知 | Enum8/Enum16 |
| 值可能频繁变化 | LowCardinality(String) |
| 需要插入时校验 | Enum |
| 需要查询中的自然排序 | Enum |
二者的本质区别在于:Enum在插入时强制校验(写入"shiped"这类拼写错误会直接报Unknown element),并提供基于枚举值的自然排序;LowCardinality则不校验任何值,只做存储压缩。Langfuse 事件表之所以大量使用LowCardinality(String)而非Enum,正是因为type、level、environment等列的取值集合会随产品演进持续扩展(新增观测类型、新增环境名),而Enum的 ALTER 成本更高、灵活性更低。
Langfuse 仓库中的真实落地:observations 与 traces 表
规则不是纸面建议——Langfuse 的规范迁移脚本(canonical migrations)就是最佳实践的直接体现。迁移文件统一位于 packages/shared/clickhouse/migrations/canonical 目录。
observations 表:五类 LowCardinality 应用
0002_observations.up.sql 完整展示了五种典型用法:
CREATE TABLE observations {CLICKHOUSE_CLUSTER_CLAUSE} ( ... `type` LowCardinality(String), -- 观测类型:GENERATION/SPAN/EVENT... `metadata` Map(LowCardinality(String), String), -- Map 的 key 列 `level` LowCardinality(String), -- 日志级别:DEBUG/INFO/WARNING/ERROR... `provided_usage_details` Map(LowCardinality(String), UInt64), -- usage key 列 `usage_details` Map(LowCardinality(String), UInt64), `provided_cost_details` Map(LowCardinality(String), Decimal64(12)), -- cost key 列 `cost_details` Map(LowCardinality(String), Decimal64(12)), ... )要点拆解:
- 单列直接包装:
type、level是典型枚举语义的低基数列(各自只有个位数到几十个取值),直接声明为LowCardinality(String); - Map 的 key 使用
LowCardinality(String):metadata这类 KV 字典中,key 集合(如"input"、"output"、"model")是高度复用的,对 Map 的 key 应用字典编码能显著压缩键名重复存储;Langfuse 的 traces 表(0001_traces.up.sql)同样遵循Map(LowCardinality(String), String)模式; - 不适用于高基数列:
id、trace_id、project_id、name等近乎全唯一或中等基数的列仍保持普通String/Nullable(String),与 10K 阈值规则一致。
environment 列:通过 ALTER 追加低基数列
0008_add_environments_column.up.sql 展示了如何对存量表补充低基数列:
ALTER TABLE traces {CLICKHOUSE_CLUSTER_CLAUSE} ADD COLUMN environment LowCardinality(String) DEFAULT 'default' AFTER project_id{CLICKHOUSE_CLUSTERED_ONLY: SETTINGS alter_sync = 2}; ALTER TABLE observations {CLICKHOUSE_CLUSTER_CLAUSE} ADD COLUMN environment LowCardinality(String) DEFAULT 'default' AFTER project_id{CLICKHOUSE_CLUSTERED_ONLY: SETTINGS alter_sync = 2}; ALTER TABLE scores {CLICKHOUSE_CLUSTER_CLAUSE} ADD COLUMN environment LowCardinality(String) DEFAULT 'default' AFTER project_id{CLICKHOUSE_CLUSTERED_ONLY: SETTINGS alter_sync = 2};environment(如production、staging、development)取值有限,选LowCardinality(String)且带DEFAULT 'default';同时按 Langfuse 的迁移规范附加{CLICKHOUSE_CLUSTERED_ONLY: SETTINGS alter_sync = 2},确保集群模式下元数据变更同步完成(详见 SKILL.md 中关于alter_sync的说明)。SDK 归因列ingestion_sdk_name、ingestion_sdk_version(见 0035_add_ingestion_attribution_columns.up.sql)同样采用LowCardinality(String) DEFAULT 'unknown',因为 SDK 名称与版本号的组合规模有限且高度复用。
与分区基数的联动
低基数列还会影响分区设计。规则 schema-partition-low-cardinality 指出分区基数应控制在 100–1,000 个,且其“正确示例”中event_type正是以LowCardinality(String)作为排序键的组成部分——Langfuse 的observations表将LowCardinality(String)的type同时放入PRIMARY KEY与ORDER BY(位于project_id之后、toDate(start_time)之前),正是“低到高基数排序”这一主键设计规则(schema-pk-cardinality-order)的体现:低基数列在前,高基数列(如id)殿后。
与 Nullable 的搭配原则
LowCardinality也可以与Nullable组合(如Nullable(LowCardinality(String))),但技能库的另一条 HIGH 级规则 schema-types-avoid-nullable 提醒:Nullable会为每列维护额外的UInt8标记列,带来存储与性能开销。Langfuse 的实践中,语义上可空的低基数列(如version、release、parent_observation_id)保持Nullable(String),而能用默认值表达的列(如environment DEFAULT 'default'、ingestion_sdk_name DEFAULT 'unknown')则直接用DEFAULT而非Nullable。仅当NULL具有独立业务语义(如deleted_at表示“未删除”)时才使用Nullable。
落地检查清单
结合规则与 Langfuse 源码,在建表或加列时可依次核对:
- 识别重复列:该列取值集合是否远小于行数?用
SELECT uniq(column_name) FROM table_name;验证,唯一值 < 10,000 才考虑LowCardinality; - 选型三问:值集合是否固定不变?——是则考虑
Enum8/Enum16;长度是否严格固定?——是则考虑FixedString(N);其余低基数可变字符串一律LowCardinality(String); - Map 键压缩:
metadata、usage_details等 KV 结构的 key 声明为Map(LowCardinality(String), ...); - 配合主键设计:低基数列放在
PRIMARY KEY/ORDER BY前部(参考 0002_observations.up.sql 的列序); - 避免过度使用:高基数列(ID、名称、payload 内容)保持普通
String——字典编码对近乎全唯一的数据没有收益,甚至会带来字典维护开销。
小结
LowCardinality(String)是 ClickHouse 表结构中性价比最高的优化手段之一:对唯一值 < 10K 的重复字符串启用字典编码,即可在大幅削减存储的同时提升过滤与聚合效率。Langfuse 在observations、traces、scores等核心事件表上的真实迁移脚本,完整示范了单列、Map 键、增量 ALTER 三种落地形态,并与主键排序、分区基数、Enum/Nullable选型等相邻规则形成一套可复用的建表决策框架。对任何以 ClickHouse 存储海量事件数据、且存在大量重复枚举值的系统,这套方法论都值得直接借鉴。
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考