已插入行的自动 upsert
ALTER 或 DELETE 语句。它的实现方式是允许你插入同一行的多个副本,并将其中一个标记为最新版本。随后,后台进程会异步移除同一行的旧版本,从而通过不可变插入高效地模拟更新操作。
这依赖于表引擎识别重复行的能力。具体来说,它通过 ORDER BY 子句来判断唯一性:如果两行在 ORDER BY 指定列上的值相同,就会被视为重复行。在定义表时指定的 version 列,则用于在两行被识别为重复时保留该行的最新版本,也就是保留 version 值最高的那一行。
我们将在下面的示例中说明这一过程。这里,行通过 A 列 (即该表的 ORDER BY) 唯一标识。我们假设这些行分两个批次插入,因此在磁盘上形成了两个 parts。随后,在异步后台处理过程中,这些 parts 会被合并。
ReplacingMergeTree 还允许指定一个 deleted 列。该列的值只能是 0 或 1,其中 1 表示该行 (及其重复行) 已被删除,0 则表示未删除。注意:已删除的行不会在合并时被移除。
在这一过程中,parts 合并期间会发生以下情况:
- 对于由 A 列值 1 标识的行,既有一条版本为 2 的更新行,也有一条版本为 3 的删除行 (其
deleted列值为 1) 。因此,最新的那一行会被保留,而该行已被标记为删除。 - 对于由 A 列值 2 标识的行,有两条更新行。后插入的那一行会被保留,
price列的值为 6。 - 对于由 A 列值 3 标识的行,有一条版本为 1 的行和一条版本为 2 的删除行。最终会保留这条删除行。
请注意,已删除的行永远不会被移除。可以通过
OPTIMIZE table FINAL CLEANUP 强制删除它们。这需要启用 Experimental 设置 allow_experimental_replacing_merge_with_cleanup=1。只有在以下条件下才应执行此操作:
- 你能够确保,在执行该操作之后,不会再插入旧版本的行 (即那些将通过 cleanup 删除的行) 。否则,这些行会被错误地保留下来,因为对应的已删除行已经不存在了。
- 在执行 cleanup 之前,确保所有副本都已同步。可以通过以下命令实现:
只有在删除量较低到中等 (少于 10%) 的表上,才建议使用 ReplacingMergeTree 处理删除,除非能够按上述条件安排清理窗口。
提示:你也可以针对不再发生变更的特定分区执行 OPTIMIZE FINAL CLEANUP。
选择主键/去重键
ORDER BY 中各列的值必须能够在发生变更时唯一标识一行。因此,如果是从 Postgres 这类事务型数据库迁移,原始的 Postgres 主键也应包含在 ClickHouse 的 ORDER BY 子句中。
ClickHouse 用户应该很熟悉如何为其表的 ORDER BY 子句选择列,以优化查询性能。通常,这些列应根据你的高频查询来选择,并按基数递增的顺序排列。需要注意的是,ReplacingMergeTree 还增加了一个额外约束——这些列必须是不可变的。也就是说,如果是从 Postgres 复制数据,只有当底层 Postgres 数据中的这些列不会发生变化时,才应将其加入该子句。虽然其他列可以变化,但这些列必须保持一致,才能唯一标识行。
对于分析型工作负载,Postgres 主键通常用处不大,因为你很少会执行单行点查。鉴于我们建议按基数递增的顺序排列列,并且在 ORDER BY 中排得更靠前的列通常匹配更快,因此 Postgres 主键应追加到 ORDER BY 的末尾 (除非它本身具有分析价值) 。如果在 Postgres 中有多个列共同构成主键,则应将它们一并追加到 ORDER BY 中,同时兼顾基数和查询价值。你也可以通过 MATERIALIZED 列拼接多个值来生成唯一主键。
以 Stack Overflow 数据集中的 posts 表为例。
(PostTypeId, toDate(CreationDate), CreationDate, Id) 作为 ORDER BY 键。每篇帖子唯一的 Id 列可确保行能够去重。还会按要求在 schema 中添加 Version 和 Deleted 列。
查询 ReplacingMergeTree
ORDER BY 列的值用作唯一标识符,并且要么只保留最高版本,要么在最新版本表示删除时移除所有重复项。然而,这只能提供最终一致的正确性——并不能保证行一定会被去重,因此不应依赖它。因此,由于查询时会将更新行和删除行也纳入考虑,查询结果可能不正确。
要获得正确结果,你需要结合 background merges,并在 query time 执行去重和删除移除。这可以通过使用 FINAL 运算符来实现。
以上面的 posts 表为例。我们可以使用加载该数据集的常规方法,但额外指定 deleted 列和 version 列,并将它们的值设为 0。为了便于演示,这里我们只加载 10000 行。
INSERT INTO SELECT 来模拟这一过程:
INSERT INTO SELECT 语句来模拟。
FINAL 可得到正确结果。
FINAL 性能
FINAL 运算符确实会给查询带来少量性能开销。
当查询没有按主键列过滤时,这一点会尤为明显,
因为这会读取更多数据并增加去重开销。如果你
在 WHERE 条件中使用键列进行过滤,加载并传递给
去重的数据就会减少。
如果 WHERE 条件没有使用键列,ClickHouse 目前在使用 FINAL 时不会利用 PREWHERE 优化。这种优化旨在减少对未参与过滤的列所读取的行数。有关如何模拟这种 PREWHERE 从而可能提升性能的示例,可在此处找到。
利用 ReplacingMergeTree 中的分区
do_not_merge_across_partitions_select_final=1 来提升 FINAL 查询的性能。启用该设置后,在使用 FINAL 时,各个分区会独立进行合并和处理。
来看下面这个 posts 表,这里我们没有使用分区:
FINAL 确实有事可做,我们通过插入重复行来增加 100 万行数据的 AnswerCount,从而更新这些行。
FINAL 计算每年的答案总和:
do_not_merge_across_partitions_select_final=1 再次执行上述查询。
合并行为注意事项
合并选择逻辑
大型 parts 上的合并行为
max_bytes_to_merge_at_max_space_in_pool 阈值时,即使设置了 min_age_to_force_merge_seconds,它也不会再被选中进行后续合并。因此,随着数据持续插入而不断累积的重复项,将无法再依赖自动合并来清除。
为了解决这个问题,可以调用 OPTIMIZE FINAL 手动合并 parts 并移除重复项。与自动合并不同,OPTIMIZE FINAL 会绕过 max_bytes_to_merge_at_max_space_in_pool 阈值,仅根据可用资源 (尤其是磁盘空间) 合并 parts,直到每个分区中只剩下一个 part。不过,这种方法在大型表上可能非常耗费内存,而且随着新数据不断写入,可能需要反复执行。
如果希望采用一种更可持续且兼顾性能的解决方案,建议对表进行分区。这有助于避免 parts 达到最大合并大小,并减少持续手动优化的需要。