TT Lab
开始
学习 学习路径 课程

ClickHouse — 从内部理解列式分析数据库

ReplacingMergeTree — 用 INSERT 模拟更新与删除

在 TT Lab 中继续学习

一句话总结

ReplacingMergeTree 是一种在合并时把排序键相同的行缩减为一行的引擎。版本列较大的行胜出,如果胜出的行带有删除标记,就视为没有这个键。合并什么时候发生是不确定的,所以需要准确答案的查询,必须在查询时用 FINAL 或 argMax 应用同样的规则。

为什么需要它

把生产数据库的用户表迁到分析型数据库,很快就会随之而来“套餐变了”“用户注销了”这样的变更。对行式数据库来说,这就是一行 UPDATE、DELETE,但正如前面模块所见,MergeTree 的数据片段一旦写出就不会修改。要改一行,就得把含有这一行的数据片段的列文件重写一遍,如果每次变更都这么做,写入就会暴增。

所以要把思路颠倒过来。不要修改,而是再写入一行新版本。注销也是写入一个“已注销”的版本。官方指南(Working with the ReplacingMergeTree engine)把这称为“用不可变的 INSERT 模拟更新”。清理旧版本的工作,交给本来就会发生的后台合并。写入始终是追加(append),所以很快,代价是“合并之前还能看到旧版本”。

工作原理

三次 INSERT 创建了三个数据片段,其中散落着用户 7 的版本 1、2 和删除标记版本 3,以及用户 8 的版本 1。普通合并之后,留下用户 7 的删除标记行和用户 8 的行,CLEANUP 合并之后只留下用户 8。此后如果用户 7 的版本 1 迟到又一次写入,在保留了删除标记的表中,版本 3 胜出,保持已删除的状态,而在做过 CLEANUP 的表中,用户 7 会复活

引擎声明是 ReplacingMergeTree(ver, is_deleted)。参考文档规定的规则如下。

用本实验的数据(100,000 个用户 · 30,058 行更新 · 5,108 行注销)测出的数字,原样展示了这套规则。停止合并后写入,count() 是 135,166 行,加上 FINAL 是 94,892 个用户。用 OPTIMIZE ... FINAL 合并之后,行仍是 100,000 个——每个用户一行,但其中 5,108 个是删除标记行,所以不加 FINAL 来数,依然是错的。要到 CLEANUP 合并之后,才变成 94,892 行。

FINAL 在查询的同时应用合并规则。同样的答案也可以用 argMax 得到。

SELECT plan, count() FROM (
  SELECT user_id, argMax(plan, ver) AS plan, argMax(is_deleted, ver) AS d
  FROM rmt.users GROUP BY user_id
) WHERE d = 0 GROUP BY plan;

argMax(x, ver) 是 ver 最大的那一行的 x。应该选什么,由查询自己说明,所以在不能用 FINAL 的地方(其他引擎、合并多张表的结果)也同样适用。

还有一点——同一个 INSERT 内的重复,会在写入的那一刻被缩减。由于默认设置 optimize_on_insert = 1,把三个版本放在一个 INSERT 里写入,数据片段一开始就是 100,000 行。想看“合并之前”,就要分开写入各个版本,并用 SYSTEM STOP MERGES 停止那张表的合并。对停止的表执行 OPTIMIZE,会以“Cancelled merging parts”被拒绝。

在现场相遇的样子

最常见的事故是把会变化的列放进排序键。如果为了查询性能,把它定成 ORDER BY (user_id, plan),套餐变了的用户,排序键就变了,成了“另一行”。在实验中,这样创建的表即使用 FINAL 来数,也得到 115,062 行(95,919 个用户)——旧套餐的行永远不会被合并,删除标记也盖不住旧套餐的行,已注销的用户仍然活着。这就是指南明确要求“ORDER BY 的列不能变化”的原因。

第二种是 CLEANUP 之后的重发。管道把旧批次重新发送是常有的事(重新处理、偏移量回拨)。保留了删除标记的表,版本 3 胜过版本 1,安然无恙,但用 CLEANUP 清掉了删除行的表,没有可比较的对象,旧的行就这样复活了。在实验中,把用户 1–1000 的首次加载行再写入一次,只有做过 CLEANUP 的表中有 56 个用户复活。指南说,只有“确信旧版本不会再次写入”时,才能使用 CLEANUP。

第三种是 FINAL 的成本。FINAL 在查询时做合并,所以过滤条件越是作用在排序键上,就越便宜。如果分区键不会随行而变,指南中还出现了让它按分区分别处理的 do_not_merge_across_partitions_select_final。

下一项实验要做什么

用 ReplacingMergeTree(ver, is_deleted) 创建 rmt.users,在停止合并的情况下写入三批变更记录。比较不加 FINAL 和加 FINAL 数出的行数,并不用 FINAL,用 argMax 求出各套餐的当前用户数。再创建两张新表,按版本迁移数据,一张做普通合并,一张做 CLEANUP 合并,然后数剩下的行,看把 plan 放进排序键的表会让什么复活。最后重新写入旧行,记录两张表的反应有何不同。