跳至主要内容
跳至主要内容

去重策略

去重指的是移除数据集中的重复行的过程。在 OLTP 数据库中,这很容易实现,因为每行都有唯一的键——但代价是插入速度较慢。每一行插入都需要先搜索,如果找到,则需要替换。

ClickHouse 在数据插入速度方面是为速度而设计的。存储文件是不可变的,并且 ClickHouse 在插入行之前不会检查是否存在主键——因此去重需要付出更多的努力。这也意味着去重不是立即发生的——它是最终一致的,这会产生一些副作用

  • 在任何时候,您的表仍然可能存在重复项(具有相同排序键的行)
  • 实际的重复行删除发生在 parts 合并期间
  • 您的查询需要考虑到可能存在重复项
ClickHouse 提供关于去重以及许多其他主题的免费培训。 删除和更新数据培训模块 是一个好的起点。

去重选项

ClickHouse 中使用以下表引擎实现去重

  1. ReplacingMergeTree 表引擎:使用此表引擎,在合并期间会删除具有相同排序键的重复行。 ReplacingMergeTree 是模拟 upsert 行为(您希望查询返回最后插入的行)的一个不错的选择。

  2. 折叠行:CollapsingMergeTreeVersionedCollapsingMergeTree 表引擎使用一种逻辑,即“取消”现有行并插入新行。与 ReplacingMergeTree 相比,它们实现起来更复杂,但您的查询和聚合可以更简单地编写,而无需担心数据是否已合并。当您需要频繁更新数据时,这两个表引擎很有用。

我们将在下面介绍这两种技术。有关更多详细信息,请查看我们的免费按需 删除和更新数据培训模块

使用 ReplacingMergeTree 进行 Upsert

让我们看一个简单的例子,其中一个表包含 Hacker News 评论,其中 views 列表示评论被查看的次数。假设文章发布时插入一行,并在每天更新一次总浏览量(如果值增加)

CREATE TABLE hackernews_rmt (
    id UInt32,
    author String,
    comment String,
    views UInt64
)
ENGINE = ReplacingMergeTree
PRIMARY KEY (author, id)

让我们插入两行

INSERT INTO hackernews_rmt VALUES
   (1, 'ricardo', 'This is post #1', 0),
   (2, 'ch_fan', 'This is post #2', 0)

要更新 views 列,插入具有相同主键的新行(请注意 views 列的新值)

INSERT INTO hackernews_rmt VALUES
   (1, 'ricardo', 'This is post #1', 100),
   (2, 'ch_fan', 'This is post #2', 200)

表现在有 4 行

SELECT *
FROM hackernews_rmt
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │     0 │
│  1 │ ricardo │ This is post #1 │     0 │
└────┴─────────┴─────────────────┴───────┘
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │   200 │
│  1 │ ricardo │ This is post #1 │   100 │
└────┴─────────┴─────────────────┴───────┘

输出中上面的单独框演示了幕后的两个 parts——此数据尚未合并,因此重复行尚未删除。让我们在 SELECT 查询中使用 FINAL 关键字,这将导致查询结果的逻辑合并

SELECT *
FROM hackernews_rmt
FINAL
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │   200 │
│  1 │ ricardo │ This is post #1 │   100 │
└────┴─────────┴─────────────────┴───────┘

结果只有 2 行,并且插入的最后一行是返回的行。

注意

如果您的数据量很小,使用 FINAL 还可以。如果您处理大量数据,使用 FINAL 可能不是最佳选择。让我们讨论一个更好的选项来查找列的最新值。

避免 FINAL

让我们再次更新 views 列,用于两个唯一的行

INSERT INTO hackernews_rmt VALUES
   (1, 'ricardo', 'This is post #1', 150),
   (2, 'ch_fan', 'This is post #2', 250)

表现在有 6 行,因为尚未发生实际合并(仅在使用了 FINAL 时的查询时间合并)

SELECT *
FROM hackernews_rmt
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │   200 │
│  1 │ ricardo │ This is post #1 │   100 │
└────┴─────────┴─────────────────┴───────┘
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │     0 │
│  1 │ ricardo │ This is post #1 │     0 │
└────┴─────────┴─────────────────┴───────┘
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │   250 │
│  1 │ ricardo │ This is post #1 │   150 │
└────┴─────────┴─────────────────┴───────┘

与其使用 FINAL,不如使用一些业务逻辑——我们知道 views 列始终在增加,因此我们可以使用 max 函数在所需列上分组后选择具有最大值的行

SELECT
    id,
    author,
    comment,
    max(views)
FROM hackernews_rmt
GROUP BY (id, author, comment)
┌─id─┬─author──┬─comment─────────┬─max(views)─┐
│  2 │ ch_fan  │ This is post #2 │        250 │
│  1 │ ricardo │ This is post #1 │        150 │
└────┴─────────┴─────────────────┴────────────┘

如上所示的分组实际上可能更有效(就查询性能而言),而不是使用 FINAL 关键字。

我们的 删除和更新数据培训模块 扩展了此示例,包括如何使用 version 列与 ReplacingMergeTree

使用 CollapsingMergeTree 频繁更新列

更新列涉及删除现有行并用新值替换它。如您所见,ClickHouse 中这种类型的更改是最终一致的——在合并期间发生。如果您有很多行要更新,实际上避免 ALTER TABLE..UPDATE 而只是将新数据与现有数据一起插入可能更有效。我们可以添加一列,指示数据是陈旧还是新的……实际上,有一个表引擎已经很好地实现了这种行为,特别是考虑到它可以自动删除陈旧数据。

假设我们使用外部系统跟踪 Hacker News 评论的浏览次数,并且每隔几个小时,我们将数据推送到 ClickHouse。我们希望删除旧行,并且新行代表每个 Hacker News 评论的新状态。我们可以使用 CollapsingMergeTree 来实现此行为。

让我们定义一个表来存储浏览次数

CREATE TABLE hackernews_views (
    id UInt32,
    author String,
    views UInt64,
    sign Int8
)
ENGINE = CollapsingMergeTree(sign)
PRIMARY KEY (id, author)

请注意,hackernews_views 表有一个名为 sign 的 Int8 列,称为 sign 列。sign 列的名称是任意的,但 Int8 数据类型是必需的,并且请注意将列名传递给 CollapsingMergeTree 表的构造函数。

CollapsingMergeTree 表的 sign 列是什么?它代表行的状态,sign 列只能是 1 或 -1。以下是如何工作的

  • 如果两行具有相同的主键(或者如果主键与排序键不同,则具有相同的排序顺序),但 sign 列的值不同,则最后插入的带有 +1 的行将成为状态行,而其他行将相互取消
  • 相互取消的行在合并期间会被删除
  • 没有匹配对的行将被保留

让我们将一行添加到 hackernews_views 表中。由于它是此主键的唯一行,我们将其状态设置为 1

INSERT INTO hackernews_views VALUES
   (123, 'ricardo', 0, 1)

现在假设我们想更改 views 列。您插入两行:一行取消现有行,另一行包含行的新状态

INSERT INTO hackernews_views VALUES
   (123, 'ricardo', 0, -1),
   (123, 'ricardo', 150, 1)

表现在有 3 行,主键为 (123, 'ricardo')

SELECT *
FROM hackernews_views
┌──id─┬─author──┬─views─┬─sign─┐
│ 123 │ ricardo │     0 │   -1 │
│ 123 │ ricardo │   150 │    1 │
└─────┴─────────┴───────┴──────┘
┌──id─┬─author──┬─views─┬─sign─┐
│ 123 │ ricardo │     0 │    1 │
└─────┴─────────┴───────┴──────┘

请注意,添加 FINAL 会返回当前状态行

SELECT *
FROM hackernews_views
FINAL
┌──id─┬─author──┬─views─┬─sign─┐
│ 123 │ ricardo │   150 │    1 │
└─────┴─────────┴───────┴──────┘

但是,当然,不建议对大型表使用 FINAL

注意

在我们的示例中传递给 views 列的值实际上并不需要,也不必与旧行的 views 的当前值匹配。事实上,您可以用主键和 -1 来取消一行

INSERT INTO hackernews_views(id, author, sign) VALUES
   (123, 'ricardo', -1)

来自多个线程的实时更新

使用 CollapsingMergeTree 表,行使用 sign 列相互取消,行的状态由最后插入的行确定。但是,如果您从不同的线程插入行,而行的插入顺序被打乱,这可能会有问题。在这种情况下,使用“最后”一行不起作用。

这就是 VersionedCollapsingMergeTree 有用的地方——它像 CollapsingMergeTree 一样折叠行,但不是保留最后插入的行,而是保留具有您指定的版本列的最高值的行。

让我们看一个例子。假设我们想跟踪 Hacker News 评论的浏览次数,并且数据经常更新。我们希望报告使用最新的值,而无需强制或等待合并。我们从一个类似于 CollapsedMergeTree 的表开始,除了我们添加一列来存储行状态的版本

CREATE TABLE hackernews_views_vcmt (
    id UInt32,
    author String,
    views UInt64,
    sign Int8,
    version UInt32
)
ENGINE = VersionedCollapsingMergeTree(sign, version)
PRIMARY KEY (id, author)

请注意,该表使用 VersionsedCollapsingMergeTree 作为引擎,并传递了 sign 列version 列。以下是表的工作方式

  • 它删除具有相同主键和版本以及不同 sign 的每一对行
  • 插入行的顺序无关紧要
  • 请注意,如果版本列不是主键的一部分,ClickHouse 会将其隐式地作为最后一个字段添加到主键中

您在编写查询时使用相同的逻辑——按主键分组,并使用巧妙的逻辑来避免已被取消但尚未删除的行。让我们向 hackernews_views_vcmt 表添加一些行

INSERT INTO hackernews_views_vcmt VALUES
   (1, 'ricardo', 0, 1, 1),
   (2, 'ch_fan', 0, 1, 1),
   (3, 'kenny', 0, 1, 1)

现在我们更新两行并删除其中一行。要取消一行,请确保包含先前的版本号(因为它也是主键的一部分)

INSERT INTO hackernews_views_vcmt VALUES
   (1, 'ricardo', 0, -1, 1),
   (1, 'ricardo', 50, 1, 2),
   (2, 'ch_fan', 0, -1, 1),
   (3, 'kenny', 0, -1, 1),
   (3, 'kenny', 1000, 1, 2)

我们将运行与之前相同的查询,该查询巧妙地根据 sign 列加减值

SELECT
    id,
    author,
    sum(views * sign)
FROM hackernews_views_vcmt
GROUP BY (id, author)
HAVING sum(sign) > 0
ORDER BY id ASC

结果是两行

┌─id─┬─author──┬─sum(multiply(views, sign))─┐
│  1 │ ricardo │                         50 │
│  3 │ kenny   │                       1000 │
└────┴─────────┴────────────────────────────┘

让我们强制进行表合并

OPTIMIZE TABLE hackernews_views_vcmt

结果中应该只有两行

SELECT *
FROM hackernews_views_vcmt
┌─id─┬─author──┬─views─┬─sign─┬─version─┐
│  1 │ ricardo │    50 │    1 │       2 │
│  3 │ kenny   │  1000 │    1 │       2 │
└────┴─────────┴───────┴──────┴─────────┘

当您想在从多个客户端和/或线程插入行时实现去重时,VersionedCollapsingMergeTree 表非常有用。

为什么我的行没有被去重?

插入的行未被去重的一个原因是,您在 INSERT 语句中使用了一个非幂等函数或表达式。例如,如果您插入具有列 createdAt DateTime64(3) DEFAULT now() 的行,您的行保证是唯一的,因为每行都会为 createdAt 列生成一个唯一的默认值。MergeTree / ReplicatedMergeTree 表引擎不会知道要对行进行去重,因为每个插入的行都会生成唯一的校验和。

在这种情况下,您可以为每一批行指定自己的 insert_deduplication_token,以确保同一批行的多次插入不会导致同一行被重新插入。请参阅 关于 insert_deduplication_token 的文档,了解有关如何使用此设置的更多详细信息。

    © . This site is unofficial and not affiliated with ClickHouse, Inc.