首页
/ ClickHouse 写入优化实战:以 ReplacingMergeTree 替代 ALTER TABLE UPDATE(Langfuse 工程实践)

ClickHouse 写入优化实战:以 ReplacingMergeTree 替代 ALTER TABLE UPDATE(Langfuse 工程实践)

2026-09-09 11:07:53作者:贡沫苏Truman

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 BYLIMIT 1 BY 等聚合手段,把去重交给查询优化器。

四、原理纵深:mutation 与 ReplacingMergeTree 的本质差异

从实现机制看,二者走的是完全不同的路径:

  • mutation(ALTER UPDATE):立即在 system.mutations 注册任务,后台逐 part 重写。它是"同步语义、异步执行",会真实触碰并重写磁盘上的数据文件,属于破坏性重写
  • ReplacingMergeTree:新数据以追加方式落盘,不触碰已有 part;去重只发生在后台合并(merge)阶段,或在查询时通过 FINAL / 聚合函数即时完成。它是**追加式(append-only)**的,天然契合列式存储与 LSM 合并模型。

因此对于"业务上需要更新、但底层是海量时序/事件数据"的场景,ReplacingMergeTree 几乎是唯一正确的选择。规则对这两条 insert-mutation-* 规则(insert-mutation-avoid-update.mdinsert-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 FINAL on the events table; it is designed so FINAL is 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.tslimitBy(...) 的实现)保留每个 span 的最新事件,从而在查询期完成与 FINAL 等价的去重,但把排序键控制权交给查询优化器。
  • argMaxIf(col, event_ts, cond) 聚合:在 event-query-builder.tsEVENTS_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 来改 bookmarkedpublic,每次操作都会重写整个 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 的 ALTERMATERIALIZE …UPDATEDELETE)都必须附加 {CLICKHOUSE_CLUSTERED_ONLY: SETTINGS mutations_sync = 2},以确保迁移执行完 mutation 后再进入下一个文件,避免集群内元数据版本不一致导致 code 517 错误——这从侧面印证了 mutation 的异步重写特性给工程带来的额外同步负担。

七、落地建议与自检清单

参考 SKILL.md 的 Insert Strategy Reviews 检查项,结合本文规则,给出落地时的自检清单:

  1. 凡是"更新"需求,先问是否高频/大规模:是,则走 ReplacingMergeTree/CollapsingMergeTree 路线,绝不写 ALTER TABLE UPDATE
  2. 显式指定版本列ReplacingMergeTree(updated_at) 或 Langfuse 式的 ReplacingMergeTree(event_ts, is_deleted),不要依赖"最后插入行"这一不确定语义。
  3. 版本列要与写入单调递增对齐:保证新版本的版本列值严格大于旧版本,否则合并可能保留旧值。
  4. 查询侧避免滥用 FINAL:优先 argMax(col, version) + GROUP BYORDER BY version DESC + LIMIT 1 BY,把去重成本控制在查询优化器可管理的范围。
  5. 逻辑删除与物理删除分离:用 is_deleted 标记位 + 查询过滤实现"软删除",物理清理交给后台合并与 TTL/DROP PARTITION
  6. 存量数据迁移务必同步 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.sql0001_traces.up.sql。理解并践行"以追加替代更新",是让 ClickHouse 集群在高吞吐写入下保持稳定性能的关键一步。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395