Langfuse 的 ClickHouse 分区查询性能权衡:理解分区剪枝的双刃剑效应
【免费下载链接】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 仓库内 ClickHouse 最佳实践技能包中的 schema-partition-query-tradeoffs 规则 为核心,剖析分区键(Partition Key)如何既能让查询受益于分区剪枝(Partition Pruning),又可能因跨分区扫描而拖累性能;同时结合events_core、events_full等真实表结构与scores.ts、events.ts、experiments.ts等仓储层的分区剪枝工程实践,说明 Langfuse 如何落地这一权衡,帮助你为自建 Langfuse 或任何 MergeTree 表设计正确的分区策略。
规则速览:分区键是一把双刃剑
在 Langfuse 的 ClickHouse 最佳实践技能包(SKILL.md,共 28 条原子规则、按 schema / query / insert 三大类别组织)中,分区策略被归入HIGH 优先级(schema-partition-前缀,共 4 条规则),而本文讨论的schema-partition-query-tradeoffs规则自身评级为MEDIUM:
- 潜在提升:按分区键过滤的查询可以从分区剪枝中获益,只读取必要分区;
- 潜在退化:跨越大量分区的查询会显著增加需要扫描的数据部分(parts)总数;
- 机制前提:ClickHouse 会在分区列上自动构建MinMax 索引;同时,数据合并(merge)只在分区内部发生,绝不会跨分区进行。
这一规则的核心结论是:分区键对查询性能的帮助是"有条件"的——只有当查询条件与分区表达式对齐时,剪枝收益才会兑现;一旦查询范围横跨多个分区,你不仅拿不到剪枝收益,还要为更多 parts 付出扫描与元数据成本。
MinMax 索引与"合并不跨分区"的底层逻辑
理解这条规则,需要先弄清楚 ClickHouse 分区机制的两个底层事实:
分区列上的 MinMax 索引:每个分区在磁盘上都会记录其分区表达式的最小值与最大值。当查询的
WHERE条件作用于分区列时,ClickHouse 的查询引擎会先比对分区级 MinMax,直接跳过那些不满足范围的分区目录——这就是分区剪枝的物理基础。它不是普通二级索引,而是分区目录级别的粗粒度过滤,代价极低、收益直接。合并仅限分区内部:后台合并线程只会把同一分区内的数据部分合并为更大的部分;不同分区的数据永远不会被合并到一起。这意味着"跨越多少分区"约等于"最终有多少组 parts 需要被处理",跨分区查询的扫描成本会随分区数量线性增长。
Langfuse 的表结构正是按月分区,可以直观地看到这一权衡。以 0040_create_events_core.up.sql 为例:
CREATE TABLE IF NOT EXISTS events_core {CLICKHOUSE_CLUSTER_CLAUSE} ( ... start_time DateTime64(6), ... ) ENGINE = {CLICKHOUSE_REPLICATION_PREFIX}ReplacingMergeTree(event_ts, is_deleted) PARTITION BY toYYYYMM(start_time) PRIMARY KEY (project_id, toStartOfMinute(start_time), xxHash32(trace_id)) ORDER BY (project_id, toStartOfMinute(start_time), xxHash32(trace_id), span_id, start_time)同构的events_full表在 0039_create_events_full.up.sql 中同样采用PARTITION BY toYYYYMM(start_time)。
这里的两个设计信号值得注意:
- 按月(
toYYYYMM)而不是按天(toDate)分区:这是对schema-partition-low-cardinality规则(分区基数控制在 100–1,000)的直接呼应。按月分区一年只有 12 个分区,即使保留数年数据,分区总数也保持在可控范围,避免"日分区 10 年 3650 个"式的 parts 爆炸。 - 分区键与 ORDER BY 中的时间维度错位设计:分区键用粗粒度的
toYYYYMM(start_time),而主键/排序键用细粒度的toStartOfMinute(start_time)。这保证了"按时间范围剪枝分区 + 按分钟粒度走主键索引"可以协同工作——时间过滤同时命中分区级 MinMax 与稀疏索引两级优化。
错误与正确写法的直接对比(规则原文)
规则文档给出了最具代表性的正反示例,直接决定查询能否触发分区剪枝。
错误写法——查询必须扫描全部分区:
-- 没有时间条件,无法利用分区剪枝 SELECT count(*) FROM events WHERE event_type = 'click'; -- No partition pruning当event_type不参与分区表达式时,这个查询只能对每个分区都做全量扫描:分区级 MinMax 帮不上忙(因为过滤条件不是时间),稀疏索引也帮不上忙(因为event_type不在 ORDER BY 前缀中),最终退化成一个全表扫描。
正确写法——查询剪枝到单个分区:
-- 带上时间范围,查询只读取单个分区 SELECT count(*) FROM events WHERE timestamp >= '2024-01-01' AND timestamp < '2024-02-01' AND event_type = 'click';加入与分区表达式对齐的时间范围后,ClickHouse 依据分区级 MinMax 直接剪枝到 2024-01 这一个分区,扫描量理论上缩小到全表的 1/N(N 为分区数)。
这条规则在 Langfuse 中的应用场景非常典型:Langfuse 的所有时间序列分析(trace 列表、观测列表、得分统计、仪表盘查询)几乎都带日期范围过滤,因此按start_time的月份分区能够同时服务于"数据保留"与"时间窗口查询"两类需求。
Langfuse 源码中的分区剪枝工程实践
规则是理论,而 Langfuse 仓储层把"如何让查询与分区键对齐"变成了可复用的工程模式。以下五处源码展示了分区剪枝在真实系统中的落地方式。
1. 得分查询:用minTimestamp显式声明剪枝下界
在 scores.ts 中,GetScoresForObservationsProps的minTimestamp参数注释直接写明其目的:
When provided, adds
AND s.timestamp >= minTimestamp - SCORE_TO_TRACE_OBSERVATIONS_INTERVALto the query so ClickHouse can prune monthly partitions and avoid full-table scans.
生成的实际 SQL(scores.ts)为:
select ... from scores s WHERE s.project_id = {projectId: String} AND s.observation_id IN ({observationIds: Array(String)}) AND s.data_type IN ({dataTypes: Array(String)}) ${minTimestamp ? `AND s.timestamp >= {minTimestamp: DateTime64(3)} - ${SCORE_TO_TRACE_OBSERVATIONS_INTERVAL}` : ""} ORDER BY s.event_ts DESC这里有一个关键细节:下界不是观察点的精确时间,而是减去了一个偏斜区间(skew interval)。因为 score 的timestamp可能略早于其所属观察点/ trace 的开始时间,直接以下界过滤会漏数据;减去偏斜区间(如 7 天)在"不漏数据"与"仍能剪枝到少数几个月份分区"之间取得了平衡。
2. 按 ID 查找观测:用锚点时间做分区/parts 剪枝
在 events.ts 的观测点查询中,调用方可以传入startTimeLowerBound(例如父 trace 的时间戳)作为锚点:
// Lower-bound start_time on an anchor (e.g. the parent trace's timestamp) so // the lookup can prune events_full parts/partitions. Subtract the skew // interval because an observation may start slightly before its anchor. .when(Boolean(startTimeLowerBound), (b) => b.whereRaw( `start_time >= {startTimeLowerBound: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}`, { startTimeLowerBound: convertDateToClickhouseDateTime(startTimeLowerBound!) }, ), )按span_id查找单条观测本来是一个点查,但如果不加时间下界,查询引擎无法判断该观测落在哪个月份分区;锚定到 trace 的开始时间后,只需要扫描与目标观测相邻的少数分区——这正是规则中"剪枝到单个分区"在点查场景的变体。
3. 安全边界:剪枝是性能提示,绝不是授权控制
events.ts 中读取观测 I/O 流的 WHERE 子句附带了一段非常重要的注释:
The ±1s window around
startTimeonly prunes the primary key (project_id, toStartOfMinute(start_time), xxHash32(trace_id)); it is a performance hint, never an authorization control.
WHERE e.project_id = {projectId: String} AND e.trace_id = {traceId: String} AND e.span_id = {observationId: String} AND e.start_time >= {minTimestamp: DateTime64(3)} AND e.start_time <= {maxTimestamp: DateTime64(3)}±1 秒窗口让主键(project_id, toStartOfMinute(start_time), xxHash32(trace_id))的第一分钟粒度前缀可被命中,从而裁剪 parts 扫描。但 Langfuse 明确把租户隔离建立在project_id+trace_id上,时间窗口只负责性能。这一实践对任何使用分区剪枝的系统都有普适警示:分区键/主键上的范围条件应当只做性能优化,访问控制必须由独立的身份与项目条件保证,绝不能依赖"反正查询会剪枝到对应分区"这种隐式假设。
4. 实验对比:用粗粒度下界做分区剪枝
在 experiments.ts 的实验项聚合中,Langfuse 用min(start_time)作为"粗粒度分区剪枝下界":
const queryBuilder = new EventsAggQueryBuilder({ projectId, groupByColumn: "e.experiment_item_id", // min(start_time) is the item's root start_time (WHERE below restricts // to root rows), used as a coarse partition-prune lower bound for Query 2. selectExpression: "e.experiment_item_id as item_id, min(e.start_time) as start_time", })这里体现了分区剪枝的另一个用法:先用一条廉价查询算出数据的起始时间,再把它作为第二条查询的下界,让第二条查询免于全分区扫描。它不追求精确的分钟级定位,只要求把扫描范围收敛到少量月份分区,即"coarse(粗粒度)"的含义。
5. 分析集成导出:把时间窗口放进 CTE 以便分区剪枝生效
在 observations.ts 和 scores.ts 的分析集成(PostHog/Mixpanel)导出中,注释揭示了分区剪枝与查询优化器的一个微妙交互:
Pre-filter traces in a CTE so the trace timestamp window prunes partitions directly, instead of living alongside the LEFT JOIN where the planner cannot push it down.
WITH selected_traces AS ( SELECT ... FROM traces t FINAL WHERE t.project_id = {projectId: String} AND t.timestamp >= {minTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL} AND t.timestamp <= {maxTimestamp: DateTime64(3)} + ${TRACE_TO_OBSERVATIONS_INTERVAL} ) SELECT ...这条实践价值极高:分区剪枝能否生效,取决于时间条件在查询计划中的位置。如果把时间窗口放在LEFT JOIN之后的OR子句中,优化器无法将其下推到表扫描层,剪枝就形同虚设;而把过滤提前到 CTE 内,时间窗口直接作用于traces表的扫描,分区剪枝才能真实生效。这与规则的"跨分区扫描拖累性能"警告是同一枚硬币的两面——过滤条件放不对位置,分区设计得再好也白费。
与配套分区规则组合使用
schema-partition-query-tradeoffs并非孤立规则,它与技能包中另外三条分区规则构成完整的分区决策框架:
| 规则文件 | 核心结论 | 与查询权衡的关系 |
|---|---|---|
| schema-partition-query-tradeoffs.md | 分区剪枝提升单分区查询,跨分区查询拖累性能 | 本文主题 |
| schema-partition-low-cardinality.md | 分区基数保持 100–1,000,防止 parts 爆炸与 "too many parts" 错误(受max_parts_in_total、parts_to_throw_insert限制) | 控制分区数量是跨分区扫描成本可控的前提 |
| schema-partition-lifecycle.md | 分区本质是数据生命周期管理工具:DROP PARTITION是瞬时元数据操作、TTL 保留、分层存储、跨表归档 | 用时间对齐的分区同时服务生命周期与查询剪枝 |
| schema-partition-start-without.md | 没有明确生命周期需求时可以先不分区,后续再补 | 避免"为查询而分区"的过度设计 |
三条规则的联动逻辑很清晰:
- 先问"要不要分区"(schema-partition-start-without.md):分区主要是生命周期工具,若仅为了查询性能而分区,应当先做基准测试验证剪枝收益,否则保持简单。
- 再问"分区基数多大"(schema-partition-low-cardinality.md):若分区过多(例如
PARTITION BY user_id产生百万分区),parts 爆炸本身就会触发写入失败,更遑论查询收益——这正是本规则"跨分区扫描"成本失控的极端形态。 - 最后评估"查询与分区对齐吗"(本文规则):只有按分区键过滤的查询才享受剪枝,跨分区查询必然付出更多 parts 扫描代价。
Langfuse 的落地是一个很好的参考组合:events_core/events_full按toYYYYMM(start_time)分区(一年 12 个分区、基数受控),同时traces的聚合合并树(0023_traces_aggregating_merge_trees.up.sql)通过TTL toDate(start_time) + INTERVAL 7 DAY/+ INTERVAL 30 DAY配合分区做生命周期清理——分区同时服务于"按月剪枝查询"和"按 TTL 退役数据"两个目标。
实践检查清单
结合 SKILL.md 中针对 Schema Review 与 Query Review 的流程,围绕本规则可提炼出以下自查项:
Schema 设计阶段(CREATE TABLE / ALTER TABLE):
- 分区表达式是否与时间维度对齐(如
toYYYYMM(timestamp)),能否支撑 TTL 与DROP PARTITION; - 分区基数是否落在 100–1,000 区间(检查
system.parts中分区数量与 parts 分布); - 是否真的需要分区——没有明确生命周期或已验证的剪枝收益时,考虑先从无分区开始;
- 分区键与 ORDER BY 是否各自承担职责(分区做粗粒度生命周期,主键做细粒度查询定位)。
Query 编写阶段(SELECT / JOIN / 聚合):
- 每个查询是否携带与分区表达式对齐的时间范围条件;
- 时间条件是否放在能下推的位置(CTE 内或表扫描层,而非 JOIN 后的 OR 子句);
- 使用
system.query_log观察扫描的 parts 数量,确认剪枝真实生效(Langfuse 的查询属性记录于 queryTags.ts 写入的log_comment); - 时间下界是否考虑了数据到达的偏斜区间(skew),避免为剪枝而漏数据。
小结
schema-partition-query-tradeoffs规则的本质是提醒你:分区键不是查询性能的万能开关,而是一笔需要算清的账。对齐分区键的查询获得剪枝收益(借助自动构建的分区列 MinMax 索引),跨分区的查询则因 parts 数量增长付出额外代价,且后台合并永远无法跨分区整合数据。Langfuse 的实践给出了完整的参考答案:按月分区控制基数、把时间条件放到优化器能下推的位置、用锚点时间与 skew 区间为点查提供剪枝下界,同时严格区分"剪枝是性能提示"与"授权是安全边界"。把这套决策框架与 schema-partition-low-cardinality、schema-partition-lifecycle、schema-partition-start-without 三条规则配合使用,你就能在 Langfuse 或任何自建 ClickHouse 集群上做出经得起时间检验的分区决策。
【免费下载链接】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),仅供参考