博客 / 工程

ClickHouse 24.8 LTS 版本发布

author avatar
ClickHouse 团队
2024 年 9 月 3 日 - 20 分钟阅读

又一个月过去了,这意味着又到了发布新版本的时候!

ClickHouse 24.8 版本包含 19 个新功能 🎁 18 项性能优化 🛷 65 个错误修复 🐛

此版本是 LTS(长期支持)版本,这意味着它将在发布后支持 12 个月。要了解有关稳定版本和 LTS 版本的更多信息,请参阅文档

在此版本中,我们推出了全新改进的 JSON 类型、用于时间序列数据的表引擎、Kafka 消息的精确一次处理,当然还有连接改进!

新贡献者

与往常一样,我们特别欢迎 24.9 版本中的所有新贡献者!ClickHouse 的普及很大程度上归功于社区的贡献。看到社区不断壮大总是令人感到荣幸。

以下是新贡献者的姓名

Graham Campbell, Halersson Paris, Jacob Reckhard, Lennard Eijsackers, Miсhael Stetsyuk, Peter Nguyen, Ruihang Xia, Samuele Guerrini, Sasha Sheikin, Vladimir Varankin, Zhukova, Maria, heguangnan, khodyrevyurii, sakulali, shiyer7474, xc0derx, zoomxi

提示:如果您好奇我们是如何生成此列表的… 这里

您还可以查看演示文稿的幻灯片

JSON 数据类型

由 Pavel Kruglov 贡献

此版本发布了新的 JSON 数据类型的实验性版本。这项工作已经进行了一段时间,在之前的版本帖子中,我们讨论了它所依赖的几种类型——VariantDynamic

JSON 数据类型用于存储半结构化数据,其中每行的结构可能与其他行不同,或者我们不想将其分解为单独的列。

要使用 JSON 数据类型,我们需要启用以下功能

SET allow_experimental_json_type=1;

我们将使用 StatsBomb 提供的足球/足球数据集来尝试此功能。该数据集包含比赛、阵容和事件。

对于我们的目的而言,最有趣的数据是事件。这些事件可能是传球、解围、拦截或比赛中可能发生的其他事情。相同类型的事件具有相同的结构,但不同事件类型具有不同的字段。

如果您想继续操作,可以通过运行以下命令下载数据集

wget https://github.com/statsbomb/open-data/archive/refs/heads/master.zip

接下来,让我们快速查看一下我们将要处理的数据。我们将以 JsonAsObject 格式读取数据,以便 ClickHouse 不会尝试推断 JSON 中每个属性的类型。

SELECT
    replaceRegexpAll(splitByRegexp('/', _file)[-1], '.json', '') AS matchId,
    json
FROM file('master.zip :: **/data/events/*.json', JSONAsObject)
LIMIT 1
FORMAT Vertical

Row 1:
──────
matchId: 15946
json:    {"duration":0,"id":"9f6e2ecf-6685-45df-a62e-c2db3090f6c1","index":"1","minute":"0","period":"1","play_pattern":{"id":"1","name":"Regular Play"},"possession":"1","possession_team":{"id":"217","name":"Barcelona"},"second":"0","tactics":{"formation":"442","lineup":[{"jersey_number":"1","player":{"id":"20055","name":"Marc-André ter Stegen"},"position":{"id":"1","name":"Goalkeeper"}},{"jersey_number":"2","player":{"id":"6374","name":"Nélson Cabral Semedo"},"position":{"id":"2","name":"Right Back"}},{"jersey_number":"3","player":{"id":"5213","name":"Gerard Piqué Bernabéu"},"position":{"id":"3","name":"Right Center Back"}},{"jersey_number":"23","player":{"id":"5492","name":"Samuel Yves Umtiti"},"position":{"id":"5","name":"Left Center Back"}},{"jersey_number":"18","player":{"id":"5211","name":"Jordi Alba Ramos"},"position":{"id":"6","name":"Left Back"}},{"jersey_number":"5","player":{"id":"5203","name":"Sergio Busquets i Burgos"},"position":{"id":"9","name":"Right Defensive Midfield"}},{"jersey_number":"4","player":{"id":"5470","name":"Ivan Rakitić"},"position":{"id":"11","name":"Left Defensive Midfield"}},{"jersey_number":"20","player":{"id":"6379","name":"Sergi Roberto Carnicer"},"position":{"id":"12","name":"Right Midfield"}},{"jersey_number":"11","player":{"id":"5477","name":"Ousmane Dembélé"},"position":{"id":"16","name":"Left Midfield"}},{"jersey_number":"9","player":{"id":"5246","name":"Luis Alberto Suárez Díaz"},"position":{"id":"22","name":"Right Center Forward"}},{"jersey_number":"10","player":{"id":"5503","name":"Lionel Andrés Messi Cuccittini"},"position":{"id":"24","name":"Left Center Forward"}}]},"team":{"id":"217","name":"Barcelona"},"timestamp":"00:00:00.000","type":{"id":"35","name":"Starting XI"}}

此行表示 Starting XI 事件,但还存在数百种其他事件类型。让我们看看如何将此数据加载到 ClickHouse 中。

接下来,我们将创建一个 events 表。我们将有一个 json 列,用于存储每个事件的 JSON,以及一个 matchId 列,用于存储比赛 ID,我们将从文件名中提取该 ID。

创建 ClickHouse 表时,我们必须定义一个排序键,该键将影响数据在磁盘上的排序方式。我们的键将是球队 ID,可通过 possession_team.id 访问。

目前我们无法将嵌套字段作为表的排序键,但此功能已计划在未来版本中推出。现在,我们将创建一个 MATERIALIZED 类型的列,该列从 JSON 列中提取值。我们将使用物化列作为排序键。表创建查询如下所示

CREATE TABLE events
(
    matchId String,
    json JSON,
    possession_team_id String MATERIALIZED getSubcolumn(json, 'possession_team.id')
)
ENGINE = MergeTree
ORDER BY possession_team_id;

然后,我们可以复制之前的 SELECT 查询,并在其前面加上 INSERT INTO events 以加载数据

INSERT INTO events 
SELECT
    replaceRegexpAll(splitByRegexp('/', _file)[-1], '.json', '') AS matchId,
    json
FROM file('master.zip :: **/data/events/*.json', JSONAsObject)

0 rows in set. Elapsed: 72.967 sec. Processed 12.08 million rows, 10.39 GB (165.60 thousand rows/s., 142.42 MB/s.)
Peak memory usage: 3.52 GiB.

从输出到加载 1200 万个事件需要一分多钟。然后,我们可以编写一个使用 JSON 点语法来查找最流行的事件类型的查询

SELECT
    json.type.name,
    count() AS count
FROM events
GROUP BY ALL
ORDER BY count DESC
LIMIT 10

┌─json.type.name─┬───count─┐
│ Pass           │ 3358652 │
│ Ball Receipt*3142664 │
│ Carry          │ 2609610 │
│ Pressure       │ 1102075 │
│ Ball Recovery  │  363161 │
│ Duel           │  255791 │
│ Clearance      │  157713 │
│ Block          │  130858 │
│ Dribble        │  121105 │
│ Goal Keeper    │  105390 │
└────────────────┴─────────┘

当我们想要读取字面值时,点语法很有用,但如果我们读取子对象,则点语法不起作用。例如,以下查询计算最流行的 possesion_team

SELECT
    json.possession_team AS team,
    count()
FROM events
GROUP BY team
ORDER BY count() DESC
LIMIT 10

┌─team─┬──count()─┐
│ ᴺᵁᴸᴸ │ 12083338 │
└──────┴──────────┘

嗯,全是 null!

出于性能原因,点语法不读取嵌套对象。数据存储方式使得按路径读取字面值非常高效,但按路径读取所有子对象需要更多数据,有时可能会更慢。

当我们想要返回对象时,我们需要使用 .^ 代替。此特殊语法只能用于从具有 JSON 数据类型的字段中读取嵌套对象

SELECT
    json.^possession_team AS team,
    count()
FROM events
GROUP BY team
ORDER BY count() DESC
LIMIT 10

┌─team──────────────────────────────────────┬─count()─┐
│ {"id":"217","name":"Barcelona"}           │ 1326515 │
│ {"id":"131","name":"Paris Saint-Germain"} │  239930 │
│ {"id":"1","name":"Arsenal"}               │  154789 │
│ {"id":"904","name":"Bayer Leverkusen"}    │  147473 │
│ {"id":"220","name":"Real Madrid"}         │  135421 │
│ {"id":"968","name":"Arsenal WFC"}         │  131637 │
│ {"id":"746","name":"Manchester City WFC"} │  131017 │
│ {"id":"971","name":"Chelsea FCW"}         │  115761 │
│ {"id":"212","name":"Atlético Madrid"}     │  110893 │
│ {"id":"169","name":"Bayern Munich"}       │  104804 │
└───────────────────────────────────────────┴─────────┘

如果您要向下读取多个嵌套子对象并想要返回一个子对象,则只需对路径中的第一个对象使用 .^ 语法即可。例如

select json.^pass.body_part AS x, toTypeName(x)
FROM events 
LIMIT 1;

┌─x───────────────────────────────┬─toTypeName(x)─┐
│ {"id":"40","name":"Right Foot"} │ JSON          │
└─────────────────────────────────┴───────────────┘

此语法仅用于返回对象。如果您尝试使用它来返回字面值,您将获得一个空的 JSON 对象

SELECT
    json.^possession_team.name AS team, toTypeName(team),
    count()
FROM events
GROUP BY team
ORDER BY count() DESC
LIMIT 10;

┌─team─┬─toTypeName(team)─┬──count()─┐
│ {}   │ JSON             │ 12083338 │
└──────┴──────────────────┴──────────┘

我们计划添加一个新的运算符.$,它将字面值和子对象都作为单个子列返回。

当我们返回字面值时,它们的类型将是 Dynamic。我们可以使用 dynamicType 函数来确定每个值的底层类型

SELECT
    json.possession_team.name AS team,
    dynamicType(team) AS teamType,
    json.duration AS duration,
    dynamicType(duration) AS durationType
FROM events
LIMIT 1

┌─team────┬─teamType─┬─duration─┬─durationType─┐
│ Arsenal │ String   │ 0.657763 │ Float64      │
└─────────┴──────────┴──────────┴──────────────┘

我们还可以使用 .:<Type> 后缀来假定数据类型,并使用 ::<Type> 后缀来强制转换为数据类型。

SELECT
    json.possession_team.name AS team,
    toTypeName(team),
    json.possession_team.name.:String AS teamAssume,
    toTypeName(teamAssume) AS assumeType,
    json.possession_team.name::String AS teamCast,
    toTypeName(teamCast) AS castType
FROM events
LIMIT 1;

┌─team────┬─toTypeName(team)─┬─teamAssume─┬─assumeType───────┬─teamCast─┬─castType─┐
│ Arsenal │ Dynamic          │ Arsenal    │ Nullable(String) │ Arsenal  │ String   │
└─────────┴──────────────────┴────────────┴──────────────────┴──────────┴──────────┘

最后要注意的一点是,具有 JSON 数据类型的列可以进一步配置。例如,如果我们想要排除正在存储的 JSON 对象的一些部分,我们可以使用 SKIPSKIP REGEXP 在 JSON 路径解析期间跳过特定路径。

例如,以下表创建语句跳过 pass.body.part 路径和任何以字母 t 开头的路径

CREATE TABLE events2
(
    matchId String,
    json JSON(
      SKIP pass.body_part, 
      SKIP REGEXP 't.*'
    ),
    possession_team_id String MATERIALIZED getSubcolumn(json, 'possession_team.id')
)
ENGINE = MergeTree
ORDER BY possession_team_id;

当将数据摄取到具有带有额外设置的 JSON 列的表中时,ClickHouse 不会自动将传入数据转换为正确的类型,但这将在未来版本中修复。目前,我们需要在导入查询中显式定义 JSON 列类型

INSERT INTO events2
SELECT
    replaceRegexpAll(splitByRegexp('/', _file)[-1], '.json', '') AS matchId,
    json
FROM file(
  'master.zip :: **/data/events/*.json',
  JSONAsObject,
  '`json` JSON(SKIP `pass.body_part`, SKIP REGEXP \'t.*\')'
);

0 rows in set. Elapsed: 75.122 sec. Processed 12.08 million rows, 10.39 GB (160.85 thousand rows/s., 138.33 MB/s.)
Peak memory usage: 3.52 GiB.

如果我们查询 events2,我们将看到这些子路径不再存在

SELECT json.^pass AS pass
FROM events3
WHERE empty(pass) != true
LIMIT 3
FORMAT Vertical;

Row 1:
──────
pass: {"angle":-3.1127546,"end_location":[49.6,39.7],"height":{"id":"1","name":"Ground Pass"},"length":10.404326,"recipient":{"id":"401732","name":"Jaclyn Katrina Demis Sawicki"},"type":{"id":"65","name":"Kick Off"}}

Row 2:
──────
pass: {"angle":2.9699645,"end_location":[28,44.2],"height":{"id":"1","name":"Ground Pass"},"length":22.835499,"recipient":{"id":"401737","name":"Hali Moriah Candido Long"}}

Row 3:
──────
pass: {"angle":-1.7185218,"end_location":[27.1,27.1],"height":{"id":"1","name":"Ground Pass"},"length":16.984993,"recipient":{"id":"389446","name":"Jessika Rebecca Macayan Cowart"}}

我们还可以为路径提供类型提示

CREATE TABLE events3
(
    matchId String,
    json JSON(
      pass.height.name String, 
      pass.height.id Int64 
    ),
    possession_team_id String MATERIALIZED getSubcolumn(json, 'possession_team.id')
)
ENGINE = MergeTree
ORDER BY possession_team_id;

还有一些其他设置:max_dynamic_pathsmax_dynamic_types。这些设置控制数据在磁盘上的存储方式。您可以在 JSON 数据类型文档中阅读有关它们的更多信息。

合并期间对投影的控制

由 ShiChao Jin 贡献

ClickHouse 中的表可以具有“投影”,即与原始表保持同步的隐藏表副本。投影通常具有与原始表不同的主键(因此,行顺序也不同)。也可以在投影中增量预计算聚合值。

当用户执行查询时,ClickHouse 会选择从原始表或其投影之一读取。这在下图中显示

0_24.8blog.png

从原始表还是其投影之一读取的决定是针对每个表部分单独做出的。ClickHouse 通常旨在读取尽可能少的数据,并采用一些技巧来识别要读取的最佳部分,例如,对部分的主键进行采样。在某些情况下,源表部分没有相应的投影部分。例如,这可能是因为在 SQL 中为表创建投影默认是“惰性的” - 它仅影响新插入的数据,但保持现有部分不变。

下图显示了一个更具体的示例,该示例计算英国出售的房产按城镇和街道分组的最高价格

1_24.8blog.png

由于其中一个投影已经包含预计算的聚合值,ClickHouse 尝试从相应的投影部分读取,以避免在查询运行时再次聚合。如果特定部分缺少相应的投影部分,则查询执行将回退到原始部分。

但是,如果原始表中的行通过非平凡的数据部分后台合并以非平凡的方式更改,会发生什么情况?

例如,假设表使用 ClickHouse 的 ReplacingMergeTree 表引擎存储。如果在合并期间在多个输入部分中检测到同一行,则仅保留最新的行版本(来自最近插入的部分),而所有旧版本都将被丢弃。

同样,如果表使用 AggregatingMergeTree 表引擎存储,则合并操作可能会将输入部分中的相同行(基于主键值)折叠成单行以更新部分聚合状态。

在 ClickHouse v24.8 之前,投影部分要么以静默方式与主数据不同步,要么某些操作(如更新和删除)根本无法运行,因为如果表具有投影,数据库会自动抛出异常。

自 v24.8 起,新的表级设置 deduplicate_merge_projection_mode 控制在前述非平凡的后台合并操作发生在原始表的某些部分时的行为。

删除 mutation 是另一种部分合并操作的示例,它会删除原始表部分中的行。自 v24.7 起,我们还提供了一个设置来控制由轻量级删除触发的删除 mutation 的行为:lightweight_mutation_projection_mode

以下是 deduplicate_merge_projection_modelightweight_mutation_projection_mode 的可能值

  • throw:抛出异常,防止投影部分不同步。

  • drop:受影响的投影表部分被删除。对于受影响的投影部分,查询将回退到原始表部分。

  • rebuild:重建受影响的投影部分,以与原始表部分中的数据保持一致。

我们用两个图表演示了 drop 行为。第一个图表显示原始表数据部分中的一行被删除(通过运行 mutation 合并操作)。为了防止投影部分不同步,关联的投影表数据部分被删除

2_24.8blog.png

示例聚合查询仍然可以运行(尽管速度较慢) - 对于找不到投影部分的部分,它将简单地回退到原始表部分。但是,当投影部分仍然存在时,查询执行将优先选择这些部分而不是原始表部分

3_24.8blog.png

如果 deduplicate_merge_projection_mode 设置为 rebuild,ClickHouse 将重建与修改后的原始表数据部分关联的投影表数据部分

4_24.8blog.png

请注意,此行为优于增量物化视图,增量物化视图类似于投影。但是,增量物化视图仅对原始表中的数据插入做出反应和同步。当原始表数据更新、删除、替换等时,物化视图会(静默地)不同步。

TimeSeries 表引擎

由 Vitaly Baranov 贡献

此版本还引入了 TimeSeries 表引擎。此表引擎允许您使用 ClickHouse 作为 Prometheus 的存储,使用 remote-write 协议。Prometheus 还可以使用 remote-read 协议从 ClickHouse 查询数据,如下图所示。

5_24.8blog.png

TimeSeries 表引擎是一项实验性功能,因此您必须设置 allow_experimental_time_series_table 属性才能启用它。我们将通过服务器配置文件来完成此操作

config.d/allow_experimental_time_series.xml

<clickhouse>
    <profiles>
        <default>
            <allow_experimental_time_series_table>1</allow_experimental_time_series_table>
        </default>
    </profiles>
</clickhouse>

我们还需要设置一些其他配置,以在 ClickHouse 中启用 remove-writeremote-read 端点。

config.d/prometheus.xml

<clickhouse>
    <prometheus>
        <port>9092</port>
        <handlers>
            <my_rule_1>
                <url>/write</url>
                <handler>
                    <type>remote_write</type>
                    <table>default.prometheus</table>
                </handler>
            </my_rule_1>
            <my_rule_2>
                <url>/read</url>
                <handler>
                    <type>remote_read</type>
                    <table>default.prometheus</table>
                </handler>
            </my_rule_2>
        </handlers>
    </prometheus>
</clickhouse>

然后我们可以启动 ClickHouse。我们应该在日志输出中看到以下行

2024.08.27 15:41:19.970465 [ 14489686 ] {} <Information> Application: Listening for Prometheus: http://[::1]:9092
...
2024.08.27 15:41:19.970523 [ 14489686 ] {} <Information> Application: Listening for Prometheus: http://127.0.0.1:9092

接下来,我们将使用 ClickHouse 客户端连接到服务器并运行以下查询

CREATE TABLE prometheus ENGINE=TimeSeries;

运行此查询将创建三个目标表

  • data,其中包含与某些标识符关联的时间序列
  • tags,其中包含为指标名称和标签的每个组合计算的标识符。
  • metrics,其中包含有关已收集的指标、这些指标的类型及其描述的一些信息。

您可以通过运行以下查询来查看这些表的名称

SHOW TABLES

┌─name───────────────────────────────────────────────────┐
│ .inner_id.data.bcd5b4e6-01d3-45d1-ab27-bbe9de2bc74b    │
│ .inner_id.metrics.bcd5b4e6-01d3-45d1-ab27-bbe9de2bc74b │
│ .inner_id.tags.bcd5b4e6-01d3-45d1-ab27-bbe9de2bc74b    │
│ prometheus                                             │
└────────────────────────────────────────────────────────┘

现在让我们运行 Prometheus,并让它以一种非常元的方式收集关于自身的数据。我们将有以下配置文件

prometheus.yml

global:
  scrape_interval:     15s
  external_labels:
    monitor: 'codelab-monitor'

scrape_configs:
  - job_name: 'prometheus'
    scrape_interval: 5s
    static_configs:
      - targets: ['localhost:9090']      

remote_write:
  - url: "https://127.0.0.1:9092/write"

remote_read:
  - url: "https://127.0.0.1:9092/read"

需要注意的重要部分是 remote_writeremote_read,它们指向在 ClickHouse 服务器上运行的 HTTP 端点。您可以下载 Prometheus,解压 tar/zip 文件后,运行以下命令(或类似命令)

./prometheus-2.54.0.darwin-amd64/prometheus --config prometheus.yml

然后,我们可以查看 Prometheus 中的指标,或者在 ClickHouse 中可以使用一些表函数。它们具有前缀 timeSeries,并将 ``TimeSeries 表名称作为参数。第一个是 timeSeriesMetrics,它列出了所有指标

SELECT *
FROM timeSeriesMetrics(prometheus)
LIMIT 3
FORMAT Vertical

Query id: 07f4cce2-ad47-45e1-b0e3-6903e474d76c

Row 1:
──────
metric_family_name: go_gc_cycles_automatic_gc_cycles_total
type:               counter
unit:
help:               Count of completed GC cycles generated by the Go runtime.

Row 2:
──────
metric_family_name: go_gc_cycles_forced_gc_cycles_total
type:               counter
unit:
help:               Count of completed GC cycles forced by the application.

Row 3:
──────
metric_family_name: go_gc_cycles_total_gc_cycles_total
type:               counter
unit:
help:               Count of all completed GC cycles.

我们还有 timeSeriesDatatimeSeriesTags,最好一起查询

SELECT *
FROM timeSeriesData(prometheus) AS data
INNER JOIN timeSeriesTags(prometheus) AS tags ON tags.id = data.id
WHERE metric_name = 'prometheus_tsdb_head_chunks_created_total'
LIMIT 1
FORMAT Vertical

Row 1:
──────
id:          a869dbe8-ba86-1416-47d3-c51cda7334b1
timestamp:   2024-08-27 15:54:46.655
value:       8935
tags.id:     a869dbe8-ba86-1416-47d3-c51cda7334b1
metric_name: prometheus_tsdb_head_chunks_created_total
tags:        {'instance':'localhost:9090','job':'prometheus','monitor':'codelab-monitor'}
min_time:    2024-08-27 13:46:05.725
max_time:    2024-08-27 16:00:26.649

在未来的版本中,我们还将实现 /query 端点,以便您可以直接针对 ClickHouse 执行 Prometheus 样式的查询。

连接改进

由于每个 ClickHouse 版本都包含 JOIN 改进,因此 ClickHouse v24.8 也不例外,提供了更多增强功能。

具有不等式条件的 JOIN 严格性的更多变体

由 Lgbo-USTC 贡献

ClickHouse v24.5 引入了对 JOIN 的 ON 子句中不等式条件的实验性支持。此版本支持更多连接严格性(LEFT/RIGHT SEMI/ANTI/ANY JOIN),其中不等式条件涉及左表和右表中的列。

优化 Join 表的查询以减少其内存占用

由 Duc Canh Le 贡献

具有 join 表引擎的 ClickHouse 表包含一个内存中的 预计算哈希表,其中包含右侧表数据,以便进行 JOIN。

在 ClickHouse v24.8 中,您现在可以在 Join 表上运行 OPTIMIZE TABLE 语句,这将通过更好的打包来降低内存使用率。这可以将表的内存占用减少 30%。

新的 Kafka 引擎

由 János Benjamin Antal 贡献

我们还有一个新的实验性 Kafka 引擎。此版本使 Kafka 消息的精确一次处理成为可能。

在现有引擎中,Kafka 偏移量通过非原子提交存储在 Kafka 和 ClickHouse 中。这导致在重试的情况下可能出现重复项。

在新版本中,偏移量由 ClickHouse Keeper 处理。如果插入尝试失败,它将获取相同的数据块并重复插入,而与网络或服务器故障无关。

我们可以使用以下设置启用新引擎

CREATE TABLE ... ENGINE = Kafka(
    'localhost:19092', 'topic', 'consumer', 'JSONEachRow')
SETTINGS
    kafka_keeper_path = '/clickhouse/{database}/kafka',
    kafka_replica_name = 'r1';
分享此帖子

订阅我们的新闻通讯

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