又过了一个月,这意味着又到了发布新版本的时候了!
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 数据类型的实验版本。这已经进行了一段时间了,在之前的版本发布帖文中,我们讨论过它所依赖的两种类型——Variant 和 Dynamic。
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 对象的某些部分,我们可以使用 SKIP
和 SKIP 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_paths
和 max_dynamic_types
。这些设置控制数据在磁盘上的存储方式。您可以在JSON 数据类型文档中阅读有关它们的更多信息。
合并期间的投影控制
由 ShiChao Jin 贡献
ClickHouse 中的表可以拥有“投影”,即与原始表同步的隐藏表副本。投影通常具有与原始表不同的主键(因此,行顺序也不同)。还可以增量地在投影中预先计算聚合值。
当用户执行查询时,ClickHouse 会选择从原始表还是其投影之一读取数据。下图展示了这一过程。
决定从原始表还是其投影之一读取数据的决策是针对每个表部分单独做出的。ClickHouse 通常旨在尽可能少地读取数据,并使用一些技巧来识别要读取的最佳部分,例如对部分主键进行采样。在某些情况下,源表部分没有对应的投影部分。例如,这是因为在 SQL 中创建表的投影默认情况下是“惰性的” - 它只影响新插入的数据,但保留现有部分不变。
下图显示了一个更具体的示例,该示例计算了在英国出售的房产的最大价格,按城镇和街道分组。
由于其中一个投影已经包含了预先计算的聚合值,ClickHouse 试图从对应的投影部分读取数据,以避免在查询运行时再次进行聚合。如果某个特定部分缺少对应的投影部分,查询执行将回退到原始部分。
但是,如果原始表中的行以非平凡的方式通过非平凡数据部分的后台合并而发生变化,会发生什么情况?
例如,假设该表使用 ClickHouse 的ReplacingMergeTree
表引擎存储。如果在合并期间在多个输入部分中检测到相同行,则只保留最新的行版本(来自最后插入的部分),而所有旧版本将被丢弃。
类似地,如果该表使用AggregatingMergeTree
表引擎存储,合并操作可能会将输入部分中的相同行(基于主键值)折叠成单行,以更新部分聚合状态。
在 ClickHouse v24.8 之前,投影部分要么静默地与主数据不同步,要么某些操作(例如更新和删除)根本无法运行,因为如果表有投影,数据库会自动抛出异常。
从 v24.8 开始,一个新的表级设置deduplicate_merge_projection_mode
控制上述非平凡后台合并操作发生在原始表部分时时的行为。
删除变异是另一个示例,它会导致部分合并操作丢弃原始表部分中的行。从 v24.7 开始,我们也有一个设置来控制针对轻量级删除触发的删除变异的行为:lightweight_mutation_projection_mode
。
以下是deduplicate_merge_projection_mode
和 lightweight_mutation_projection_mode
的可能值。
-
throw
: 抛出异常,防止投影部分不同步。 -
drop
: 影响到的投影表部分会被删除。查询将回退到原始表部分,以获取受影响的投影部分。 -
rebuild
: 影响到的投影部分将被重建,以保持与原始表部分中的数据一致。
我们将用两个图表演示drop
行为。第一个图表显示原始表数据部分中的一行被删除(通过运行变异合并操作)。为了防止投影部分不同步,关联的投影表数据部分被删除。
示例聚合查询仍然可以运行(尽管速度较慢) - 它只是在无法找到投影部分的部分回退到原始表部分。但是,当投影部分仍然存在时,查询执行会优先选择这些部分而不是原始表部分。
如果deduplicate_merge_projection_mode
设置为rebuild
,ClickHouse 将重建与修改后的原始表数据部分关联的投影表数据部分。
请注意,这种行为比增量物化视图有明显优势,增量物化视图类似于投影。但是,增量物化视图只对原始表中的数据插入做出反应并同步。当原始表数据被更新、删除、替换等时,物化视图会(静默地)不同步。
TimeSeries 表引擎
由 Vitaly Baranov 贡献
此版本还引入了TimeSeries
表引擎。这个表引擎允许你使用 ClickHouse 作为 Prometheus 的存储,使用remote-write
协议。Prometheus 也可以使用remote-read
协议从 ClickHouse 查询数据,如下图所示。
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-write
和remote-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_write
和remote_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.
我们还有timeSeriesData
和timeSeriesTags
,最好将它们一起查询。
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 贡献
具有连接表引擎 的 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';