博客 / 工程

ClickHouse 中的语义版本控制 UDF

author avatar
Juan S. Carrillo
2024 年 10 月 9 日 - 8 分钟阅读

我在 Embrace 工作,我们基于 OpenTelemetry (OTel) 构建了唯一以用户为中心的移动应用可观测性解决方案。我们使用 ClickHouse 来支持我们的时间序列分析产品。

对于 Embrace 用户来说,最重要的排序类别之一是应用版本。应用版本通常使用语义版本控制,其中版本将以 <MAJOR>.<MINOR>.<PATCH> 格式描述。您根据以下规则递增它们

  1. 当您进行不兼容的 API 更改时,主版本 (MAJOR)
  2. 当您以向后兼容的方式添加功能时,次版本 (MINOR)
  3. 当您进行向后兼容的错误修复时,补丁版本 (PATCH)

我们希望能够对应用版本进行排序,以便 2.1.0、2.1.2 和 2.1.10 按照该顺序出现,而不是 2.1.0、2.1.10 和 2.1.2,当您按字典顺序排序时会发生这种情况。

ClickHouse 没有提供开箱即用的语义版本控制排序方法。但是,您可以使用用户定义函数 (UDF),ClickHouse v21.10 中引入了 UDF 来解决这个问题。

我们使用的最终 UDF 可以在下面找到。如果您想了解我们是如何构建它的,以及我们在查询和推理方面所做的改进,请继续阅读。

CREATE FUNCTION sortableSemVer AS version -> 
  arrayMap(
    x -> toUInt32OrZero(x), 
    splitByChar('.', extract(version, '(\\d+(\\.\\d+)+)'))
  )

字符串中的整数版本

版本最常作为字符串存储在数据库中。正如你们许多人可能知道的那样,使用字典顺序对版本字符串进行排序将无法按预期工作。

SELECT *
FROM
(
    SELECT ['1.0', '2.0', '3.0.0', '10.0'] AS versions
)
ARRAY JOIN versions
ORDER BY versions DESC

┌─versions─┐
│ 3.0.0    │
│ 2.0      │
│ 10.0<< ???
│ 1.0      │
└──────────┘

基本思想是我们将使用整数数组并对它们进行排序。如果我们将语义版本重写为整数数组,则排序将按预期工作。它甚至适用于不同长度的版本!

SELECT *
FROM
(
    SELECT [[1, 0], [2, 0], [3, 0, 0], [10, 0]] AS versions
)
ARRAY JOIN versions
ORDER BY versions DESC

┌─versions─┐
│ [10,0]   │
│ [3,0,0]  │
│ [2,0]    │
│ [1,0]    │
└──────────┘

让我们编写一个 lambda 函数,将版本字符串转换为整数数组。

SELECT
    version,
    arrayMap(x -> toUInt32(x), splitByChar('.', version)) AS sem_ver_arr
FROM
(
    SELECT ['1.0', '2.0', '3.0.0', '10.0'] AS version
)
ARRAY JOIN version
ORDER BY sem_ver_arr DESC

┌─version─┬─sem_ver_arr─┐
│ 10.0    │ [10,0]      │
│ 3.0.0   │ [3,0,0]     │
│ 2.0     │ [2,0]       │
│ 1.0     │ [1,0]       │
└─────────┴─────────────┘

让我们分解一下

  1. splitByChar('.', version) 根据句点 . 将版本字符串拆分为字符串数组,将 10.0 转换为 ['10', '0']
  2. arrayMap(x -> toUInt32(x), arr) 将每个数字字符串转换为 int32

我们可以通过定义 UDF 来节省一些输入

CREATE FUNCTION sortableSemVer AS version -> 
  arrayMap(x -> toUInt32(x), splitByChar('.', version));

让我们使用它!

SELECT
    version,
    sortableSemVer(version) AS sem_ver_arr
FROM
(
    SELECT ['1.0', '2.0', '3.0.0', '10.0'] AS version
)
ARRAY JOIN version
ORDER BY sem_ver_arr DESC

┌─version─┬─sem_ver_arr─┐
│ 10.0    │ [10,0]      │
│ 3.0.0   │ [3,0,0]     │
│ 2.0     │ [2,0]       │
│ 1.0     │ [1,0]       │
└─────────┴─────────────┘

您甚至可以完全排除 sem_ver_arr 列,而仅在 ORDER BY 子句中使用 sortableSemVer

SELECT version
FROM
(
    SELECT ['1.0', '2.0', '3.0.0', '10.0'] AS version
)
ARRAY JOIN version
ORDER BY sortableSemVer(version) DESC

┌─version─┐
│ 10.0    │
│ 3.0.0   │
│ 2.0     │
│ 1.0     │
└─────────┘

假设您有格式良好的语义版本,您可以按原样使用该函数并完成它。如果您的版本字符串看起来像这样 my-app-1.2.3(456)-alpha-45dbbdf9ab,请继续阅读。

作为复杂字符串的版本

让我们继续看一个更简单的例子:1.2.3.production。我们之前的函数将失败,因为 production 不是有效的数字。

select arrayMap(x -> toUInt32(x), splitByChar('.', '1.2.3.production'));

Received exception from server (version 23.8.15):
Code: 6. DB::Exception: Received from localhost:9000. DB::Exception: Cannot parse string 'production' as UInt32: syntax error at begin of string. Note: there are toUInt32OrZero and toUInt32OrNull functions, which returns zero/NULL instead of throwing exception.: while executing 'FUNCTION toUInt32(x :: 0) -> toUInt32(x) UInt32 : 1': while executing 'FUNCTION arrayMap(__lambda :: 1, splitByChar('.', '1.2.3.production') :: 0) -> arrayMap(lambda(tuple(x), toUInt32(x)), splitByChar('.', '1.2.3.production')) Array(UInt32) : 2'. (CANNOT_PARSE_TEXT)

我们可以将 toUInt32 替换为 toUInt32OrZero,对于非数字字符串,它将默认为 0。事实上,这也允许我们处理不包含任何看起来像数字的字符串。

SELECT
    version,
    arrayMap(x -> toUInt32OrZero(x), splitByChar('.', version)) AS sem_ver_arr
FROM
(
    SELECT [
        '1.0', '2.0', '3.0.0', 
        '10.0', 'production', '1.2.3.production'
        ] AS version
)
ARRAY JOIN version
ORDER BY sem_ver_arr DESC

┌─version──────────┬─sem_ver_arr─┐
│ 10.0             │ [10,0]      │
│ 3.0.0            │ [3,0,0]     │
│ 2.0              │ [2,0]       │
│ 1.2.3.production │ [1,2,3,0]   │
│ 1.0              │ [1,0]       │
│ production       │ [0]         │
└──────────────────┴─────────────┘

当然,如果版本是 1.2.3-production,我们将错过补丁版本,因为我们是按句点拆分的。我们可以使用带有正则表达式的 extract 函数提取任何看起来像语义版本的内容。这个将抓取字符串开头的语义版本。

SELECT extract('1.2.3-production', '^\\d+\\.\\d+\\.\\d+')

┌─extract('1.2.3-production', '^\\d+\\.\\d+\\.\\d+')─┐
│ 1.2.3                                              │
└────────────────────────────────────────────────────┘

我们可以进一步调整正则表达式,以允许出现在字符串其他位置的语义版本。

SELECT extract('my-app1.2.3-production', '\\d+\\.\\d+\\.\\d+')

┌─extract('my-app1.2.3-production', '\\d+\\.\\d+\\.\\d+')─┐
│ 1.2.3                                                   │
└─────────────────────────────────────────────────────────┘

让我们进一步更改它,以允许包含 2 个或更多子部分的语义版本。

SELECT extract('1.2.3.4.5.6.7-production', '(\\d+(\\.\\d+)+)')

┌─extract('1.2.3.4.5.6.7-production', '(\\d+(\\.\\d+)+)')─┐
│ 1.2.3.4.5.6.7                                           │
└─────────────────────────────────────────────────────────┘

请注意,我们将整个正则表达式包装在括号中,以捕获整个版本而不是重复的第二组。否则,您只会捕获正则表达式的最后一部分。

SELECT extract('1.2.3.4.5.6.7-production', '\\d+(\\.\\d+)+')

┌─extract('1.2.3.4.5.6.7-production', '\\d+(\\.\\d+)+')─┐
│ .7                                                    │
└───────────────────────────────────────────────────────┘

^--- Where did the rest of it go?!?

让我们修改原始 UDF 以包含新的正则表达式功能!

--- Drop the previous definition
DROP FUNCTION IF EXISTS sortableSemVer;

--- Create the new definition
CREATE FUNCTION sortableSemVer AS version -> 
  arrayMap(
    x -> toUInt32OrZero(x), 
    splitByChar('.', extract(version, '(\\d+(\\.\\d+)+)'))
  );

让我们添加更多版本字符串,看看它的行为如何。

SELECT
    version,
    sortableSemVer(version) AS sem_ver_arr
FROM
(
    SELECT [
        '1.0', '2.0', '3.0.0', '10.0', 'production', '1.2.3.production', 
        'my-app-1.2.3-prod', '3.5.0(ac22da)-test', '1456', '1.2.3.45', ''
        ] AS version
)
ARRAY JOIN version
ORDER BY sem_ver_arr DESC

Screenshot 2024-09-30 at 11.27.13.png

当然,这并非适用于所有情况。像这样的版本字符串无法正确解析

SELECT sortableSemVer('100.731a9bd8-5edbc015-SNAPSHOT') AS sem_ver_arr

┌─sem_ver_arr─┐
│ [100,731]   │
└─────────────┘

也没有办法按后缀正确排序,因为这些后缀已被删除

SELECT
    version,
    sortableSemVer(version) AS sem_ver_arr
FROM
(
    SELECT ['1.2.3-prod', '1.2.3', '1.2.3-stg'] AS version
)
ARRAY JOIN version
ORDER BY sem_ver_arr DESC

┌─version────┬─sem_ver_arr─┐
│ 1.2.3-prod │ [1,2,3]     │
│ 1.2.3      │ [1,2,3]     │
│ 1.2.3-stg  │ [1,2,3]     │
└────────────┴─────────────┘

不同的版本控制模式也会绑定

SELECT
    version,
    sortableSemVer(version) AS sem_ver_arr
FROM
(
    SELECT [
        'my-app-1.2.3-prod', 
        '1.2.3', 
        '1.2.3(af012342)-ALPHA'
        ] AS version
)
ARRAY JOIN version
ORDER BY sem_ver_arr DESC

Query id: 5bce759d-8ddb-4327-8e84-6f682b71b022

┌─version───────────────┬─sem_ver_arr─┐
│ my-app-1.2.3-prod     │ [1,2,3]     │
│ 1.2.3                 │ [1,2,3]     │
│ 1.2.3(af012342)-ALPHA │ [1,2,3]     │
└───────────────────────┴─────────────┘

但是,这通常不是问题,因为客户倾向于使用相同的版本控制模式。ClickHouse 的 UDF 是一种使用 lambda 处理数据的强大方法。试用本指南中的 UDF,以最适合您的需求。对于我们的目的,我们发现这已经足够好了。

分享这篇文章

订阅我们的新闻通讯

随时了解功能发布、产品路线图、支持和云产品!
正在加载表单...
关注我们
X imageSlack imageGitHub image
Telegram imageMeetup imageRss image
©2025ClickHouse, Inc. 总部位于加利福尼亚州湾区和荷兰阿姆斯特丹。