对于分析型数据库而言,将数据汇总成容易理解的报表是其关键能力之一。人们在面对数十亿条网页访问记录的时候其实很难得出什么结论性的东西,但是比如我们说平均访问时长从上个月的25秒提升到了本月的32秒,这就很容易理解了。在ClickHouse中我们将这个过程称之为聚合(译者注:事实上,在其它数据库或数据分析软件中也是这么叫的)。这是从海量数据中提取信息的基本方法。

在这个系列中我们将探索clickhouse中的聚合函数是如何工作的,如何测量聚合函数的性能以及如何让聚合函数运行的更快和更有效率。为了提升可读性,我们将拿一些简单的例子进行举例,但是原理性的东西同样适用于更复杂的查询。

下面的例子使用的clickhouse版本是22.1.3.7,,运行在Altinity Cloud上。使用的数据来自美国交通局的实时航班数据,数据量大概有1亿9700万条,每一行数据表示一个商业航空公司的一趟航班。

聚合查询的工作原理

让我们举一个简单的例子看看Clickhouse内部是如何处理聚合的吧。下面这是一个查询用来计算美国航班的平均出发晚点时间,并将查询结果进行逆序排列取出晚点时间最长的三个航班。

SELECT Carrier, avg(DepDelay) AS Delay
FROM ontime
GROUP BY Carrier 
ORDER BY Delay DESC LIMIT 3
. . .
┌─Carrier─┬──────────────Delay─┐
│ B6      │ 12.058290698785067 │
│ EV      │ 12.035012037703922 │
│ NK      │ 10.437692933474269 │
└─────────┴────────────────────┘

在我自己的单个云主机节点上,这个查询耗时大概0.75秒。该查询读取了全部的数据集,总共1亿9700万条记录,看起来是相当快的。那么,clickhouse内部发生了什么?下图展示了整个查询的流程。

Clickhouse AggregateFunction 按天汇聚 clickhouse聚合函数_clickhouse


让我们关注一下整个扫描过程中处理聚合操作的两个重要特征吧:

  1. 并行化的查询过程。Clickhouse分配了多个线程-本例中是4个线程-分别来各自读取不同数据表的parts文件中的数据,每个线程负责读取数据然后进行初步的聚合。
  2. 聚合操作是在哈希表中完成累加的。 键值是每一个GROUP BY的值。

一旦扫描结束,最后一步就是将所有的聚合结果进行合并然后排序。这就引发了下一个问题:Clickhouse是如何并行计算聚合操作的?下图就展示了它是如何做到这一点的。

Clickhouse AggregateFunction 按天汇聚 clickhouse聚合函数_数据库_02


最后一个问题:Clickhosue是如何在合并前收集和存储各部分数据的?正如我们上面提到的,答案就是哈希表,key值就是GROUP BY语句中指定的value,部分汇聚的结果以list的方式进行存储,和每个key值关联。下面是一个说明图:

Clickhouse AggregateFunction 按天汇聚 clickhouse聚合函数_字段_03


Clickhouse实际汇聚的过程要远比上面图上画的过程复杂,也更快一些。

首先,Clickhouse会根据GROUP BY字段的数量和类型选择不同的聚合方法以及哈希表的配置。数据结构和收集算法根据数据的不同而不同。这种个性化的处理方式确保了在不同的数据类型下都能根据性能的差异选择更合适的聚合方法。

其次,Clickhouse的聚合过程是动态的。刚开始的时候使用的是单层哈希表,这种结构在key值规模较小的时候速度更快,随着存储的key值越来越多,Clickhouse会自动切换到两级哈希表,使得操作更快。Clickhouse事实上是自适应数据的,从而可以在不需要太多查询计划开销的情况下达到很高的性能。

说到查询计划,有人正在试着添加一些统计信息从而能够让Clickhouse提前预测到它需要直接使用两级哈希表。这是我们看到未来有可能会实现的用于优化聚合操作性能的方式之一。

顺便提一下,将聚合操作安排成队列的形式,然后对队列里的内容进行归并操作是不是听着很熟悉?你也许听说过MapReduce,就是由Jeff Dean 和 Sanjay Ghemawat共同编写的那篇著名的论文的主题,大名鼎鼎的Hadoop正是在此基础上研发出来的。事实上在Hadoop让这个技术广泛流行开始之前数据仓库已经使用了这种技术很多年了。

聚合操作性能测量工具

既然我们已经探索了聚合操作是如何工作的,显然我们就会提出一些和聚合操作相关的有趣的问题。我们需要工具来了解查询速度,内存占用以及其它种种。同样我们也需要一种方式能够窥视到整个查询过程以及聚合操作的细节。

使用system.query_log

幸运的是系统表system.query_log为我们提供了需要的信息来了解聚合操作的性能。该功能在安装Clickhouse的时候是默认开启的。下面我们通过一个简单的查询来看看我们的示例SQL的执行时间以及占用的内存大小,对应结果列表中的第三行。

SELECT
    event_time, query_duration_ms / 1000 AS secs,
    formatReadableSize(memory_usage) AS memory,
    Settings['max_threads'] AS threads,
    substring(query, 1, 20) AS query
FROM system.query_log AS ql
WHERE (user = 'default') AND (type = 'QueryFinish')
ORDER BY event_time DESC
LIMIT 50

Query id: 94c00711-31cb-4280-8270-717ccf942748

┌──────────event_time─┬──secs─┬─memory───┬─threads─┬─query────────────────┐
│ 2022-03-14 13:30:03 │ 0.007 │ 0.00 B   │ 4       │ select event_time, q │
│ 2022-03-14 13:23:52 │ 0.032 │ 0.00 B   │ 4       │ select event_time, q │
│ 2022-03-14 13:23:40 │ 2.023 │ 0.00 B   │ 4       │ SELECT Carrier, avg( │
│ 2022-03-14 13:23:20 │ 0.002 │ 0.00 B   │ 4       │ SELECT message FROM  │
│ 2022-03-14 13:23:20 │ 0.061 │ 4.24 MiB │ 4       │ SELECT DISTINCT arra │

system.query_log表的功能有很多,但是我们只关注其中的关键几列。首先,每次查询在表中都会记录多个事件,事件名称对应的就是表中的type字段。我们只关注QueryFinish类型的事件,因为它会显示该查询所有的统计信息。

其次,query_duration_msmemory_usage字段表示该查询的持续时间和内存占用大小。这些很好理解,但是你可能会疑惑为什么很多查询的内存使用都是0字节呢,显然这是不可能的。

原因很简单,Clickhouse会忽略掉那些内存大小占用在max_untracked_memory参数以下的值,该值默认是4,194,304。我们可以尝试将该参数降低为1,然后就可以看到那些查询的内存占用大小就都显示出来了。下面是一个例子:

SET max_untracked_memory = 1

SELECT Carrier, avg(DepDelay) AS Delay
FROM ontime
GROUP BY Carrier 
ORDER BY Delay DESC LIMIT 3

SELECT
    event_time, query_duration_ms / 1000 AS secs,
    formatReadableSize(memory_usage) AS memory,
    Settings['max_threads'] AS threads,
    substring(query, 1, 20) AS query
FROM system.query_log
WHERE (user = 'default') AND (type = 'QueryFinish')
ORDER BY event_time DESC
LIMIT 50

┌──────────event_time─┬──secs─┬─memory───┬─threads─┬─query────────────────┐
│ 2022-03-14 13:35:09 │  0.85 │ 8.40 MiB │ 4       │ SELECT Carrier, avg( │

取平均值只占用很少的内存因为这个聚合操作是简单的。更复杂的聚合操作会占用更多的内存,因此在这种情况下你一般不需要修改max_untracked_memory这个参数的值。

在system.query_log表中还有其它许多有用的字段,但是我们上面例子中使用的简单查询已经足够帮助我们来测量聚合操作过程并作出权衡了。如果你还感兴趣的话可以研究一下其它的字段信息。

使用查询日志

另一个重要的工具就是使用clickhouse服务提供的日志。你可以通过把日志信息写入到system.text_log表中直接进行查看,或者在使用clickhouse-client的时候加上send_message_logs参数。下面就展示了如何使能日志追踪来查看查询操作的细节,并且展示了你可以从中获取到哪些有用的信息。

SET send_logs_level = 'trace'

SELECT Carrier, FlightDate, avg(DepDelay) AS Delay
FROM ontime
GROUP BY Carrier, FlightDate
ORDER BY Delay DESC LIMIT 3
[chi-clickhouse101-clickhouse101-0-0-0] 2022.03.16 22:23:39.802817 [ 485 ] {287891a8-d444-4d43-a96a-ca7aa5cd6ff5} <Debug> executeQuery: (from xx.xxx.xx.xx:43802, user: default) -- #2
 SELECT Carrier, FlightDate, avg(DepDelay) AS Delay FROM ontime GROUP BY Carrier, FlightDate ORDER BY Delay DESC LIMIT 3 ;
. . . 
[chi-clickhouse101-clickhouse101-0-0-0] 2022.03.16 22:23:39.810727 [ 282 ] {287891a8-d444-4d43-a96a-ca7aa5cd6ff5} <Trace> Aggregator: Aggregation method: keys32
. . .
[chi-clickhouse101-clickhouse101-0-0-0] 2022.03.16 22:23:40.530650 [ 282 ] {287891a8-d444-4d43-a96a-ca7aa5cd6ff5} <Debug> AggregatingTransform: Aggregated. 50160884 to 42533 rows (from 382.70 MiB) in 0.721708392 sec. (69502980.090 rows/sec., 530.28 MiB/sec.)

Debug级别的日志一般已经足够我们来查看各个线程的运行状态了。如果你想知道聚合操作选用了什么样的聚合方法,那么trace能够给你提供帮助。它会针对一个大一点儿的查询产生惊人的日志。

小结

在第一部分中我们探究了Clickhouse聚合操作的内部实现原理,然后我们介绍了两个基本的工具来追踪聚合操作:system.query_log和Clickhouse的debug以及trace级别的日志。

在下一部分中我们将把这些知识用于到实战中。我们会通过具体的例子来探索聚合操作的性能,然后给出建议,如何能够通过减少资源消耗的同时还能提升查询速度。敬请期待!