
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/NN 为分区数。这条规则在 Langfuse 中的应用场景非常典型Langfuse 的所有时间序列分析trace 列表、观测列表、得分统计、仪表盘查询几乎都带日期范围过滤因此按start_time的月份分区能够同时服务于数据保留与时间窗口查询两类需求。Langfuse 源码中的分区剪枝工程实践规则是理论而 Langfuse 仓储层把如何让查询与分区键对齐变成了可复用的工程模式。以下五处源码展示了分区剪枝在真实系统中的落地方式。1. 得分查询用minTimestamp显式声明剪枝下界在 scores.ts 中GetScoresForObservationsProps的minTimestamp参数注释直接写明其目的When provided, addsAND s.timestamp minTimestamp - SCORE_TO_TRACE_OBSERVATIONS_INTERVALto the query so ClickHouse can prune monthly partitions and avoid full-table scans.生成的实际 SQLscores.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 traces 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 aroundstartTimeonly 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_idtrace_id上时间窗口只负责性能。这一实践对任何使用分区剪枝的系统都有普适警示分区键/主键上的范围条件应当只做性能优化访问控制必须由独立的身份与项目条件保证绝不能依赖反正查询会剪枝到对应分区这种隐式假设。4. 实验对比用粗粒度下界做分区剪枝在 experiments.ts 的实验项聚合中Langfuse 用min(start_time)作为粗粒度分区剪枝下界const queryBuilder new EventsAggQueryBuilder({ projectId, groupByColumn: e.experiment_item_id, // min(start_time) is the items 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),仅供参考