首页
/ Langfuse 中的 ClickHouse 最佳实践:Schema 设计、查询优化与数据写入的 28 条规则指南

Langfuse 中的 ClickHouse 最佳实践:Schema 设计、查询优化与数据写入的 28 条规则指南

2026-09-08 21:38:46作者:柯茵沙

本篇技术指南以 Langfuse 仓库内置的 clickhouse-best-practices Agent Skill(README.mdSKILL.md)为核心骨架,系统讲解 Langfuse 在 ClickHouse 上的 Schema 设计、查询优化与数据写入三大主题下的 28 条原子规则。阅读全文后,你将掌握:如何围绕 ORDER BY 设计不可变主键、如何选择原生类型与 LowCardinality、如何规划分区与生命周期、如何优化 JOIN 与跳过索引、如何用物化视图做实时聚合,以及如何用正确的批量写入替代代价高昂的 mutation——同时了解这些规则在 Langfuse 的 events/observations/traces 等真实表结构与迁移体系中的落地方式。

一、Skill 概览:它是什么、包含什么

clickhouse-best-practices 是 Langfuse 仓库 .agents/skills/ 目录下为 AI Agent 提供的 ClickHouse 专项技能包,定位是"在审查 ClickHouse Schema、查询或配置时必须使用的规则集":SKILL.md 的 frontmatter 明确声明 MUST USE when reviewing ClickHouse schemas, queries, or configurations. Contains 28 rules that MUST be checked before providing recommendations。也就是说,任何 Agent 在给 Langfuse 相关代码提 ClickHouse 建议之前,都必须先读取对应规则文件,并在回复中引用具体规则。

1.1 安装方式

该 Skill 由 ClickHouse Inc 维护(frontmatter 中 author: ClickHouse Incversion: "0.3.0"license: Apache-2.0),可通过官方 skills 仓库安装:

npx skills add ClickHouse/clickhouse-agent-skills

1.2 28 条原子规则一览

规则按前缀分组,共 28 条,覆盖 Schema、Query、Insert 三大类:

Prefix 数量 覆盖范围
schema-pk-* 4 PRIMARY KEY 选择、基数排序
schema-types-* 5 数据类型、LowCardinality、Nullable
schema-partition-* 4 分区策略、生命周期管理
schema-json-* 1 JSON 类型使用
query-join-* 5 JOIN 算法、过滤、替代方案
query-index-* 1 数据跳过索引(Data Skipping Indices)
query-mv-* 2 增量与可刷新物化视图
insert-batch-* 1 批量大小(10K-100K 行)
insert-async-* 2 异步插入、数据格式
insert-mutation-* 2 避免 mutation
insert-optimize-* 1 避免 OPTIMIZE FINAL

1.3 触发短语(Trigger Phrases)

Skill 在以下场景自动激活:"Create a table for...""Optimize this query...""Design a schema for...""Why is this query slow?""How should I insert data into...""Should I use UPDATE or..."。对应到实际开发中,就是遇到 CREATE TABLEALTER TABLEORDER BY/PRIMARY KEY 讨论、数据类型选择、慢查询排查、JOIN 优化、数据摄入管道设计、更新/删除策略、ReplacingMergeTree 等专用引擎使用、分区策略决策等场景时激活。

1.4 文件结构

文件 用途
SKILL.md 审查工作流、快速参考、规则选择入口
README.md Skill 概览、规则清单、触发短语
rules/*.md 28 条独立规则定义
rules/_sections.md 三大分区的定义、顺序与影响级别
rules/_template.md 规则文件的统一模板

每条规则文件统一包含:YAML frontmatter(title、impact 级别、tags)、规则重要性说明、反例(anti-pattern)+ 解释、正例(best practice)+ 解释、额外上下文(权衡、适用时机、参考)。

二、如何应用这套 Skill:五步优先级工作流

SKILL.md 规定了回答 ClickHouse 问题前的强制顺序:

  1. 检查 rules/ 目录中是否有适用规则
  2. 若有规则:应用规则并在回复中引用,格式为 Per <rule-name>...
  3. 若无规则:使用 LLM 自身的 ClickHouse 知识或检索官方文档;
  4. 若不确定:使用 Web 搜索获取当前最佳实践;
  5. 始终注明来源:规则名、"general ClickHouse guidance" 或文档 URL。

为什么规则优先于通用数据库直觉?因为 ClickHouse 有列式存储、稀疏索引、MergeTree 合并机制等特殊行为,通用数据库经验在这里常常具有误导性,而规则编码的是经过验证的 ClickHouse 专项指导。_sections.md 进一步解释了三大分区的重要性:Schema 设计是性能基石(ORDER BY 建表后不可变,选错需要全量数据迁移,列类型与顺序对查询速度的影响可达数量级);查询模式决定性能上限(JOIN 算法、过滤策略、跳过索引、物化视图可以把查询从分钟级降到毫秒级,预聚合只读数千行而非数十亿行);每次 INSERT 都会生成一个 data part,单行插入会压垮合并进程。

三、Langfuse 特有规则(结合源码)

除通用 28 条规则外,SKILL.md 还包含一组 Langfuse-Specific Rules,这些是 Langfuse 在长期运行中沉淀的项目级约束,直接对应当前仓库的真实代码。

3.1 events 表查询必须走 Query Builder

events 表的查询应使用 event-query-builder.ts,除非先确认该构建器无法表达该查询,否则不要手写 events SQL。对应测试见 event-query-builder.test.ts

3.2 永远不要对 events 表使用 FINAL

events 表在设计上就不需要 FINAL,使用该关键字会损害性能。这条约束也呼应通用规则 insert-optimize-avoid-final 中"SELECT 的 FINAL 与 OPTIMIZE FINAL 不是一回事"的辨析。

3.3 查询归属(Query Attribution)写入 log_comment

ClickHouse 查询归属信息以 JSON 形式存储在 system.query_log.log_comment 中,来源是 queryTags.ts。需要解析时使用:

SELECT JSONExtractString(log_comment, 'surface'),
       JSONExtractString(log_comment, 'route'),
       JSONExtractString(log_comment, 'projectId')
FROM system.query_log;

已知的 surface 取值有 trpcpublicapiworkermcpunknownClickhouseWriter 的插入使用 projectId = "MULTI_PROJECT"

从源码看,queryTags.tsclickHouseQuerySurfaces 常量数组定义了 trpcworkerpublicapimcp 四种 surface,normalizeClickHouseQueryTags 会从 OpenTelemetry baggage 读取或从显式传入的 tags 中归一化出 tag_schema_versionsurfacerouteprojectId 等字段,最终由 buildClickHouseLogComment 序列化为 JSON 字符串(第 99-101 行)。注意 sdkName/sdkVersion/userAgent 仅在 surface === "publicapi" 时才会被写入,这一细节体现了对公共 API 流量可观测性的专门设计。

3.4 归属信息通过 OpenTelemetry Baggage 传播

查询归属通过 OpenTelemetry baggage 传播:入口点调用 headerPropagation.ts 中的 contextWithLangfuseProps(...) 设置 ClickHouse surface、可选的 routeprojectId;仓库层(如 repositories/clickhouse.ts)通过 normalizeClickHouseQueryTags(...) 读取 baggage 并写入 log_comment。最佳实践是在入口点设置归属,而不是在每个仓库调用中传递 tags——这与 queryTags.ts 中"先读显式 tags、再回退到 baggage"的优先级设计一致(第 50-72 行),CLICKHOUSE_QUERY_TAG_BAGGAGE_KEYS 定义了 langfuse.clickhouse.surfacelangfuse.clickhouse.routelangfuse.project.id 等 baggage key 名。

3.5 Canonical 迁移模板与集群占位符

packages/shared/clickhouse/migrations/canonical/ 是集群与非集群安装统一渲染的单一权威模板树。所有集群感知的 DDL 位置必须放置 {CLICKHOUSE_CLUSTER_CLAUSE}{CLICKHOUSE_REPLICATION_PREFIX} 仅用于那些有意在不同模式下使用不同引擎的表,且部分表在两种模式下都刻意保持非复制。

0001_traces.up.sql 为例,可以清晰看到这套模板的写法:

CREATE TABLE traces {CLICKHOUSE_CLUSTER_CLAUSE} (
    `id` String,
    `timestamp` DateTime64(3),
    ...
    `is_deleted` UInt8,
    INDEX idx_id id TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_res_metadata_key mapKeys(metadata) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_metadata_value mapValues(metadata) TYPE bloom_filter(0.01) GRANULARITY 1
) 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(低基数租户列)→ toDate(timestamp)(日期粗粒度)→ id(高基数)的低到高基数排序(schema-pk-cardinality-order);时间对齐的月级分区 toYYYYMM(timestamp)schema-partition-lifecycle);ReplacingMergeTree(event_ts, is_deleted) 版本列设计(insert-mutation-avoid-update);对非 ORDER BY 列 id、metadata 键值建 bloom_filter 跳过索引(query-index-skipping-indices)。0002_observations.up.sql 同样展示了 typelevelLowCardinality(String) 列、Decimal64(12) 金额类型与 Map(LowCardinality(String), UInt64) 的使用。

3.6 元数据 ALTER 必须同步设置 alter_sync / mutations_sync

新 canonical 迁移中的每个元数据 ALTERADD/DROP/MODIFY COLUMNADD/DROP INDEX)都必须包含 {CLICKHOUSE_CLUSTERED_ONLY: SETTINGS alter_sync = 2};每个产生 mutation 的 ALTERMATERIALIZE …UPDATEDELETE)都必须包含 {CLICKHOUSE_CLUSTERED_ONLY: SETTINGS mutations_sync = 2}。即使某个文件只含单个 ALTER 也必须如此——因为竞争发生在迁移文件之间而非文件内部。alter_sync 默认值为 1,语句在发起副本于 Keeper 中推进表元数据版本后即返回;golang-migrate 随即打开下一个文件,其首个针对该表的 ALTER 可能落到仍处于旧元数据版本的副本上,ClickHouse 会因副本元数据版本落后于公共元数据版本而拒绝排队,并以 code 517 中止整个迁移运行。注意 mutations_sync 不能替代 alter_sync:它控制的是 mutation 何时完成,而非元数据传播。对于非集群的 MergeTree 迁移,渲染器会省略这些片段;{CLICKHOUSE_UNCLUSTERED_ONLY:...} 仅用于刻意的模式差异。不要为了规范化而给已上线的历史迁移回填同步设置——历史兼容性测试有意保护它们既有的输出。两种渲染模式均需通过 prepareMigrations.test.ts

3.7 禁止 CREATE OR REPLACE VIEW/TABLE 与 EXCHANGE TABLES

迁移中永远不要使用 CREATE OR REPLACE VIEW(也不要用 CREATE OR REPLACE TABLE / EXCHANGE TABLES)。原子替换依赖 renameat2 文件系统支持,而基于 NFS 的自托管部署(例如数据放在 AWS EFS 上)不支持该调用——迁移会失败并导致部署在启动时中止(对应 GitHub issue #14906)。正确做法是在同一迁移文件中用两条语句重定义普通视图:先 DROP VIEW IF EXISTS <name> {CLICKHOUSE_CLUSTER_CLAUSE};,再 CREATE VIEW <name> {CLICKHOUSE_CLUSTER_CLAUSE} AS …。由于迁移运行器传 x-multi-statement=true,golang-migrate 按 ; 切分文件且不解析 SQL,所以注释和字符串字面量里不能出现分号;每条语句必须幂等(IF EXISTS/IF NOT EXISTS),这样脏的、半应用的迁移在 migrate force 后可以重跑。drop→create 窗口期内读取该视图的查询会短暂失败——对 analytics_* 导出视图可以接受,所以普通视图要避免放在产品热路径上。

3.8 物化视图禁止 drop-and-recreate

绝不对源表仍在接收实时插入的物化视图做"先删后建":DROP 与 CREATE 之间插入的每一行都会从目标表中静默永久丢失。修改 MV 的 SELECT 应使用 ALTER TABLE <mv> {CLICKHOUSE_CLUSTER_CLAUSE} MODIFY QUERY <select>,该操作在不中断摄入的情况下切换变换逻辑。当变更新增列时,先 ALTER 目标表(ADD COLUMN IF NOT EXISTS …),再 MODIFY QUERY;这些目标表 ALTER 必须携带集群专用的 alter_sync 模板片段,以免任何主机在其目标副本拥有新列之前应用新的 MV 查询。MODIFY QUERY 只对 TO 表型 MV 可行——Langfuse 的全部 MV 都使用 TO

四、三类审查工作流

SKILL.md 按审查对象划分了三套流程,每套都有固定的规则阅读顺序与检查清单。

4.1 Schema 审查(CREATE TABLE / ALTER TABLE)

按序阅读:schema-pk-plan-before-creationschema-pk-cardinality-orderschema-pk-prioritize-filtersschema-types-native-typesschema-types-minimize-bitwidthschema-types-lowcardinalityschema-types-avoid-nullableschema-partition-low-cardinalityschema-partition-lifecycle

检查清单:

  • [ ] PRIMARY KEY / ORDER BY 列顺序(低到高基数)
  • [ ] 数据类型匹配真实数据范围
  • [ ] 合适的字符串列应用了 LowCardinality
  • [ ] 分区键基数有界(100-1,000 个值)
  • [ ] 若使用 ReplacingMergeTree 则必须有版本列
  • [ ] 新 canonical 迁移中每个元数据 ALTER 都含 {CLICKHOUSE_CLUSTERED_ONLY: SETTINGS alter_sync = 2}(包括单 ALTER 文件),每个 MATERIALIZE …/UPDATE/DELETE 都含对应 mutations_sync 片段;mutations_sync 不能替代 alter_sync;不得规范化已上线迁移的输出;两种渲染模式均通过 prepareMigrations.test.ts
  • [ ] 迁移中无 CREATE OR REPLACE VIEW/TABLEEXCHANGE TABLES(会破坏 NFS/EFS 自托管);普通视图在同一文件内用 DROP VIEW IF EXISTS + CREATE VIEW 重定义
  • [ ] 源表仍接收插入时,物化视图绝不被 drop 后重建;SELECT 变更通过目标表 ALTER 之后的 ALTER TABLE <mv> MODIFY QUERY 完成

4.2 查询审查(SELECT / JOIN / 聚合)

按序阅读:query-join-choose-algorithmquery-join-filter-beforequery-join-use-anyquery-index-skipping-indicesschema-pk-filter-on-orderby

检查清单:

  • [ ] 过滤条件使用 ORDER BY 前缀列
  • [ ] JOIN 在连接前过滤(而非连接后)
  • [ ] 根据表规模选择了正确的 JOIN 算法
  • [ ] 为非 ORDER BY 过滤列配置了跳过索引

4.3 插入策略审查(摄入、更新、删除)

按序阅读:insert-batch-sizeinsert-mutation-avoid-updateinsert-mutation-avoid-deleteinsert-async-small-batchesinsert-optimize-avoid-final

检查清单:

  • [ ] 每次 INSERT 批量大小 10K-100K 行
  • [ ] 高频变更不使用 ALTER TABLE UPDATE
  • [ ] 更新模式使用 ReplacingMergeTree 或 CollapsingMergeTree
  • [ ] 高频小批量启用了异步插入

4.4 输出格式模板

任何审查结论都应按下述结构输出,使 Agent 回复可审计、可定位:

## Rules Checked
- `rule-name-1` - Compliant / Violation found
- `rule-name-2` - Compliant / Violation found
...

## Findings

### Violations
- **`rule-name`**: Description of the issue
  - Current: [what the code does]
  - Required: [what it should do]
  - Fix: [specific correction]

### Compliant
- `rule-name`: Brief note on why it's correct

## Recommendations
[Prioritized list of changes, citing rules]

五、规则优先级总览

优先级 类别 影响 前缀 规则数
1 Primary Key 选择 CRITICAL schema-pk- 4
2 数据类型选择 CRITICAL schema-types- 5
3 JOIN 优化 CRITICAL query-join- 5
4 插入批量 CRITICAL insert-batch- 1
5 Mutation 规避 CRITICAL insert-mutation- 2
6 分区策略 HIGH schema-partition- 4
7 跳过索引 HIGH query-index- 1
8 物化视图 HIGH query-mv- 2
9 异步插入 HIGH insert-async- 2
10 OPTIMIZE 规避 HIGH insert-optimize- 1
11 JSON 使用 MEDIUM schema-json- 1

六、Schema 设计规则详解

6.1 主键(Primary Key)——CRITICAL

schema-pk-plan-before-creation:建表前规划 PRIMARY KEY。 ClickHouse 的 ORDER BY 决定了物理数据排序与稀疏索引。与其他数据库不同,ORDER BY 建表后不可修改,选错就需要重建表并迁移全部数据。反例:任意用 ORDER BY (event_id) 建表,后来发现查询都按 user_id 过滤,却无法用 ALTER TABLE events MODIFY ORDER BY (user_id, timestamp) 修复。正例:建表前先列出 top 5-10 查询模式、识别 WHERE 子句列及频次、优先能排除大量行的列、按基数从低到高排序、限制在 4-5 个键列(通常足够)。预建表检查清单:列出查询模式、识别高频过滤列、优先高选择性列、低基数在前、键列不超过 4-5 个。

-- 反例:未分析查询模式就建表
CREATE TABLE events (event_id UUID, user_id UInt64, timestamp DateTime)
ENGINE = MergeTree()
ORDER BY (event_id);  -- 随意选择,事后不可改

-- 正例:查询驱动的 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);

schema-pk-cardinality-order:键列按基数低到高排序。 稀疏主索引作用于数据块(granule)而非单行,低基数的前置列能生成更有用的索引条目,从而跳过整个块。反例 ORDER BY (event_id, event_type, timestamp):每个 granule 的 event_id 都不同,索引无法跳过任何数据。正例 ORDER BY (event_type, event_date, event_id):索引可以跳过整个 event_type 组。列序参考:第 1 位低基数(event_type、status、country),第 2 位日期粗粒度(toDate(timestamp)),第 3 位及以后中高基数(user_id、session_id),最后一位高基数(event_id、uuid)。小技巧:日级过滤足够时用 toDate(timestamp) 替代原始 DateTime,可将索引大小从 32 位降到 16 位表示。

schema-pk-prioritize-filters:ORDER BY 优先覆盖高频过滤列。 不在 ORDER BY 中的列会导致全表扫描。反例:多数查询按 tenant_id 过滤,表却 ORDER BY (event_id)。正例:ORDER BY (tenant_id, event_date, event_id),此时 WHERE tenant_id = 123 AND event_date >= '2024-01-01' 可以走主索引。可用 EXPLAIN indexes = 1 验证,输出中应出现带 Key Condition 的 PrimaryKey

schema-pk-filter-on-orderby:查询过滤必须使用 ORDER BY 前缀列。 即使 Schema 设计正确,跳过前缀列或过滤非 ORDER BY 列仍会禁用索引。给定 ORDER BY (tenant_id, event_type, timestamp)WHERE tenant_id = 123 全索引可用;WHERE tenant_id = 123 AND event_type = 'click' 全索引可用;WHERE event_type = 'click' 因跳过前缀完全不可用;WHERE timestamp > '2024-01-01' 因跳过两个前缀不可用。

6.2 数据类型(Data Types)——CRITICAL

schema-types-native-types:用原生类型而非 String。 全用 String 浪费存储、阻碍压缩、拖慢比较。例如 UUID 用 16 字节而非 36 字节字符串、DateTime 用 4 字节而非 19 字节字符串、Bool 用 1 字节而非 4 字节。速查表:顺序 ID 用 UInt32/UInt64;UUID 用 UUID;状态/类别用 Enum8 或 LowCardinality(String);时间戳用 DateTime;纯日期用 Date/Date32;计数用能容纳的最小 UInt8/16/32;金额用 Decimal(P,S) 或按分存储的 Int64;布尔用 Bool 或 UInt8。

schema-types-minimize-bitwidth:数字类型最小位宽。 选择能容纳数据范围的最小数字类型,无需负数时优先无符号。例如 HTTP 状态码用 UInt16 而非 Int64,年龄用 UInt8,年份用 UInt16。参考范围:UInt8 0-255(1 字节)、UInt16 0-65,535(2 字节)、UInt32 0-43 亿(4 字节)、UInt64 0-1800 京(8 字节),以及对应的 Int8/16/32/64 有符号版本。

schema-types-lowcardinality:重复字符串用 LowCardinality。 字典编码可显著降低存储。唯一值 < 10,000 用 LowCardinality,> 10,000 用普通 String;决定前先 SELECT uniq(column_name) FROM table_name; 检查基数。FixedString 仅保留给严格定长数据(如 2 位国家码 country_code FixedString(2));变长低基数文本用 LowCardinality(String) 优于 FixedString

schema-types-enum:有限值集合用 Enum。 Enum 在插入时提供校验,且支持自然顺序查询。Enum8 最多 256 个值(1 字节),Enum16 最多 65,536 个值(2 字节)。反例:status String 允许 "shiped" 这种拼写错误进入,排序还要手写 CASE;正例:status Enum8('pending' = 1, 'processing' = 2, 'shipped' = 3, 'delivered' = 4),非法值插入直接报 ERROR: Unknown element 'shiped'ORDER BY statusWHERE status > 'processing' 都按自然序工作。值集固定已知用 Enum;值可能频繁变动用 LowCardinality(String)。

schema-types-avoid-nullable:避免 Nullable,用 DEFAULT 代替。 Nullable 列会额外维护一个 UInt8 掩码列,增加存储并拖慢性能。name String DEFAULT ''age UInt8 DEFAULT 0login_count UInt32 DEFAULT 0 都是合适的默认值方案。仅在语义上真正需要 NULL 时使用:deleted_at Nullable(DateTime)(NULL=未删除)、parent_id Nullable(UInt64)(NULL=无父级)、discount_percent(NULL=无折扣,0=零折扣)。默认值速查:String 用 ''、UInt*/Int* 用 0、DateTime 用 now()toDateTime(0)、UUID 用 generateUUIDv4()

6.3 分区(Partitioning)——HIGH

schema-partition-low-cardinality:分区基数保持 100-1,000。 分区值过多会产生过量 data parts,最终触发 "too many parts" 错误(由 max_parts_in_totalparts_to_throw_insert 设置约束)。反例:PARTITION BY user_id(数百万分区)、PARTITION BY toDate(timestamp)(十年 3650 个分区)。正例:PARTITION BY toStartOfMonth(timestamp) 每年 12 个分区。健康检查 SQL:

SELECT partition, count() as parts, sum(rows) as rows,
       formatReadableSize(sum(bytes_on_disk)) as size
FROM system.parts
WHERE table = 'events' AND active
GROUP BY partition ORDER BY partition;

schema-partition-lifecycle:分区服务于数据生命周期,而非查询优化。 分区擅长:整分区删除(单个元数据操作)、TTL 保留策略、分层存储(移动旧分区到冷存储)、归档(分区在表间移动)。反例 PARTITION BY event_type 导致无法按时间高效删除旧数据,只能 DELETE FROM events WHERE timestamp < '2023-01-01' 逐行扫描。正例:

CREATE TABLE events (
    timestamp DateTime,
    event_type LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY toStartOfMonth(timestamp)
ORDER BY (event_type, timestamp)
TTL timestamp + INTERVAL 1 YEAR DELETE;  -- 整分区删除

ALTER TABLE events DROP PARTITION '202301';                    -- 元数据级操作,极快
ALTER TABLE events_archive ATTACH PARTITION '202301' FROM events;  -- 归档到冷存储

schema-partition-query-tradeoffs:理解分区对查询性能的双刃剑。 按分区键过滤的查询可能受益于分区裁剪;但跨多分区的查询会增加扫描的 parts 总数。ClickHouse 会自动为分区列构建 MinMax 索引,且数据合并只在分区内部进行,不跨分区。WHERE event_type = 'click' 会扫描所有分区;WHERE timestamp >= '2024-01-01' AND timestamp < '2024-02-01' AND event_type = 'click' 可裁剪到单分区。

schema-partition-start-without:考虑先不分区。 无明确需求时先不分区,仅当有清晰的数据生命周期需求(保留/归档)、访问模式明确受益于分区裁剪、且理解基数影响时再引入(分区需要重建表或物化视图迁移)。决策表:时间保留需求→加;冷存储归档→加;时间范围查询性能→或许(先测试);无生命周期需求→不加。

6.4 JSON——MEDIUM

schema-json-when-to-use:动态 Schema 用 JSON 类型。 ClickHouse 的 JSON 类型把对象拆成独立子列,支持字段级查询优化。反例:为事件属性建上百个 Nullable 列;或把需要字段查询的 JSON 存成 String(无字段级优化)。正例:

CREATE TABLE events (
    event_id UUID DEFAULT generateUUIDv4(),
    event_type LowCardinality(String),
    timestamp DateTime DEFAULT now(),
    properties JSON  -- 灵活 Schema + 类型推断
)
ENGINE = MergeTree()
ORDER BY (event_type, timestamp);

SELECT event_type, properties.url as page_url, properties.amount as purchase_amount
FROM events
WHERE event_type = 'page_view' AND properties.url = '/home';

-- 为已知路径显式声明类型
CREATE TABLE events (
    properties JSON(url String, amount Float64, product_id UInt64)
)

适用场景:结构不可预测变化→用;字段类型随时间变化→用;需要字段级查询→用;固定已知 Schema→不用(用类型化列);JSON 作为不查询的不透明 blob→不用(用 String)。

七、查询优化规则详解

7.1 JOIN——CRITICAL

query-join-choose-algorithm:选择正确的 JOIN 算法。 ClickHouse 默认哈希连接会把右表整体载入内存,选错算法可能 OOM。算法选择表:

算法 适用 权衡
parallel_hash 中小型内存表 24.11 起默认;快、可并发
hash 通用、全 JOIN 类型 单线程构建哈希表
direct 字典查找(INNER/LEFT) 最快;无需构建哈希表
full_sorting_merge 连接键已排序的表 预排序时跳过排序;低内存
partial_merge 大表、内存受限 内存最小化;执行较慢
grace_hash 大数据集、内存可调 灵活;支持磁盘溢出
auto 自适应选择 先尝试哈希,内存压力时回退
SET join_algorithm = 'auto';                       -- 让 ClickHouse 自动选择
SET join_algorithm = 'partial_merge';              -- 大表对大表、内存受限
SELECT * FROM large_a JOIN large_b ON large_b.id = large_a.id;

SET join_algorithm = 'full_sorting_merge';         -- 按主键列连接时跳过排序
SELECT * FROM table_a a JOIN table_b b ON b.pk_col = a.pk_col;

注意:ClickHouse 24.12+ 会自动把小表放到右侧;更早版本需手动保证小表在 RIGHT 侧。

query-join-use-any:只需一个匹配时用 ANY JOIN。 LEFT ANY JOIN 每行至多取右表一个匹配,内存更少、执行更快。还有 INNER ANY JOIN(至多一个匹配且仅返回匹配行)与 RIGHT ANY JOIN(至多一个来自左表的匹配)。

query-join-filter-before:连接前先过滤。 先连全表再过滤浪费资源。若自动下推失败,应改写为子查询:

-- 反例:先全量连接再过滤
SELECT o.order_id, c.name, o.total
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2024-01-01' AND c.country = 'US';

-- 正例:子查询内先过滤再连接
SELECT o.order_id, c.name, o.total
FROM (SELECT order_id, customer_id, total FROM orders WHERE created_at > '2024-01-01') o
JOIN (SELECT id, name FROM customers WHERE country = 'US') c ON c.id = o.customer_id;

-- 更优:连接前先聚合
SELECT c.country, o.total_revenue
FROM (SELECT customer_id, sum(total) as total_revenue FROM orders
      WHERE created_at > '2024-01-01' GROUP BY customer_id) o
JOIN customers c ON c.id = o.customer_id;

query-join-consider-alternatives:考虑 JOIN 的替代方案。 反复对维度表 JOIN 有额外开销,字典或反规范化把计算负担从查询时转移到插入/预处理时。字典方案:CREATE DICTIONARY customer_dict ... SOURCE(CLICKHOUSE(TABLE 'customers')) LAYOUT(HASHED()) LIFETIME(MIN 300 MAX 360),然后用 dictGet('customer_dict', 'name', customer_id) 替代 JOIN(走最快的 direct 算法)。反规范化方案:用物化视图预生成 orders_enriched 宽表。方案对比:字典适合频繁查询小维度(最快,内存中);反规范化适合分析总需要富化数据;IN 子查询适合存在性过滤(常快于 JOIN);JOIN 适合低频或复杂连接。关键警告:字典会对重复键静默去重、只保留最终值,仅当源表键唯一时才可使用。

query-join-null-handling:优化外连接中的 NULL 处理。 SET join_use_nulls = 0 时,未匹配行使用列默认值(空串、0)而非 NULL,避免 Nullable 包装的内存开销。join_use_nulls = 1(默认)时未匹配行返回 NULL,适合需要区分"无匹配"与"匹配到默认值"的场景。

7.2 跳过索引——HIGH

query-index-skipping-indices:为非 ORDER BY 过滤列建数据跳过索引。 过滤不在 ORDER BY 中的列无法使用主索引、导致全扫描;跳过索引在块上存储元数据并跳过确定不匹配的 granule。重要前提:应在优化数据类型、主键选择、物化视图之后再考虑跳过索引。适用:整体基数高但块内基数低、稀有值对搜索关键(错误码、特定 ID)、列与主键相关。不适用:作为第一步优化、匹配值分散在大量块中、未在真实数据上测试。

CREATE TABLE events (
    event_type LowCardinality(String),
    timestamp DateTime,
    user_id UInt64,
    INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4  -- 跳过索引
)
ENGINE = MergeTree()
ORDER BY (event_type, toDate(timestamp));

-- 或对现有表补充
ALTER TABLE events ADD INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
ALTER TABLE events MATERIALIZE INDEX idx_user_id;

索引类型速查:bloom_filter 适合高基数等值过滤(WHERE user_id = 123);set(N) 适合低基数(N 个唯一值,WHERE status IN ('a','b'));minmax 适合范围查询(WHERE amount > 1000);ngrambf_v1 适合文本搜索(LIKE '%term%');tokenbf_v1 适合 token 搜索。用 EXPLAIN indexes = 1 验证输出中 "Skip" 是否显示跳过的 granule。在 Langfuse 的 0001_traces.up.sql 中,idx_ididx_res_metadata_keyidx_res_metadata_value 三个 bloom_filter 索引正是这条规则的工程实例——对不在 ORDER BY 中的 id 与 metadata 键值做等值/包含过滤加速。

7.3 物化视图——HIGH

query-mv-incremental:实时聚合用增量物化视图。 增量 MV 在插入时自动对新数据块应用视图查询,结果写入目标表,部分结果随时间合并。反例是每次仪表盘加载都全量聚合 7 天数据(扫描数十亿行)。正例:AggregatingMergeTree 目标表 + -State 聚合函数的 MV,查询时用 -Merge 函数读取预聚合数据——"读数千行而非数十亿行"。要点:MV 中用 -State 函数、查询中用 -Merge 函数;增量 MV 不自动包含存量数据(需单独回填);插入时集群开销极小。

CREATE TABLE events_hourly (
    event_type LowCardinality(String),
    hour DateTime,
    events AggregateFunction(count),
    unique_users AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree()
ORDER BY (event_type, hour);

CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT event_type, toStartOfHour(timestamp) as hour,
       countState() as events, uniqState(user_id) as unique_users
FROM events GROUP BY event_type, hour;

SELECT event_type, hour, countMerge(events) as events, uniqMerge(unique_users) as unique_users
FROM events_hourly WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY event_type, hour;

query-mv-refreshable:复杂 JOIN 与批量工作流用可刷新物化视图。 可刷新 MV 按计划周期性执行完整查询并覆盖(或追加)目标表。适合:亚毫秒延迟且可容忍少量过期、缓存 top N 结果或查找表、需要反规范化的复杂多表 JOIN、批量工作流与 DAG 依赖。REFRESH EVERY 5 MINUTE 语法;REPLACE(默认)覆盖旧内容,适合当前状态/查找表;APPEND 追加新行,适合周期快照/历史累积。关键警告:查询耗时应远小于刷新间隔——查询要 10+ 秒就不要每 10 秒调度一次。

八、数据写入规则详解

8.1 批量大小——CRITICAL

insert-batch-size:适当批量(10K-100K 行)。 每次 INSERT 都会创建新 data part,单行或小批量插入会产生成千上万个微小 part,压垮合并进程并导致集群不稳定。反例:循环单行插入(1 万个事件产生 1 万个 part)、每批 100 行的微小批量。正例:

# 理想批量大小:10,000-100,000 行
BATCH_SIZE = 10_000
for batch in chunks(events, BATCH_SIZE):
    client.execute("INSERT INTO events VALUES", batch)

参考阈值:最小 1,000 行;理想 10,000-100,000 行;同步插入速率约每秒 1 次。监控 parts 数量(每分区超过 3,000 个 parts 会阻塞插入):

SELECT table, count() as parts, sum(rows) as total_rows
FROM system.parts
WHERE active AND database = 'default'
GROUP BY table ORDER BY parts DESC;

8.2 异步插入——HIGH

insert-async-small-batches:高频小批量用异步插入。 客户端无法批量时,异步插入在服务端缓冲并自动合并成更大的 part:

client.execute("SET async_insert = 1")
client.execute("SET wait_for_async_insert = 1")  # 确认持久化
for batch in chunks(events, 100):
    client.execute("INSERT INTO events VALUES", batch)
# 服务端自动缓冲并生成更大 parts

服务端按用户配置(满足其一即冲刷):缓冲达 async_insert_max_data_size(如 10MB);超时 async_insert_busy_timeout_ms(如 1s);累积插入查询数达上限。返回模式:wait_for_async_insert=1 等待冲刷并确认持久化(推荐);wait_for_async_insert=0 即发即忘、感知不到错误(有风险,仅在可接受数据丢失时使用)。

ALTER USER my_app_user SETTINGS
    async_insert = 1,
    wait_for_async_insert = 1,
    async_insert_max_data_size = 10000000,  -- 10MB 冲刷
    async_insert_busy_timeout_ms = 1000;    -- 1s 冲刷

insert-format-native:插入性能用 Native 格式。 格式影响插入性能:Native 列式、解析开销最小(推荐);RowBinary 高效的行式替代;JSONEachRow 易用但解析昂贵。示例:client.execute("INSERT INTO events VALUES", data, settings={'input_format': 'Native'})

8.3 Mutation 规避——CRITICAL

insert-mutation-avoid-update:避免 ALTER TABLE UPDATE。 mutation 是异步后台进程,会重写受影响的整个 data part,对高频或大规模操作极其昂贵。问题:写放大(微小变更也重写整个 part)、磁盘 I/O 尖峰(拖累集群)、不可回滚、读取不一致(SELECT 可能读到已变异与未变异 parts 的混合)。正确做法是 ReplacingMergeTree:

CREATE TABLE users (
    user_id UInt64,
    name String,
    status LowCardinality(String),
    updated_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;

INSERT INTO users (user_id, name, status) VALUES (123, 'John', 'inactive');  -- 新版本插入

SELECT * FROM users FINAL WHERE user_id = 123;                    -- FINAL 取最新版
SELECT user_id, argMax(status, updated_at) as status FROM users GROUP BY user_id;  -- 或聚合

Langfuse 的 0001_traces.up.sqlReplacingMergeTree(event_ts, is_deleted) 正是该模式的实现:以 event_ts 为版本列,配合 is_deleted 标记实现"逻辑删除+逻辑更新",用插入新版本替代 mutation。

insert-mutation-avoid-delete:避免 ALTER TABLE DELETE。 同样重写整个 data part。替代方案:CollapsingMergeTree 软删除(sign 列,1=活跃,-1=已删,查询用 sum(total * sign) ... HAVING sum(sign) > 0 折叠);轻量 DELETE(23.3+,先标记行、物理删除在常规合并中发生,DELETE FROM orders WHERE status = 'cancelled');整分区删除(ALTER TABLE events DROP PARTITION '202301',瞬时完成,远快于 ALTER TABLE events DELETE WHERE toYYYYMM(timestamp) = 202301)。策略对比:ALTER DELETE 慢(仅用于罕见修正);CollapsingMergeTree 快(高频软删除);轻量 DELETE 中等(偶发删除);DROP PARTITION 瞬时(按分区批量删除)。

8.4 优化规避——HIGH

insert-optimize-avoid-final:避免 OPTIMIZE TABLE FINAL。 OPTIMIZE TABLE ... FINAL 强制把每分区所有 parts 立即合并成一个,资源密集且极少必要——ClickHouse 本身会智能后台合并。注意区分:SELECT 查询中的 FINAL 修饰符对 ReplacingMergeTree 去重可能是必要的,一般可以安全使用;OPTIMIZE FINAL 是另一回事。问题:无论是否需要都重写整个分区、无视约 150GB 的 part 大小保护、可能引发内存压力或 OOM、大数据集执行时间长。可接受的场景:冻结表前的最终化、导出前准备、一次性操作(而非常规工作流)。更好的替代:ReplacingMergeTree 去重用 SELECT 的 FINAL 修饰符;减少 part 数依赖后台合并。

九、规则的工程价值与落地建议

这套 28 条规则并非抽象建议,而是与 Langfuse 的实际 ClickHouse 架构一一对应的工程约束:

  • 表结构层面tracesobservations 等核心表遵循"低基数租户列 + 日期粗粒度 + 高基数 ID"的键设计,配合 LowCardinality 字符串、Decimal64 金额、bloom_filter 跳过索引与 ReplacingMergeTree(event_ts, is_deleted) 版本列,见 0001_traces.up.sql0002_observations.up.sql
  • 迁移体系层面:canonical 模板的 {CLICKHOUSE_CLUSTER_CLAUSE} / {CLICKHOUSE_REPLICATION_PREFIX} 占位符、alter_sync = 2 / mutations_sync = 2 片段、禁 CREATE OR REPLACE 与 MV drop-and-recreate 的硬性规定,保护了自托管(尤其 NFS/EFS)部署的稳定性,参见 migrations/canonicalmigrations/README.md
  • 可观测性层面:查询归属通过 OpenTelemetry baggage 与 log_comment JSON 贯通全链路,surface/route/projectIdsystem.query_log 成为可审计的性能分析入口,实现见 queryTags.tsheaderPropagation.ts
  • 查询构建层面events 表强制走 event-query-builder.ts,确保 JOIN、过滤与索引使用符合规则,且"永不使用 FINAL"。

实际应用时,建议把第 4 节的"五步优先级工作流"与"三类审查流程 + 输出格式模板"固化为代码审查的一部分:任何涉及 ClickHouse DDL/DML 的改动,先按规则前缀定位适用规则(schema-pk-*query-join-*insert-batch-* 等),再用模板化的 ## Rules Checked / ## Findings / ## Recommendations 结构输出审查结论,最后用 EXPLAIN indexes = 1system.partssystem.query_log 等实证手段验证。这样既能享受 ClickHouse 列式存储的性能红利,又能避免"通用数据库直觉"在 MergeTree 语义下引入的隐蔽回归。

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

项目优选

收起
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