ClickHouse生产踩坑指南:你以为 FINAL 在帮你去重,其实它在前台偷偷跑了一次后台 Merge?
2026/9/24 17:35:41 网站建设 项目流程

了解过ClickHouse 的都知道,在 ClickHouse 中,FINAL 是一个非常核心但也极容易被滥用的查询修饰符(Modifier)。这是发生在本周的一个生产的真实的场景,发现表里出现了重复数据,或者状态没有及时更新,想当然的在表的结尾随手加上一个 FINAL,然后问题就得到很好的解决了。看似类似的问题都可以按照这种方式很巧妙的解决,然而,如果被 DBA 发现了你的这个操作,他们大概率直接给你发给信息,甚至跑来找你:“赶紧把这个查询杀掉!集群 CPU 快被打满了!”

那么问题就出现了:为什么一个仅仅用来去重的修饰符,会成为 ClickHouse 的性能杀手?今天我们就以一篇文章彻底扒开 FINAL 的底层逻辑,并教大家如何优雅地告别它。

1.为什么需要 FINAL?这要从 MergeTree 说起

要想理解 FINAL,我们必须先理解 ClickHouse 的灵魂——MergeTree(合并树)引擎家族。ClickHouse 之所以写入速度极快,是因为它采用了追加写入(Append-only) 的设计。当我们每次执行 INSERT 操作,ClickHouse 都会把这批数据打包成一个新的数据片段(Part)直接落盘。它不会像传统的数据库如MySQL去检查历史数据里有没有主键冲突,因为先查找再更新的操作太慢了。

那么问题来了:对于像 ReplacingMergeTree(用于替换更新)或 CollapsingMergeTree(用于折叠删除)这样的引擎,那么它们的“去重”和“删除”又是怎么做,又是什么时候发生的呢?

答案就是:在后台一个空闲的时间点异步合并。(和我们传统的大数据组件HBase有点像,这里不展开。)

就像上图所示那样,ClickHouse 会在后台默默地挑选一些小的数据片段(Parts),将它们加载到内存中进行合并,生成一个更大的片段。正是在这个合并的过程中,ClickHouse 才会真正执行去重或折叠的逻辑。

但这带来了一个可能会造成业务数据不准去的时间差:在后台合并发生之前,你的表中会同时存在新旧两份数据。 这时如果你直接 SELECT,就会查出“脏数据”。而这就是 FINAL 关键字出现的原因了,用它可以完美的去解决这个时间差。

那么接下来我们要直面的问题就是FINAL 是如何工作的?

2.FINAL 是如何工作的?

这里我们以最常用的 ReplacingMergeTree 为例。假设我们有一个用户余额表,用版本号(_version)来控制更新。

当执行普通的 SELECT 时,可能会同时查出版本 0 和版本 4 的两条记录。但当你执行 SELECT * FROM table FINAL 时,ClickHouse 在查询时做了以下极其繁重的高CPU的动作:

  1. 跨片段读取:它不能只读单个片段,必须把所有包含该主键的底层数据块全部唤醒。

  2. 破坏索引跳数优化:为了绝对精确地找到所有相同的主键进行比对,ClickHouse 往往需要扫描比平时多得多的颗粒(Granules)。

  3. 内存中实时合并:把读取到的海量数据加载到内存中,在前台单次查询的关键路径上,其实是做了一次后台的 Merge 动作。

3.为什么 FINAL 被称为“性能杀手”?

在早期的 ClickHouse 版本中,FINAL 是单线程执行的,性能是非常差的。虽然现代版本的 ClickHouse 已经为 FINAL 引入了多线程并发执行(通过设置 max_final_threads),但使用它依然是极度昂贵的操纵。

使用 FINAL 会带来三大最致命的灾难:

  • CPU 与内存双重暴增:原本平摊在后台几十个小时里的 IO 和计算压力,被强行挤压到了用户点击查询的这 1 秒钟内。

  • 查询延迟百倍级放大:一个普通的带主键过滤的查询可能只需 50 毫秒,加了 FINAL 后极有可能飙升到 2-5 秒以上。

  • 挤占并发资源:当你的并发请求中包含大量 FINAL 时,整个集群的计算资源会被瞬间抽干,导致其他正常的分析查询排队超时。

4.最佳实践:如何优雅地抛弃 FINAL?

既然 FINAL 这么重,我们在生产环境中该如何获取最新状态的数据呢?以下是三种被广泛验证的替代方案。

替代方案一:使用 argMax 函数

不让存储引擎帮我们去重,在查询层我们自己做聚合! ClickHouse 提供了强大的向量化聚合函数,性能极高。假设你的表结构包含:user_id (主键), balance (余额), update_time (更新时间)。

错误写法(使用 FINAL):

-- 极慢,消耗大量 CPU SELECT user_id, balance FROM user_balance FINAL WHERE user_id = 10086;

正确写法(使用 argMax):

-- 极快,充分利用 ClickHouse 聚合性能 SELECT user_id, argMax(balance, update_time) AS latest_balance FROM user_balance WHERE user_id = 10086 GROUP BY user_id;

注:argMax(A, B) 的含义是:按照 B(时间)进行排序,取 B 最大时对应的 A(余额)的值。这种写法不仅快,而且逻辑极其清晰。

官方参考链接:https://clickhouse.com/docs/zh/reference/functions/aggregate-functions/argMax

替代方案二:利用 AggregatingMergeTree 预计算

如果需要查询的是全局的“最新状态总和”(例如:当前所有用户的最新余额总计),直接跑 argMax 全表扫描依然很慢。这时,可以创建一个基于 AggregatingMergeTree 的物化视图(Materialized View)。在数据写入时,通过视图将状态预先聚合好。查询时直接查视图,彻底规避了查询期的合并开销。

替代方案三:轻量级删除与更新(Lightweight Mutations)

如果业务场景属于“偶尔修改个别数据”(例如修改某条配置),而不是高频流式覆盖,那么自 ClickHouse 22.8 版本起,推荐直接使用标准的 UPDATE 或 DELETE 语法。ClickHouse 引入了轻量级更新机制,它在底层会给被删除的行打上一个隐藏的掩码(Mask)标记,查询时自动过滤这些行,性能损耗平均只有 10% 左右,完全不需要加 FINAL。

这里我们以轻量级删除为例,简要说一下其原理,它采用了一种“逻辑删除 + 延迟物理合并”的策略。

当执行 DELETE FROM table WHERE id = 100 时,内部流程如下:

  1. 生成隐藏掩码(Mask):ClickHouse 会为受影响的数据块快速生成一个隐藏的系统列,名为 _row_exists。这是一个非常紧凑的位图(Bitmap)。

  2. 标记为 0:将位图中对应 id = 100 那一行的状态从 1(存在)改为 0(删除)。这个操作非常轻量,只会写入很小的位图文件,完全不碰原始的数据文件(.bin 和 .mrk)。

  3. 查询过滤:当你发起 SELECT 查询时,ClickHouse 会自动先读取 _row_exists 列。遇到标记为 0 的行,直接跳过。因此,被删除的数据会立刻对用户不可见。

  4. 后台物理清理:那些被标记为 0 的“死数据”一直留存在磁盘上,直到 ClickHouse 后台触发正常的 Merge(合并)操作时,才会顺手把这些垃圾数据彻底丢弃。

切记,说一千道一万,ClickHouse 依然是一个 OLAP 数据库。不管是轻量级删除还是 Mutation,都绝对不能像 MySQL 那样每秒执行成百上千次的单行修改。如果有这种的需求,还是要使用 ReplacingMergeTree 或者 CollapsingMergeTree。

5.那么,FINAL 就一无是处了吗?

那答案肯定是否定的,否则引入FINAL就没有任何意义了。在以下几个小众场景中,我们依然可以安全地使用它:

  1. 开发与调试阶段:用来快速验证表里的数据去重逻辑是否符合预期。

  2. 较小的数据表:比如几兆到几十兆甚至几百兆大小的配置表、字典表,全表加载到内存合并也只需要几毫秒,此时用 FINAL 可以简化 SQL。

  3. 数据导出/冷备归档:如果需要将干净的数据导出为 Parquet 或 CSV 文件,这种一次性的离线批处理任务,可以挂载 FINAL 来执行。

最后给大家要给小的tips:除了小表和离线任务,在任何高并发的业务查询 SQL 中,最好不要直接使用FINAL,使用 argMax 和聚合思维,才是玩转 ClickHouse 的正确姿势!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询