DoubleCloud 即将关闭。使用限时免费迁移服务迁移到 ClickHouse。立即联系我们 ->->

博客 / 工程

在 ClickHouse 中处理更新和删除

author avatar
Denys Golotiuk
2023 年 3 月 10 日

ClickHouse Delete Statement.png

作为全球最快的实时分析数据库,许多 ClickHouse 工作负载涉及大量数据,这些数据只写入一次且不常修改(例如,物联网设备生成的遥测事件或电子商务网站生成的客户点击)。虽然这些数据通常是不可变的,但在分析期间提供上下文的其他关键数据集(例如,根据设备或客户 ID 的信息进行的查找表)可能需要修改。

从历史上看,在 ClickHouse 中更新和删除数据有多种方法,具体取决于您的目标和性能要求。本文的其余部分将描述每种方法及其权衡,以及最近解决一些常见挑战的轻量级删除的进展。我们建议最佳实践,并突出显示考虑方法时的一些重要事项。

在继续之前,请确定更新是否为解决问题的最佳方法。例如,对于不常更改的数据,对数据进行版本控制可能是一个更好的选择。ClickHouse 是存储效率和查询性能方面排名第一的分析数据库,因此在许多情况下,仅保存数据的多个版本而不是更新可能效果更好。

轻量级删除

轻量级删除 代表从 ClickHouse 中删除数据的首选且最有效的方法。通过 DELETE FROM 表语法,用户可以指定条件以删除特定行,如下所示

DELETE FROM table WHERE col1 = 'Hi' AND col2 = 2

此操作默认情况下是异步的,除非设置 mutations_sync 为 1(见下文)。执行删除操作时,ClickHouse 会为每行保存一个掩码,指示它是否已在 _row_exists 列中删除。后续查询将反过来排除那些已删除的行,如下所示。

lightweight_deletes_v2.png

在内部,ClickHouse 将数据排序到部分,每个部分包含列数据文件和索引。常规合并循环负责组合(合并)和重写这些部分。这确保了随着更多数据的插入,文件数量不会继续增长,从而保持查询速度。这些合并会考虑轻量级删除,在新建的部分中排除那些标记为删除的行。

lightweight-delete-merge(1).png

轻量级删除已在 22.8 版本中发布,截至撰写本文时,它仍处于实验阶段,预计将在下一个版本中正式发布。在此之前,使用轻量级删除需要设置 allow_experimental_lightweight_delete=true

用户应注意,通过依赖正常的后台合并循环,行将仅最终从磁盘中删除。虽然已从搜索结果中排除,但这些行将驻留在磁盘上,直到其部分被合并。发生这种情况所花费的时间不是确定性的。这有一些影响

  • 节省的空间不会像通过变异发出删除那样立即可见——见下文。如果节省空间至关重要,例如磁盘空间不足,请考虑使用变异。
  • 由于无法保证删除,具有合规性要求的用户可能希望使用变异来确保数据被删除。

轻量级删除操作的成本取决于 WHERE 子句中匹配行的数量和当前的数据部分数量。当匹配少量行时,此操作效率最高。用户还应注意,轻量级删除在宽部分上表现最佳,在宽部分中,列数据文件分别存储,而紧凑部分则使用单个文件存储所有列数据。前者允许将掩码 _row_exists 存储为单独的文件,从而允许它独立于其他列进行写入。通常,紧凑部分将在插入后形成。一旦部分超过 一定大小(例如,由于合并),就会使用宽格式。对于大多数工作负载,这应该不是问题。

最后,请注意,轻量级删除使用与我们将在下面描述的相同的变异队列和后台线程。我们建议参考 此处 的文档以了解有关内部实现的更多详细信息。

变异

使用变异更新数据

在 ClickHouse 表中更新数据的最简单方法是使用 ALTER…UPDATE 语句。

ALTER TABLE table
    UPDATE col1 = 'Hi' WHERE col2 = 2

此查询将使用给定的过滤器更新 table 表上的 col1

与某些数据库不同,ClickHouse 的 ALTER UPDATE 语句默认情况下是异步的。这意味着更新将在后台进行,您将不会对表产生即时影响。此更新表的处理称为 变异

mutation_01.png

这里需要注意的是,更新数据是一个繁重的查询,因为 ClickHouse 需要执行大量工作来优化存储和处理。变异操作会强制删除包含那些行的所有数据部分以重新写入,并在形成新部分时将目标行排除在外。这会导致相当大的 I/O 和集群开销,因此请谨慎使用或考虑下面讨论的替代方案。

使用变异删除数据

与更新一样,删除也可以使用变异进行,并为轻量级删除提供替代方案。在大多数情况下,由于重写所有列的变异成本,轻量级删除更适合数据删除。更具体地说,与轻量级删除不同,所有列都被重写,而不是仅重写 _row_exists 掩码列。

但是,鉴于轻量级删除的“最终从磁盘删除数据”属性,用户可能更喜欢这种基于变异的方法来实现保证的磁盘空间节省。此外,当用户需要保证数据从磁盘中删除时(例如,由于合规性原因),这种方法可能是合适的。

ALTER TABLE table
    DELETE WHERE col2 = 3

在此查询中,将删除所有 col2 值为 3 的行。与其他变异一样,删除默认情况下也是异步的。可以使用上面描述的相同 mutations_sync 设置使其同步。

检查变异进度

由于变异是异步运行的,因此可以通过 system.mutations 表进行监控。这使用户能够检查表上特定变异的进度。

SELECT
    command,
    is_done
FROM system.mutations
WHERE table = 'tablename'

┌─command───────────────────────────────┬─is_done─┐
│ UPDATE col1 = 'Hi' WHERE col2 = 21 │
│ UPDATE col1 = 'All hi' WHERE col2 > 00 │
└───────────────────────────────────────┴─────────┘

如果特定变异的 is_done 值为 0,则它仍在执行中。变异针对每个表部分执行,其中变异部分会立即变得可用

mutation_progress.png

同步更新

对于需要同步更新的用户,可以将 mutations_sync 参数设置为 1(如果我们还希望等待所有副本更新,则为 2)。

SET mutations_sync = 1

现在我们的更新查询将等待变异完成

ALTER TABLE table
    UPDATE col1 = 'bye' WHERE col2 > 0

0 rows in set. Elapsed: 1.182 sec. 

注意此查询完成耗时 1 秒,而 ClickHouse 在后台等待变异完成。请注意,此参数也适用于轻量级删除。

更新整个表

在某些情况下,用户需要更新整列的值。最初,用户可能会尝试使用不带 `WHERE` 子句的 `ALTER TABLE` 查询来实现此目的。但是,这将失败,如下所示

ALTER TABLE table UPDATE col1 = 'bye';

Syntax error: failed at position 38 (end of query):
ALTER TABLE table UPDATE col1 = 'bye';

ClickHouse 不会让你更新整个表,因为更新很重。一种强制 ClickHouse 接受此操作的方法是使用始终为真的过滤器

ALTER TABLE table
    UPDATE col1 = 'bye' WHERE true

但是,更优化的方式是创建一个新的列,并将新值作为默认值,然后交换旧列和新列。例如

ALTER TABLE table ADD COLUMN col1_new String DEFAULT 'global hi';

ALTER TABLE table
    RENAME COLUMN col1 TO col1_old,
    RENAME COLUMN col1_new TO col1,
    DROP COLUMN col1_old;

我们使用 `col1_new` 列的默认值来指定我们想用作更新值的內容。这很安全,而且效率更高,因为我们跳过了这里繁重的变异操作。

使用 JOIN 进行更新和删除

有时,我们需要根据关系删除或更新行;因此,我们必须联接表。这在 ClickHouse 中使用 Join 表引擎joinGet 函数来实现。假设我们有两个表 - 一个包含所有页面浏览量,另一个包含所有登录跟踪

CREATE TABLE pageviews
(
    `user_id` UInt64,
    `time` DateTime,
    `session_id` UInt64
)
ENGINE = MergeTree
ORDER BY time;

CREATE TABLE logins
(
    `user_id` UInt64,
    `time` DateTime
)
ENGINE = MergeTree
ORDER BY time;

这两个表之间的区别在于 `logins` 表只存储每个会话的一个事件。假设在某个时间点,我们决定在 `logins` 表中添加 `session_id` 列

ALTER TABLE logins
    ADD COLUMN `session_id` UInt64

现在,我们需要使用 `user_id` 和 `time` 进行 JOIN,用 `pageviews` 表中的相应值更新 `logins.session_id` 列

SELECT *
FROM logins AS l
JOIN pageviews AS p ON (p.user_id = l.user_id) AND (p.time = l.time)

┌─user_id─┬────────────────time─┬─p.user_id─┬──────────────p.time─┬─session_id─┐
│       22023-01-09 12:23:1622023-01-09 12:23:162752888102 │
│       12023-01-09 13:23:1612023-01-09 13:23:164135462640 │
└─────────┴─────────────────────┴───────────┴─────────────────────┴────────────┘

首先,我们需要创建并填充一个特殊的 `Join`

CREATE TABLE pageviews_join
ENGINE = Join(ANY, LEFT, user_id, time) AS
SELECT *
FROM pageviews

此表将允许我们在执行更新查询时使用 `joinGet` 函数根据 JOIN 获取值

ALTER TABLE logins
    UPDATE session_id = joinGet('pageviews_join', 'session_id', user_id, time) WHERE session_id = 0

我们可以看到,`logins` 表已根据 JOIN 进行了相应更新

SELECT * FROM logins

┌─user_id─┬────────────────time─┬─session_id─┐
│       22023-01-09 12:23:162752888102 │
│       12023-01-09 13:23:164135462640 │
└─────────┴─────────────────────┴────────────┘

因为我们通过添加 `session_id` 列修改了 `logins` 表,所以我们可以在更改完成后删除 `pageviews_join` 表(在删除之前,请确保检查 `system.mutations` 表)

DROP TABLE pageviews_join

相同的方法可用于使用轻量级或变异删除来删除数据。

高效地删除大型块

如果我们必须删除大量数据,用户可以对表进行分区,以便可以根据需要删除分区。这是一个轻量级操作。假设我们有以下表

CREATE TABLE hits
(
    `project` String,
    `url` String,
    `time` DateTime,
    `hits` UInt32
)
ENGINE = MergeTree
PARTITION BY project
ORDER BY (project, path, time)

通过 `project` 列对该表进行分区,我们可以通过删除整个分区来删除具有特定 `project` 值的行。让我们删除所有 `project` = `c` 的內容

ALTER TABLE hits
    DROP PARTITION 'c'

这里,`c` 是我们要删除的 `project` 列值

partition_delete.png

可以在 `system.parts` 表中找到可用分区的列表

SELECT partition
FROM system.parts
WHERE table = 'hits'

┌─partition─┐
│ c         │
│ a         │
│ b         │
└───────────┘

我们还可以使用 DETACH 和 ATTACH 语句在表之间移动分区(例如,如果我们想将数据移动到 `trash` 表而不是删除它)。

在 DDL 中设置分区时,请注意通过具有高基数的列或表达式进行分区的常见陷阱。这会导致创建许多部分,从而导致性能问题。

定期删除旧数据

时间序列数据 的情况下,我们可能希望定期删除过时数据。ClickHouse 具有 TTL 功能,专门用于此用例。这需要配置一个表并指定我们想要删除哪些数据以及何时删除。假设我们希望从 `hits` 表中删除一个月以上的数据

ALTER TABLE hits
    MODIFY TTL time + INTERVAL 1 MONTH

这里,我们要求 ClickHouse 删除所有 `time` 列值比当前时间早一个月以上的所有行。TTL 也可以 设置在列上,以便在一段时间后将其值重置为默认值。通过按日期(四舍五入到适当的时间单位,例如天)进行分区,可以提高此操作的效率。ClickHouse 将在执行 TTL 规则时以最有效的方式自动删除数据。同样,表 **不应** 按具有高基数的时间列(例如毫秒粒度)进行分区,以避免出现高分区数。通常,按天或月进行分区足以满足大多数 TTL 操作。

使用 CollapsingMergeTree 进行删除和更新

如果我们必须频繁更新单个行,我们可以使用 CollapsingMergeTree 引擎来有效地管理数据更新。

假设我们有一个包含文章统计信息表,用于跟踪每篇文章的阅读深度。我们希望有一行显示每个用户阅读每篇文章的深度。这里的挑战是我们必须在用户阅读文章时更新实际的阅读进度。让我们为我们的数据创建一个表

CREATE TABLE article_reads
(
    `user_id` UInt32,
    `article_id` UInt32,
    `read_to` UInt8,
    `read_start` DateTime,
    `read_end` DateTime,
    `sign` Int8
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY (read_start, article_id, user_id)

特殊的 `sign` 列用于 CollapsingMergeTree,作为一种方式告诉 ClickHouse 我们希望更新特定行。如果我们为 `sign` 列插入 `-1`,则整行将被删除。如果我们插入一行,`sign = 1`,ClickHouse 将保留该行。要更新的行是根据创建表时 `ORDER BY ()` DDL 语句中使用的排序键识别的

collapsing_merge_tree.png

为了满足排序键上的重复数据删除条件,我们必须为 `read_start, article_id, user_id` 列插入相同的值以更新行。例如,当用户开始阅读文章时,我们会插入以下行

INSERT INTO article_reads
            VALUES(1, 12, 0, now(), now(), 1);

现在,表中只有一行

SELECT *
FROM article_reads

┌─user_id─┬─article_id─┬─read_to─┬──────────read_start─┬────────────read_end─┬─sign─┐
│       11202023-01-06 15:20:322023-01-06 15:20:321 │
└─────────┴────────────┴─────────┴─────────────────────┴─────────────────────┴──────┘

一分钟后,当用户到达我们文章的 70% 时,我们会插入以下两行

INSERT INTO article_reads
            VALUES(1, 12, 0, '2023-01-06 15:20:32', now(), -1),
                  (1, 12, 70, '2023-01-06 15:20:32', now(), 1);

第一行 `sign=-1` 用于告诉 ClickHouse 它应该删除现有行(基于 `ORDER BY` 元组中的值 - `read_start`、`article_id` 和 `user_id` 列)。第二行(`sign=1`)是新的行,其中 `read_to` 列设置为新的值 `70`。

由于数据更新 在后台进行,并且结果最终一致,因此我们应该筛选 `sign` 列以获得正确的结果

SELECT
    article_id,
    user_id,
    max(read_end),
    max(read_to)
FROM article_reads
WHERE sign = 1
GROUP BY
    user_id,
    article_id

┌─article_id─┬─user_id─┬───────max(read_end)─┬─max(read_to)─┐
│         1212023-01-06 15:21:5970 │
└────────────┴─────────┴─────────────────────┴──────────────┘

1 row in set. Elapsed: 0.004 sec.

现在,`CollapsingMergreTree` 引擎将负责在后台有效地从存储中删除已取消的行,因此我们不必手动删除它们。您可以找到使用 CollapsingMergeTree 引擎的更多示例 此处.

使用版本控制和 ReplacingMergeTree 进行 Upsert

对于更复杂的情况,我们可能希望使用基于 ReplacingMergeTree 引擎的版本控制。该引擎实现了一种高效的方式来执行其他 DBMS 中的 `UPSERT`,它使用特殊的版本列来跟踪应删除哪些行。如果存在具有相同排序键的多行,则只有版本最大的那一行保留在存储中,而其他行则被删除

replacing_merge_tree.png

对于我们之前有关文章阅读的示例,我们可以使用以下结构

CREATE TABLE article_reads
(
    `user_id` UInt32,
    `article_id` UInt32,
    `read_to` UInt8,
    `read_time` DateTime,
    `version` Int32
)
ENGINE = ReplacingMergeTree(version)
ORDER BY (article_id, user_id)

请注意特殊的 `version` 数字列,它将由 ReplacingMergeTree 引擎用来标记要删除的行。让我们模拟用户从 0 到 80% 阅读文章的时间

INSERT INTO article_reads
           VALUES(1, 12, 0, '2023-01-06 15:20:32', 1),
                 (1, 12, 30, '2023-01-06 15:21:42', 2),
                 (1, 12, 45, '2023-01-06 15:22:13', 3),
                 (1, 12, 80, '2023-01-06 15:23:10', 4);

这里,我们随着阅读进度的跟踪而增加了 `version` 列的值。行删除过程也在后台通过正常的合并周期执行,因此我们需要根据查询时的最新版本进行筛选

SELECT *
FROM article_reads
WHERE (user_id = 1) AND (article_id = 12)
ORDER BY version DESC
LIMIT 1

┌─user_id─┬─article_id─┬─read_to─┬───────────read_time─┬─version─┐
│       112802023-01-06 15:23:105 │
└─────────┴────────────┴─────────┴─────────────────────┴─────────┘

或者,我们可以使用 LIMIT 1 BY 获取具有最新版本的行列表

SELECT
    user_id,
    article_id,
    read_to
FROM article_reads
ORDER BY version DESC
LIMIT 1 BY
    user_id,
    article_id

┌─user_id─┬─article_id─┬─read_to─┐
│       11280 │
└─────────┴────────────┴─────────┘

同样,我们不必关心旧版本的删除 - 这由 ClickHouse 在后台自动完成。

总结

在分析环境中更新和删除数据可能具有挑战性,并会严重影响数据库性能。为了解决这个问题,ClickHouse 提供了多种强大的方法来有效地更新和删除不同情况下的数据

  • 通过 `DELETE FROM` 语法进行轻量级删除,用于从 ClickHouse 中删除数据。这是删除数据的最有效方法,前提是无需立即节省磁盘空间,并且用户可以容忍已删除的数据“存在”于磁盘上。
  • 在需要立即节省磁盘空间的情况下,通过 `ALTER…DELETE` 进行基于变异的删除。例如,合规性要求需要围绕从磁盘删除数据的保证。
  • 在不规则和不频繁更改的情况下,使用 `ALTER…UPDATE` 进行基于变异的更新
  • 使用 TTL 定期删除(过时)数据,具体取决于日期/时间。
  • 使用 CollapsingMergeTree 频繁更新或删除单个行。
  • 使用 ReplacingMergeTree 实现基于版本控制的 Upsert(插入/更新)。
  • 定期删除大量数据时,删除分区
  • 创建新列(并删除旧列)也可能是更新整个表的更有效方法。
分享这篇文章

订阅我们的新闻稿

及时了解功能发布、产品路线图、支持和云产品信息!
正在加载表单...
关注我们
Twitter imageSlack imageGitHub image
Telegram imageMeetup imageRss image