ReplacingMergeTree 实时去重真相:FINAL 关键字带来的全表合并开销
在将关系型数据库(如 MySQL、PostgreSQL)的实时 Binlog 通过 CDC(如 Flink / Canal)同步至 ClickHouse 时,绝大多数数据研发都会选择ReplacingMergeTree引擎作为实时数仓 ODS 层的落盘底座。
理论听起来无懈可击:上游订单发生多次状态修改(从“待支付”变为“已支付”、再变为“已发货”),下游 ClickHouse 只要按order_id主键自动替换旧版本,保留版本号(ver)最新的那一条记录即可。
然而,当业务或者 BI 同学来查报表时,发现数据里经常能查出两行相同的order_id。问了资深同学,得到的建议是:“在查询表名后面加个FINAL就行了,像SELECT ... FROM orders FINAL”。
结果在大促压测期间,一条平平无奇的实时聚合 SQL 刚加上FINAL跑起来,ClickHouse 集群的 CPU 瞬间飙到 100% 报警,单次查询响应时间从原本的25 毫秒断崖式下跌至 9.4 秒!
FINAL绝不是一个免费的去重语法糖。在千万级乃至亿级数据的大促现场,不理解其底层执行机制的滥用,无异于在生产集群中亲手引爆一枚性能炸弹。
ReplacingMergeTree 的底层物理真相:异步去重不是实时去重
要搞懂FINAL为什么这么慢,必须先戳破很多新人对ReplacingMergeTree的美好幻想。
ClickHouse 的本质是一个追加写入型(Append-only)的列式存储引擎。它在底层物理上根本不存在类似传统 B+ 树就地更新(In-Place Update)的能力:
[ 上游写入第 1 次: order_id = 1001, status = '待支付', ver = 1 ] ──→ 落盘为 Part A (独立物理目录) [ 上游写入第 2 次: order_id = 1001, status = '已支付', ver = 2 ] ──→ 落盘为 Part B (独立物理目录) │ ▼ 后台 Merge 线程尚未被调度触发 [ 磁盘物理状态: Part A 与 Part B 同时并存! 两个相同的 order_id 物理存在! ]1. 为什么平时查会有重复数据?
因为后台的Merge线程是完全异步运作的。只有当系统负载较低、或者某个分区内积累了足够多的小 Part 时,后台才会启动合并任务,把 Part A 和 Part B 读入内存做归并排序,把旧版本ver = 1物理擦除,生成干净的新 Part。在这个异步合并发生之前的长达几分钟甚至数小时内,磁盘上物理上就是同时存在两行数据的!
2. 加了 FINAL,底层到底干了什么?
当你在 SQL 后面挂上FINAL关键字时,你实际上是在对 ClickHouse 发出一条极其霸道的强行指令:
“我不管你后台合并线程有没有空,现在、立刻、马上把该分区下所有未合并的几十个甚至上百个 Data Part 全部拉进内存,在查询执行前,强行为我做一次全量的多路归并排序(Multi-way Merge Sort),当场算出谁是最新版本!”
原本 ClickHouse 查数据是一路顺风顺水的高速流式向量扫描;一旦加上FINAL,查询引擎被迫退化为一个庞大的多路归并排序机。几十个数据流在内存中逐行比对主键与版本号,CPU 缓存行频繁失效,向量化指令完全熄火,耗时瞬间暴增几百倍。
[ 执行普通查询 (无 FINAL) ] ├── 磁盘顺序大块读取 ──→ SIMD 向量化计算 ──→ 秒级返回 (但可能含旧版本微量脏数据) [ 执行带 FINAL 查询 ] ├── 必须打开全量待合并 Parts (几十个文件句柄) ├── 内存中维持 Priority Queue 进行多路逐行比对 (单核瓶颈极重) └── CPU 算力被排序彻底吃光,I/O 吞吐断崖式下跌!生产破局之道:消灭 FINAL 的三大工业级平替
为了在保证“数据版本绝对最新”的前提下彻底消灭FINAL带来的 CPU 暴击,业界沉淀出了三种成熟的替代战术:
方案一:利用argMax()聚合函数进行语义去重
如果业务只需要进行汇总统计(如求各渠道的成交总额),完全不需要在底层做物理行的去重,而是利用 ClickHouse 极其强悍的argMax(value, version)聚合函数,在分组计算的同时取版本最高的字段值:
-- 优雅替代 FINAL 的生产级聚合范式 SELECT merchant_id, -- 当同一个订单有多个版本时,严格取 ver 最大时的 pay_amount SUM(latest_pay_amount) AS total_gmv FROM ( SELECT merchant_id, order_id, argMax(pay_amount, ver) AS latest_pay_amount FROM dw.orders_local WHERE dt = '2026-10-04' GROUP BY merchant_id, order_id ) GROUP BY merchant_id;由于argMax是完全支持向量化多核并发的内存聚合算子,不需要在存储层做复杂的多路归并排序,实测在两千万行数据集上比FINAL快 12 倍以上!
方案二:开启并行 FINAL 优化(max_final_threads)
如果业务确实是一个离线报表导出任务,非要查看全量明细行,必须强制要求 ClickHouse 开启多线程并行处理FINAL。
在老版本中,FINAL默认是单线程串行处理所有 Parts。必须在查询时或者全局配置中注入并行线程参数:
-- 开启并行 FINAL 算子加速 SELECT * FROM dw.orders_local FINAL SETTINGS max_final_threads = 16, -- 允许调用 16 个 CPU 线程并行切片归并 do_not_merge_across_partitions_select_final = 1; -- 严格限制不跨分区归并仅这一项配置,就能让 64 核服务器上的FINAL耗时缩短 60%~75%。
方案三:分层数仓架构,将去重前置在 Flink 流计算
对于双 11 的核心秒杀交易大屏,把去重压力甩给 ClickHouse 是一种失职的数仓设计。
最佳实践:在 Flink 消费 Kafka Binlog 时,直接利用 Flink 的RowTime+ 双流或状态去重,在流中就已经把撤回流(Retract Stream)合并完毕,流入 ClickHouse 的数据天然是一条条绝对唯一的最终态。底层直接采用无状态的纯MergeTree引擎,彻底告别ReplacingMergeTree与FINAL的双重枷锁。
基准性能压测对比
我们在包含 4500 万行订单、累积有 35 个未合并 Part 的生产测试表上进行了多方案对比评测:
| 执行方案 | 查询总耗时 | CPU 核心峰值占用率 | 吞吐 (行/秒) |
|---|---|---|---|
朴素SELECT ... FROM orders FINAL | 9.42 秒 | 98.6% (几乎打崩节点) | 470 万 |
开启max_final_threads = 16 | 2.85 秒 | 92.4% | 1580 万 |
子查询 +argMax()语义去重 | 0.78 秒 | 45.2% (极其平稳) | 5700 万 |
| Flink 前置去重 + 纯 MergeTree 直查 | 0.08 秒 | 18.5% | 全速扫描 |
从 9.4 秒到 0.78 秒,再到极限的 80 毫秒,性能差距跨越了整整两个数量级!
总结
ClickHouse 是一台极其追求流式吞吐的列式赛车,而FINAL就像是在高速飞驰的轮毂里强行塞进了一把沉重的机械铁钳。
在架构设计中,永远把“消灭查询时的实时全量去重”作为第一原则。善用argMax,用流计算前置化解冲突,让 ClickHouse 专心致志地做它最擅长的极速列扫描,你的即席查询大屏才能在亿级流量冲击下依然秒级出数。