ClickHouse 原生类型选型指南:Langfuse 事件表 schema 设计与存储优化实战
导读
本文以 Langfuse 仓库中 ClickHouse 最佳实践技能包(.agents/skills/clickhouse-best-practices)中的 schema-types-native-types 规则为核心,系统讲解「为什么不要用 String 存一切、如何为每一列选择正确的原生类型」。ClickHouse 采用列式存储,列类型直接决定数据压缩率、比较运算速度和可执行的操作语义,Langfuse 将 trace、observation、score 等观测数据持久化在 ClickHouse 中,其 schema 设计(见 canonical 迁移目录)正是该规则在生产环境中的真实落地。读完本文,你将掌握:原生类型 vs String 的存储开销对比、类型选择速查表、相关配套规则(位宽最小化、LowCardinality、Enum、规避 Nullable),以及如何结合 Langfuse 迁移文件与查询构造器理解这些原则的实际应用。
为什么「全用 String」是 ClickHouse schema 设计的头号反模式
列式存储架构下的类型敏感度
与行式存储(如 PostgreSQL)不同,ClickHouse 是列式存储数据库:每一列的数据在磁盘上连续存放,并在数据块(granule)内独立压缩。这意味着每一列的类型直接决定该列的数据体积、压缩算法效果以及扫描速度:
- 存储浪费:String 每行都完整保存文本内容,且无类型约束。一个 UUID 文本串需要 36 字节,而原生
UUID类型固定 16 字节;时间戳文本需要 19 字节,而DateTime只占 4 字节。 - 无法压缩优化:数字、日期、布尔这些类型在 ClickHouse 中有专门的编码与压缩策略(如 delta、Gorilla 等),String 只能做通用文本压缩,效果差得多。
- 比较与运算语义缺失:String 上无法做数学运算(
count + 1会报错),比较操作按字典序而非数值序,聚合函数(sum、avg)完全不可用。
规则中的存储开销对照
原规则给出了两组直观的 CREATE TABLE 对照(.agents/skills/clickhouse-best-practices/rules/schema-types-native-types.md):
错误示范——String 全类型:
CREATE TABLE events (
event_id String, -- "550e8400-e29b-41d4-a716-446655440000" = 36 bytes
user_id String, -- "12345" = 5 bytes (no numeric operations)
created_at String, -- "2024-01-15 10:30:00" = 19 bytes
count String, -- "42" - can't do math!
is_active String -- "true" = 4 bytes
)
正确示范——原生类型:
CREATE TABLE events (
event_id UUID DEFAULT generateUUIDv4(), -- 16 bytes (vs 36)
user_id UInt64, -- 8 bytes, numeric ops
created_at DateTime DEFAULT now(), -- 4 bytes (vs 19)
count UInt32 DEFAULT 0, -- 4 bytes, math works
is_active Bool DEFAULT true -- 1 byte (vs 4)
)
仅此一张示例表,五列从 String 迁移到原生类型,存储就能减少 2~10 倍(规则 frontmatter 标注的 impactDescription 为 "2-10x storage reduction; enables compression and correct semantics")。这个数量级差异在 Langfuse 这种以千万级 trace / observation 为常态的观测平台上,直接决定磁盘成本与查询延迟。
规则元数据与优先级
该规则在技能包中被标记为:
- impact: CRITICAL(关键级)
- tags:
[schema, data-types, storage] - 在 SKILL.md 的 schema 评审检查单中排第 4 位,紧跟在三条主键(ORDER BY)规则之后,属于「数据表创建前必须核对」的核心约束。
类型选择速查表:六类数据的正确映射
原文档提供了完整的速查表,这里逐行补充使用细节与边界条件:
| 数据 | 推荐类型 | 应避免 | 补充说明 |
|---|---|---|---|
| 自增/顺序 ID | UInt32 / UInt64 | String | 能用无符号就用无符号;如确需存储外部文本 ID,可考虑 LowCardinality(String)(见下文) |
| UUID | UUID | String | UUID 固定 16 字节,且可配合 generateUUIDv4() 默认值;注意 UUID 不能直接做聚合键的字符串拼接 |
| 状态/分类 | Enum8 或 LowCardinality(String) | String | 有限枚举且值固定选 Enum8/Enum16(自带插入期校验与自然排序);值集可能变化选 LowCardinality |
| 时间戳 | DateTime | DateTime64、String | DateTime 秒级精度 4 字节;需要毫秒级精度(如 Langfuse 的 DateTime64(3))时再升级 |
| 仅日期 | Date 或 Date32 | DateTime、String | 只有日期无时间时不要引入时间分量;Date 2 字节、Date32 4 字节 |
| 计数 | UInt8/16/32(能放下就选最小) | Int64、String | 位宽最小化规则(见下一节) |
| 金额 | Decimal(P,S) 或 Int64(存分) | Float64、String | 浮点有精度误差;Decimal64(12) 是 Langfuse 成本字段的实际选择 |
| 布尔值 | Bool 或 UInt8 | String | Bool 1 字节;历史 schema 中常见 UInt8 的写法同样合规 |
表格底部原始规则给出了官方参考链接(ClickHouse 官方文档 "Select Data Types"),此处不再外链,仅强调:本表是 ClickHouse 官方向导的浓缩版,适用于所有 ClickHouse 使用者,而非 Langfuse 专属约定。
配套规则一:数值位宽最小化(schema-types-minimize-bitwidth)
原生类型选型并不止于「别用 String」,同类数值内部还要选择能容纳数据范围的最小位宽类型,并在不需要负数时优先使用无符号类型。规则文件 .agents/skills/clickhouse-best-practices/rules/schema-types-minimize-bitwidth.md(impact: HIGH)给出了完整数值类型参考:
| 类型 | 范围 | 字节 |
|---|---|---|
| UInt8 | 0 ~ 255 | 1 |
| UInt16 | 0 ~ 65,535 | 2 |
| UInt32 | 0 ~ 43 亿 | 4 |
| UInt64 | 0 ~ 1800 京 | 8 |
| Int8 | -128 ~ 127 | 1 |
| Int16 | -32,768 ~ 32,767 | 2 |
| Int32 | -21 亿 ~ 21 亿 | 4 |
| Int64 | -900 京 ~ 900 京 | 8 |
典型反例与修正:
-- 错误:全部用 Int64
status_code Int64, -- HTTP 状态码只有 100-599,UInt16 即可
age Int64, -- 人的年龄 UInt8 就够
item_count Int64 -- 多数场景 UInt32 足够
-- 正确:按数据范围选最小
status_code UInt16,
age UInt8,
year UInt16,
item_count UInt32
位宽减小的收益不只是磁盘:更小的列意味着相同数据量下更少的内存带宽与 CPU 缓存占用,列式扫描性能也随之提升。
配套规则二:LowCardinality——低基数字符串的正确打开方式
并不是所有 String 都应该被替换成数字类型。对于「重复值非常多、但去重后只有少量唯一值」的字符串列,规则 .agents/skills/clickhouse-best-practices/rules/schema-types-lowcardinality.md(impact: HIGH)推荐使用字典编码的 LowCardinality(String):
-- 错误:高重复度 String 反复存储完整文本
country String, -- "United States" 存储 5 亿次
browser String, -- "Chrome" 存储 3 亿次
event_type String -- "page_view" 存储 8 亿次
-- 正确:唯一值少 → 字典编码
country LowCardinality(String), -- 约 200 个唯一值
browser LowCardinality(String), -- 约 50 个唯一值
event_type LowCardinality(String) -- 约 100 个唯一值
判定标准与验证手段:唯一值数 < 10,000 用 LowCardinality,> 10,000 用普通 String。决策前先用 SELECT uniq(column_name) FROM table_name; 实测基数,不要凭感觉。此外,FixedString 只留给严格定长数据(如两位国家码 FixedString(2));变长低基数文本用 LowCardinality(String) 全面优于 FixedString。
配套规则三:Enum——有限值集的插入期校验与自然排序
当列的取值集合在 schema 设计时就已确定且不会频繁变动,Enum8(≤256 个值,1 字节)/ Enum16(≤65,536 个值,2 字节)比 String 更强:插入期即校验,拼写错误(如 'shiped')会被直接拒绝;查询还能利用枚举值的自然顺序做比较与排序,无需手写 CASE:
CREATE TABLE orders (
status Enum8('pending' = 1, 'processing' = 2, 'shipped' = 3, 'delivered' = 4)
)
INSERT INTO orders VALUES ('shiped'); -- ERROR: Unknown element 'shiped'
SELECT * FROM orders ORDER BY status; -- 按枚举值 1,2,3,4 排序
SELECT * FROM orders WHERE status > 'processing'; -- 返回 shipped 和 delivered
取舍矩阵(来自 .agents/skills/clickhouse-best-practices/rules/schema-types-enum.md,impact: MEDIUM):
| 场景 | 选择 |
|---|---|
| schema 时值集已固定 | Enum8 / Enum16 |
| 值可能频繁变化 | LowCardinality(String) |
| 需要插入期校验 | Enum |
| 查询需要自然排序 | Enum |
| 唯一值 < 256 | Enum8(1 字节) |
| 唯一值 256~65,536 | Enum16(2 字节) |
配套规则四:规避 Nullable,用 DEFAULT 表达「缺失」
Nullable(T) 内部会额外维护一个 UInt8 标记列,抬高存储与查询开销。规则 .agents/skills/clickhouse-best-practices/rules/schema-types-avoid-nullable.md(impact: HIGH)的建议是:能用 DEFAULT 表达的缺失语义就不要用 NULL:
-- 错误:所有列都 Nullable
id Nullable(UInt64), -- ID 永远不该为空
name Nullable(String), -- 空字符串完全可以表达「未知」
age Nullable(UInt8), -- 0 是合法默认值
login_count Nullable(UInt32)
-- 正确:DEFAULT + 仅语义上需要时用 Nullable
id UInt64,
name String DEFAULT '',
age UInt8 DEFAULT 0,
login_count UInt32 DEFAULT 0,
deleted_at Nullable(DateTime), -- NULL = 未删除(有语义)
parent_id Nullable(UInt64) -- NULL = 无父节点(有语义)
DEFAULT 对照表:String → '';UInt*/Int* → 0;DateTime → now() 或 toDateTime(0);UUID → generateUUIDv4()。只有「NULL 本身承载业务含义」(如 deleted_at 表示未删除、parent_id 表示无父节点、discount_percent 区分「无折扣」与「0% 折扣」)时才使用 Nullable。
从规则到工程:Langfuse 的 ClickHouse schema 实例剖析
规则的最终价值在于落地。Langfuse 的 ClickHouse 建表 SQL 全部位于 packages/shared/clickhouse/migrations/canonical,该目录是集群模式与非集群模式共用的唯一模板树,其中 {CLICKHOUSE_CLUSTER_CLAUSE}、{CLICKHOUSE_REPLICATION_PREFIX} 等占位符会在渲染时按部署模式替换(见 SKILL.md 的迁移规范)。下面结合真实迁移文件逐条印证上述规则。
例一:traces 表(0001_traces.up.sql)
0001_traces.up.sql 的关键列:
CREATE TABLE traces {CLICKHOUSE_CLUSTER_CLAUSE} (
`id` String,
`timestamp` DateTime64(3),
`user_id` Nullable(String),
`metadata` Map(LowCardinality(String), String),
`project_id` String,
`public` Bool,
`bookmarked` Bool,
`tags` Array(String),
`input` Nullable(String) CODEC(ZSTD(3)),
`output` Nullable(String) CODEC(ZSTD(3)),
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now(),
`event_ts` DateTime64(3),
`is_deleted` UInt8
) ENGINE = {CLICKHOUSE_REPLICATION_PREFIX}ReplacingMergeTree(event_ts, is_deleted) Partition by toYYYYMM(timestamp)
PRIMARY KEY (project_id, toDate(timestamp))
ORDER BY (project_id, toDate(timestamp), id);
可以看到规则的影子:
- 时间戳用 DateTime64(3) 而非 String:观测数据需要毫秒精度,因此从 4 字节的 DateTime 升级为 DateTime64(3),这是规则速查表中「Timestamps → DateTime(需要更高精度时再升级)」的实践变体。
- 布尔列用
Bool:public、bookmarked均为 1 字节原生布尔。 - 删除标记用
UInt8:is_deleted采用无符号最小位宽表达标记位,与 minimize-bitwidth 规则一致。 - 低基数 Map 键:
metadata使用Map(LowCardinality(String), String),对 metadata 键做字典编码,因为键集合(如模型名、环境名)基数远小于 10,000。 - 文本内容列使用 ZSTD(3) 压缩编码:
input/output这类大文本列配合专用 CODEC,是压缩优化的一部分。 - Nullable 仅用于语义场景:
user_id、input、output等列允许为 NULL(因为「没有 user」「没有 input」是真实业务状态),而created_at/updated_at使用DEFAULT now(),正是「DEFAULT 优先、Nullable 语义化」的体现。
例二:observations 表(0002_observations.up.sql)
0002_observations.up.sql 进一步展示了枚举与数值位宽的应用:
`type` LowCardinality(String), -- OBSERVATION 类型,低基数
`level` LowCardinality(String), -- 日志级别(DEBUG/INFO/...),低基数
`provided_usage_details` Map(LowCardinality(String), UInt64), -- token 用量,无符号
`provided_cost_details` Map(LowCardinality(String), Decimal64(12)), -- 成本,Decimal
`total_cost` Nullable(Decimal64(12)), -- 金额用 Decimal 而非 Float
`prompt_version` Nullable(UInt16), -- 版本号,小位宽无符号
- 枚举语义用 LowCardinality 而非 Enum:
type(GENERATION / SPAN / EVENT / OBSERVATION 等)与level虽然取值有限,但 Langfuse 选择LowCardinality(String)而非Enum8。从 schema 演进角度看,这是因为枚举值集合需要随产品功能扩展(新观测类型、新日志级别),Enum 在线上增删枚举成员需要ALTER TABLE MODIFY COLUMN,而 LowCardinality 无此负担——这正是「值可能频繁变化 → LowCardinality(String)」取舍的注脚。 - 金额用
Decimal64(12):成本与 token 计费字段一律 Decimal,避免 Float 精度误差,与「Money → Decimal(P,S)」规则完全一致。 - 用量计数用
UInt64:token 计数可能超过 43 亿(UInt32 上限),因此用到 8 字节无符号,位宽选择以数据实际范围为据。 prompt_version用UInt16:版本号天然非负且不会超过 65,535,符合最小位宽原则。
例三:event_log 表(0007_add_event_log.up.sql)
0007_add_event_log.up.sql 展示了 String 的合理使用场景:
CREATE TABLE event_log {CLICKHOUSE_CLUSTER_CLAUSE}
(
`id` String, -- UUID 文本形式(历史兼容)
`project_id` String,
`entity_type` String,
`entity_id` String,
`event_id` Nullable(String),
`bucket_name` String,
`bucket_path` String,
`created_at` DateTime64(3) DEFAULT now(),
`updated_at` DateTime64(3) DEFAULT now()
) ENGINE = MergeTree()
ORDER BY (project_id, entity_type, entity_id);
这张表用于 blob 存储文件事件记录。entity_type、entity_id、bucket_path 这类标识性字符串虽然也有重复,但多数是唯一值或高基数(路径几乎不重复),因此保留 String 是合理决策——这印证了规则的边界:「不用 String 存一切」不等于「禁用 String」,高基数、无运算需求的文本标识符用 String 完全正确。
在 Langfuse 中审查 schema:把规则放进开发流程
Langfuse 将这套规则固化进了技能包的评审流程(SKILL.md):
- 触发场景:遇到
CREATE TABLE、ALTER TABLE、数据类型选择问题、慢查询排查、数据管道设计时,必须先按顺序阅读对应规则文件并逐条引用(Per "schema-types-native-types"...)。 - Schema 评审检查单(与本文相关的部分):
- 数据类型是否匹配实际数据范围;
- 是否对合适的字符串列应用了 LowCardinality;
- 分区键基数是否控制在 100~1,000;
- 使用 ReplacingMergeTree 时是否带版本列(Langfuse 用
event_ts+is_deleted做版本与删除标记)。
- 迁移与查询的 Langfuse 特化约束:查询
events表必须经由 event-query-builder.ts 构造 SQL,禁止手写;禁止在events表上使用FINAL;新迁移中每个元数据ALTER必须携带{CLICKHOUSE_CLUSTERED_ONLY: SETTINGS alter_sync = 2},产生 mutation 的ALTER必须携带mutations_sync = 2;禁止CREATE OR REPLACE VIEW/TABLE(NFS 文件系统不支持renameat2,自托管部署会启动失败)。
这意味着当你在 Langfuse 中新增一张 ClickHouse 表时,正确的流程是:先规划 ORDER BY(不可变)→ 按低到高基数排序列 → 选择原生类型(本规则)→ 最小化位宽 → 低基数字符串加 LowCardinality → 有限值集评估 Enum → 用 DEFAULT 替代非语义 Nullable。每一步都有对应的规则文件可以引用,评审输出格式统一为「Rules Checked / Findings / Recommendations」三段式。
结语:原生类型是列式数据库性能的地基
schema-types-native-types 规则用一句话概括:在 ClickHouse 中,类型不是「存储格式」,而是「查询引擎的能力开关」。String 存一切会让每一列都失去压缩与运算能力,而正确选择原生类型(配以最小位宽、LowCardinality、Enum 与语义化 Nullable)能带来 2~10 倍的存储缩减与可预期的查询性能。Langfuse 的 traces / observations / event_log 迁移文件展示了这套规则在生产观测平台上的完整落地:时间戳用 DateTime64(3)、布尔用 Bool、金额用 Decimal64(12)、低基数字符串用 LowCardinality、标记列用 UInt8,String 只留给高基数标识符。对于任何正在设计 ClickHouse schema 的团队,都可以直接复用本仓库的技能包规则(.agents/skills/clickhouse-best-practices/rules/)作为评审清单,把类型选型从「经验直觉」升级为「可审计的工程规范」。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00