clickhouse建宽表多少列最合适?

2,025 阅读15分钟

大家在使用**「ClickHouse」**时候经常会问: “多少列才算合适?我可以要一个有100列的表吗?1000列?5000列?”事实上,没有确切的答案。让我们还是先去测试下。于是便决定创建一个极端的实验,比如创建10000列的表。

我有个大胆的想法

也不用想那么多,不管三七二十一,试验下看看,其实只要足够的内存和cpu是可以的,只是不清楚需要多少。如果你想了解实验的具体情况,那就来看这篇文章吧~

分析一波

  • metrics :clickhouse的监控指标

考虑一个从各种设备类型收集指标的监视应用程序。每种设备类型都可能有一组独特的不同指标。当存储在单个表中时,可能产生数百甚至数千个metrics。最有效的方法是将每个metrics保存在单独的列中。当提前知道metrics标准时,这种方法效果最好。对于可变的时间序列,例如当动态添加新的设备类型时,可以使用其他方法,例如在键值数组或类地图结构中存储metrics。

但是,向 ClickHouse 添加新列的成本非常低廉,而且我们已经看到了 ClickHouse 用户定期添加新列的常规操作。例如,当出现一个新的指标时,或者为了实现存储在原始 JSON 或数组中最常用的指标。这可能导致具有数百或数千列的非常宽的表。

创建10K 列的表

当列数不太大时,使用 CREATETABLE 语句创建表很容易。然而,如果我们需要数以千计的列数,那就需要用到自动化语句,下面是自动化语句sql:

WITH cols as (SELECT 'col' || toString(number) as col_name, 'Int32' as col_type from numbers(10000))
SELECT
'CREATE TABLE events_wide (
timestamp DateTime,
device_id String,
' || arrayStringConcat(arrayMap( (k, t) -> k || ' ' || t, groupArray(col_name), groupArray(col_type)), ',\n')
|| '
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (device_id, timestamp)
' as create_table
FROM cols
FORMAT TSVRaw

在这里,我们使用 ClickHouse 数字生成器生成一个具有10K 行的数据集,这些行定义列和数据类型。然后,我们使用例程数组魔术将行转换为列定义。其中FORMAT TSVRaw 很重要——它保持所有行尾和引号字符就位。

最终结果,是一个10K + 行的大表,创表语句:

CREATE TABLE events_wide (
timestamp DateTime,
device_id String,
col0 Int32,
col1 Int32,
col2 Int32,
...
col9999 Int32
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (device_id, timestamp)

为了将表定义加载到 ClickHouse,我们可以复制/粘贴巨大的 DDL 语句,或者使用管道:

clickhouse-local --query="WITH cols as (SELECT 'col' || toString(number) as col_name, 'Int32' as col_type from numbers(10000))
SELECT
'CREATE TABLE events_wide (
timestamp DateTime, 
device_id String,
' || arrayStringConcat(arrayMap( (k, t) -> k || ' ' || t, groupArray(col_name), groupArray(col_type)), ',\n')
|| '
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (device_id, timestamp)
' as create_table
FROM cols
FORMAT TSVRaw" | clickhouse-client -h <my-server> --user=<my-user> --password

现在我们有了一个10K 列表,接下来是什么?我们还要创建一个表,将所有指标存储在一个 Map 中以进行比较:

CREATE TABLE events_map (
 timestamp DateTime,
 device_id String,
 metrics Map(LowCardinality(String), Int32)
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (device_id, timestamp)

创建了表之后,我们需要加载测试数据。

插入数据到10K 列的表中

有多种方法可以将数据加载到 ClickHouse 中。为了生成巨大的 INSERT 语句,我们将在这里再次使用元编程。它将允许我们探索一些 ClickHouse 的限制:

WITH cols as (SELECT 'col' || toString(number) as col_name, 'Int32' as col_type from numbers(10000)),
     1000 as devices,
     1000000 as rows
SELECT
'
INSERT INTO events_wide
SELECT
now() timestamp,
toString(rand(0)%' || toString(devices) || ') device_id,
' || arrayStringConcat( arrayMap( c -> 'number as ' || c, groupArray(col_name)),',\n')
||
'
FROM numbers(' || toString(rows) || ')'
FROM cols
FORMAT TSVRaw

这个脚本有三个可以修改的变量:

  • 「Protocol」:生成列列表,就像我们对 CREATE TABLE 所做的那样

  • 「device」:指定唯一设备 _ ids 的数量

  • 「rows」:指定要插入的行数

在每一行中,所有的列都得到相同的值,但是对于本文的研究来说,这是可以的。

生成的**「INSERT」**语句可以存储在一个文件中,因此我们可以快速重新运行它并进行小的调整。它会像这样:

INSERT INTO events_wide
SELECT
now() timestamp,
toString(rand(0)%1000) device_id,
number as col0,
number as col1,
number as col2,
...
number as col9999
FROM numbers(1000000)

一旦我们生成了 INSERT 语句并尝试执行它,我们就得到了第一个凸点:

Max query size exceeded: '9114'. (SYNTAX_ERROR)

我们的 INSERT 语句大小为287K,高于默认的 ClickHouse 限制(256K)。我们需要增加 max _ query _ size 设置。它可以作为参数添加到 clickhouse-client,例如:

Cat q.sql | clickhouse-client-max _ query _ size = 1000000

让我们将它设置为1M 并再次尝试运行加载脚本。

AST is too big. Maximum: 50000. (TOO_BIG_AST)

又报错!现在 ClickHouse 解析器抱怨这个查询太复杂了。Max _ ast _ element 需要增加,默认值为50K。我们也增加一点。现在我们遇到了记忆问题:

Memory limit (total) exceeded: would use 12.60 GiB (attempt to allocate chunk of 4194320 bytes)

最初,我们使用了一个4vCPU 和16GB RAM 的服务器进行这些测试,但似乎不足以支持10K 列。ClickHouse 为每个列分配2MB 的缓冲区,因此对于10K 列,可能需要超过20GB 的 RAM。让我们将集群重新扩展到更大的节点大小,并证明如果有更多 RAM,我们可以加载数据。于是,便尝试用16 vCPU/64 GB RAM 服务器,但报了下面的错误:

Memory limit (for query) exceeded: would use 53.11 GiB (attempt to allocate chunk of 8000256 bytes), maximum: 53.10 GiB. (MEMORY_LIMIT_EXCEEDED)

心好累

内存溢出了,再用 32 vCPU/128 GB 的服务器试下。

2000年后

在等待了167秒后,我们终于完成了1M 行数据存到了一个10K 列的表中!现在我们可以检查 query _ log 并查看内存使用峰值为64GB。

有可能减少内存占用吗?如果我们为 MergeTree 表启用紧凑部分会怎样?压缩部分中的列被存储在一个单独的文件中(准确地说,是数据和标记的单独文件) ,每个列的偏移量也被存储。因此,它仍然是柱状的,但不是每列写和读单独的文件,ClickHouse 寻找少量的文件。使用较少数量的文件,我们可以期望更好的插入性能和更少的内存消耗。

为了启用紧凑型部件,我们需要将 min _ bytes _ for _ wide _ part 和 min _ rows _ for _ wide _ part 设置为一些更大的值。这些是表级设置,可以用 ALTERTABLE 语句修改。然而,为了进行比较,我们将创建一个新表:

CREATE TABLE events_compact as events_wide
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (device_id, timestamp)
SETTINGS
min_bytes_for_wide_part=1048576000,
min_rows_for_wide_part=1048576;

因此我们用这种方法为紧凑的部件设置100MB 和1M 的行限制。

让我们运行与前面相同的 insert 语句,但是对象是已更改的表。相当令人惊讶的是,插入 events _ compacttable 需要多花费25% 的时间和 RAM: 214s 和82GB!

最后,让我们使用以下 SQL 将数据加载到 events _ map 表中。我们不需要元编程,因为我们可以在查询执行期间构造 map:

INSERT INTO events_map
WITH arrayMap( n -> 'col' || toString(n), range(10000)) as cols
SELECT
  now() timestamp,
  toString(rand(0)%1000) device_id,
  arrayMap( c -> (c,number), cols)
FROM numbers(1000000)

这个查询为在 INSERT 上转换为 Map 数据的每一行构造一个元组数组[(‘ col0’,number) ,... ,(‘ col9999’,number)]。这又耗尽了内存,所以我们必须做另一个技巧: 减少块大小。方法如下:

SELECT ... SETTINGS max_block_size=100000

加载到 events _ map 表的速度慢如龟速。即使块大小减小,45 GB 的峰值内存使用也需要13分钟。但是,本例中的问题不是 INSERT,而是查询的 SELECT 部分。与对宽表和紧凑表的查询不同,在这里我们必须对每一行使用10K 元素数组进行操作。它是缓慢和内存密集型的。为了优化这个查询,我们已经尝试了许多实验,但是即使在插入常量或从文件中加载准备好的数据时,也不能得到更好的结果!看起来主要的开销是处理巨大的数组。

由于100K 行块有助于减少 events _ map 的内存占用,因此让我们对宽表和紧凑表尝试同样的做法。结果非常有趣: 紧凑部件表的内存使用显著减少,但宽表的内存使用几乎保持不变。以下是一个总结:

「表名」「加载1M 行」「RAM 1M 行」「加载10x100K 行」「RAM 10x100K 行」
事件范围16764GB15650GB
Events _ 压缩21282GB1579.3 GB
Events _ map78045GB

现在让我们看看所有表的各个部分:

select table, part_type, count(), sum(rows), sum(bytes_on_disk) bytes_on_disk, sum(data_uncompressed_bytes) uncompressed from system.parts where table like 'events_%' group by table, part_type order by table

┌─table──────────┬─part_type─┬─count()─┬─sum(rows)─┬─bytes_on_disk─┬─uncompressed─┐
│ events_compact │ Compact   │      10 │   1000000 │   41794344282 │  40007890218 │
│ events_map     │ Wide      │       2 │   1000000 │     843832859 │  60463651504 │
│ events_wide    │ Wide      │      10 │   1000000 │   41089353783 │  40007889959 │
└────────────────┴───────────┴─────────┴───────────┴───────────────┴──────────────┘

Events _ map 表中未压缩的数据大小更大,因为列名现在存储在 map 中的每一行上,但是压缩的数据大小明显更小——这要归功于有大量重复的测试数据,这些数据可以更好地压缩为行格式。

宽表的另一个潜在警告是后台合并性能。我们还来看一下对各个表运行 OPTIMIZETABLEFINAL 语句的合并性能。

「表」「合并时间到」
事件范围410
Events _ 压缩2500
Events _ map1060

首先,我们可以看到合并是非常缓慢的!合并10M 宽的行要比最初加载它们花费更多的时间。合并性能为一个紧凑的部件表只是可怕的!尽管我们在插入 events _ compact 时节省了 RAM,但是后来我们在 merge 上花费了很大的负载。

实验要求:

  • 将 max _ query _ size 设置增加到1M,这是处理大型 SQL 所必需的

  • 将 max _ ast _ element 设置增加到256K,这也是处理大型 SQL 所必需的

  • 内存超过64GB

  • 如果内存不足,减小 max _ block _ size。

  • 使用紧凑的部件可能会降低 RAM 的利用率,但也有其他缺点,如插入速度较慢和合并速度非常慢。

查询10K 行的表

为了测试查询性能,我们运行以下查询:

  1. 全面扫描查询:
select count() from events_wide where not ignore(*)

  1. 以某种标准衡量,排名前十的设备有:
select device_id, max(col1234) col1234
  from events_wide
 group by device_id
 order by col1234 desc limit 10

  1. 按时间维度分组的单个设备的多个指标:
select timestamp ts,
  avg(col111) col111, avg(col222) col222, avg(col333) col333, avg(col444) col444
  from events_wide
 where device_id = '555'
 group by ts
 order by ts

对 events _ map 表的查询需要稍作修改,以使用指标[‘ colXYZ’]而不是 colXYZ。

以下是查询的运行时间和内存使用情况:

「表」「问题1」「问2」「问3」
事件范围126s/47GB0.052秒/58MB0.047秒/13 MB
Events _ 压缩160s/40GB0.046秒/59MB0.072秒/10MB
Events _ map4.5秒/255 MB8.8 s/9 GB0.170秒/102 MB

当选择所有列时,10K 列表的速度预计会很慢,而且内存不足,但是对于触及列子集的查询来说,速度快得惊人。

在全面扫描时,Map 表的速度要快得多,在单个设备上运行良好,但是在所有设备上扫描一个metrics时,速度要慢得多,因为 ClickHouse 每次都必须读取和扫描庞大的 Map 列。

一般来说,典型的监视查询 Q2和 Q3的查询性能不受许多列的影响——这要感谢 ClickHouse 柱状数据格式。只有 events _ map 表(本质上是一种行格式)感觉到额外的负载。

缩减到1000列

在加载10,000个列时,我们遇到了一些挑战。如果我们把列的数量减少到只有一千列呢?我们来测试一下!

创建模式和数据的相同查询可以像上面那样使用,只需将列数从10,000改为1000即可。对于1000列,我们不需要应用任何特殊的设置,系统默认设置工作得很好,我们可以在一个块中加载1M 行。

1000列的加载性能是10000列的10倍,而 merge 也很快:

「表名」「加载1M 行」「RAM 1M 行」「合并1M 行」
事件范围126.5 GB11分
Events _ 压缩138.2 GB9.3秒
Events _ map7436GB4.1秒

ClickHouse 默认的插入块大小是1048545。所以我们可以假设,在默认情况下,对于更大的插入,ClickHouse 也将消耗相同数量的 RAM。尽管如此,events _ map 表的内存使用率仍然很高。处理巨大的映射和数组是内存密集型的。这会引导我们进入下一部分。

稀疏的宽表怎么样?

在实际应用中,一个设备永远不会产生10K 的指标。通常,一种设备类型负责10-100个指标。但是,多种设备类型可能会产生不同的metrics指标集。当存储在单个监视表中时,可能会产生上一节所讨论的宽表。这些表必须是稀疏的,因为每行只有一小部分列有数据,其他列为空。由于我们按设备对表进行排序,并且单个设备的指标通常分组在一起,因此矩阵中的数据分布是块稀疏的。

在这种情况下,映射表将更加紧凑,因为它不必存储空值。在这个场景中测量 ClickHouse 也会很有趣,但是这会使本文太长。让我们演示一下稀疏映射表是什么样子的。

下面的脚本生成1M 行,但是对于每个设备,它只在10K 可能的行中放入100个指标。

INSERT INTO events_map_sparse
SELECT
now() timestamp,
toString(rand(0)%1000 as did) device_id,
arrayMap( c -> ('col' || toString(c), number), range(did*10, did*10 + 100))
FROM numbers(1000000)
SETTINGS function_range_max_elements_in_block=1000000000

这只需要8加载在一个单一的块!与完整的10K 值相比,数据大小减少了100倍:

┌─table─────────────┬─part_type─┬─count()─┬─sum(rows)─┬─bytes_on_disk─┬─uncompressed─┐
│ events_map        │ Wide      │       2 │   1000000 │     843832859 │  60463651504 │
│ events_map_sparse │ Wide      │       1 │   1000000 │       8014874 │    601591449 │
└───────────────────┴───────────┴─────────┴───────────┴───────────────┴──────────────┘

查询也相应地更快。

宽表或多表

另一种设计方法是使用多个表,每个设备类型一个。因此,我们可以有100个表,每个表有100个列,而不是10K 列宽的表。乍看之下,这似乎有些道理,但这只是另一种权衡。虽然插入到这样的“普通”表很容易,但插入次数要多100倍。此外,管理大量表可能会很复杂。与一个宽表相比,100个表的合并总开销将更高。摄入管道看起来也更复杂。应用程序层需要知道如何将查询路由到适当的表等等。当然,这些根本不是阻碍因素,可能适用于那些准备自己承担管理开销的用户。

最终结论

极限测试clickhouse这个过程挺有意思,虽然过程有点艰难。

现在让我们总结下测试的结论吧。clickhouse对于10000列也是能扛住的,但是在插入数据和执行维护操作(例如合并或突变)时可能需要额外的调优和大量 RAM。但只要RAM足够,用户可以获得出色的 ClickHouse 查询性能。对于典型的分析查询,10个列表和10000个列表没有什么不同。当不需要处理数千列时,可以使用 Map 数据类型。它可以在方便性和性能之间提供很好的折衷,特别是对于稀疏表,因为在一行中只使用了10K 列的一小部分。

回答标题提到的问题:**「clickhouse建宽表多少列最合适?」**1000列内都可以,原因看文章实验即可。

ClickHouse 的魅力在于它的灵活性,用户不一定要使用单个模式设计,有多种选择。理解不同方法的优缺点对于每个 ClickHouse 用户都很重要,同时实践才能检验真理!