ClickHouse 建表前必须先规划 PRIMARY KEY:Langfuse 中不可变 ORDER BY 的实战指南
ClickHouse 建表前必须先规划 PRIMARY KEYLangfuse 中不可变 ORDER BY 的实战指南【免费下载链接】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 这个面向 LLM 可观测性与评测的开源 AI 工程平台中ClickHouse 承担着 traces、observations、scores、event_log 等核心数据表的存储与查询。由于 ClickHouse 的ORDER BY即主键排序键在建表后不可修改一次错误的键设计就意味着全量数据迁移。本文以仓库技能文档 schema-pk-plan-before-creation.md 为核心系统讲解为何必须在建表前完成 PRIMARY KEY 规划、如何基于查询模式做键设计并结合 Langfuse 真实的 ClickHouse 迁移源码给出可直接复用的实践模板。为什么说 ORDER BY 是不可变约束理解 ClickHouse 主键的本质绝大多数关系型数据库允许你事后用ALTER TABLE修改主键或索引但 ClickHouse 完全不同。ClickHouse 的ORDER BY子句同时决定了三件事物理数据的落盘顺序同一分区partition内数据按排序键有序存储稀疏主索引sparse primary index的结构每 N 行一个 granule默认 8192 行在索引中记录一个键值条目查询裁剪pruning能力只有查询过滤条件命中排序键前缀才能跳过大量数据块。关键差异在于ClickHouse 没有可事后追加的二级主索引skipping index 只能解决部分非排序键过滤问题见下节。排序键一旦写入数据的物理顺序已经固化在 MergeTree 的 data part 中因此文档明确给出结论ORDER BYcannot be modified after table creation. A wrong choice requires creating a new table and migrating all data.这正是规则文件将其标记为impact: CRITICAL的原因——修复成本是“重建表 全量数据迁移”而不仅仅是“改一条 DDL”。-- 反例没有分析查询模式就随意建表 CREATE TABLE events ( event_id UUID, user_id UInt64, timestamp DateTime ) ENGINE MergeTree() ORDER BY (event_id); -- 随意选择 -- 后来发现“大多数查询都按 user_id 过滤” -- 无法修复ALTER TABLE events MODIFY ORDER BY (user_id, timestamp) -- ERROR: Cannot modify ORDER BY反模式剖析任意 ORDER BY 的三个典型代价规则文件指出了随意选择排序键时的具体后果全表扫描过滤条件如user_id不在排序键中稀疏索引完全无法参与裁剪查询退化为全分区扫描数据迁移成本修改排序键只能新建表、INSERT ... SELECT重灌、切换表名数据量越大成本越高难以收敛的连锁影响排序键同时影响分区合并、压缩效率与物化视图的依赖关系牵一发动全身。在 Langfuse 中ClickHouse 表被大量实时写入与聚合查询同时访问traces 表日增大量 LLM 调用记录一次全表迁移会直接阻塞生产写入路径这也是技能库把这条规则排在Schema Reviews 流程第 1 位见 SKILL.md的原因。正确姿势查询驱动query-driven的 ORDER BY 设计规则文档给出了完整的两步法核心思想是“先记录查询模式再设计键”。第 1 步在写 DDL 之前把查询模式写成注释固化下来。/* Query Analysis: - 60% of queries: WHERE user_id ? AND timestamp BETWEEN ? AND ? - 25% of queries: WHERE event_type ? AND timestamp ? - 15% of queries: WHERE event_id ? Conclusion: user_id and event_type are primary filters */第 2 步基于分析结果构建 ORDER BY并让键的列顺序遵循“基数从低到高”原则。CREATE TABLE events ( event_id UUID DEFAULT generateUUIDv4(), user_id UInt64, event_type LowCardinality(String), timestamp DateTime, event_date Date DEFAULT toDate(timestamp) ) ENGINE MergeTree() PARTITION BY toYYYYMM(event_date) ORDER BY (user_id, event_date, event_id);这个示例同时展示了几个关键点PARTITION BY toYYYYMM(event_date)按月份分区分区键与排序键解耦event_date用Date而非DateTime作为排序键成员——文档另一条规则 schema-pk-cardinality-order.md 指出当日粒度过滤够用时toDate(timestamp)能显著缩小索引体积高基数的event_idUUID放在键的最后——因为稀疏索引按块工作高基数前置会导致每个 granule 的键值都不同索引无法跳过任何块。与相邻规则的协同一组完整的 schema-pk-* 决策链schema-pk-plan-before-creation是schema-pk-*四条规则的第一环规划建表时应把四条规则作为一个整体执行规则文件解决的问题核心结论schema-pk-plan-before-creation.md建表前规划ORDER BY 不可变必须基于查询模式设计schema-pk-cardinality-order.md键内列顺序低基数在前、高基数在后保证 granule 可跳过schema-pk-prioritize-filters.md键内选列优先纳入高频 WHERE 列尤其是能排除大量行的列schema-pk-filter-on-orderby.md查询侧配合查询必须命中 ORDER BY 前缀跳过前缀列 放弃索引键内列序低基数在前-- 反例UUID 在前索引无法裁剪 ORDER BY (event_id, event_type, timestamp); -- 正例低基数在前可跳过整个 event_type 组 ORDER BY (event_type, event_date, event_id);位置基数示例第 1 位低去重值少event_type、status、country第 2 位日期粗粒度toDate(timestamp)第 3 位中到高user_id、session_id末尾高如需event_id、uuid选列优先纳入高频过滤列-- 反例大部分查询按 tenant_id 过滤却按 event_id 排序 ORDER BY (event_id); -- tenant_id 查询将全表扫描 -- 正例排序键匹配真实过滤模式 ORDER BY (tenant_id, event_date, event_id); -- 验证索引是否生效 EXPLAIN indexes 1 SELECT * FROM events WHERE tenant_id 123; -- 期望输出中出现 PrimaryKey 及对应 Key Condition查询侧必须使用 ORDER BY 前缀-- 给定ORDER BY (tenant_id, event_type, timestamp) -- 完整前缀匹配 - 性能最佳 SELECT * FROM events WHERE tenant_id 123 AND event_type click; -- 部分前缀 - 仍走索引 SELECT * FROM events WHERE tenant_id 123; -- 前缀等值 后续范围 - 正常走索引 SELECT * FROM events WHERE tenant_id 123 AND event_type click AND timestamp 2024-01-01;过滤条件索引是否使用WHERE tenant_id 123完整使用WHERE tenant_id 123 AND event_type click完整使用WHERE event_type click未使用跳过了前缀列WHERE timestamp 2024-01-01未使用跳过了两个前缀列建表前检查清单把规则落成可执行的核对步骤规则文档提供的 checklist 是每次 CREATE TABLE 评审的硬性关卡SKILL.md 将其明确纳入 Schema Reviews 流程的检查项列出前 510 个查询模式写入 DDL 注释中识别 WHERE 子句中的列及其出现频率优先纳入能排除大量数据行的列键内列按基数从低到高排列键列控制在 45 列以内通常已足够键列顺序遵循“等值列在前、范围列在后、高基数收尾”用EXPLAIN indexes 1验证关键查询确实命中主索引Langfuse 源码实证真实业务表如何践行这一规则规则不是纸上谈兵——Langfuse 仓库中的 ClickHouse 迁移文件就是这套方法论的真实落地。所有迁移模板集中在 packages/shared/clickhouse/migrations/canonical 目录由prepare-migrations.mjs渲染成 clustered / unclustered 两套 SQL机制详见 migrations/README.md。例 1traces 表 —— 多租户过滤 时间范围查询的典型键设计0001_traces.up.sql 展示了 Langfuse 的核心链路数据表如何设计排序键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 );对照本文规则逐条验证过滤列优先project_id是 Langfuse 一切查询的第一过滤维度多租户隔离放在排序键第 1 位完全符合schema-pk-prioritize-filters日期粗粒度化toDate(timestamp)而非原始DateTime64(3)符合schema-pk-cardinality-order的日期建议高基数收尾idtrace 唯一 ID放在最后避免破坏 granule 跳过能力分区与排序键解耦PARTITION BY toYYYYMM(timestamp)按月分区管理生命周期如数据保留、DROP PARTITION而不干扰查询裁剪索引补充表上还定义了idx_id、idx_res_metadata_key等bloom_filter跳过索引skipping index用于覆盖排序键之外的高频过滤场景——这正是规则体系中“非 ORDER BY 过滤列用 skipping index 兜底”见 query-index-skipping-indices.md的配合用法。例 2event_log 表 —— 查询驱动设计的另一形态0007_add_event_log.up.sql 中的event_log表CREATE TABLE event_log {CLICKHOUSE_CLUSTER_CLAUSE} ( id String, project_id String, entity_type String, entity_id String, ... ) ENGINE MergeTree() ORDER BY ( project_id, entity_type, entity_id );键设计遵循“项目维度 → 实体类型 → 实体 ID”的基数递增顺序同样符合低基数在前、高基数在后的准则。例 3迁移文件的可逆性设计每张表都配套.down.sql如 0001_traces.down.sql且所有 DDL 使用{CLICKHOUSE_CLUSTER_CLAUSE}占位符以适配单机/集群两种部署模式。这提醒我们建表前规划不仅是键设计还包括迁移的可回滚性——这进一步放大了“一次键选错 迁移地狱”的成本。实用建议在 Langfuse 中新增 ClickHouse 表时的操作流程结合本规则与 Langfuse 的迁移机制当你在 Langfuse 中新增一张 ClickHouse 表时推荐按以下顺序执行收集查询模式列出该表未来 510 个主要查询按频率与数据裁剪量排序写入 DDL 顶部注释设计键按“高频过滤列 → 粗粒度日期 → 中基数 → 高基数收尾”排列键列不超过 45 列无法覆盖的高频过滤列考虑 bloom_filter 跳过索引选择引擎与分区多租户/更新场景考虑ReplicatedReplacingMergeTreeLangfuse 的 traces 表即此模式分区键服务于数据生命周期管理而非查询裁剪在 canonical 目录新增迁移文件文件命名NNNN_name.up.sql/.down.sqlDDL 中正确使用{CLICKHOUSE_CLUSTER_CLAUSE}与{CLICKHOUSE_REPLICATION_PREFIX}占位符见 migrations/README.md验证运行pnpm --filter langfuse/shared run test prepareMigrations.test.ts确认迁移渲染正确对关键查询执行EXPLAIN indexes 1验证索引命中评审对照本文检查清单逐项核验再提交代码审查。小结ClickHouse 的ORDER BY是不可变契约这决定了建表前的 PRIMARY KEY 规划是 ClickHouse 架构设计中最不可逆、也最值得投入的环节。本规则的完整方法论是先分析查询模式再按“过滤列优先、基数低到高、高基数收尾、45 列封顶”设计排序键最后用EXPLAIN indexes 1持续验证查询与键的匹配度。Langfuse 的 traces、event_log 等真实生产表已经用project_id → toDate(timestamp) → id这样的键结构为这套规则提供了可对照学习的样板——下一张新表请在敲下CREATE TABLE之前先回答一个问题这 5 个最重要的查询分别怎么过滤【免费下载链接】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),仅供参考