
本文字数:10726;估计阅读时间:27 分钟
审校:庄晓东(魏庄)

Meetup活动
ClickHouse Chengdu User Group第1届 Meetup 倒计时1天,报名从速,详见文末海报!

ClickHouse 不仅支持标准的聚合函数,还提供了许多更高级的函数,以满足大多数分析用例的需求。除了聚合函数之外,ClickHouse 还提供了聚合组合器,这是对查询功能的强大扩展,可以满足大量需求。
组合器允许扩展和混合聚合以处理各种数据结构。这种能力将使我们能够调整查询而不是表,以回答甚至最复杂的问题。
在本篇博客文章中,我们将探讨聚合组合器以及它们如何潜在地简化您的查询,并避免对数据进行结构性更改的需求。

要使用组合器,我们需要做两件事。首先,选择我们想要使用的聚合函数;假设我们想要使用 sum() 函数。其次,选择我们案例所需的组合器;假设我们需要一个 If 组合器。要在查询中使用它,我们将组合器添加到函数名称中:
SELECT sumIf(...)
SELECT sumArrayIf(...)

这个特定的示例将允许我们有条件地对数组列的内容进行求和。
让我们探讨一些实际案例,看看组合器可以用在哪些情况下。

有时,我们需要根据特定条件对数据进行聚合。我们可以使用 If 组合器,并将条件指定为组合函数的最后一个参数,而不是使用 WHERE 子句:

假设我们有一个具有以下结构的用户支付表(填充了示例数据):
CREATE TABLE payments(`total_amount` Float,`status` ENUM('declined', 'confirmed'),`create_time` DateTime,`confirm_time` DateTime)ENGINE = MergeTreeORDER BY (status, create_time)
SELECT sumIf(total_amount, status = 'confirmed') FROM payments┌─sumIf(total_amount, equals(status, 'declined'))─┐│ 10780.18000793457 │└─────────────────────────────────────────────────┘
SELECT sumIf(total_amount, (status = 'confirmed') AND (confirm_time > (create_time + toIntervalMinute(1)))) AS confirmed_and_checkedFROM payments┌─confirmed_and_checked─┐│ 11195.98991394043 │└───────────────────────┘
SELECTcountIf((status = 'confirmed') AND (confirm_time > (create_time + toIntervalMinute(1)))) AS num_confirmed_checked,sumIf(total_amount, (status = 'confirmed') AND (confirm_time > (create_time + toIntervalMinute(1)))) AS confirmed_checked_amount,countIf(status = 'declined') AS num_declined,sumIf(total_amount, status = 'declined') AS dec_amount,avgIf(total_amount, status = 'declined') AS dec_averageFROM payments┌─num_confirmed_checked─┬─confirmed_checked_amount─┬─num_declined─┬────────dec_amount─┬───────dec_average─┐│ 39 │ 11195.98991394043 │ 50 │ 10780.18000793457 │ 215.6036001586914 │└───────────────────────┴──────────────────────────┴──────────────┴───────────────────┴───────────────────┘

计算唯一条目的数量是一个常见的情况。ClickHouse有几种方法可以做到这一点,可以使用 COUNT(DISTINCT col)(与 uniqExact 相同)或 uniq(),当需要估计值(但更快速)时。尽管如此,我们可能希望从不同的聚合函数中使用在列中的唯一值。可以使用 Distinct 组合器来实现这一点:

一旦将 Distinct 添加到聚合函数中,它就会忽略重复的值:
SELECTcountDistinct(toHour(create_time)) AS hours,avgDistinct(toHour(create_time)) AS avg_hour,avg(toHour(create_time)) AS avg_hour_allFROM payments┌─hours─┬─avg_hour─┬─avg_hour_all─┐│ 2 │ 13.5 │ 13.74 │└───────┴──────────┴──────────────┘

由于组合器可以组合在一起,我们可以将前面的组合器和avgDistinctIf函数一起使用,以处理更高级的逻辑:
SELECT avgDistinctIf(toHour(create_time), total_amount > 400) AS avg_hourFROM payments┌─avg_hour─┐│ 13 │└──────────┘

我们可能想要将数据分成几组,然后分别计算每组的指标,而不是进行最小值/最大值分析。这可以使用 Resample 组合器来解决。
它接受一个列、范围(开始/停止)和你想要根据哪个步长来分割数据。然后,它为每个组返回一个聚合值:

假设我们想要根据 total_amount(从最小值0到最大值500)以步长100来分割我们的支付表数据。然后,我们想要知道每个组中有多少条目,以及每个组的总平均数:
SELECTcountResample(0, 500, 100)(toInt16(total_amount)) AS group_entries,avgResample(0, 500, 100)(total_amount, toInt16(total_amount)) AS group_totalsFROM paymentsFORMAT VerticalRow 1:──────group_entries: [21,20,24,31,4]group_totals: [50.21238123802912,157.32600135803222,246.1433334350586,356.2583834740423,415.2425003051758]
请注意,countResample() 函数只有一个参数(因为 count() 根本不需要参数),avgResample() 函数有两个参数(第一个是要计算平均值的列)。最后,我们必须使用 toInt16 将 total_amount 转换为整数,因为 Resample 组合器需要这样。
要以表格布局获取 Resample() 组合器的输出,我们可以使用 arrayZip() 和 arrayJoin() 函数:
SELECTround(tp.2, 2) AS avg_total,tp.1 AS entriesFROM(SELECTarrayJoin(arrayZip(countResample(0, 500, 100)(toInt16(total_amount)),avgResample(0, 500, 100)(total_amount, toInt16(total_amount)))) AS tpFROM payments)┌─avg_total─┬─entries─┐│ 50.21 │ 21 ││ 157.33 │ 20 ││ 246.14 │ 24 ││ 356.26 │ 31 ││ 415.24 │ 4 │└───────────┴─────────┘

聚合函数在结果集为空时的反应不同。例如,count() 会返回 0,而 avg() 则会产生一个 nan 值。
我们可以使用 OrDefault() 和 OrNull() 这两个组合器来控制这种行为。它们都会改变在数据集为空时聚合函数返回的值:
OrDefault() 会返回函数的默认值,而不是 nan,
OrNull() 会返回 NULL(并且还会改变返回类型为 Nullable)。
考虑以下示例:
SELECTcount(),countOrNull(),avg(total_amount),avgOrDefault(total_amount),sumOrNull(total_amount)FROM paymentsWHERE total_amount > 1000┌─count()─┬─countOrNull()─┬─avg(total_amount)─┬─avgOrDefault(total_amount)─┬─sumOrNull(total_amount)─┐│ 0 │ ᴺᵁᴸᴸ │ nan │ 0 │ ᴺᵁᴸᴸ │└─────────┴───────────────┴───────────────────┴────────────────────────────┴─────────────────────────┘

与其他组合器一样,orNull() 和 orDefault() 可以与不同的组合器一起使用,以实现更高级的逻辑:
SELECTsumIfOrNull(total_amount, status = 'declined') AS declined,countIfDistinctOrNull(total_amount, status = 'confirmed') AS confirmed_distinctFROM paymentsWHERE total_amount > 420┌─declined─┬─confirmed_distinct─┐│ ᴺᵁᴸᴸ │ 1 │└──────────┴────────────────────┘

ClickHouse 的 Array 类型在其用户中很受欢迎,因为它为表结构带来了很大的灵活性。为了有效地操作 Array 列,ClickHouse 提供了一组数组函数。为了简化对 Array 类型进行聚合,ClickHouse 提供了 Array() 组合器。这些组合器将给定的聚合函数应用于数组列中的所有值,而不是数组本身:

假设我们有以下表(填充了示例数据):
CREATE TABLE article_reads(`time` DateTime,`article_id` UInt32,`sections` Array(UInt16),`times` Array(UInt16),`user_id` UInt32)ENGINE = MergeTreeORDER BY (article_id, time)┌────────────────time─┬─article_id─┬─sections─────────────────────┬─times────────────────────────────────┬─user_id─┐│ 2023-01-18 23:44:17 │ 10 │ [16,18,7,21,23,22,11,19,9,8] │ [82,96,294,253,292,66,44,256,222,86] │ 424 ││ 2023-01-20 22:53:00 │ 10 │ [21,8] │ [30,176] │ 271 ││ 2023-01-21 03:05:19 │ 10 │ [24,11,23,9] │ [178,177,172,105] │ 536 │...
让我们使用 uniqArray() 函数来计算每篇文章阅读的唯一部分数,再结合 avgArray() 来获得每个部分的平均时间:
SELECTarticle_id,uniqArray(sections) sections_read,round(avgArray(times)) time_per_sectionFROM article_readsGROUP BY article_id┌─article_id─┬─sections_read─┬─time_per_section─┐│ 14 │ 22 │ 175 ││ 18 │ 25 │ 159 │...│ 17 │ 25 │ 170 │└────────────┴───────────────┴──────────────────┘
SELECTminArray(times),maxArray(times)FROM article_reads┌─minArray(times)─┬─maxArray(times)─┐│ 30 │ 300 │└─────────────────┴─────────────────┘
SELECTarticle_id,groupUniqArrayArray(sections)FROM article_readsGROUP BY article_id┌─article_id─┬─groupUniqArrayArray(sections)───────────────────────────────────────┐│ 14 │ [16,13,24,8,10,3,9,19,23,14,7,25,2,1,21,18,12,17,22,4,6,5] │...│ 17 │ [16,11,13,8,24,10,3,9,23,19,14,7,25,20,2,1,15,21,6,5,12,22,4,17,18] │└────────────┴─────────────────────────────────────────────────────────────────────┘
SELECTarticle_id,anyArray(sections)FROM article_readsGROUP BY article_id┌─article_id─┬─anyArray(sections)─┐│ 14 │ 19 ││ 18 │ 6 ││ 19 │ 25 ││ 15 │ 15 ││ 20 │ 1 ││ 16 │ 23 ││ 12 │ 16 ││ 11 │ 2 ││ 10 │ 16 ││ 13 │ 9 ││ 17 │ 20 │└────────────┴────────────────────┘
使用 Array 与其他组合器
Array 组合器可以与任何其他组合器一起使用:
SELECTarticle_id,sumArrayIfOrNull(times, length(sections) > 8)FROM article_readsGROUP BY article_id┌─article_id─┬─sumArrayOrNullIf(times, greater(length(sections), 8))─┐│ 14 │ 4779 ││ 18 │ 3001 ││ 19 │ NULL │...│ 17 │ 14424 │└────────────┴───────────────────────────────────────────────────────┘
如果我们将数组函数与组合器一起使用,可以处理更加复杂的情况:
SELECTarticle_id,countArray(arrayFilter(x -> (x > 120), times)) AS sections_engagedFROM article_readsGROUP BY article_id┌─article_id─┬─sections_engaged─┐│ 14 │ 26 ││ 18 │ 44 │...│ 17 │ 98 │└────────────┴──────────────────┘
聚合映射
ClickHouse 中还提供了另一种强大的类型,即 Map。与数组类似,我们可以使用 Map() 组合器对此类型应用聚合。
假设我们有以下具有 Map 列类型的表:
CREATE TABLE page_loads(`time` DateTime,`url` String,`params` Map(String, UInt32))ENGINE = MergeTreeORDER BY (url, time)┌────────────────time─┬─url─┬─params───────────────────────────────┐│ 2023-01-25 17:44:26 │ │ {'load_speed':100,'scroll_depth':59} ││ 2023-01-25 17:44:37 │ │ {'load_speed':400,'scroll_depth':12} │└─────────────────────┴─────┴──────────────────────────────────────┘
SELECTsumMap(params)['load_speed'] AS total_load_time,avgMap(params)['scroll_depth'] AS average_scrollFROM page_loads┌─total_load_time─┬─average_scroll─┐│ 500 │ 35.5 │└─────────────────┴────────────────┘
SELECT sumMapIf(params, url = '/404')['scroll_depth'] AS average_scroll FROM page_loads

另一种处理数组列的方法是从两个数组中聚合相应的值。这将产生另一个数组。这可以用于矢量化数据(如向量或矩阵),并通过 ForEach() 组合器实现:

假设我们有以下具有向量的表:
SELECT * FROM vectors┌─title──┬─coordinates─┐│ first │ [1,2,3] ││ second │ [2,2,2] ││ third │ [0,2,1] │└────────┴─────────────┘
SELECT avgForEach(coordinates) FROM vectors┌─avgForEach(coordinates)─┐│ [1,2,2] │└─────────────────────────┘
当然,也支持与其他组合器一起使用:
SELECT avgForEachIf(coordinates, title != 'second') FROM vectors┌─avgForEachIf(coordinates, notEquals(title, 'second'))─┐│ [0.5,2,2] │└───────────────────────────────────────────────────────┘

ClickHouse允许处理中间聚合状态而不是结果值。假设我们需要计算唯一值的数量,但我们不想保存值本身(因为它占用空间)。在这种情况下,我们可以使用 State() 组合器为 uniq() 函数保存中间聚合状态,然后使用 Merge() 组合器计算实际值:
SELECT uniqMerge(u)FROM(SELECT uniqState(number) AS u FROM numbers(5)UNION ALLSELECT uniqState(number + 1) AS u FROM numbers(5))┌─uniqMerge(u)─┐│ 6 │└──────────────┘

为什么我们要这样做?因为聚合状态所占用的空间比原始数据少得多。当我们想要将此状态存储在磁盘上时,这一点尤为重要。例如,uniqState() 数据比100万个整数数字少15倍的空间:
SELECTtable,formatReadableSize(total_bytes) AS sizeFROM system.tablesWHERE table LIKE 'numbers%'┌─table─────────┬─size───────┐│ numbers │ 3.82 MiB │ <- we saved 1 million ints here│ numbers_state │ 245.62 KiB │ <- we save uniqState for 1m ints here└───────────────┴────────────┘
CREATE TABLE payments_totals(`date` Date,`total_amount` AggregateFunction(sum, Float))ENGINE = AggregatingMergeTreeORDER BY date
INSERT INTO payments_totals SELECTdate(create_time) AS date,sumState(total_amount)FROM paymentsWHERE status = 'confirmed'GROUP BY date
┌─sumMerge(total_amount)─┐│ 12033.219916582108 │└────────────────────────┘

聚合函数组合器为ClickHouse中的任何数据结构上的分析查询带来了几乎无限的可能性。我们可以为聚合添加条件,对数组元素应用函数,或获取中间状态以以聚合形式存储数据,但仍可用于查询。

好消息:ClickHouse Chengdu User Group第1届 Meetup 已经开放报名了,将于2024年4月13日在成都市双流区华阳镇街道梓州大道6288号成都OPPO大厦18层1801会议室举行,扫码免费报名


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






