介绍
这篇文章是我们 Parquet 和 ClickHouse 博客系列的第二部分。在这篇文章中,我们将更详细地探讨 Parquet 格式,重点介绍使用 ClickHouse 读取和写入文件时需要考虑的关键细节。对于更经验丰富的 Parquet 用户,我们还将讨论用户在写入 Parquet 文件时可以进行的优化,以最大程度地提高压缩率,以及一些使用并行化来优化读取性能的最新进展。
在我们的示例中,我们继续使用英国房价数据集。该数据集包含从 1995 年到撰写本文时为止英国和威尔士房地产价格的信息。我们将此数据集以 Parquet 格式分发到公共 s3 存储桶 s3://datasets-documentation/uk-house-prices/parquet/
中。我们使用 ClickHouse Local 读取和写入本地和 S3 托管的 Parquet 文件。ClickHouse Local 是 ClickHouse 的一个易于使用的版本,非常适合需要使用 SQL 对本地和远程文件进行快速处理的开发人员,而无需安装完整的数据库服务器。最重要的是,ClickHouse Local 和 ClickHouse Server 共享相同的 Parquet 读取和写入代码,因此所有详细信息都适用于两者。有关更多详细信息,请参阅我们本系列的上一篇文章以及其他最近的专用内容。
Parquet 格式概述
结构
了解 Parquet 文件格式可以让用户在写入文件时做出决策,这将直接影响压缩级别和后续读取性能。以下描述是简化的,但对于大多数用户来说已经足够了。
Parquet 格式依赖于三个相互关联的层次化概念:**行组**、列**块**和**页**。
从最高层级来看,一个文件被分为**行组**。它包含最多 N 行,在写入时确定。在每个行组中,我们为每列都有一个**块** - 每个块包含其相应列的数据,从而提供列方向。虽然理论上每个列块的行数可以不同,但为了简化起见,我们假设它们是相同的。这些块由**页**组成。原始数据存储在这些数据页中。每个数据页的最大大小可能可配置,但当前在 ClickHouse 中没有公开,它使用默认值 1MB。数据块在写入之前也会压缩(见下文)。
我们将在下面可视化这些概念和逻辑结构。为了便于说明,我们假设行组大小为 6,总共 11 行,每行有 3 列。我们假设数据页大小始终为每页 3 个值(除了第二个行组的最后一页中的最后一个块)。
页有两种类型:数据页和字典页。当对数据页中的值应用字典编码时,就会生成字典页。对于 ClickHouse,在写入 Parquet 文件时默认情况下会启用此功能。对于已进行字典编码的数据页,它们之前将是字典页。这实际上意味着字典页和数据页交替出现,如下所示。可以对字典页大小施加限制,默认值为 1MB。如果超过此限制,写入器将恢复为写入包含值的纯数据页。
以上是对 Parquet 格式的简化说明。对于希望更深入了解的用户,我们建议阅读有关重复和定义级别的信息,因为这些信息对于完全理解数据页如何相对于数组和嵌套类型以及空值工作至关重要。
请注意,虽然 Parquet 正式被描述为基于列的,但行组的引入以及列块的顺序存储意味着它通常被称为混合基格式。这使得格式的读取器可以轻松地实现投影和下推,如下所述。
元数据、投影和下推
除了存储数据值外,Parquet 格式还包含元数据。这是在文件末尾的页脚中写入的,以便促进单次传递写入(更高效)并包含对行组、块和页的引用。
来源: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 - 传递给箭头客户端的块中的最小行数,较小的块将压缩成更大的块。默认值为 100 万。
- output_format_parquet_row_group_size。行组大小(以行数计),默认值为 100 万。
用户可以根据总行数、平均大小和目标行组数来调整这些值。
对于使用 FORMAT
子句(例如 SELECT * FROM uk_price_paid FORMAT Parquet
)以及 INTO OUTFILE
子句的 SELECT
查询,确定组大小的因素更为复杂。最重要的是,这些方法往往会导致文件中的行组数量非常多,这可能会对压缩和读取性能产生负面影响。出于这些原因,我们目前建议使用 INSERT INTO FUNCTION
方法。
这两种导出 Parquet 文件方法的差异会导致文件大小根据方法和最终的有效压缩率而有所不同。对于从我们之前的博客文章中返回的读者,这解决了为什么不同的查询会产生不同的文件大小 - 更小的行组大小压缩效果不佳。总的来说,我们建议使用 INSERT INTO FUNCTION <file/s3>
方法进行写入,因为它使用更合理的默认值,并允许轻松控制行组大小。在未来的版本中,我们计划解决行为上的任何不一致。
其他工具以及用于编写 Parquet 文件的官方 Apache Arrow 库(ClickHouse 使用)也允许配置行组数量。有关行组大小如何可能影响读取性能,请阅读以下内容。
类型和编码
Parquet 是一种二进制格式,其中列值使用特定类型的 布尔值、数字(int32、int64、int96、float、double)、字节数组(二进制)或固定长度二进制 进行存储。这些基本类型可以使用指定如何解释它们以创建“逻辑类型”(例如字符串和枚举)的信息进行注释。例如,逻辑字符串类型在字节数组中进行编码,并带有指示 UTF8 编码的注释。有关更多详细信息,请参见 这里。
自 Parquet 最初创建以来,它已经有了许多扩展,尤其是在数据编码方式方面。这包括
- 字典编码会构建一个包含所有不同列值的字典,用该字典中相应的值索引替换原始值。这对于低基数列特别有效,有助于在不同列类型之间提供一致的性能。这可以应用于基于数字和字节数组的类型。
- 字典编码 值、布尔值、重复级别和定义级别使用 行程长度编码 (RLE)。通过将连续的重复值替换为一次出现和一个指示重复次数的数字,从而对列进行压缩。在这种情况下,当更多相同的值连续出现时,压缩率更高。因此,列的基数也会直接影响压缩效率。请注意,此 RLE 与 位打包 相结合,以最大程度地减少存储所需的位数。
- 增量编码 可以应用于整数值。在这种情况下,存储的是值之间的增量,而不是实际值(第一个值除外)。当连续的值具有小的或恒定的变化时,这特别有效,例如具有毫秒精度的 DateTime 值,因为增量占用的位数更少。
- 现在还有其他编码技术可用,包括 字节流拆分。
目前,ClickHouse 在写入 Parquet 文件时使用默认编码,这默认情况下会启用字典编码。用于列的编码无法通过设置进行控制,从而阻止对整数使用增量编码,尽管这正在作为未来改进的一部分进行考虑(见下文)。
Parquet 类型在读取时必须转换为 ClickHouse 类型,反之亦然,在写入时必须转换为 ClickHouse 类型。完整的支持的 Parquet 逻辑类型列表及其等效的 ClickHouse 类型可以在 这里 找到,其中一些正在等待 实施。
字符串
ClickHouse 编写的文件也将对字符串使用基本 BYTE_ARRAY
类型。如果您打算稍后使用 ClickHouse 读取这些文件,则这足够了,因为这与我们自己的内部 字符串表示 一致,其中字节按原样存储(具有在假设字符串包含一组表示 UTF-8 编码文本的字节的情况下工作的函数的不同变体,例如 lengthUTF8)。但是,某些应用程序可能需要使用 逻辑类型字符串 来表示字符串。在这些情况下,您可以在写入文件之前设置 output_format_parquet_string_as_string=1
设置。
枚举
由于 最近的改进,ClickHouse 枚举将在将来的 ClickHouse 版本中作为 Int8/Int16 序列化到 Parquet 文件中(支持仍在等待,以允许它们作为字符串写入)。相反,在读取文件时,这些整型可以转换为 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 的 房价数据集 的大小,这些压缩技术使用其默认设置。在此示例中,我们使用 ClickHouse Local 和 file 函数 导出没有 ORDER BY
子句的数据,依赖于 ClickHouse 的自然排序(随机,非确定性)。
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 s | 4.68 s | 11.78 s |
LZ4 (1) | 16.6 s | 24.4 s | 12.4 s |
GZIP (6) | 14.1 s | 19.6 s | 17.56 s |
ZSTD (1) | 6.5 s | 11.3 s | 12.5 s |
Snappy | 不支持 | 不支持 | 12.3 s |
XZ (6) | 176 s | 173 s | 不支持 |
BZIP2 (6) | 362.5 s | 837.8 s | 不支持 |
Brotli (1) | 14.8 s | 23.7 s | 31.7 s |
注意:默认情况下(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 会并行读取一组文件,其中在路径中提供了通配符模式。以下通过计算每个文件上每年的平均价格与 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 上,此处的最新改进适用。
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.)
相反,远远超过内核数量的行组数量也可能对性能不利。这可能会导致许多微小的读取,从而增加与实际解码工作相关的 I/O 延迟量。如果只读取几列,由于读取的碎片化,这一点将最为明显。当选择所有列时,可以缓解这种情况,因为相邻读取将合并。这些行为在使用 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 函数需要多个文件才能在集群中的所有节点上分配读取操作,其中发起者节点在动态分发与通配符模式匹配的每个文件之前,会建立与集群中所有节点的连接。这提高了并行化和性能。您的服务器实例的区域本地性和网络吞吐量也可能对查询性能产生重大影响。
我们之前已经注意到,小型行组(以及由此产生的小型列块)对读取性能的影响。这也有可能影响 S3 费用。从 S3 请求数据时,每个列块都会发出一个 GET 请求。因此,更大的行组大小应减少列块的数量以及由此产生的 GET 请求,从而可能降低成本。再次,当请求连续的列时,这一点会被缓解,因为请求将合并,并应与并行解码的潜力相平衡。用户需要进行实验以优化成本/性能。
多个文件和数据湖格式
虽然 Parquet 已成为数据湖的首选数据文件格式,但表通常会表示为位于存储桶或文件夹中的一组文件。虽然 ClickHouse 可用于读取目录中的多个 Parquet 文件,但这通常只足以满足临时查询。管理大型数据集会变得很麻烦,这意味着工具(例如 ClickHouse)对表的抽象充其量是松散建立的,并且不支持模式演化或写入一致性。对于 ClickHouse 来说,最重要的是,这种方法将依赖于文件列出操作 - 这在对象存储(例如 s3)上可能会很昂贵。数据过滤需要打开和读取所有数据,除了通过对命名方案使用通配符模式来限制文件的能力有限。
现代数据格式(例如 Apache Iceberg)旨在通过以一种开放且可访问的方式为数据湖中的文件带来类似 SQL 表的功能来解决这些挑战,包括以下功能:
- 模式演化,以跟踪表随时间推移的变化。
- 创建数据快照的能力,这些快照定义了特定版本。这些版本可以被查询,允许用户在不同代之间“时间旅行”。
- 支持快速回滚到数据的先前版本。
- 文件的自动分区,以帮助进行过滤 - 从历史上看,用户需要手动执行此项容易出错的任务,并在更新期间维护它。
- 查询引擎可以使用元数据来提供高级规划和过滤。
这些表功能通常由清单文件提供。这些清单维护底层数据文件的历史记录,其中包含其模式、分区和文件信息的完整描述。这种抽象允许支持不可变快照,这些快照在分层结构中有效地组织,以跟踪表随时间推移的所有更改。
我们将在以后的博客文章中探讨这些文件格式及其如何在 ClickHouse 中使用。敬请期待。
结论和未来工作
这篇博文详细探讨了 Parquet 格式,以及在读取和写入文件时重要的 ClickHouse 设置和注意事项。我们还重点介绍了有关并行化的最新发展。我们一直在继续发展和改进我们对 Parquet 的支持,计划的可能改进包括但不限于
- 利用任何
WHERE
子句中的条件进行元数据分析,可能会极大地提高包含范围条件(例如,日期过滤)的查询的性能。此元数据还可以用于改进 特定聚合函数(例如计数)。 - 在写入 Parquet 文件时,我们目前不允许用户控制用于列的编码,而是使用合理的默认值。未来的改进将允许我们利用其他压缩技术,例如 Delta 用于对日期时间和数值进行编码,或关闭特定列的字典编码。
- Arrow API 在 写入文件 时公开了一些设置,包括限制字典大小的能力。我们欢迎用户对哪些设置值得公开提出建议。
- 并行化读取是一个持续的努力,有多个可能的低级改进 [1][2]。我们预计并行化编码将对写入性能产生重大影响。
- 我们的 Parquet 支持正在不断改进。除了解决这篇博文中突出的行为中的一些不一致之外,我们还计划进行其他改进,例如改进逻辑类型支持 [1] 以及确保正确识别空列 [2]。
有关 Parquet 最新发展的更多信息,我们建议您关注我们每月的发布网络研讨会和博客,或 直接关注问题!