首页
/ ClickHouse 原生类型选型指南:Langfuse 事件表 schema 设计与存储优化实战

ClickHouse 原生类型选型指南:Langfuse 事件表 schema 设计与存储优化实战

2026-09-09 14:53:19作者:咎岭娴Homer

导读

本文以 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 会报错),比较操作按字典序而非数值序,聚合函数(sumavg)完全不可用。

规则中的存储开销对照

原规则给出了两组直观的 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(需要更高精度时再升级)」的实践变体。
  • 布尔列用 Boolpublicbookmarked 均为 1 字节原生布尔。
  • 删除标记用 UInt8is_deleted 采用无符号最小位宽表达标记位,与 minimize-bitwidth 规则一致。
  • 低基数 Map 键metadata 使用 Map(LowCardinality(String), String),对 metadata 键做字典编码,因为键集合(如模型名、环境名)基数远小于 10,000。
  • 文本内容列使用 ZSTD(3) 压缩编码input/output 这类大文本列配合专用 CODEC,是压缩优化的一部分。
  • Nullable 仅用于语义场景user_idinputoutput 等列允许为 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 而非 Enumtype(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_versionUInt16:版本号天然非负且不会超过 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_typeentity_idbucket_path 这类标识性字符串虽然也有重复,但多数是唯一值或高基数(路径几乎不重复),因此保留 String 是合理决策——这印证了规则的边界:「不用 String 存一切」不等于「禁用 String」,高基数、无运算需求的文本标识符用 String 完全正确。

在 Langfuse 中审查 schema:把规则放进开发流程

Langfuse 将这套规则固化进了技能包的评审流程(SKILL.md):

  • 触发场景:遇到 CREATE TABLEALTER 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/)作为评审清单,把类型选型从「经验直觉」升级为「可审计的工程规范」。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395