Langfuse 中的 ClickHouse 最佳实践:Schema 设计、查询优化与数据写入的 28 条规则指南
本篇技术指南以 Langfuse 仓库内置的 clickhouse-best-practices Agent Skill(README.md 与 SKILL.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 Inc,version: "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 TABLE、ALTER TABLE、ORDER 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 问题前的强制顺序:
- 检查
rules/目录中是否有适用规则; - 若有规则:应用规则并在回复中引用,格式为
Per <rule-name>...; - 若无规则:使用 LLM 自身的 ClickHouse 知识或检索官方文档;
- 若不确定:使用 Web 搜索获取当前最佳实践;
- 始终注明来源:规则名、"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 取值有 trpc、publicapi、worker、mcp 和 unknown;ClickhouseWriter 的插入使用 projectId = "MULTI_PROJECT"。
从源码看,queryTags.ts 中 clickHouseQuerySurfaces 常量数组定义了 trpc、worker、publicapi、mcp 四种 surface,normalizeClickHouseQueryTags 会从 OpenTelemetry baggage 读取或从显式传入的 tags 中归一化出 tag_schema_version、surface、route、projectId 等字段,最终由 buildClickHouseLogComment 序列化为 JSON 字符串(第 99-101 行)。注意 sdkName/sdkVersion/userAgent 仅在 surface === "publicapi" 时才会被写入,这一细节体现了对公共 API 流量可观测性的专门设计。
3.4 归属信息通过 OpenTelemetry Baggage 传播
查询归属通过 OpenTelemetry baggage 传播:入口点调用 headerPropagation.ts 中的 contextWithLangfuseProps(...) 设置 ClickHouse surface、可选的 route 和 projectId;仓库层(如 repositories/clickhouse.ts)通过 normalizeClickHouseQueryTags(...) 读取 baggage 并写入 log_comment。最佳实践是在入口点设置归属,而不是在每个仓库调用中传递 tags——这与 queryTags.ts 中"先读显式 tags、再回退到 baggage"的优先级设计一致(第 50-72 行),CLICKHOUSE_QUERY_TAG_BAGGAGE_KEYS 定义了 langfuse.clickhouse.surface、langfuse.clickhouse.route、langfuse.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 同样展示了 type、level 等 LowCardinality(String) 列、Decimal64(12) 金额类型与 Map(LowCardinality(String), UInt64) 的使用。
3.6 元数据 ALTER 必须同步设置 alter_sync / mutations_sync
新 canonical 迁移中的每个元数据 ALTER(ADD/DROP/MODIFY COLUMN、ADD/DROP INDEX)都必须包含 {CLICKHOUSE_CLUSTERED_ONLY: SETTINGS alter_sync = 2};每个产生 mutation 的 ALTER(MATERIALIZE …、UPDATE、DELETE)都必须包含 {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-creation → schema-pk-cardinality-order → schema-pk-prioritize-filters → schema-types-native-types → schema-types-minimize-bitwidth → schema-types-lowcardinality → schema-types-avoid-nullable → schema-partition-low-cardinality → schema-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/TABLE或EXCHANGE TABLES(会破坏 NFS/EFS 自托管);普通视图在同一文件内用DROP VIEW IF EXISTS+CREATE VIEW重定义 - [ ] 源表仍接收插入时,物化视图绝不被 drop 后重建;SELECT 变更通过目标表 ALTER 之后的
ALTER TABLE <mv> MODIFY QUERY完成
4.2 查询审查(SELECT / JOIN / 聚合)
按序阅读:query-join-choose-algorithm → query-join-filter-before → query-join-use-any → query-index-skipping-indices → schema-pk-filter-on-orderby。
检查清单:
- [ ] 过滤条件使用 ORDER BY 前缀列
- [ ] JOIN 在连接前过滤(而非连接后)
- [ ] 根据表规模选择了正确的 JOIN 算法
- [ ] 为非 ORDER BY 过滤列配置了跳过索引
4.3 插入策略审查(摄入、更新、删除)
按序阅读:insert-batch-size → insert-mutation-avoid-update → insert-mutation-avoid-delete → insert-async-small-batches → insert-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 status 与 WHERE status > 'processing' 都按自然序工作。值集固定已知用 Enum;值可能频繁变动用 LowCardinality(String)。
schema-types-avoid-nullable:避免 Nullable,用 DEFAULT 代替。 Nullable 列会额外维护一个 UInt8 掩码列,增加存储并拖慢性能。name String DEFAULT ''、age UInt8 DEFAULT 0、login_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_total、parts_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_id、idx_res_metadata_key、idx_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.sql 中 ReplacingMergeTree(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 架构一一对应的工程约束:
- 表结构层面:
traces、observations等核心表遵循"低基数租户列 + 日期粗粒度 + 高基数 ID"的键设计,配合LowCardinality字符串、Decimal64金额、bloom_filter跳过索引与ReplacingMergeTree(event_ts, is_deleted)版本列,见 0001_traces.up.sql 与 0002_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/canonical 与 migrations/README.md; - 可观测性层面:查询归属通过 OpenTelemetry baggage 与
log_commentJSON 贯通全链路,surface/route/projectId让system.query_log成为可审计的性能分析入口,实现见 queryTags.ts 与 headerPropagation.ts; - 查询构建层面:
events表强制走 event-query-builder.ts,确保 JOIN、过滤与索引使用符合规则,且"永不使用 FINAL"。
实际应用时,建议把第 4 节的"五步优先级工作流"与"三类审查流程 + 输出格式模板"固化为代码审查的一部分:任何涉及 ClickHouse DDL/DML 的改动,先按规则前缀定位适用规则(schema-pk-*、query-join-*、insert-batch-* 等),再用模板化的 ## Rules Checked / ## Findings / ## Recommendations 结构输出审查结论,最后用 EXPLAIN indexes = 1、system.parts、system.query_log 等实证手段验证。这样既能享受 ClickHouse 列式存储的性能红利,又能避免"通用数据库直觉"在 MergeTree 语义下引入的隐蔽回归。
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