去重策略
去重是指删除数据集中重复行的过程。在 OLTP 数据库中,这很容易实现,因为每一行都有唯一的主键——但代价是写入速度较慢。每次插入前都需要先查找该主键,如果已存在则需要进行替换。
ClickHouse 在数据写入方面针对速度进行了优化。存储文件是不可变的,并且 ClickHouse 在插入一行之前不会检查是否已经存在相同的主键——因此去重需要多做一些工作。这也意味着去重不是即时完成的,而是最终完成的(eventual),这会带来一些副作用:
- 在任意时刻,你的表中仍然可能存在重复数据(具有相同排序键的行)
- 实际删除重复行是在数据分片(parts)合并过程中发生的
- 你的查询需要能够处理可能存在的重复数据
![]() | ClickHouse 提供关于去重和许多其他主题的免费培训。Deleting and Updating Data 培训模块是一个很好的起点。 |
去重选项
在 ClickHouse 中,通过以下表引擎实现去重:
-
ReplacingMergeTree表引擎:使用该表引擎时,在合并过程中会删除具有相同排序键的重复行。ReplacingMergeTree是模拟 upsert 行为的一个不错选择(当您希望查询返回最后插入的那一行时)。 -
折叠行:
CollapsingMergeTree和VersionedCollapsingMergeTree表引擎采用的逻辑是“取消”一条现有行并插入一条新行。与ReplacingMergeTree相比,它们实现起来更复杂,但您的查询和聚合可以写得更简单,而无需担心数据是否已经完成合并。这两种表引擎在您需要频繁更新数据时非常有用。
下面我们将演示这两种技术的使用方法。欲了解更多详情,请查看我们免费的按需培训模块 Deleting and Updating Data 培训模块。
使用 ReplacingMergeTree 实现 Upsert
来看一个简单示例:一张表中包含 Hacker News 的评论,并有一个 views 列表示某条评论被查看的次数。假设在文章发布时我们插入一条新记录,并且之后如果浏览量增加,每天为该评论插入一条包含最新总浏览次数的新记录来完成 upsert 操作:
我们来插入两行数据:
要更新 views 列,请插入一条具有相同主键的新记录(注意 views 列的新值):
该表现在包含 4 行数据:
上面输出中的两个独立框展示了在后台运作的两个部分——这些数据尚未被合并,因此重复行也尚未被移除。让我们在 SELECT 查询中使用 FINAL 关键字,对查询结果进行逻辑合并:
结果只有 2 行,并且最后插入的那一行就是被返回的那一行。
如果数据量较小,使用 FINAL 还可以接受。但如果你要处理的是海量数据,
使用 FINAL 可能就不是最佳选择了。下面我们来讨论一种更好的方式,
用于查找某一列的最新值。
避免使用 FINAL
我们再次更新这两条唯一记录的 views 列:
该表现在有 6 行,这是因为真正的合并尚未发生(目前只有在我们使用 FINAL 时才会在查询时进行合并)。
我们可以不用 FINAL,而是利用一些业务逻辑——我们知道 views 列的值始终递增,因此可以在按所需列分组后,使用 max 函数选出值最大的那一行:
如上面查询所示那样进行分组,在查询性能方面实际上可能比使用 FINAL 关键字更高效。
我们的删除和更新数据培训模块在此示例基础上进行了扩展,其中包括如何将 version 列与 ReplacingMergeTree 一起使用。
使用 CollapsingMergeTree 处理频繁的列更新
更新一列意味着删除一条现有行并用新值替换它。正如前文所示,这类变更在 ClickHouse 中是最终才生效的——会在合并过程中完成。如果你需要更新大量行,与其使用 ALTER TABLE..UPDATE,往往更高效的做法是直接将新数据与现有数据一并插入。我们可以添加一列,用于标记数据是过期的还是最新的,而且其实已经有一个表引擎很好地实现了这种行为,特别是它还能自动为你删除过期数据。下面来看它是如何工作的。
假设我们通过一个外部系统跟踪每条 Hacker News 评论的浏览次数,并且每隔几个小时就将这些数据推送到 ClickHouse。我们希望旧行被删除,新行能够表示每条 Hacker News 评论的最新状态。我们可以使用 CollapsingMergeTree 来实现这种行为。
让我们定义一个表来存储浏览次数:
请注意,hackernews_views 表中有一个名为 sign 的 Int8 列,我们称之为 sign 列。sign 列的名称可以任意命名,但数据类型必须是 Int8,并且请注意,该列名被传递给了 CollapsingMergeTree 表的构造函数。
CollapsingMergeTree 表中的 sign 列有什么作用?它表示该行的状态,并且 sign 列的取值只能是 1 或 -1。其工作机制如下:
- 如果两行具有相同的主键(或者如果排序键与主键不同,则具有相同的排序键),但 sign 列的值不同,那么最后插入、且 sign 为 +1 的那一行会成为状态行,而其余行会相互抵消
- 在合并过程中,相互抵消的行会被删除
- 没有匹配对的行会被保留
让我们向 hackernews_views 表中添加一行。由于这是此主键对应的唯一一行,我们将其状态设置为 1:
现在假设我们要更改 views 列。需要插入两行记录:一行用于抵消现有行,另一行表示该行的新状态:
该表现在包含 3 行,主键为 (123, 'ricardo'):
请注意,添加 FINAL 将返回当前状态的那一行:
当然,不推荐在大表上使用 FINAL。
在我们的示例中传入的 views 列的值其实并不是必需的,也不需要与旧行当前的 views 值相匹配。实际上,你只需要主键和 -1 就可以作废一行记录:
来自多个线程的实时更新
在 CollapsingMergeTree 表中,行通过一个符号列(sign 列)彼此抵消,一行的状态由最后插入的那一行决定。但是,如果你从不同的线程插入行,而且这些行可能乱序插入,就会出现问题。在这种情况下,使用“最后”一行的方法是行不通的。
这就是 VersionedCollapsingMergeTree 派上用场的场景——它与 CollapsingMergeTree 一样会折叠(合并)行,但它不是保留最后插入的那一行,而是保留你指定的版本列中值最高的那一行。
来看一个例子。假设我们想跟踪自己在 Hacker News 上评论的浏览次数,而且这些数据会被频繁更新。我们希望报表使用最新的值,而不需要强制执行或等待合并操作。我们从一个与 CollapsedMergeTree 类似的表开始,只是额外添加了一列,用于存储该行状态的版本信息:
请注意,该表使用 VersionsedCollapsingMergeTree 作为引擎,并传入了符号列(sign column)和版本列(version column)。该表的工作机制如下:
- 删除每一对主键和版本相同但符号不同的行
- 行的插入顺序无关紧要
- 请注意,如果版本列不是主键的一部分,ClickHouse 会自动将其作为最后一个字段隐式添加到主键中
在编写查询时,你需要使用相同的逻辑——按主键进行分组,并通过合适的条件来排除那些已经被取消但尚未被删除的行。让我们向 hackernews_views_vcmt 表中添加一些行:
现在我们更新其中两行并删除一行。要作废一行记录,请务必包含其之前的版本号(因为它是主键的一部分):
我们将再次运行之前的那个查询,该查询会根据 sign 列的符号巧妙地进行加减运算:
结果是两行数据:
强制触发表合并:
结果中应只包含两行:
当你希望在从多个客户端和/或线程插入数据时实现去重时,VersionedCollapsingMergeTree 表非常实用。
为什么我的行没有被去重?
插入的行可能没有被去重的一个原因,是在 INSERT 语句中使用了非幂等函数或表达式。例如,如果你插入的行中包含列 createdAt DateTime64(3) DEFAULT now(),那么这些行一定是唯一的,因为每一行的 createdAt 列默认值都是唯一的。MergeTree / ReplicatedMergeTree 表引擎不会对这些行进行去重,因为每一行插入时都会生成唯一的校验和。
在这种情况下,你可以为每一批行指定自己的 insert_deduplication_token,以确保对同一批数据进行多次插入时,不会导致相同行被重复插入。有关如何使用该设置的更多详细信息,请参阅 insert_deduplication_token 的文档。
