DoubleCloud 即将停止运营。利用限时免费迁移服务迁移到 ClickHouse。立即联系我们 ->->

博客 / 工程

ClickHouse 中的语义版本 UDF

author avatar
Juan S. Carrillo
2024年10月9日

我在 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 如下所示。如果您想了解我们是如何构建它的,以及我们在查询和推理方面所做的改进,请继续阅读。

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)

我们可以用 toUInt32OrZero 替换 toUInt32,它将为非数字字符串默认为 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 处理数据的强大方法。尝试使用本指南中的这些方法来最佳地满足您的需求。就我们而言,我们发现这已经足够好了。

分享此帖子

订阅我们的新闻通讯

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