在 ClickHouse 中处理更新和删除以及 Upserts

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

根据你的目标和性能要求,ClickHouse 有多种更新和删除数据的方法。本文的其余部分将描述每种方法及其优缺点,以及解决一些常见挑战的轻量级删除的一些最新进展。我们推荐最佳实践,并强调在考虑一种方法时需要考虑的一些重要事项。

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

轻量级删除

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

sql 复制代码
DELETE FROM table WHERE col1 = 'Hi' AND col2 = 2

默认情况下,该操作是异步的,除非 mutations_sync 设置为 1(见下文)。执行删除时,ClickHouse 会为每一行保存一个掩码,在 _row_exists 列中表示是否被删除。随后的查询依次排除这些已删除的行,如下所示。

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

它于 22.8 发布,在撰写本文时仍处于试验阶段,轻量级删除将在下一个版本中成为生产就绪。在此之前,使用轻量级删除需要设置allow_experimental_lightweight_delete=true

用户应该意识到,依靠正常的后台合并周期,行最终只会从磁盘中删除。虽然从搜索结果中排除,但这些行将驻留在磁盘上,直到它们的部分被合并。这种情况发生所需的时间是不确定的。这有几个含义:

  • 节省的空间不会像通过突变发出删除那样直接------见下文。如果节省空间非常重要,例如磁盘空间不足,请考虑使用突变。
  • 由于不能保证删除,有遵从性需求的用户可能希望使用突变来确保删除数据。

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

最后,请注意,轻量级删除使用与我们下面描述的相同的突变队列和后台线程。关于内部实现的更多细节,我们推荐使用这里的文档。

突变

使用突变更新数据

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

sql 复制代码
ALTER TABLE table
    UPDATE col1 = 'Hi' WHERE col2 = 2

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

与一些数据库不同,ClickHouse 的 ALTER UPDATE 语句默认情况下是异步的。这意味着更新发生在后台,您不会立即对表产生影响。这个更新表的过程称为突变

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

使用突变删除数据

与更新一样,删除也可以通过突变进行,并提供轻量级删除的另一种选择。在大多数情况下,由于重写所有列的突变成本,轻量级删除更适合于数据删除。更具体地说,与轻量级删除不同,所有列都被重写,而不仅仅是一个 _row_exists 掩码列。

然而,考虑到轻量级删除的 "最终从磁盘删除数据" 属性,用户可能更喜欢这种基于突变的方法来实现有保证的磁盘空间节省。此外,当用户需要保证从磁盘中删除数据时,例如由于遵从性原因,这种方法是合适的。

sql 复制代码
ALTER TABLE table
    DELETE WHERE col2 = 3

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

检查突变进展

由于突变是异步运行的,因此可以通过 system.mutations 表进行监测。这允许用户需要检查他们在表上的特定突变的进度。

sql 复制代码
SELECT
    command,
    is_done
FROM system.mutations
WHERE table = 'tablename'

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

如果 is_done 的值对于特定的突变是 0,那么它仍然在执行。对每个表部分执行突变,突变后的部分立即可用:

同步更新

对于需要同步更新的用户,mutations_sync 参数可以设置为 1(或者 2,如果我们也想等到所有副本也被更新):

sql 复制代码
SET mutations_sync = 1

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

sql 复制代码
ALTER TABLE table
    UPDATE col1 = 'bye' WHERE col2 > 0

0 rows in set. Elapsed: 1.182 sec. 

注意,当 ClickHouse 等待后台突变完成时,这个查询花了 1 秒钟的时间。注意,此参数也适用于轻量级删除。

更新整个表

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

sql 复制代码
ALTER TABLE table UPDATE col1 = 'bye';

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

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

sql 复制代码
ALTER TABLE table
    UPDATE col1 = 'bye' WHERE true

然而,更优的方法是创建一个新列,将新值作为默认值,然后在新旧列之间切换。例如:

sql 复制代码
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 进行更新和删除

有时,我们需要根据关系删除或更新行; 因此,我们必须 join 表。在 ClickHouse 中,使用 Join 表引擎和 joinGet 函数可以最好地实现这一点。假设我们有两个表-一个与所有的页面浏览量和另一个其他所有的登录跟踪:

sql 复制代码
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 表每个会话只存储一个事件。假设在某个时间点,我们决定将 session_id 列添加到 logins 表中:

sql 复制代码
ALTER TABLE logins
    ADD COLUMN `session_id` UInt64

我们现在需要使用 user_idtime 上的 JOIN,用 pageviews 表中的相应值更新 logins.session_id 列:

sql 复制代码
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─┐
│       2 │ 2023-01-09 12:23:16 │         2 │ 2023-01-09 12:23:16 │ 2752888102 │
│       1 │ 2023-01-09 13:23:16 │         1 │ 2023-01-09 13:23:16 │ 4135462640 │
└─────────┴─────────────────────┴───────────┴─────────────────────┴────────────┘

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

sql 复制代码
CREATE TABLE pageviews_join
ENGINE = Join(ANY, LEFT, user_id, time) AS
SELECT *
FROM pageviews

该表将允许我们在执行更新查询时使用 joinGet 函数来基于 JOIN 获取值:

sql 复制代码
ALTER TABLE logins
    UPDATE session_id = joinGet('pageviews_join', 'session_id', user_id, time) WHERE session_id = 0

我们可以看到,logins 表被相应的更新了 JOIN:

sql 复制代码
SELECT * FROM logins

┌─user_id─┬────────────────time─┬─session_id─┐
│       2 │ 2023-01-09 12:23:16 │ 2752888102 │
│       1 │ 2023-01-09 13:23:16 │ 4135462640 │
└─────────┴─────────────────────┴────────────┘

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

sql 复制代码
DROP TABLE pageviews_join

同样的方法也可以用于通过轻量级或基于突变的删除来删除数据。

高效删除大块数据块

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

sql 复制代码
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 的内容:

sql 复制代码
ALTER TABLE hits
    DROP PARTITION 'c'

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

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

sql 复制代码
SELECT partition
FROM system.parts
WHERE table = 'hits'

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

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

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

定期删除旧数据

对于时间序列数据,我们可能希望定期删除过时的数据。ClickHouse 对于这个确切的用例具有 TTL 特性。这需要配置一个表,并指定我们希望删除哪些数据以及何时删除。假设我们想从 hits 表中删除超过一个月的数据:

sh 复制代码
ALTER TABLE hits
    MODIFY TTL time + INTERVAL 1 MONTH

在这里,我们要求 ClickHouse 删除当前时间的时间列值超过一个月的所有行。还可以在列上设置 TTL,以便在一段时间后将其值重置为默认值。通过按日期进行分区,四舍五入到适当的时间单位,例如,days,可以使该操作更有效。在执行 TTL 规则时,ClickHouse 将以最有效的方式自动删除数据。同样,表不应该按照高基数(例如毫秒粒度)的时间列进行分区,以避免高部分计数。通常按天或月划分对于大多数 TTL 操作来说就足够了。

使用 CollapsingMergeTree 删除和更新

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

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

sql 复制代码
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 语句中使用的排序键来标识的:

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

sql 复制代码
INSERT INTO article_reads
            VALUES(1, 12, 0, now(), now(), 1);

现在我们在表中有了一行:

sql 复制代码
SELECT *
FROM article_reads

┌─user_id─┬─article_id─┬─read_to─┬──────────read_start─┬────────────read_end─┬─sign─┐
│       1 │         12 │       0 │ 2023-01-06 15:20:32 │ 2023-01-06 15:20:32 │    1 │
└─────────┴────────────┴─────────┴─────────────────────┴─────────────────────┴──────┘

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

sql 复制代码
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_iduser_id 列)。第二个插入的行( sign=1 )是 read_to 列设置为新值 70 的新行。

由于数据更新是在后台进行的,因此最终的结果是一致的,我们应该对 sign 列进行过滤以获得正确的结果:

sql 复制代码
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)─┐
│         12 │       1 │ 2023-01-06 15:21:59 │           70 │
└────────────┴─────────┴─────────────────────┴──────────────┘

1 row in set. Elapsed: 0.004 sec.

现在,CollapsingMergreTree 引擎会在后台有效地从存储中删除被取消的行,所以我们不必手动删除它们。你可以在这里找到更多使用 CollapsingMergeTree 引擎的例子。

使用版本控制和 ReplacingMergeTree 的 Upserts

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

对于前面的阅读文章的例子,我们可以使用下面的结构:

sql 复制代码
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% 的时间内阅读一篇文章:

sql 复制代码
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 列的值。删除行的过程也是通过正常的合并周期在后台执行的,所以我们需要根据查询时的最新版本进行过滤:

sql 复制代码
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─┐
│       1 │         12 │      80 │ 2023-01-06 15:23:10 │       5 │
└─────────┴────────────┴─────────┴─────────────────────┴─────────┘

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

sql 复制代码
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─┐
│       1 │         12 │      80 │
└─────────┴────────────┴─────────┘

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

总结

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

  • 轻量删除通过 DELETE FROM 语法删除数据从 ClickHouse。这是最有效的删除数据的方法,前提是不需要立即节省磁盘空间,并且用户可以容忍删除的数据"存在"在磁盘上。
  • 在需要立即节省磁盘空间的情况下,通过 ALTER...DELETE 进行基于突变的删除。例如,遵从性需求需要保证从磁盘中删除数据。
  • 在不规律和不频繁的变化情况下,使用 ALTER...UPDATE 进行基于突变的更新
  • 使用 TTLs 定期删除基于日期/时间的(过时的)数据;
  • 使用 CollapsingMergeTree 频繁地更新或删除单个行。
  • 使用 ReplacingMergeTree 实现基于版本控制的 upsert(插入/更新)。
  • 定期删除大数据块时删除分区
  • 创建新列(并删除旧列)可能也是更新整个表的更有效的方法。

Refs

相关推荐
莫叫石榴姐7 分钟前
SQL百题斩:从入门到精通,一站式解锁数据世界
大数据·数据仓库·sql·面试·职场和发展
Hello.Reader25 分钟前
Flink 状态后端(State Backends)实战原理、选型、配置与调优
大数据·flink
shan~~41 分钟前
linux达梦数据库操作
linux·数据库·chrome
间彧1 小时前
Spring Cloud Gateway与Kong或Nginx等API网关相比有哪些优劣势?
后端
间彧1 小时前
如何基于Spring Cloud Gateway实现灰度发布的具体配置示例?
后端
间彧1 小时前
在实际项目中如何设计一个高可用的Spring Cloud Gateway集群?
后端
间彧1 小时前
如何为Spring Cloud Gateway配置具体的负载均衡策略?
后端
间彧1 小时前
Spring Cloud Gateway详解与应用实战
后端
武文斌771 小时前
项目学习总结:LVGL图形参数动态变化、开发板的GDB调试、sqlite3移植、MQTT协议、心跳包
linux·开发语言·网络·arm开发·数据库·嵌入式硬件·学习
CoderIsArt1 小时前
SQLite架构
数据库·sqlite