
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 FINAL9.42 秒98.6% (几乎打崩节点)470 万开启max_final_threads 162.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 专心致志地做它最擅长的极速列扫描你的即席查询大屏才能在亿级流量冲击下依然秒级出数。