Bootstrap

ClickHouse 24.12 版本发布说明

图片

本文字数:11871;估计阅读时间:30 分钟

作者: ClickHouse官方

本文在公众号【ClickHouseInc】首发

图片

又到了月度版本更新的时间!

发布概要

ClickHouse 24.12 版本重磅发布,本次更新带来了16项全新功能🦃、16性能优化⛸️36个bug修复🏕️

本次版本新增了多项实用功能,包括改进 Enum 的可用性、支持 Iceberg REST 目录和模式演进、实现反序表排序、支持将 JSON 子列作为主键、自动优化 JOIN 的执行顺序等更多亮点功能!

新贡献者  

我们热烈欢迎 24.12 版本中的所有新贡献者!ClickHouse 的成功离不开社区的共同努力。每次看到社区不断壮大,都会让我们倍感激励和鼓舞。

以下是本次版本的新贡献者名单:

Emmanuel Dias, Xavier Leune, Zawa_ll, Zaynulla, erickurbanov, jotosoares, zhangwanyun1, zwy991114, JiaQi

Enum 可用性改进  

贡献者:ZhangLiStar  

本次发布改进了 Enum 类型的可用性。我们将通过 Reddit 评论数据集示例来展示这些变化。首先,创建一个包含几个字段的表:

CREATE TABLE reddit
    (
        subreddit LowCardinality(String),
        subreddit_type Enum(
            'public' = 1, 'restricted' = 2, 'user' = 3, 
            'archived' = 4, 'gold_restricted' = 5, 'private' = 6
        ),
    )
    ENGINE = MergeTree
    ORDER BY (subreddit);

随后,我们可以插入数据,如下所示:

INSERT INTO reddit
SELECT subreddit, subreddit_type
FROM s3(        
  'https://clickhouse-public-datasets.s3.eu-central-1.amazonaws.com/reddit/original/RC_2017-12.xz',
  'JSONEachRow'
);

假如我们希望统计 subreddit_type 中包含字符 "e" 的帖子数量,可以使用 LIKE 运算符编写如下查询:

SELECT
    subreddit_type,
    count() AS c
FROM reddit
WHERE subreddit_type LIKE '%restricted%'
GROUP BY ALL
ORDER BY c DESC;

在 24.12 版本之前运行该查询,会显示以下错误信息:

Received exception:
Code: 43. DB::Exception: Illegal type Enum8('public' = 1, 'restricted' = 2, 'user' = 3, 'archived' = 4, 'gold_restricted' = 5, 'private' = 6) of argument of function like: In scope SELECT subreddit, count() AS c FROM reddit WHERE subreddit_type LIKE '%e%' GROUP BY subreddit ORDER BY c DESC LIMIT 20. (ILLEGAL_TYPE_OF_ARGUMENT)

而在 24.12 版本中运行相同的查询,则会返回如下结果:

   ┌─subreddit_type─┬──────c─┐
1. │ restricted     │ 698263 │
2. │ user           │  39640 │
   └────────────────┴────────┘

此外,等号 (=) 和 IN 运算符现在也支持未知值。例如,以下查询会返回类型为 Foo 或 public 的所有记录:

SELECT count() AS c
FROM reddit
WHERE subreddit_type IN ('Foo', 'public')
GROUP BY ALL;

在 24.12 版本之前运行这段查询时,会显示以下错误信息:

Received exception:
Code: 691. DB::Exception: Unknown element 'Foo' for enum: while converting 'Foo' to Enum8('public' = 1, 'restricted' = 2, 'user' = 3, 'archived' = 4, 'gold_restricted' = 5, 'private' = 6). (UNKNOWN_ELEMENT_OF_ENUM)

而在 24.12 版本中运行该查询,则会返回如下结果:

   ┌────────c─┐
1. │ 85235907 │ -- 85.24 million
   └──────────┘

反向表排序  

贡献者:Amos Bird  

本次版本新增了一个 MergeTree 设置 allow_experimental_reverse_key,支持在 MergeTree 排序键中启用降序排序。以下是一个简单的示例:  

ENGINE = MergeTree 
ORDER BY (time DESC, key)
SETTINGS allow_experimental_reverse_key=1;

这个表会按照 time 字段进行降序排列。  

这种降序排序功能在时间序列分析中非常有用,尤其是处理 Top N 查询时效果显著。  

JSON 子列作为表主键  

贡献者:Pavel Kruglov  

ClickHouse 引入了全新的 JSON 实现,可以将每个唯一的 JSON 路径存储为真正的列式数据:  

图片

上图展示了 ClickHouse 如何将 JSON 键路径以原生子列的形式存储(并支持读取)。这种方式不仅提供了出色的数据压缩,还能保持与传统数据类型相同的查询性能。  

在本次发布中,ClickHouse 现已支持将 JSON 子列用作表的主键列:  

CREATE TABLE T
(
    data JSON()
)
ORDER BY (data.a, data.b);

这意味着,写入的 JSON 文档会根据用作主键的 JSON 子列,按分片顺序在磁盘上进行排序。此外,ClickHouse 会为这些主键列自动创建主索引文件,从而加速基于主键的过滤查询:  

图片

同时,当主键列按基数从低到高排序时,JSON 子列的 *.bin 数据文件也能实现最佳压缩效果。  

以下是一个更具体的示例:  

测试中我们使用了一台 AWS EC2 m6i.8xlarge 实例,配置为 32 个 vCPU 和 128 GiB 内存,并选用了 Bluesky 数据集。  

我们将 1 亿条 Bluesky 事件(每个事件为一个 JSON 文档)加载到两个不同的 ClickHouse 表中。  

第一个表没有使用任何 JSON 子列作为主键列:  

CREATE TABLE bluesky_100m_raw
(
    data JSON()
)
ORDER BY ();

第二个表则使用了一些 JSON 子列作为主键列(还为部分列添加了类型提示,以避免查询中的类型转换):  

CREATE TABLE bluesky_100m_primary_key
(
    data JSON(
        kind LowCardinality(String), 
        commit.operation LowCardinality(String), 
        commit.collection LowCardinality(String), 
        time_us UInt64
    )
)
ORDER BY (
    data.kind, 
    data.commit.operation, 
    data.commit.collection, 
    fromUnixTimestamp64Micro(data.time_us)
);

这两个表都存储了完全相同的 1 亿条 JSON 文档。  

接着,我们在没有主键的表上运行一个查询(查询内容为“人们何时在 Bluesky 上屏蔽他人”,改编自“人们何时使用 Bluesky?”的查询,您可以在 ClickHouse SQL playground 上试运行):  

SELECT
    toHour(fromUnixTimestamp64Micro(data.time_us::UInt64)) AS hour_of_day,
    count() AS block_events
FROM bluesky_100m_raw
WHERE (data.kind = 'commit') 
AND (data.commit.operation = 'create') 
AND (data.commit.collection = 'app.bsky.graph.block')
GROUP BY hour_of_day
ORDER BY hour_of_day ASC;
    ┌─hour_of_day─┬─block_events─┐
 1. │           0 │        89395 │
 2. │           1 │       143542 │
 3. │           2 │       154424 │
 4. │           3 │       162894 │
 5. │           4 │        65893 │
 6. │           5 │        39556 │
 7. │           6 │        34359 │
 8. │           7 │        35230 │
 9. │           8 │        30812 │
10. │           9 │        35620 │
11. │          10 │        31094 │
12. │          16 │        33359 │
13. │          17 │        65555 │
14. │          18 │        65135 │
15. │          19 │        65775 │
16. │          20 │        70096 │
17. │          21 │        65640 │
18. │          22 │        75840 │
19. │          23 │       143024 │
    └─────────────┴──────────────┘

19 rows in set. Elapsed: 0.607 sec. Processed 100.00 million rows, 10.21 GB (164.83 million rows/s., 16.83 GB/s.)
Peak memory usage: 337.52 MiB.

然后,我们在带有主键的表上运行相同的查询(需要注意,该查询对主键列的前缀字段进行了过滤):  

SELECT
    toHour(fromUnixTimestamp64Micro(data.time_us)) AS hour_of_day,
    count() AS block_events
FROM bluesky_100m_primary_key
WHERE (data.kind = 'commit') 
AND (data.commit.operation = 'create') 
AND (data.commit.collection = 'app.bsky.graph.block')
GROUP BY hour_of_day
ORDER BY hour_of_day ASC;
    ┌─hour_of_day─┬─block_events─┐
 1. │           0 │        89395 │
 2. │           1 │       143542 │
 3. │           2 │       154424 │
 4. │           3 │       162894 │
 5. │           4 │        65893 │
 6. │           5 │        39556 │
 7. │           6 │        34359 │
 8. │           7 │        35230 │
 9. │           8 │        30812 │
10. │           9 │        35620 │
11. │          10 │        31094 │
12. │          16 │        33359 │
13. │          17 │        65555 │
14. │          18 │        65135 │
15. │          19 │        65775 │
16. │          20 │        70096 │
17. │          21 │        65640 │
18. │          22 │        75840 │
19. │          23 │       143024 │
    └─────────────┴──────────────┘

19 rows in set. Elapsed: 0.011 sec. Processed 1.47 million rows, 16.16 MB (129.69 million rows/s., 1.43 GB/s.)
Peak memory usage: 2.18 MiB.

Boom!查询速度提升了 50 倍,内存使用减少了 150 倍。

Iceberg REST catalog 和模式演化支持  

贡献者:Daniil Ivanik 和 Kseniia Sumarokova  

在本次版本中,ClickHouse 新增了对 Apache Iceberg REST catalog 查询的支持。目前已支持 Unity 和 Polaris catalog。我们可以通过 Iceberg 表引擎创建表:  

CREATE TABLE unity_demo
ENGINE = Iceberg('https://dbc-55555555-5555.cloud.databricks.com/api/2.1/unity-catalog/iceberg')
SETTINGS
  catalog_type = 'rest',
  catalog_credential = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:...',
  warehouse = 'unity',
  oauth_server_uri = 'https://dbc-55555555-5555.cloud.databricks.com/oidc/v1/token',
  auth_scope = 'all-apis,sql';

接着,可以查询 catalog 中底层表的数据:  

SHOW TABLES FROM unity_demo;
SELECT * unity_demo."webinar.test";

Iceberg 表引擎还支持模式演化功能,包括列的新增和移除、列名的修改,以及在原始数据类型之间的类型变更。

并行哈希 JOIN 默认启用  

贡献者:Nikita Taranov  

每次 ClickHouse 的新版本发布,都会对 JOIN 功能进行优化。这次的圣诞特别版本也不例外,带来了许多 JOIN 相关的增强功能!✨  

在 24.11 版本的发布文章中,我们提到并行哈希 JOIN 已成为 ClickHouse 的默认 JOIN 策略。在这里,我们将通过具体示例展示这一改进带来的性能提升。  

我们在一台 AWS EC2 m6i.8xlarge 实例上进行了测试,该实例配置了 32 个 vCPU 和 128 GiB 内存。  

测试数据集选用的是 TPC-H 数据集,扩展因子为 100,表示所有表中的总数据量约为 100 GB。  

我们按照官方文档的指引,创建并加载了 8 个表,这些表模拟了一个批发供应商的数据仓库。 

首先,我们在使用 ClickHouse 之前默认的 JOIN 策略(哈希 JOIN)时,运行了 TPC-H 基准查询集中第 3 个查询: 

SELECT
    l_orderkey,
    sum(l_extendedprice * (1 - l_discount)) AS revenue,
    o_orderdate,
    o_shippriority
FROM
    customer,
    orders,
    lineitem
WHERE
    c_mktsegment = 'BUILDING'
    AND c_custkey = o_custkey
    AND l_orderkey = o_orderkey
    AND o_orderdate < DATE '1995-03-15'
    AND l_shipdate > DATE '1995-03-15'
GROUP BY
    l_orderkey,
    o_orderdate,
    o_shippriority
ORDER BY
    revenue DESC,
    o_orderdate
FORMAT Null
SETTINGS join_algorithm='hash';
0 rows in set. Elapsed: 38.305 sec. Processed 765.04 million rows, 15.03 GB (19.97 million rows/s., 392.40 MB/s.)
Peak memory usage: 25.42 GiB.

接下来,我们切换为 ClickHouse 新的默认 JOIN 策略(并行哈希 JOIN),运行相同的查询:

SELECT
    l_orderkey,
    sum(l_extendedprice * (1 - l_discount)) AS revenue,
    o_orderdate,
    o_shippriority
FROM
    customer,
    orders,
    lineitem
WHERE
    c_mktsegment = 'BUILDING'
    AND c_custkey = o_custkey
    AND l_orderkey = o_orderkey
    AND o_orderdate < DATE '1995-03-15'
    AND l_shipdate > DATE '1995-03-15'
GROUP BY
    l_orderkey,
    o_orderdate,
    o_shippriority
ORDER BY
    revenue DESC,
    o_orderdate
FORMAT Null
SETTINGS join_algorithm='default';
0 rows in set. Elapsed: 5.099 sec. Processed 765.04 million rows, 15.03 GB (150.04 million rows/s., 2.95 GB/s.)
Peak memory usage: 29.65 GiB.

使用并行哈希 JOIN 后,查询速度提升了约 8 倍。

JOIN 自动重排序  

贡献者:Vladimir Cherkasov  

圣诞节版本新增了一项功能——JOIN 自动重排序。  

首先,我们回顾一下 ClickHouse 的最快 JOIN 算法(例如其默认的并行哈希 JOIN)的工作原理。这些算法依赖于内存中的哈希表,分为两个主要阶段:  

第一步,将 JOIN 查询右侧表的数据加载到哈希表中(构建阶段);  

第二步,将左侧表的数据流式读取,并通过哈希表进行匹配(扫描阶段):  

图片

需要注意的是,由于 ClickHouse 会将右侧表的数据加载到内存中并创建哈希表,因此将较小的表放在 JOIN 的右侧可以更高效地使用内存,并且速度通常更快。  

类似地,ClickHouse 的部分合并 JOIN(基于外部排序的 JOIN 算法)也有类似的构建和扫描阶段。例如,部分合并 JOIN 会先对右表进行排序,然后扫描左表。因此,将较小的表作为右表也能显著提高效率。  

为了更灵活地选择构建表,ClickHouse 引入了新的设置项 query_plan_join_swap_table,可以根据需要调整构建表的选择逻辑。其取值如下:  

  • auto(默认值):自动选择行数较少的表作为构建表,几乎适用于所有 JOIN 查询。  

  • false:固定使用右表作为构建表。  

  • true:固定使用左表作为构建表。  

接下来,我们通过一个 TPC-H 查询示例演示 query_plan_join_swap_table 的 auto 模式(有关表的创建和加载方法以及测试硬件信息,请参见上一节)。该查询将连接 lineitem 表和 part 表。  

首先,查看两个表的大小:  

SELECT
    table,
    formatReadableQuantity(sum(rows)) AS rows,
    formatReadableSize(sum(bytes_on_disk)) AS size_on_disk
FROM system.parts
WHERE active AND (table IN ['lineitem', 'part'])
GROUP BY table
ORDER BY table ASC;
   ┌─table────┬─rows───────────┬─size_on_disk─┐
1. │ lineitem │ 600.04 million │ 26.69 GiB    │
2. │ part     │ 20.00 million  │ 896.47 MiB   │
   └──────────┴────────────────┴──────────────┘

可以看到,lineitem 表的规模远大于 part 表。  

下一条查询语句将 lineitem 表和 part 表进行连接,并且把规模大得多的lineitem表放在连接操作的右侧:

SELECT 100.00 * sum(
  CASE
  WHEN p_type LIKE 'PROMO%'
  THEN l_extendedprice * (1 - l_discount)
  ELSE 0 END) / sum(l_extendedprice * (1 - l_discount)) AS promo_revenue
FROM part, lineitem
WHERE l_partkey = p_partkey;

接着运行一个查询,将规模更大的 lineitem 表放在 JOIN 的右侧,同时将 query_plan_join_swap_table 设置为 false(默认行为)。此时,ClickHouse 会将 lineitem 表的数据加载到内存中(并行加载到多个哈希表中,因为并行哈希 JOIN 是默认算法):  

SELECT 100.00 * sum(
  CASE
  WHEN p_type LIKE 'PROMO%'
  THEN l_extendedprice * (1 - l_discount)
  ELSE 0 END) / sum(l_extendedprice * (1 - l_discount)) AS promo_revenue
FROM part, lineitem
WHERE l_partkey = p_partkey
SETTINGS query_plan_join_swap_table='false';
   ┌──────promo_revenue─┐
1. │ 16.650141208349083 │
   └────────────────────┘

1 row in set. Elapsed: 55.687 sec. Processed 620.04 million rows, 12.67 GB (11.13 million rows/s., 227.57 MB/s.)
Peak memory usage: 24.39 GiB.

然后,将 query_plan_join_swap_table 设置为 auto 并运行相同的查询。此时,ClickHouse 会基于表大小的估算结果,选择将较小的 part 表作为构建表,先将其加载到哈希表中,再对 lineitem 表进行流式连接:  

SELECT 100.00 * sum(
  CASE
  WHEN p_type LIKE 'PROMO%'
  THEN l_extendedprice * (1 - l_discount)
  ELSE 0 END) / sum(l_extendedprice * (1 - l_discount)) AS promo_revenue
FROM part, lineitem
WHERE l_partkey = p_partkey
SETTINGS query_plan_join_swap_table='auto';
   ┌──────promo_revenue─┐
1. │ 16.650141208349083 │
   └────────────────────┘

1 row in set. Elapsed: 9.447 sec. Processed 620.04 million rows, 12.67 GB (65.63 million rows/s., 1.34 GB/s.)
Peak memory usage: 4.72 GiB.

结果显示,查询速度提升了 5 倍以上,内存使用量减少了 5 倍。

JOIN 优化:表达式提取  

贡献者:János Benjamin Antal  

对于包含多个 OR 条件链的 JOIN 查询,例如以下抽象案例:  

JOIN ... ON (a=b AND x) OR (a=b AND y) OR (a=b AND z)

…当 ClickHouse 使用基于哈希表的 JOIN 算法时,会为每个条件单独创建一个哈希表。  

为了减少哈希表的数量并支持更高效的谓词下推(将过滤条件推到更接近数据源的位置以减少数据量),可以从上述 JOIN 查询的 ON 子句中提取公共表达式:  

JOIN ...ON a=b AND (x OR y OR z)

这一优化功能可以通过开启新设置 optimize_extract_common_expressions(设为 1)来实现。由于该功能仍处于实验阶段,默认值为 0(关闭)。  

接下来,我们用一个 TPC-H 查询展示该设置的效果(有关表的创建、数据加载及测试硬件说明,请参考上一节)。  

首先,我们运行以下包含多个 OR 条件链的 JOIN 查询,并将 optimize_extract_common_expressions 设置为 0(禁用优化):  

SELECT
  sum(l_extendedprice * (1 - l_discount)) AS revenue
FROM
  lineitem, part
WHERE
(
        p_partkey = l_partkey
    AND p_brand = 'Brand#12'
    AND p_container in ('SM CASE', 'SM BOX','SM PACK', 'SM PKG')
    AND l_quantity >= 1 AND l_quantity <= 1 + 10
    AND p_size BETWEEN 1 AND 5
    AND l_shipmode in ('AIR', 'AIR REG')
    AND l_shipinstruct = 'DELIVER IN PERSON'
)
OR
(
        p_partkey = l_partkey
    AND p_brand = 'Brand#23'
    AND p_container in ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK')
    AND l_quantity >= 10 AND l_quantity <= 10 + 10
    AND p_size BETWEEN 1 AND 10
    AND l_shipmode in ('AIR', 'AIR REG')
    AND l_shipinstruct = 'DELIVER IN PERSON'
)
OR
(
        p_partkey = l_partkey
    AND p_brand = 'Brand#34'
    AND p_container in ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG')
    AND l_quantity >= 20 AND l_quantity <= 20 + 10
    AND p_size BETWEEN 1 AND 15
    AND l_shipmode in ('AIR', 'AIR REG')
    AND l_shipinstruct = 'DELIVER IN PERSON'
)
SETTINGS optimize_extract_common_expressions = 0;

在测试机器上,这个查询运行了 30 分钟,但仅完成了 3% 的进度……我们随即中止了查询,并将 optimize_extract_common_expressions 设置为 1(启用优化),再次运行相同的查询:  

SELECT
  sum(l_extendedprice * (1 - l_discount)) AS revenue
FROM
  lineitem, part
WHERE
(
        p_partkey = l_partkey
    AND p_brand = 'Brand#12'
    AND p_container in ('SM CASE', 'SM BOX','SM PACK', 'SM PKG')
    AND l_quantity >= 1 AND l_quantity <= 1 + 10
    AND p_size BETWEEN 1 AND 5
    AND l_shipmode in ('AIR', 'AIR REG')
    AND l_shipinstruct = 'DELIVER IN PERSON'
)
OR
(
        p_partkey = l_partkey
    AND p_brand = 'Brand#23'
    AND p_container in ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK')
    AND l_quantity >= 10 AND l_quantity <= 10 + 10
    AND p_size BETWEEN 1 AND 10
    AND l_shipmode in ('AIR', 'AIR REG')
    AND l_shipinstruct = 'DELIVER IN PERSON'
)
OR
(
        p_partkey = l_partkey
    AND p_brand = 'Brand#34'
    AND p_container in ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG')
    AND l_quantity >= 20 AND l_quantity <= 20 + 10
    AND p_size BETWEEN 1 AND 15
    AND l_shipmode in ('AIR', 'AIR REG')
    AND l_shipinstruct = 'DELIVER IN PERSON'
)
SETTINGS optimize_extract_common_expressions = 1;
   ┌───────revenue─┐
1. │ 298937728.882 │ -- 298.94 million
   └───────────────┘

1 row in set. Elapsed: 3.021 sec. Processed 620.04 million rows, 38.21 GB (205.24 million rows/s., 12.65 GB/s.)
Peak memory usage: 2.79 GiB.

这次查询仅用 3 秒便完成了执行并返回结果,性能提升极其显著。

非等值 JOIN:默认支持  

贡献者:Vladimir Cherkasov  

自 24.05 版本以来,ClickHouse 已以实验功能形式支持在 JOIN 的 ON 子句中使用非等值条件:  

-- Equi join
SELECT t1.*, t2.* FROM t1 JOIN t2 ON t1.key = t2.key;

-- Non-equi joins
SELECT t1.*, t2.* FROM t1 JOIN t2 ON t1.key != t2.key;
SELECT t1.*, t2.* FROM t1 JOIN t2 ON t1.key > t2.key

在当前版本中,这项功能已全面启用,并默认支持。  

敬请期待今年后续版本更新,我们将继续带来更多令人兴奋的 JOIN 改进!

征稿启示

面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:[email protected]

;