ReplacingMergeTree
虽然事务性数据库针对事务更新和删除工作负载进行了优化,但 OLAP 数据库则在此类操作上提供较低的保证。相反,它们优化了以批次插入的不可变数据,以此显著加快分析查询的速度。尽管 ClickHouse 通过突变提供更新操作,并且以轻量级方式删除行,但其面向列的结构意味着这些操作应小心安排,如上所述。这些操作是异步处理的,使用单线程进行处理,并且在更新的情况下需要重写磁盘上的数据。因此,不应将其用于大量的小变更。
为了在避免上述使用模式的情况下处理更新和删除行的流,我们可以使用 ClickHouse 表引擎 ReplacingMergeTree。
自动插入的更新
ReplacingMergeTree 表引擎 允许对行应用更新操作,无需使用低效的 ALTER
或 DELETE
语句,用户可以插入同一行的多个副本,并指定其中一个为最新版本。反过来,后台进程异步地删除同一行的旧版本,利用不可变插入有效地模拟更新操作。
这依赖于表引擎识别重复行的能力。这是通过 ORDER BY
子句来确定唯一性实现的,即如果两行在 ORDER BY
指定的列上具有相同的值,则被视为重复行。在定义表时指定的 version
列允许在识别出重复行时保留行的最新版本,即保留具有最高版本值的行。
我们在下面的例子中说明这一过程。在这里,行由 A 列唯一标识(表的 ORDER BY
)。我们假设这些行已作为两批插入,导致磁盘上形成两个数据分片。稍后,在异步后台处理期间,这些分片被合并在一起。
ReplacingMergeTree 还允许指定一个删除列。该列可以包含 0 或 1,其中值为 1 表示行(及其重复项)已被删除,而零表示相反。注意:已删除的行不会在合并时被移除。
在此过程中,分片合并期间发生以下情况:
- 标识为 A 列值为 1 的行同时有一个版本为 2 的更新行和一个版本为 3 的删除行(删除列值为 1)。因此,标记为删除的最新行被保留。
- 标识为 A 列值为 2 的行有两个更新行。保留后一个行,其价格列的值为 6。
- 标识为 A 列值为 3 的行有一个版本为 1 的行和一个版本为 2 的删除行。此删除行被保留。
通过此合并过程,我们最终有四行表示最终状态:

请注意,已删除的行永远不会被移除。可以通过 OPTIMIZE table FINAL CLEANUP
强制删除它们。这需要实验性设置 allow_experimental_replacing_merge_with_cleanup=1
。应仅在以下条件下执行此操作:
- 您可以确定,发出操作后不会插入包含旧版本的行(对于正在被清理的行)。如果插入这些行,它们将被错误地保留,因为已删除的行将不再存在。
- 在发出清理之前确保所有副本处于同步状态。可以通过以下命令实现:
我们建议在保证 (1) 后暂停插入,直到此命令及后续清理完成。
建议仅在低至中等数量的删除(少于 10%)的表上使用 ReplacingMergeTree 处理删除,除非可以根据上述条件安排清理周期。
提示:用户还可以对不再受更改影响的选择性分区发出
OPTIMIZE FINAL CLEANUP
。
选择主键/去重键
上面提到,我们必须满足 ReplacingMergeTree 的一个重要附加约束: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
列对于每个帖子是唯一的,确保行可以去重。Version
和 Deleted
列根据需要添加到模式中。
查询 ReplacingMergeTree
在合并时,ReplacingMergeTree 使用 ORDER BY
列的值识别重复行,将其作为唯一标识,保留最高版本或删除所有副本(如果最新版本指示删除)。然而,这只提供最终准确性——它不保证行会被去重,因此您不应依赖它。因此,查询可能会因考虑到更新和删除行而产生不正确的答案。
为了获得正确的答案,用户需要用查询时间的去重和删除移除补充后台合并。这可以使用 FINAL
操作符实现。
考虑上面的 posts 表。我们可以使用加载该数据集的常规方法,但除了值 0 之外还指定一个删除和版本列。为了示例目的,我们仅加载 10000 行。
让我们确认行数:
我们现在更新我们的帖子-答案统计信息。我们没有更新这些值,而是插入 5000 行的新副本并将它们的版本号加一(这意味着表中将存在 150 行)。我们可以用简单的 INSERT INTO SELECT
来模拟这一点:
此外,我们通过重新插入这些行(但将删除列值设置为 1)来删除 1000 条随机帖子。再次模拟这一过程可以使用简单的 INSERT INTO SELECT
。
以上操作的结果将是 16000 行,即 10000 + 5000 + 1000。这里的正确总数实际上应为比我们原始总数少 1000 行,即 10000 - 1000 = 9000。
您的结果可能会因发生的合并而有所不同。我们可以看到总数不同,因为我们有重复行。对表应用 FINAL
可以得到正确的结果。
FINAL 性能
FINAL
操作符在查询时会产生性能开销,尽管持续改进。尤其在查询不针对主键列时,这种开销最为显著,因为会读取更多数据并增加去重开销。如果用户在使用 WHERE
条件时过滤主键列,则加载和传递用于去重的数据将减少。
如果 WHERE
条件不使用主键列,ClickHouse 目前在使用 FINAL
时不会利用 PREWHERE
优化。此优化旨在减少读取未过滤列的行数。有关模拟此 PREWHERE
以可能提高性能的示例,可以参见 这里。
利用分区的 ReplacingMergeTree
ClickHouse 中的数据合并发生在分区级别。当使用 ReplacingMergeTree 时,我们建议用户根据最佳实践对表进行分区,前提是用户可以确保该 分区键在行中不会改变。这将确保涉及同一行的更新将发送到同一 ClickHouse 分区。您可以重复使用与 Postgres 相同的分区键,只要您遵循此处列出的最佳实践。
假设确实如此,用户可以使用设置 do_not_merge_across_partitions_select_final=1
来改善 FINAL
查询性能。此设置使分区在使用 FINAL 时独立合并和处理。
考虑以下没有分区的 posts 表:
为了确保 FINAL
需要进行一些工作,我们更新 100 万行 - 通过插入重复行增加其 AnswerCount
。
使用 FINAL
计算每年的答案总和:
对按年分区的表重复上述步骤,并在重复上述查询时使用 do_not_merge_across_partitions_select_final=1
。
如上所示,分区在此案例中显著提高了查询性能,因为它允许去重过程在分区级别并行进行。
合并行为考量
ClickHouse 的合并选择机制超越了简单的分片合并。下面,我们在 ReplacingMergeTree 的上下文中检查这一行为,包括启用更积极合并旧数据的配置选项和针对大型分片的考量。
合并选择逻辑
尽管合并旨在最小化分片数量,但它也权衡了写放大成本。因此,如果合并会导致过多的写放大,则会排除某些分片范围。此行为有助于防止不必要的资源使用并延长存储组件的使用寿命。
大型分片上的合并行为
ClickHouse 中的 ReplacingMergeTree 引擎针对通过合并数据分片来管理重复行进行了优化,仅根据指定的唯一键保留每行的最新版本。然而,当合并分片达到 max_bytes_to_merge_at_max_space_in_pool
阈值时,它将不再被选为进一步合并,即使 min_age_to_force_merge_seconds
被设置。因此,不能再依赖自动合并来删除随数据插入而累积的重复项。
为了解决这个问题,用户可以调用 OPTIMIZE FINAL
来手动合并分片并删除重复项。与自动合并不同,OPTIMIZE FINAL
跳过 max_bytes_to_merge_at_max_space_in_pool
阈值,基于可用资源(特别是磁盘空间)进行分片合并,直到每个分区只剩下一个分片。然而,这种方法在大型表上可能会消耗大量内存,并且可能需要重复执行以添加新数据。
为了保持性能的更可持续解决方案,建议对表进行分区。这可以帮助防止数据分片达到最大合并大小,并减少持续手动优化的需求。
分区和跨分区合并
如在利用 ReplacingMergeTree 分区所述,我们建议将表分区作为最佳实践。分区隔离数据以提高合并效率,避免在查询执行过程中跨分区合并。这种行为在 23.12 及更高版本中得到了增强:如果分区键是排序键的前缀,则在查询时不会跨分区合并,从而提高查询性能。
调整合并以改善查询性能
默认情况下,min_age_to_force_merge_seconds
和 min_age_to_force_merge_on_partition_only
设置为 0 和 false,分别禁用这些功能。在此配置下,ClickHouse 将应用标准合并行为,而不基于分区年龄强制合并。
如果为 min_age_to_force_merge_seconds
指定了一个值,ClickHouse 将忽略正常的合并启发式,适用于超过指定周期的分片。虽然这通常仅在目标是最小化分片数量时有效,但它可以通过减少查询时需要合并的分片数量来改善 ReplacingMergeTree 的查询性能。
这种行为可以进一步通过将 min_age_to_force_merge_on_partition_only
设置为 true 来调整,要求所有分区中的分片都比 min_age_to_force_merge_seconds
更老,以实现积极合并。这种配置允许较旧的分区随时间合并为单个分片,从而整合数据并保持查询性能。
推荐设置
调整合并行为是一项高级操作。我们建议在生产工作负载中启用这些设置之前咨询 ClickHouse 支持。
在大多数情况下,将 min_age_to_force_merge_seconds
设置为一个低值——显著低于分区周期——是较好的选择。这将最小化分片数量,并防止在使用 FINAL
操作符时不必要的合并。
例如,考虑一个已经合并为单个分片的按月分区。如果一个小的零星插入在此分区内创建了一个新分片,则查询性能可能会受到影响,因为 ClickHouse 必须读取多个分片,直到合并完成。设置 min_age_to_force_merge_seconds
可以确保这些分片被积极合并,从而防止查询性能下降。