Langfuse 数据删除最佳实践:用轻量删除、ReplacingMergeTree 与 DROP PARTITION 取代 ALTER TABLE DELETE 突变
【免费下载链接】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 最佳实践规则文档(insert-mutation-avoid-delete.md),深入讲解为何必须规避ALTER TABLE DELETE这类数据突变(mutation)操作,并结合 Langfuse 实际生产表结构(traces / observations / scores / events 等)展示 ReplacingMergeTree 软删除标记、轻量 DELETE 与按月分区 DROP PARTITION 的落地用法。读完本文,你将掌握一套"可插入、可删除、不重写"的 ClickHouse 数据生命周期管理方案,并能在 Langfuse 的仓库代码中找到每一处实践对应的实现依据。
规则核心:为什么ALTER TABLE DELETE是 CRITICAL 级反模式
在 ClickHouse 中,ALTER TABLE DELETE属于突变(mutation)操作,它不是一个即时生效的普通删除,而是一个异步后台进程:凡是命中的 data part(数据分片),ClickHouse 都要把整个 part 读出来、剔除命中行、再重写回磁盘。这意味着:
- 写入放大严重:哪怕只删几行,只要这些行分散在多个 part 中,所有相关 part 都会被完整重写;
- 磁盘 I/O 尖峰:重写过程会抢占集群 I/O,拖慢同一时刻的查询与写入;
- 不可回滚:突变一旦提交无法撤销;
- 读不一致:SELECT 可能读到已突变与未突变 part 混合的结果。
该规则在规则文件 YAML 元数据中被标记为impact: CRITICAL,即影响等级最高的约束项;在 SKILL.md 的规则分类优先级表中,"Mutation Avoidance(突变规避)"同样位列 CRITICAL 等级。规则文档给出的反模式示例非常典型:
-- Mutation delete for cleanup ALTER TABLE orders DELETE WHERE status = 'cancelled'; -- Time-based cleanup via mutation (very expensive) ALTER TABLE sessions DELETE WHERE created_at < now() - INTERVAL 7 DAY;第一句按业务条件清理,第二句按时间批量清理旧数据,二者都命中大量 part,代价高昂。接下来分别介绍三种正确替代方案。
方案一:CollapsingMergeTree——高频软删除的正确姿势
针对"频繁删除少量行"的场景,规则文档推荐使用折叠树引擎 CollapsingMergeTree。其核心思想是用插入替代删除:为表增加一个sign列(1 表示活跃,-1 表示删除),删除动作变成一次sign = -1的普通插入,折叠合并引擎会在后台把同一排序键下+1/-1成对的数据自动抵消。
规则文档中的完整示例:
CREATE TABLE orders ( order_id UInt64, customer_id UInt64, total Decimal(10,2), sign Int8 -- 1 = active, -1 = deleted ) ENGINE = CollapsingMergeTree(sign) ORDER BY order_id; -- Insert order INSERT INTO orders VALUES (123, 456, 99.99, 1); -- "Delete" by inserting with sign = -1 INSERT INTO orders VALUES (123, 456, 99.99, -1); -- Query collapses +1 and -1 pairs SELECT order_id, sum(total * sign) as total FROM orders GROUP BY order_id HAVING sum(sign) > 0;关键点在于:删除动作本身是普通 INSERT,成本与一次插入完全相同,不产生任何 part 重写;查询时用sum(total * sign)聚合抵消,配合HAVING sum(sign) > 0过滤掉已删除行。代价是删除的行会继续占用存储,直到后台合并真正将+1/-1对物理清除。
Langfuse 的实践:is_deleted标记 + ReplacingMergeTree
Langfuse 没有照搬sign列,而是采用同构思路的变体:is_deleted标记列 + ReplacingMergeTree 版本列。查看 Langfuse 的 ClickHouse 迁移文件 0001_traces.up.sql:
CREATE TABLE traces {CLICKHOUSE_CLUSTER_CLAUSE} ( ... `event_ts` DateTime64(3), `is_deleted` UInt8, ... ) ENGINE = {CLICKHOUSE_REPLICATION_PREFIX}ReplacingMergeTree(event_ts, is_deleted) Partition by toYYYYMM(timestamp)同样的模式贯穿整个仓库的核心表:
- 0002_observations.up.sql:
ReplacingMergeTree(event_ts, is_deleted),按月toYYYYMM(start_time)分区; - 0003_scores.up.sql:
ReplacingMergeTree(event_ts, is_deleted); - 0011_add_blob_storage_file_log.up.sql 与 0024_dataset_run_items.up.sql:同样的引擎与标记列。
ReplacingMergeTree 的两个参数(event_ts, is_deleted)含义深刻:event_ts是版本列,合并时保留版本最新的一行;is_deleted是删除标记列,同版本号时按该列决定保留哪一行。这样"软删除"就退化成一次携带is_deleted = 1的普通插入——与规则文档"用插入替代突变"的精神完全一致。
写入侧同样遵循该约定:在 clickhouse.ts 中,Langfuse 写入blob_storage_file_log表时显式携带is_deleted: 0;读取侧在 query-fragments.ts 中通过e.is_deleted = 0过滤已删除行,保证业务查询只见活跃数据。
方案二:轻量删除(Lightweight DELETE,23.3+)——偶发删除的首选
如果删除频率不高、规模不大,规则文档推荐使用 ClickHouse 23.3+ 引入的轻量删除语法,也就是标准的DELETE FROM:
-- Marks rows, doesn't rewrite immediately DELETE FROM orders WHERE status = 'cancelled'; -- Physical deletion happens during normal merges轻量删除的精髓是"打标记、不重写":执行时只在索引中标记命中行并立即对后续查询隐藏,物理空间回收交给后台正常的 merge 过程,避免了一整轮 part 重写。它适合偶发性的中等规模删除——比突变便宜得多,又比软删除方案简单直接,无需改动表结构。
Langfuse 的实践:trace 删除路径中的轻量 DELETE
Langfuse 的 trace 删除正是这种模式的工程化样板。在 traces.ts 中,deleteTraces先做一次 preflight 计数,无命中行则直接跳过;随后执行的是带时间边界收敛的轻量 DELETE。更典型的实现在 events.ts:
const deleteQuery = (table: string) => ` DELETE FROM ${table} WHERE project_id = {projectId: String} AND trace_id IN ({traceIds: Array(String)}) AND start_time >= {minTs: String}::DateTime64(3) AND start_time <= {maxTs: String}::DateTime64(3) `;这段代码体现了三条值得复用的实战经验:
- 先查后删:先用
SELECT ... LIMIT 1/ count 预检(见hasAnyEvent与 preflight 逻辑),无数据直接跳过,避免空 DELETE; - 范围收敛:删除条件同时带上
start_time的 min/max 边界,把命中范围压缩到最小,减少需要标记的 part 数量; - 超时保护:通过
clickhouseConfigs.request_timeout使用env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS配置超时(见 events.ts),防止大删除长时间占住连接。
方案三:DROP PARTITION——按分区批量删除的瞬时方案
当删除目标是"整块旧数据"(例如按时间维度清理一个月前的数据)时,最优雅的方式是直接丢弃整个分区。规则文档的对比示例:
-- Instant deletion of old data ALTER TABLE events DROP PARTITION '202301'; -- Much faster than: ALTER TABLE events DELETE WHERE toYYYYMM(timestamp) = 202301;DROP PARTITION是元数据级操作,只需删除分区的目录引用,瞬间完成;而等价条件的ALTER TABLE DELETE却要逐 part 重写,二者性能差距是数量级的。前提是表必须按删除维度(通常是时间)设计分区键——这正是"分区服务于数据生命周期"这一设计原则的体现。
Langfuse 的实践:按月分区 + TTL 生命周期管理
Langfuse 的所有核心表都按toYYYYMM(...)按月分区(如 traces 按toYYYYMM(timestamp)、observations 按toYYYYMM(start_time)),天然为"按月 DROP PARTITION"或"按月 TTL"做好了结构准备。更进一步的实践是 TTL:
- 在 handleEventPropagationJob.ts 的注释中明确写道:"Relies on table TTL for partition cleanup instead of explicit DROP PARTITION."——Langfuse 的事件传播表依赖 TTL 自动回收分区数据,而非显式执行 DROP PARTITION;
- 在 0023_traces_aggregating_merge_trees.up.sql 中,聚合合并树表使用
TTL toDate(start_time) + INTERVAL 7 DAY与INTERVAL 30 DAY分级保留数据。
TTL 本质上就是"到期自动 DROP 分区/part"的托管化表达,与手动DROP PARTITION同属生命周期管理,但无需应用层定时任务。二者组合的完整策略是:按月分区 + TTL 自动清理 + 需要即时清除时手动 DROP PARTITION。
删除策略选型速查表
规则文档给出的四方案对比表是选型的核心依据,完整摘录如下:
| Method | Speed | When to Use |
|---|---|---|
| ALTER DELETE | Slow | Rare corrections only |
| CollapsingMergeTree | Fast | Frequent soft deletes |
| Lightweight DELETE | Medium | Occasional deletes |
| DROP PARTITION | Instant | Bulk deletion by partition |
结合 Langfuse 仓库的工程实践,可以进一步细化选型准则:
- 纠错类、一次性、命中行少:用轻量
DELETE FROM(如 events.ts 中按 trace_id + 时间范围删除); - 高频软删除 / 业务删除:用 ReplacingMergeTree +
is_deleted标记(如 0001_traces.up.sql),删除即插入; - 整块过期数据:按月分区 +
DROP PARTITION或 TTL(如 0023_traces_aggregating_merge_trees.up.sql); - 罕见修正:仅在无法用上述方案覆盖时,才考虑
ALTER TABLE DELETE突变。
迁移与部署中的配套约束
规避突变的规则在 Langfuse 的迁移流程中还有配套约束,写 SQL 时需一并遵守(详见 SKILL.md 的 Langfuse-Specific Rules 一节):
- 任何会创建突变的 ALTER(
MATERIALIZE ...、UPDATE、DELETE),在集群模式下必须携带{CLICKHOUSE_CLUSTERED_ONLY: SETTINGS mutations_sync = 2},确保突变完成后才进入下一个迁移文件; mutations_sync不能替代alter_sync:前者控制突变何时结束,后者控制元数据传播,二者用途不同;- 不要在 events 表上使用
FINAL:events 表设计上不依赖 FINAL 就能读到正确数据,使用该关键字反而伤害性能(见 SKILL.md 中 "Never use FINAL on the events table" 一条)。这与本文方案一"用聚合/标记替代 FINAL"的思路一脉相承——对 events 这类按时间流式写入的表,Langfuse 刻意避免依赖合并语义,将"正确性"前置到写入侧。
总结
ALTER TABLE DELETE是 ClickHouse 中代价最高的删除方式,应被视作最后手段而非默认选项。Langfuse 仓库用三种互补机制覆盖了全部删除场景:ReplacingMergeTree +is_deleted标记承接高频软删除(traces / observations / scores / dataset_run_items 全部采用该模式),轻量 DELETE处理偶发的中等规模删除(trace / events 删除路径配合 preflight 预检与时间范围收敛),按月分区 + TTL / DROP PARTITION负责批量过期数据回收。阅读本仓库的迁移目录(canonical)与删除相关仓库层代码(traces.ts、events.ts),即可获得一套可复制的生产级 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),仅供参考