简介
本文是我们 Parquet 和 ClickHouse 博客系列的第二部分。在本文中,我们将更详细地探讨 Parquet 格式,重点介绍使用 ClickHouse 读取和写入文件时需要考虑的关键细节。对于更有经验的 Parquet 用户,我们还将讨论用户在编写 Parquet 文件时可以进行的优化,以最大程度地提高压缩率,以及最近为优化使用并行化的读取性能而进行的一些开发。
在我们的示例中,我们将继续使用英国房价数据集。该数据集包含 1995 年至今英格兰和威尔士房地产价格的数据。我们在公共 s3 存储桶 s3://datasets-documentation/uk-house-prices/parquet/
中以 Parquet 格式分发该数据集。我们使用 ClickHouse Local 读取和写入本地和 S3 托管的 Parquet 文件。ClickHouse Local 是 ClickHouse 的一个易于使用的版本,非常适合需要使用 SQL 对本地和远程文件执行快速处理而无需安装完整数据库服务器的开发人员。最重要的是,ClickHouse Local 和 ClickHouse Server 共享相同的 Parquet 读取和写入代码,因此任何细节都适用于两者。有关更多详细信息,请参阅我们本系列之前的文章以及其他最近的专题内容。
Parquet 格式概述
结构
了解 Parquet 文件格式可以让用户在编写文件时做出决策,这将直接影响压缩级别和后续读取性能。以下描述是一个简化版本,但对于大多数用户来说已足够。
Parquet 格式依赖于三个主要概念,它们是分层相关的:行组、列块和页。
在其高层,文件被分隔成行组。这包含最多 N 行,在写入时确定。在每个行组中,我们为每列都有一个块 - 每个块都包含其各自列的数据,从而提供列方向。虽然理论上每个列块的行数可能不同,但为简化起见,我们假设这是相同的。这些块由页组成。原始数据存储在这些数据页中。每个数据页的最大大小可能是可配置的,但目前在 ClickHouse 中未公开,ClickHouse 使用默认值 1MB。数据块也在写入之前被压缩(见下文)。
我们在下面可视化这些概念和逻辑结构。为了说明目的,我们假设行组大小为 6,总共有 11 行,每行有 3 列。我们假设我们的数据页大小导致每页始终有 3 个值(除了第二个行组中每个页的最后一个块)。
页有两种类型:数据页和字典页。当字典编码应用于数据页中的值时,会产生字典页。对于 ClickHouse,默认情况下在写入 Parquet 文件时启用此功能。对于已进行字典编码的数据页,它们前面会有一个字典页。这实际上意味着字典页和数据页交替出现,如下所示。可以对字典页大小施加限制,默认值为 1MB。如果超过此限制,写入器将恢复为写入包含值的纯数据页。

以上是对 Parquet 格式的简化。对于寻求更深入了解的用户,我们建议阅读关于重复和定义级别的内容,因为这些对于充分理解数据页如何处理数组和嵌套类型以及空值也是至关重要的。
请注意,虽然 Parquet 官方被描述为基于列的格式,但行组的引入以及列块的顺序存储意味着它通常被描述为混合基础格式。这使得格式的读取器可以轻松实现投影和下推,如下所述。
元数据、投影和下推
除了存储数据值外,Parquet 格式还包括元数据。这写在文件末尾的页脚中,以方便单次写入(更高效),并包括返回行组、块和页面的引用。

Credit: https://parquet.apache.org/docs/file-format/
除了存储数据模式和辅助解码的信息(例如,偏移值和使用的编码)之外,Parquet 还包括查询引擎可以利用的信息来跳过列块。读取器首先应读取文件元数据以查找他们感兴趣的所有列块,然后再仅按顺序读取所需的列块。这被称为投影下推,旨在最大限度地减少 I/O。
此外,统计信息可以包含在行组级别,描述每列的最小值和最大值。这允许读取器根据任何谓词(如果在 SQL 中查询,则为 WHERE 子句)考虑此信息,从而进一步跳过列块。此谓词下推目前尚未在 ClickHouse 中实现,但计划添加[1][2][3]。最后,官方规范允许单独的元数据文件引用多个 Parquet 文件,例如,每列一个。ClickHouse 目前不支持此功能,但我们计划添加支持。
读取和写入行组
使用 ClickHouse 编写 Parquet 文件时,用户可能希望控制写入的行组数量,以增加读取中的并行度 - 有关更多详细信息,请参阅“并行读取”。在撰写本文时,我们建议用户使用 INSERT INTO FUNCTION <file/s3>
语法,以便在写入时控制行组的数量。此语法的设置允许轻松推断行组的数量,行数等于以下各项的最小值
- min_insert_block_size_bytes - 设置块中的最小字节数,较小的块被压缩成较大的块。这实际上通过字节大小限制了行数。默认为 256MB 未压缩。
- min_insert_block_size_rows - 传递给 arrow 客户端的块中的最小行数,较小的块被压缩成较大的块。默认为 1m。
- output_format_parquet_row_group_size。行组大小(以行为单位),默认为 1m。
用户可以根据总行数、平均大小和目标行组数来调整这些值。
对于使用 FORMAT
子句的 SELECT
查询,例如 SELECT * FROM uk_price_paid FORMAT Parquet
,以及 INTO OUTFILE
子句,确定组大小的因素更为复杂。最重要的是,这些方法往往会导致文件中出现大量行组 - 可能会对压缩和读取性能产生负面影响。由于这些原因,我们目前建议使用 INSERT INTO FUNCTION
方法。
导出 Parquet 文件的这两种方法的差异可能会导致不同的文件大小,具体取决于方法和最终的有效压缩率。对于从我们之前的博客文章返回的读者,这解释了为什么不同的查询产生不同的文件大小 - 较小的行组大小压缩效果不佳。一般来说,我们建议使用 INSERT INTO FUNCTION <file/s3>
方法进行写入,因为它使用更合理的默认值,并且允许轻松控制行组大小。在未来的版本中,我们计划解决行为中的任何不一致性。
其他工具以及用于编写 Parquet 文件的官方 Apache Arrow 库(由 ClickHouse 使用)也允许配置行组的数量。有关行组大小可能如何影响读取性能,请继续阅读。
类型和编码
Parquet 是一种二进制格式,其中列值以特定类型的布尔值、数值(int32、int64、int96、float、double)、字节数组(二进制)或固定长度二进制存储。这些原始类型可以用指定应如何解释它们的注释来创建“逻辑类型”,例如 String 和 Enum。例如,逻辑 String 类型在字节数组中编码,并带有指示 UTF8 编码的注释。有关更多详细信息,请参阅此处。
自最初创建以来,Parquet 进行了许多扩展,尤其是在数据编码方式方面。这包括
- 字典编码构建列的所有不同值的字典,用其在字典中的相应索引替换原始值。这对于低基数列尤其有效,并有助于在列类型之间提供一致的性能。这可以应用于数值和基于字节数组的类型。
- 字典编码的值、布尔值以及重复和定义级别都经过游程编码 (RLE)。这通过用一个出现次数和一个指示重复次数的数字替换连续重复的值来压缩列。在这种情况下,当更多相同的值连续出现时,可以实现更高的压缩率。因此,列的基数也直接影响压缩效率。请注意,此 RLE 与位打包相结合,以最大限度地减少存储所需的位数。
- Delta 编码可以应用于整数值。在这种情况下,存储的是值之间的增量而不是实际值(第一个值除外)。当连续值具有小的或恒定的变化时,例如具有毫秒精度的 DateTime 值,这尤其有效,因为增量占用的位数较少。
- 现在还有其他编码技术可用,包括字节流拆分。
目前,ClickHouse 在写入 Parquet 文件时使用默认编码,默认情况下启用字典编码。列使用的编码无法通过设置控制,从而无法对整数使用 delta 编码,尽管这正在作为未来改进的一部分进行考虑(见下文)。
Parquet 类型必须在读取期间转换为 ClickHouse 类型,反之亦然在写入期间。可以在此处找到支持的 Parquet 逻辑类型及其等效 ClickHouse 类型的完整列表,其中一些实现正在进行中。
字符串
ClickHouse 写入的文件还将为字符串使用原始 BYTE_ARRAY
类型。如果您打算稍后使用 ClickHouse 读取这些文件,这已足够,因为这与我们自己的内部字符串表示形式一致,其中字节按原样存储(具有在字符串包含一组表示 UTF-8 编码文本的字节的假设下工作的函数的单独变体,例如,lengthUTF8)。但是,某些应用程序可能需要字符串由逻辑类型 String表示。在这些情况下,您可以在写入文件之前设置设置 output_format_parquet_string_as_string=1
。
枚举
由于最近的改进,ClickHouse 枚举在 ClickHouse 的未来版本中写入 Parquet 文件时将序列化为 Int8/Int16(支持仍在等待允许它们作为字符串写入)。相反,在读取文件时,这些整数类型可以转换为 ClickHouse 枚举。Parquet 文件中的字符串也可以在可能的情况下转换为 ClickHouse 枚举。Parquet 枚举可以读取为字符串或兼容的 ClickHouse 枚举。
检查 Parquet 元数据
要检查 Parquet 文件的结构,用户过去需要使用第三方工具,例如 parquet-tools。借助即将发布的 ClickHouse 22.4 版本,由于添加了 ParquetMetadata 输入格式,用户可以使用简单的查询来获取此元数据。我们在下面使用它来查询我们的房价 Parquet 文件的元数据,该文件将元数据输出为单行。为了帮助提高可读性,我们还指定了输出格式 PrettyJSONEachRow
(也是 22.4 版本新增功能),并且仅显示元数据的示例。请注意,输出如何包括行组数、使用的编码和列统计信息,例如大小和压缩率。
./clickhouse local --query "SELECT * FROM file('house_prices.parquet', ParquetMetadata) FORMAT PrettyJSONEachRow"
{
"num_columns": "14",
"num_rows": "28113076",
"num_row_groups": "53",
"format_version": "2.6",
"metadata_size": "65503",
"total_uncompressed_size": "365131681",
"total_compressed_size": "255323648",
"columns": [
{
"name": "price",
"path": "price",
"max_definition_level": "0",
"max_repetition_level": "0",
"physical_type": "INT32",
"logical_type": "Int(bitWidth=32, isSigned=false)",
"compression": "LZ4",
"total_uncompressed_size": "53870143",
"total_compressed_size": "54070424",
"space_saved": "-0.3718%",
"encodings": [
"RLE_DICTIONARY",
"PLAIN",
"RLE"
]
},
...
],
"row_groups": [
{
"num_columns": "14",
"num_rows": "1000000",
"total_uncompressed_size": "10911703",
"total_compressed_size": "8395071",
"columns": [
{
"name": "price",
"path": "price",
"total_compressed_size": "1823285",
"total_uncompressed_size": "1816162",
"have_statistics": 1,
"statistics": {
"num_values": "1000000",
"null_count": "0",
"distinct_count": null,
"min": "50",
"max": "6250000"
}
},
...
]
},
...
]
}
压缩
相对于其他交换格式,Parquet 提供出色的压缩率。如下所示,我们将我们的 房价数据集的 CSV 和行分隔 JSON 以及 Parquet 的大小与使用其默认设置的各种压缩技术进行比较。在此示例中,我们导出时没有 ORDER BY
子句,依赖 ClickHouse 的自然排序(随机,不确定),使用 ClickHouse Local 和 file 函数。
INSERT INTO FUNCTION file('house_prices.<format>.<compression>') SELECT * FROM uk_price_paid
注意:不要向 parquet 格式添加压缩扩展名,例如
house_prices.parquet.gzip
。这将导致 Parquet 文件在写入后再次压缩 - 这是不必要的开销,并且几乎没有好处。
压缩(级别) | CSV | JSONEachRow | Parquet |
---|---|---|---|
无 | 3.5 GB | 6.9 GB | 348 MB |
LZ4 (1) | 459 MB | 493 MB | 244 MB |
GZIP (6) | 417 MB | 481 MB | 183 MB |
ZSTD (1) | 388 MB | 434 MB | 196 MB |
Snappy | 不支持 | 不支持 | 241 MB |
XZ (6) | 321 MB | 321 MB | 不支持 |
BZIP2 (6) | 233 MB | 248 MB | 不支持 |
Brotli (1) | 360 MB | 400 MB | 174 MB |
压缩(级别) | CSV | JSONEachRow | Parquet |
---|---|---|---|
无 | 2.14 秒 | 4.68 秒 | 11.78 秒 |
LZ4 (1) | 16.6 秒 | 24.4 秒 | 12.4 秒 |
GZIP (6) | 14.1 秒 | 19.6 秒 | 17.56 秒 |
ZSTD (1) | 6.5 秒 | 11.3 秒 | 12.5 秒 |
Snappy | 不支持 | 不支持 | 12.3 秒 |
XZ (6) | 176 秒 | 173 秒 | 不支持 |
BZIP2 (6) | 362.5 秒 | 837.8 秒 | 不支持 |
Brotli (1) | 14.8 秒 | 23.7 秒 | 31.7 秒 |
注意:默认情况下 (23.3),ClickHouse 在压缩 Parquet 文件时使用 LZ4(尽管这可能会更改,因为要与 Spark 等工具兼容)。这与 Apache Arrow 的 Snappy 默认值不同,尽管可以通过设置 output_format_parquet_compression_method
来更改。
INSERT INTO FUNCTION file('house_prices.native.zst') SELECT *
FROM uk_price_paid
-rw-r--r-- 1 dalemcdiarmid wheel 189M 26 Apr 14:44 house_prices.native.zst
数据排序
一位精明的读者可能已经注意到,RLE 编码依赖于连续值。从逻辑上讲,这种压缩技术可以通过在使用 ORDER BY
查询子句写入时简单地对数据进行排序来改进。如果从表中写入大量行,则此方法有其局限性,因为任意排序顺序可能会受到内存限制:花费的内存量与排序的数据量成正比。在这种情况下,用户有几个选项
- 使用设置
max_bytes_before_external_sort
。如果设置为 0(默认值),则禁用外部排序。如果启用,则当要排序的数据量达到指定的字节数时,收集的数据将被排序并转储到临时文件中。这将比内存排序慢得多。此值应保守设置,并且小于max_memory_usage
设置。 - 如果
ORDER BY
表达式的前缀与表排序键一致,则可以使用optimize_read_in_order
设置。默认情况下启用此功能,这意味着将利用数据的排序,从而避免内存问题。请注意,禁用此设置具有性能优势,特别是对于具有较大 LIMIT 且需要在 WHERE 条件匹配之前读取许多行的查询。
在大多数情况下,对 ClickHouse 表进行排序(通过在表创建时使用 ORDER BY 子句)将针对查询性能和压缩进行优化。虽然选项 2 通常有意义并且会立即产生改进,但结果会有所不同。用户还可以检查 Parquet 元数据(如前所示)以识别压缩不良的列以及 ORDER BY 子句的可能候选项。当列顺序将基数较低的键首先放在 ORDER BY 子句中时(类似于 ClickHouse),最有可能实现最佳压缩,从而确保最连续的值序列。
使用英国房价表的 ORDER BY 键 (postcode1, postcode2, addr1, addr2)
,我们使用 GZIP 重复我们的 Parquet 导出。这使我们的 parquet 文件减少了约 20% 至 148MB,但牺牲了写入性能。同样,我们可以使用新的 ParquetMetadata
来识别每列的压缩率 - 在下面,我们重点介绍了排序前后 postcode1 列的差异。请注意,此列的未压缩大小如何显着减小。
INSERT INTO FUNCTION file('house_prices-ordered.parquet') SELECT *
FROM uk_price_paid
ORDER BY
postcode1 ASC,
postcode2 ASC,
addr1 ASC,
addr2 ASC
0 rows in set. Elapsed: 38.812 sec. Processed 28.11 million rows, 2.68 GB (724.34 thousand rows/s., 69.07 MB/s.)
-rw-r--r-- 1 dalemcdiarmid wheel 148M 26 Apr 13:42 house_prices-ordered.parquet
-rw-r--r-- 1 dalemcdiarmid wheel 183M 26 Apr 13:44 house_prices.parquet
./clickhouse local --query "SELECT * FROM file('house_prices.parquet', ParquetMetadata) FORMAT PrettyJSONEachRow"
{
"num_columns": "14",
"num_rows": "28113076",
"num_row_groups": "53",
"format_version": "2.6",
"metadata_size": "65030",
"total_uncompressed_size": "365131618",
"total_compressed_size": "191777958",
"columns": [{
"name": "postcode1",
"path": "postcode1",
"max_definition_level": "0",
"max_repetition_level": "0",
"physical_type": "BYTE_ARRAY",
"logical_type": "None",
"compression": "GZIP",
"total_uncompressed_size": "191694",
"total_compressed_size": "105224",
"space_saved": "45.11%",
"encodings": [
"RLE_DICTIONARY",
"PLAIN",
"RLE"
]
},
INSERT INTO FUNCTION file('house_prices-ordered.parquet') SELECT *
FROM uk_price_paid
ORDER BY
postcode1 ASC,
postcode2 ASC,
addr1 ASC,
addr2 ASC
./clickhouse local --query "SELECT * FROM file('house_prices-ordered.parquet', ParquetMetadata) FORMAT PrettyJSONEachRow"
{
"num_columns": "14",
"num_rows": "28113076",
"num_row_groups": "51",
"format_version": "2.6",
"metadata_size": "62305",
"total_uncompressed_size": "241299186",
"total_compressed_size": "155551987",
"columns": [
{
"name": "postcode1",
"path": "postcode1",
"max_definition_level": "0",
"max_repetition_level": "0",
"physical_type": "BYTE_ARRAY",
"logical_type": "None",
"compression": "GZIP",
"total_uncompressed_size": "29917",
"total_compressed_size": "19563",
"space_saved": "34.61%",
"encodings": [
"RLE_DICTIONARY",
"PLAIN",
"RLE"
]
},
并行读取
从历史上看,在 ClickHouse 中读取 Parquet 文件是一个顺序操作。这限制了性能,并要求用户拆分他们的 Parquet 文件以并行化读取 - ClickHouse 将在通过 glob 模式在路径中提供的一组文件之间并行化读取。通过计算一个文件与 29 个文件(按年份分区)的每年的平均价格,使用 ClickHouse Local,可以显示这种差异。此处的所有文件都使用 GZIP 编写,并使用先前显示的 ORDER BY 键编写,我们使用 3 次运行中最快的一次。
SELECT
toYear(toDate(date)) AS year,
round(avg(price)) AS price,
bar(price, 0, 1000000, 80)
FROM file('house_prices.parquet')
GROUP BY year
ORDER BY year ASC
┌─year─┬──price─┬─bar(round(avg(price)), 0, 1000000, 80)─┐
│ 1995 │ 67937 │ █████▍ │
│ 1996 │ 71513 │ █████▋ │
│ 1997 │ 78538 │ ██████▎ │
│ 1998 │ 85443 │ ██████▊ │
│ 1999 │ 96040 │ ███████▋ │
│ 2000 │ 107490 │ ████████▌ │
│ 2001 │ 118892 │ █████████▌ │
│ 2002 │ 137957 │ ███████████ │
│ 2003 │ 155895 │ ████████████▍ │
│ 2004 │ 178891 │ ██████████████▎ │
│ 2005 │ 189361 │ ███████████████▏ │
│ 2006 │ 203533 │ ████████████████▎ │
│ 2007 │ 219376 │ █████████████████▌ │
│ 2008 │ 217043 │ █████████████████▎ │
│ 2009 │ 213423 │ █████████████████ │
│ 2010 │ 236115 │ ██████████████████▉ │
│ 2011 │ 232807 │ ██████████████████▌ │
│ 2012 │ 238385 │ ███████████████████ │
│ 2013 │ 256926 │ ████████████████████▌ │
│ 2014 │ 280024 │ ██████████████████████▍ │
│ 2015 │ 297285 │ ███████████████████████▊ │
│ 2016 │ 313548 │ █████████████████████████ │
│ 2017 │ 346521 │ ███████████████████████████▋ │
│ 2018 │ 351037 │ ████████████████████████████ │
│ 2019 │ 352769 │ ████████████████████████████▏ │
│ 2020 │ 377149 │ ██████████████████████████████▏ │
│ 2021 │ 383034 │ ██████████████████████████████▋ │
│ 2022 │ 391590 │ ███████████████████████████████▎ │
│ 2023 │ 365523 │ █████████████████████████████▏ │
└──────┴────────┴────────────────────────────────────────┘
29 rows in set. Elapsed: 0.182 sec. Processed 14.75 million rows, 118.03 MB (81.18 million rows/s., 649.41 MB/s.)
SELECT
toYear(toDate(date)) AS year,
round(avg(price)) AS price,
bar(price, 0, 1000000, 80)
FROM file('house_prices_*.parquet')
GROUP BY year
ORDER BY year ASC
…
29 rows in set. Elapsed: 0.116 sec. Processed 26.83 million rows, 214.63 MB (232.17 million rows/s., 1.86 GB/s.)
此处的示例使用 file 函数,但这同样适用于其他表函数,例如 s3(尽管此处将适用一些因素 - 请参阅“关于 S3 的一个小提示”)。在较大的文件上,这种差异可能会更明显。
幸运的是,最近在文件内并行化这项工作的开发显着提高了性能(尽管可以做更多的工作 - 请参阅“未来工作”)。这些改进目前仅与 s3 和 url 函数相关,并且代表了改进并行化的初步努力。未来版本的 ClickHouse 将并行化单个 Parquet 文件的读取和解码,包括 file 函数,线程数由设置 max_threads
控制(默认为 CPU 核心数)。下面我们使用上述查询查询单个 Parquet 文件,以突出显示有和没有更改的性能差异。请注意,这些文件位于 s3 上,最近的改进适用于 s3
SELECT
toYear(toDate(date)) AS year,
round(avg(price)) AS price,
bar(price, 0, 1000000, 80)
FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/uk-house-prices/parquet/house_prices_all.parquet')
GROUP BY year
ORDER BY year ASC
29 rows in set. Elapsed: 18.017 sec. Processed 28.11 million rows, 224.90 MB (1.56 million rows/s., 12.48 MB/s.)
//with changes
SET input_format_parquet_preserve_order = 0
SELECT
toYear(toDate(date)) AS year,
round(avg(price)) AS price,
bar(price, 0, 1000000, 80)
FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/uk-house-prices/parquet/house_prices_all.parquet')
GROUP BY year
ORDER BY year ASC
29 rows in set. Elapsed: 8.428 sec. Processed 26.69 million rows, 213.49 MB (3.17 million rows/s., 25.33 MB/s.)
如图所示,此处的性能得到了显着提高。
行组的重要性
此处的并行化是在行组级别实现的。虽然实现可能会更改和进一步改进,但此改进会将线程分配给每个负责读取和解码的行组。为了避免过多的内存消耗,input_format_parquet_max_block_size
控制每个线程一次解码多少,从而确定内存中保存的未压缩数据量。控制此功能对于高度压缩的数据或当您有许多线程时非常有用,这可能会导致高内存使用率。
鉴于并行化当前在行组级别执行,用户可能希望考虑其文件中的行组数。如前所示,可以使用 ClickHouse Local 来确定行组数
clickhouse@dclickhouse % ./clickhouse local --query "SELECT num_row_groups FROM file('house_prices.parquet', ParquetMetadata)"
53
请参阅前面关于如何在 ClickHouse 中编写 Parquet 文件时使用设置来控制行组数量的内容。
因此,您至少需要与核心数一样多的行组才能实现完全并行化。在下面,我们查询一个版本的 house_price.parquet
文件,该文件只有一个行组 - 有关如何创建此文件的信息,请参阅 此处。请注意对查询性能的影响。
SET input_format_parquet_preserve_order = 0
SELECT
toYear(toDate(date)) AS year,
round(avg(price)) AS price,
bar(price, 0, 1000000, 80)
FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/uk-house-prices/parquet/house_prices-1-row-group.parquet')
GROUP BY year
ORDER BY year ASC
29 rows in set. Elapsed: 19.367 sec. Processed 26.64 million rows, 213.12 MB (1.05 million rows/s., 8.40 MB/s.)
相反,行组数量远超核心数也可能对性能不利。这可能会导致许多微小的读取,从而增加相对于实际解码工作的 IO 延迟量。如果仅读取少量列,由于读取的碎片化,这将最明显。当选择所有列时,由于相邻读取将被合并,因此可以缓解这种情况。当使用 s3 和 url 函数读取时,这些行为将最明显(请参阅“关于 S3 的一个小提示”)。因此,需要在并行解码和高效读取之间取得平衡。100 KB 到 10MB 范围内的行组大小可以被认为是合理的尺寸。通过进一步的测试,我们希望我们在此处的建议可以变得更具体。
ClickHouse 会将正在读取的每个行组的压缩数据保存在内存中(以及线程数 * input_format_parquet_max_block_size
用于未压缩数据)。因此,大型行组将占用大量内存,尤其是在使用大量线程读取时。总而言之,默认值通常是合理的,但对于具有大量核心的机器或内存不足的环境,用户可以考虑确保行组计数更高,并且其大小与可用内存和线程数对齐。如果读取文件,请在内存不足或增加 max_threads
时考虑内存开销。
最后,请注意我们之前设置了设置 input_format_parquet_preserve_order = 0
。默认值 1 用于向后兼容性,并确保数据以原始顺序返回,即一次一个行组。这限制了可以并行化的工作量。这可能会更改,但目前,为了获得最大收益,需要更改此行为。
关于 S3 的一个小提示
虽然 Parquet 文件的并行读取有望显着提高查询性能,但其他因素也可能影响用户体验到的绝对查询时间。如果查询驻留在 S3 上的数据,则多个文件仍然会带来显着的好处。例如,s3Cluster 函数需要多个文件才能在集群中的所有节点之间分配读取,启动器节点在动态调度与 glob 模式匹配的每个文件之前,会创建与集群中所有节点的连接。这提高了并行度和性能。服务器实例的区域位置和网络吞吐量也可能显着影响查询性能。
我们之前提到过,小行组(以及由此产生的小列块)对读取性能的影响。 这也可能潜在地影响 S3 费用。 当从 S3 请求数据时,每个列块都会发出一个 GET 请求。 因此,较大的行组大小应减少列块的数量和由此产生的 GET 请求,从而可能降低成本。 同样,当请求连续列时,这种情况会得到缓解,因为请求将被合并,并且应与并行解码的潜力相平衡。 用户需要进行实验以优化成本/性能。
多种文件和数据湖格式
虽然 Parquet 已确立其作为数据湖首选数据文件格式的地位,但表通常表示为位于存储桶或文件夹中的一组文件。 虽然 ClickHouse 可以用于读取目录中的多个 Parquet 文件,但这通常仅适用于临时查询。 管理大型数据集变得繁琐,意味着 ClickHouse 等工具的表抽象充其量只是松散地建立,并且不支持模式演变或写入一致性。 对于 ClickHouse 而言,最重要的是,这种方法将依赖于文件列表操作 - 在诸如 s3 等对象存储上可能非常昂贵。 数据过滤需要打开和读取所有数据,除了通过在命名模式上使用 glob 模式来限制文件的有限能力之外。
现代数据格式(如 Apache Iceberg)旨在通过以开放和可访问的方式将类似 SQL 表的功能引入数据湖中的文件来应对这些挑战,包括以下功能:
- 模式演变,用于跟踪表随时间的变化
- 创建定义特定版本的数据快照的能力。 这些版本可以被查询,允许用户在不同代之间“时间旅行”。
- 支持快速回滚到先前版本的数据
- 自动文件分区以帮助过滤 - 历史上,用户需要手动完成这项容易出错的任务,并在更新中维护它。
- 查询引擎可以用来提供高级计划和过滤的元数据。
这些表功能通常由清单文件提供。 这些清单维护底层数据文件的历史记录,其中包含其模式、分区和文件信息的完整描述。 这种抽象支持不可变的快照,以分层结构有效组织,该结构跟踪表中随时间的所有更改。
我们将在以后的博客文章中探讨这些文件格式以及它们如何与 ClickHouse 一起使用。 请保持关注。
结论和未来工作
这篇博文详细探讨了 Parquet 格式,以及在读取和写入文件时重要的 ClickHouse 设置和注意事项。 我们还重点介绍了有关并行化的最新进展。 我们将继续发展和改进我们对 Parquet 的支持,计划的可能改进包括但不限于:
- 在任何
WHERE
子句中利用元数据将可能显着提高包含范围条件(例如,日期过滤)的查询的性能。 此元数据还可用于改进 特定的聚合函数,例如 count。 - 在写入 Parquet 文件时,我们目前不允许用户控制列使用的编码,而是使用合理的默认值。 未来的改进将使我们能够利用其他压缩技术,例如用于日期时间和数值的 Delta 编码,或者关闭特定列的字典编码。
- Arrow API 在 写入文件 时公开了多个设置,包括限制字典大小的能力。 我们欢迎用户就哪些设置值得公开提出想法。
- 并行读取是一项持续努力的工作,可能进行多项底层改进 [1][2]。 我们预计并行编码将对写入性能产生重大影响。
- 我们对 Parquet 的支持正在不断改进。 除了解决这篇博文中强调的一些行为不一致之处外,我们还计划进行其他改进,例如改进逻辑类型支持 [1] 并确保正确识别 Null 列 [2]。
有关 Parquet 发展的最新信息,我们建议您关注我们的每月发布网络研讨会和博客,或者直接关注 issues!