目录
Parquet是什么
Parquet是一种为了对表格型数据实现高性能IO所设计的数据文件格式,其使用高效的压缩算法和数据编码方式,实现数据的精简,从而减少数据IO,同时提高性能。其同时是开源的,多种语言都实现了Parquet的包,可以直接使用,比如python。
为什么使用Parquet
Parquet因为其出色的IO性能,受到了广泛的使用,并且越来越流行。其适合作为存储大量的原始数据,作为数据湖的基本数据格式,因为其不仅有出色的IO性能,同时也需要很少的磁盘空间。博主用Parquet和CSV以及HDF5做过对比,对于相同的数据,无论是IO性能还是磁盘空间,Parquet都显著由于CSV和HDF5,尤其相对于CSV,Parquet默认的压缩算法只使用了CSV四分之一的空间,而且数据读取性能可以提升200倍左右(仅是博主自己的测试数据,可能还更高)。而且相比于feather这种轻量级的数据格式,Parquet在IO性能和空间占用也毫不逊色,更关键的是,Parquet还可以使用数据集(Dataset)功能实现层级数据管理,以及读取时的数据行和列的过滤功能,不失灵活性。
此外,数据格式是独立的,不依赖任何的编程语言,即同一个Parquet文件,可以用不同的编程语言的相应的库直接使用,从而利于数据的分享。
Parquet为什么快
Parquet的高性能源于其独特的数据结构、存储方式、数据编码和压缩算法。
Parquet采用网格方式存储数据。数据存储方式有列向、行向以及网格,列向存储把同列数据放在连续的磁盘空间中,因此对列的过滤读取是相对高效的;同理,行向存储把同一行的数据保存在磁盘的连续空间中,因此对行的过滤读取相对高效;Parquet是网格存储,即先将一定大小的行作为一个row group,然后这个row group中的每列作为一个column chunk,逐个column chunk存储,同时Parquet使用了metadata,用以描述数据,可以更加高效的对数据进行处理和解析。
图片来自[4]
Parquet还使用了字典编码,以及RLE编码(Run Length Encoding),将数据值映射成数字,然后保存一个字典映射,同时记录某个值连续重复的次数,这样不用反复的保存一些占用空间较大的数据,特别是对于字符类型的数据,这种方式尤其对于重复值较多的chunk,非常高效,实际上我们的表格数据经常某些列具有较多的重复数据。因此,Parquet的这些编码方式,可以高效的节省空间,减少实际数据的IO,但是需要更多的metadata的解析,将IO密集部分变成了CPU密集的工作,CPU当然快多了。这些是Parquet如此高效的根本原因。
如何使用Parquet
Pandas实现了parquet的读写接口,可以直接读取和写入生成parquet文件。Pandas对parquet的接口相对简单,更多的功能需要去相应的引擎库中找到对应接口,具有丰富的功能。pandas对parquet接口的实现基于pyarrow引擎或者fastparquet,可以通过engine参数设置。博主安装的是pyarrow引擎,直接pip install pyarrow即可安装parquet引擎,然后便可以直接通过pandas的数据接口实现parquet的读写的,具体可以看pandas官方文档,见[2]。
这里特别强调一下,对于pyarrow引擎,pandas的读写基于pyarrow的write_table、write_to_dataset和read_table实现,因此可以参考这两个接口,实现更多的功能,比如通过read_table的filters参数,实现读取过滤,该参数可以直接通过pandas的read_parquet函数传入。具体使用方法参见[1]。
Best Practice
1. 读取parquet文件时,虽然可以使用python通过open返回的fileobject对象,但是尽量避免使用,会显著的降低IO性能,直接使用字符路径即可。
2. Parquet读取文件时,需要先对数据进行解析,无法直接使用memory map,因此并没有明显的内存改善,但是在有些系统中可以提升性能。
3. Parquet利用字典编码和RLE提升性能,但是数据量太大,以及非重复值太多,导致字典太大,超出阈值,就会使得剩下的数据直接只用plain encoding,即对数据本身逐一的编码,没有字典和RLE的优化,这时就会导致读写性能降低,磁盘空间也无法节省。对此,可以通过write_table的dictionary_page_size参数提高字典大小的限制,或者降低row_group_size,从而降低column chunk,使得单个字典编码的数据量减少。
4. 由于metadata会保存每个column chunk的min max count属性,在读取时进行行过滤(predict)时,parquet通过metadata记录的这些信息预先判断相应的group中有无目标数据,若没有就直接跳过,都不需要解析扫描这些group,从而可以显著的提高性能。基于此特点,在写入数据时,就应该先对会被经常拿来过滤数据的column进行排序,排序后再保存,这样可以使得chunk的range更小,从而最大的减少被解析扫描的数据,提高读取性能。
5. 充分利用partition。parquet最好不要太大,对于大数据集,可以使用partition_cols,传入列名,就会对这个数据集根据指定的列进行拆分,比如指定日期date,就会根据日期,在指定的文件路径下生成多个名称为date=YYYY-mm-dd的文件夹,文件夹里面存放文件名不重复的md5码parquet文件,如果指定了多列,就会以嵌套文件夹的同样命名方式保存parquet文件。这样的文件结构成为parquet dataset,假设写入时的路径参数为/data/xxx,那么在/data/xxx下就会生成多个上述文件夹和文件,/data/xxx就称为一个parquet dataset。在读取时,同样只要传入/data/xxx即可,parquet会自动通过文件夹名称识别过滤相应的文件。因为文件被拆分,可以减少数据解析和扫描,以及metadata的缘故,dataset方式可以显著提升查询IO性能。但是同时要避免太多的小文件,小文件太多,拆分太随意,毕竟每个parquet具有必要的结构,创建每个文件都有最低的开销overhead,文件太多拆分太随意,反而会增加负担,降低性能。一般单个文件几十上百兆到1Gb以下都是没问题的,甚至dataset中文件不多的情况下,单个文件几个Gb对parquet来说也是绰绰有余的,当然具体也和硬件配置有关。
6. 对于partitioned dataset,在read的时候,其中用来分隔数据集的partition_cols会转为Arrow dictionary types (pandas categorical),for some reasons,这会使得在读取时列不保序。因此对于partitioned dataset,不能预期保存和读取的数据行的顺序是一致的。如果要使得读取和保存的时候顺序一致,可以在保存数据的时候,设置数据的index为顺序索引,并设to_parquet的参数index=True,然后读取数据后,对读取后的dataframe的索引进行排序sort_index。
7. 对于pandas.DataFrame的object列,如果类型为object的列包含了np.nan值,那么parquet对于这种情况下的nan值的读写结果是不一样的。当将这种情况下的数据保存成parquet后,再读取成DataFrame后,之前的nan值会变成None,不再是nan,这点需要格外注意,因为变成None后,空值从float类型变成了None类型,可能在有些场景会不兼容(pandas对于把None也当中NaN处理,所以isna fillna等函数对None依然work),需要将None转回nan值才可。当然也可以将read_parquet的use_nullable_dtypes参数设为True,默认是False,这样会将缺失值用pd.NA替代,而不是None,但是风险在于这个参数只是实验性的,在pandas的后续版本可能随时会发生改变。
Reference
[1] Reading and Writing the Apache Parquet Format — Apache Arrow v9.0.0
[2] IO tools (text, CSV, HDF5, …) — pandas 1.5.0 documentation
[3] https://www.youtube.com/watch?v=1j8SdS7s_NY&ab_channel=Databricks
[4] https://towardsdatascience.com/demystifying-the-parquet-file-format-13adb0206705