已插入行的自动 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 列的值用作唯一标识符,并且要么只保留最高版本,要么在最新版本表示删除时移除所有重复项。然而,这只能提供最终一致的正确性——并不能保证行一定会被去重,因此不应依赖它。
使用 FINAL 读取去重后的数据由于去重只会在 background 合并 期间发生,普通的
SELECT 仍然可能返回重复行或已删除的行。要在查询时读取正确结果,请使用 FINAL 修饰符,它会在查询执行时完成去重并移除已删除的行。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 达到最大合并大小,并减少持续手动优化的需要。
分区以及跨分区合并
调优合并以提升查询性能
min_age_to_force_merge_seconds 和 min_age_to_force_merge_on_partition_only 分别设置为 0 和 false,因此这些功能默认处于禁用状态。在这种配置下,ClickHouse 会采用标准的合并行为,不会根据分区的时间强制执行合并。
如果为 min_age_to_force_merge_seconds 指定了值,ClickHouse 将对早于该时间阈值的 parts 忽略常规的合并启发式规则。虽然这种做法通常只在目标是尽量减少 parts 总数时才有明显效果,但在 ReplacingMergeTree 中,它可以通过减少查询时需要合并的 parts 数量来提升查询性能。
还可以通过设置 min_age_to_force_merge_on_partition_only=true 进一步调优这一行为。这样一来,只有当分区中的所有 parts 都早于 min_age_to_force_merge_seconds 时,才会执行更激进的合并。这种配置可使较旧的分区随着时间推移逐步合并为单个 part,从而整合数据并维持查询性能。
推荐设置
min_age_to_force_merge_seconds 设为较低的值——即明显小于分区周期。这样可以尽量减少 parts 的数量,并避免在查询时使用 FINAL 运算符进行不必要的合并。
例如,假设某个按月分区的数据已经合并成一个单独的 part。如果一次零散的小规模插入在该分区中又创建了一个新的 part,那么在合并完成前,ClickHouse 就必须读取多个 parts,从而可能影响查询性能。设置 min_age_to_force_merge_seconds 可以促使这些 parts 更积极地合并,避免查询性能下降。