ClickHouse 写入优化实战:以 ReplacingMergeTree 替代 ALTER TABLE UPDATE(Langfuse 工程实践)
ALTER TABLE UPDATE 在 ClickHouse 中属于 mutation(变更)操作,会以异步后台任务的形式重写受影响的所有数据 part,即使在仅修改少量行时也会产生巨大的写放大与磁盘 I/O 波动,是生产环境中最容易引发集群性能事故的操作之一。本文以开源 AI 可观测平台 Langfuse 仓库中内置的 ClickHouse 最佳实践规则 insert-mutation-avoid-update.md 为骨架,系统讲解为何要避免 mutation 更新、如何用 ReplacingMergeTree 以"插入新版本"替代"原地更新",并结合 Langfuse 真实的 events/traces 表结构(如 0039_create_events_full.up.sql)给出可落地的源码级参考。读完本文,你将掌握一套在不引入 mutation 的前提下实现"更新语义"的完整方案,并理解 event_ts 版本列、argMax 取最新版本、is_deleted 逻辑删除等 Langfuse 正在使用的工程手法。
一、为什么 ALTER TABLE UPDATE 是 CRITICAL 级反模式
该规则在 skill 中的 impact 等级为 CRITICAL,其 impactDescription 直言:"Mutations rewrite entire parts; use ReplacingMergeTree instead"(mutation 会重写整个 part,请改用 ReplacingMergeTree)。
ClickHouse 的存储模型是 LSM 风格的:数据以不可变 part 的形式落盘,后台通过 merge 合并。ALTER TABLE UPDATE 本质上是一种 mutation——它把受影响 part 中的全部行读出来、改写、再重写回新 part,即使你只更新一行。因此它带来四类问题:
- 写放大(Write amplification):即使只改一列的一个小值,也会重写整个 part。规则原文举例:
inventory表中某个product_id如果分布在 100 个 part 中,一次quantity = quantity - 1会重写全部 100 个 part。 - 磁盘 I/O 尖峰:大量 part 并发重写会抢占磁盘带宽,拖慢同一集群上的所有查询与写入,造成"整体性能劣化"。
- 不可回滚:mutation 提交后无法撤销,一旦提交就必须等待其执行完毕,期间无法取消。
- 读不一致:mutation 按 part 逐个重写,
SELECT在 mutation 执行过程中可能读到"已变更 part 与未变更 part 的混合结果"。
基于上述原因,规则给出了明确结论:对于高频或大规模"更新"需求,不要使用 mutation,而应使用 ReplacingMergeTree。
二、反模式示例:把 ClickHouse 当 OLTP 数据库用
规则中的两个"错误示范"非常典型,分别代表"批量过期更新"和"高频单行更新"两种场景:
-- 反模式 1:按时间批量"过期"数据,重写的数据量可能极其庞大
ALTER TABLE users UPDATE status = 'inactive'
WHERE last_login < now() - INTERVAL 90 DAY;
-- 反模式 2:高频单行"扣减"库存
ALTER TABLE inventory UPDATE quantity = quantity - 1
WHERE product_id = 123;
-- 若 product_id 分布在 100 个 part 中,会重写全部 100 个 part
第一个例子的问题在于 WHERE 条件横跨大量 part,mutation 波及面广;第二个例子的问题是单行更新也要承担全 part 重写成本——无论哪种,付出的 I/O 代价都远大于"只改一行"的实际收益。
三、正确姿势:用 ReplacingMergeTree 实现"插入即更新"
规则给出了标准的 ReplacingMergeTree 方案,核心思想是把"更新"建模成"追加一个新版本的行",由引擎在后台合并时按排序键去重、保留最新版本:
-- 表设计:以 updated_at 作为版本列
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');
-- 查询最新版本:使用 FINAL
SELECT * FROM users FINAL WHERE user_id = 123;
-- 或使用聚合取最新
SELECT user_id, argMax(status, updated_at) as status
FROM users GROUP BY user_id;
这里有几个值得展开的细节:
ORDER BY即去重键:ReplacingMergeTree仅在后台合并时,对ORDER BY键相同的行执行去重,因此user_id必须放在ORDER BY中,且去重键的设计直接决定了"版本"的粒度。- 版本列决定谁胜出:
ReplacingMergeTree(updated_at)中的updated_at是版本列,合并时对同一键的多行保留updated_at最大的一行。不指定版本列时,ClickHouse 保留"最后插入"的行——但合并是异步的、顺序不保证,因此生产环境强烈建议显式指定版本列。 FINAL的代价:SELECT ... FINAL会在查询时动态合并去重,保证读到最新版本,但会显著拖慢查询(这也是本仓库 skill 中有另一条规则insert-optimize-avoid-final的原因)。如果查询量很大,更推荐用argMax(col, version) ... GROUP BY或LIMIT 1 BY等聚合手段,把去重交给查询优化器。
四、原理纵深:mutation 与 ReplacingMergeTree 的本质差异
从实现机制看,二者走的是完全不同的路径:
- mutation(ALTER UPDATE):立即在
system.mutations注册任务,后台逐 part 重写。它是"同步语义、异步执行",会真实触碰并重写磁盘上的数据文件,属于破坏性重写。 - ReplacingMergeTree:新数据以追加方式落盘,不触碰已有 part;去重只发生在后台合并(merge)阶段,或在查询时通过
FINAL/ 聚合函数即时完成。它是**追加式(append-only)**的,天然契合列式存储与 LSM 合并模型。
因此对于"业务上需要更新、但底层是海量时序/事件数据"的场景,ReplacingMergeTree 几乎是唯一正确的选择。规则对这两条 insert-mutation-* 规则(insert-mutation-avoid-update.md 与 insert-mutation-avoid-delete.md)在 SKILL.md 中统一归类为 CRITICAL,并纳入 Insert Strategy Reviews 的强制检查清单:ReplacingMergeTree or CollapsingMergeTree for update patterns。
五、Langfuse 实战:events 表如何用 ReplacingMergeTree 承载"可更新"的观测数据
仅停留在规则层面还不够,本仓库的 events/traces 表就是这套模式的真实生产实现。
5.1 events_full 表:双版本参数的工程化演进
在 0039_create_events_full.up.sql 中,Langfuse 的 events_full 表使用了带双参数的 ReplacingMergeTree:
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)
event_ts是版本列:对应规则中的updated_at,每个事件(trace/observation 的每一次状态变更)都会带着一个单调递增的事件时间戳写入,作为"哪个版本最新"的判据。is_deleted是第二个参数(ver 列):这是比规则示例更进一步的设计——当is_deleted = 1时表示该版本已逻辑删除,合并时优先淘汰。Langfuse 用它在不执行 mutation 的前提下实现"删除语义"(配合 insert-mutation-avoid-delete.md 规则)。- 表内还显式声明了
updated_at DateTime64(6) DEFAULT now()与event_ts DateTime64(6)两个时间列,前者面向查询展示,后者面向去重版本控制,职责分离。
类似的模式也出现在 0001_traces.up.sql 中:
) 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);
5.2 查询侧:不依赖 FINAL,用排序 + 聚合取最新版本
SKILL.md 中有两条与本主题强相关的 Langfuse 专有约定:
- Never use
FINALon theeventstable; it is designed soFINALis not required and the keyword hurts performance.
也就是说,Langfuse 刻意避免在 events 表上使用 FINAL(对应仓库 skill 中的另一条规则 insert-optimize-avoid-final.md),而是用更可控的手段做版本解析:
ORDER BY event_ts DESC+ 去重:在 observations.ts 中,查询层通过ORDER BY o.event_ts DESC配合LIMIT 1 BY(见 event-query-builder.ts 中limitBy(...)的实现)保留每个 span 的最新事件,从而在查询期完成与FINAL等价的去重,但把排序键控制权交给查询优化器。argMaxIf(col, event_ts, cond)聚合:在 event-query-builder.ts 的EVENTS_AGGREGATION_FIELDS中,trace 级别的字段大量使用argMaxIf(environment, event_ts, ...)、argMaxIf(version, event_ts, ...)、argMaxIf(bookmarked, event_ts, ...)等形式,即以event_ts为版本参数、取条件成立时的最新值——这正是规则示例中argMax(status, updated_at)在真实大规模事件表上的工程化变体。is_deleted = 0过滤:在 events.ts 中,所有查询都会追加e.is_deleted = 0过滤条件,把逻辑删除的版本排除在结果之外,与ReplacingMergeTree(event_ts, is_deleted)的 ver 参数形成"写入端 + 查询端"双保险。
5.3 对 Langfuse 场景的解读
Langfuse 的核心数据模型是 trace → observation 的不可变事件流,但业务上允许对 trace 追加标签、书签、公开状态等"可变属性"。如果对 events_full 表直接执行 ALTER TABLE UPDATE 来改 bookmarked 或 public,每次操作都会重写整个 part——在每分钟数万事件写入的高吞吐场景下是不可接受的。因此 Langfuse 选择让每次属性变更都作为一条携带新 event_ts 的事件行追加写入,由后台合并负责物理去重,查询层用 argMaxIf/ORDER BY event_ts DESC 拿到最新状态。这套设计正是本文规则"insert 新版本替代 update"的最直接生产证明。
六、什么时候仍然可以使用 mutation?
规则并不是绝对禁止 ALTER TABLE UPDATE,而是禁止"高频/大规模更新"走 mutation。结合规则描述与仓库约定,可用的边界大致是:
- 低频、小范围的数据订正(例如修复一条错误写入的元数据)可以接受 mutation 的成本,但要有意识地评估其波及的 part 数量。
- 批量数据生命周期管理优先考虑
DROP PARTITION或 TTL,而不是逐行 DELETE/UPDATE(详见 insert-mutation-avoid-delete.md 的策略对比表)。 - 迁移脚本中的 mutation 必须同步等待:SKILL.md 特别强调,Langfuse 的 canonical 迁移中凡是会创建 mutation 的
ALTER(MATERIALIZE …、UPDATE、DELETE)都必须附加{CLICKHOUSE_CLUSTERED_ONLY: SETTINGS mutations_sync = 2},以确保迁移执行完 mutation 后再进入下一个文件,避免集群内元数据版本不一致导致code 517错误——这从侧面印证了 mutation 的异步重写特性给工程带来的额外同步负担。
七、落地建议与自检清单
参考 SKILL.md 的 Insert Strategy Reviews 检查项,结合本文规则,给出落地时的自检清单:
- 凡是"更新"需求,先问是否高频/大规模:是,则走
ReplacingMergeTree/CollapsingMergeTree路线,绝不写ALTER TABLE UPDATE。 - 显式指定版本列:
ReplacingMergeTree(updated_at)或 Langfuse 式的ReplacingMergeTree(event_ts, is_deleted),不要依赖"最后插入行"这一不确定语义。 - 版本列要与写入单调递增对齐:保证新版本的版本列值严格大于旧版本,否则合并可能保留旧值。
- 查询侧避免滥用
FINAL:优先argMax(col, version)+GROUP BY或ORDER BY version DESC+LIMIT 1 BY,把去重成本控制在查询优化器可管理的范围。 - 逻辑删除与物理删除分离:用
is_deleted标记位 + 查询过滤实现"软删除",物理清理交给后台合并与 TTL/DROP PARTITION。 - 存量数据迁移务必同步 mutation:如确需 mutation,为集群化部署配置
mutations_sync = 2等待其完成。
围绕本规则,仓库还提供了可继续深挖的配套资料:规则全集见 .agents/skills/clickhouse-best-practices/rules 目录,删除策略对照见 insert-mutation-avoid-delete.md,Langfuse 事件查询构建器的完整字段映射与去重逻辑见 event-query-builder.ts,生产表 DDL 见 0039_create_events_full.up.sql 与 0001_traces.up.sql。理解并践行"以追加替代更新",是让 ClickHouse 集群在高吞吐写入下保持稳定性能的关键一步。
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