解密 parquet 文件,以及如何用 Python 去处理它

发布时间 2023-04-14 16:55:24作者: 古明地盆

楔子

随着大数据时代的发展,每天都要产生大量的数据,而存储这些数据不仅需要更多的机器,怎么存也是一个问题。因为数据不是存起来就完事了,我们还要对数据进行分析、统计,所以一个合理的数据格式也是非常重要的。

而随着数据种类的不同,我们也会选择不同的格式去存储。

数据种类

数据一般可以分为三种:非结构化数据、半结构化数据、结构化数据。

非结构化数据

非结构化数据是指数据结构不规则或不完整,没有预定义的数据模型,不方便用数据库二维表来展现的数据。比如 word 等办公文档、文本、图片、HTML、各类报表、图像和音频/视频等等。

非结构化数据的格式非常多样,标准也是多样性的,而且在技术上比结构化数据更难标准化和理解。因为没有固定的结构,因此解析起来也需要更高的开销。

半结构化数据

半结构化数据是结构化数据的一种,虽然它不符合关系型数据库所要求的数据表模型,但结构也较为明确。比如具备可以用来分隔语义元素的相关标记,并且能够对记录和字段进行分层等等。因此,它也被称为自描述的结构。

XML 就是一种经典的半结构化数据:

<person>
    <name>satori</name>
    <age>17</age>
    <gender>female</gender>
</person>

虽然它不是结构化数据,但我们也可以很方便地解析它。

半结构化数据有一个特点:同一类实体可以有不同的属性,并且这些属性的顺序并不重要。

<person>
    <gender>female</gender>
    <name>satori</name>
    <age>17</age>
</person>

虽然顺序不同,但上面两个 XML 是等价的。

除了 XML 之外,半结构化数据还有 JSON,并且随着 JSON 的出现,XML 用得也越来越少了。比如 AJAX 中的 X 指的便是 XML,不过现在早已换成了 JSON,所以我觉得 AJAX 应该改名叫 AJAJ。

{"name":"satori","age":17,"gender":"female"}

XML 和 JSON 都是半结构化数据,并且是非常通用的一种数据格式,几乎所有语言都支持。当调用一个外部接口时,返回的数据格式几乎都是 JSON。

结构化数据

结构化数据是一种数据最为清晰的格式,解析起来开销也最小,数据库表便属于结构化数据。数据以行为单位,一行数据表示一个实体信息,并且每一列的数据属性是相同的。

+--------+-----+---------+
|  name  | age | address |
+--------+-----+---------+
| satori |  17 |  female |
| koishi |  16 |  female |
+--------+-----+---------+

结构化数据的特点就是清晰直观,存储和排列很有规律,解析起来也更加方便。

存储模型

聊完了数据种类之后,再来看看存储模型。数据在存储的时候有行式存储和列式存储,那么这两种存储模型有什么区别呢?

前面说过,数据不光是存起来就完事了,我们还要对数据进行分析和查询。但分析和查询数据的第一步就是要先把数据读出来,而这个过程肯定是耗时越短越好,这是毋庸置疑的。

那么如何缩短读取所需要的时间呢?一个非常流行的观点认为:如果你想让查询变得更快,最简单且有效的方法就是减少数据扫描范围和数据传输时的大小,而列式存储和数据压缩就可以实现这两点。列式存储和数据压缩通常是伴生的,因为一般来说列式存储是数据压缩的前提。

那什么是列式存储呢?

首先列式存储,或者说按列存储,相比按行存储,前者可以有效减少查询时需要扫描的数据量,我们举个例子说明一下。假设一张数据表 A,里面有 50 个字段 A1 ~ A50,如果我们需要查询前 5 个字段的数据的话,那么可以使用如下 SQL 实现:

SELECT A1, A2, A3, A4, A5 from A;

但是这样问题来了,由于数据库在存储数据的时候是按行组织的,所以每次都会逐行扫描、并获取每行数据的全部字段(这里是 50 个),然后再从中返回前 5 个字段。因此不难发现,尽管只需要前 5 个字段,但由于数据是按行进行组织的,实际上还是扫描了所有的字段。

如果数据是按列进行存储,则不会出现这样的问题,由于数据按列进行组织,数据库可以直接选择 A1 ~ A5 这 5 列的数据并返回,从而避免多余的数据扫描。为了更好地说明这两者的区别,我们画一张图:

如果是按行存储的话,那么假设我们要计算 age 这一列的平均值,就需要一行一行扫描,所以最终至少要扫描 11 个值( 3 + 3 + 3 + 2 )才能找到 age 这一列所存储的 4 个值。

这意味着我们要花费更多的时间等待 IO 完成,而且读完之后还要扔掉很多(因为我们只需要部分字段)。但如果是按列存储的话,我们只需要获取 age 这一列的连续块,即可得到我们想要的 4 个值,所以这种操作的速度更快、效率更高。

按列存储相比按行存储的另一个优势是对数据压缩的友好性,同样可以举一个例子简单说明一下压缩的本质是什么。假设有个字符串 aaaaaaaaaabc,现在对它进行压缩,如下所示:

压缩前:aaaaaaaaaabc
压缩后:a(1,9)bc

可以看到,压缩的本质就是按照一定步长对数据进行匹配扫描,当发现重复部分的时候就会编码转换。例如上面的 (1, 9),表示将前面的一个字节重复 9 次。

尽管真实的压缩算法要比这个复杂许多,但压缩的本质就是如此。数据中的重复项越多,则压缩率越高;压缩率越高,则数据体量越小;而数据体量越小,在网络中传输的速度则越快,并且对网络带宽和磁盘 IO 的压力也就越小。可怎样的数据最可能具备重复的特性呢?答案是属于同一个列字段的数据,因为它们具有相同的数据类型和现实语义,重复的可能性自然就更高。

列式存储除了降低 IO 和存储压力之外,还为向量化执行做好了铺垫。

对于大数据分析引擎来说,几乎都选择了列式存储,比如 Hbase、ClickHouse 等等。

那么问题来了,既然列式存储这么好,为啥像 MySQL 这样的关系型数据库却没有选择呢?这个答案很简单,行式存储和列式存储各有优缺点,它们分别适用不同的场景。

行式存储的特点

在行式数据库中,每一行的数据就是一串字节,行与行之间是紧挨着存放在硬盘中的。传统的关系型数据库便属于行式存储,它们主要用于 OLTP 场景,非常适合以下操作:

  • 随机增删改查操作;
  • 表的字段个数不多,并且需要查询大部分字段;
  • 增删改操作较为频繁;

但行式数据库在读取数据时,会存在一个严重的缺陷,我们上面已经说过了。当数据的列比较多,而选择查询的数据却只涉及到少数列时,就会产生性能上的浪费。因为应用程序必须完整地读取每一行,才能将想要的数据选择出来。

所以为了优化这个过程,我们一般会给字段添加索引。

列式存储的特点

列式存储是相对于行式存储来说的,在基于列式存储的数据库中,数据以列为基础逻辑存储单元,每一列的数据在硬盘中是连续存储的。列式存储主要用于 OLAP 场景,非常适合以下操作:

  • 表的字段非常多,也就是所谓的宽表,然后查找指定的列;
  • 高效地查询数据,在查询过程中能够减少 IO,避免全表扫描,并且也无需维护索引;
  • 由于数据是按列存储的,每一列的数据类型相同、现实语义相同,所以更具有相似性,数据的压缩比更高。压缩比高,那么存储的时候占用的空间就小,读取时的 IO 压力就小;

所以假设表中有 50 个字段 A1 ~ A50,但我们只需要 A1 ~ A5 这 5 个字段。如果是行式存储,那么为了选择这 5 个字段,必须要将 50 个字段都扫描一遍,工作量变成了实际的 10 倍。

而如果是列式存储,那么直接将指定的 5 个字段读取出来就行了,非常简单。

但相比行式存储,列式存储也有它相应的缺陷:

  • 不适合数据需要频繁插入和更新的操作,比如插入一条数据,如果是行式存储,那么直接尾部追加即可,非常简单,因为行与行之间是紧挨着的。但列式存储显然就很麻烦了,需要多次 IO,所以列式存储最好是一次性大批量写入,尽量不更新;
  • 在存储宽表、并且数据量庞大的时候,才具有明显的优势;
  • 不适合实时地插入和更新操作,列式存储一般都用在大数据场景,特点就是数据量大,重点在查询;
  • 由于写入一条数据需要多次 IO,所以也不适合事务;

因此行式存储和列式存储都有各自的优缺点和使用场景,当你的数据需要动态变化时,那么使用行式存储;如果数据量很大,重点在查询,而插入和更新的频率很低,那么使用列式存储。

但也有人表示:小孩子才做选择,成年人全都要,那么有没有一种存储方式能够同时兼顾行式存储和列式存储的优点呢?答案是肯定的,行列混合存储就是这样一种模式,该模式不仅适合快速读取数据,如果想进行更新或插入,也可以轻松识别记录的位置。

那么行列混合存储模式具体是怎么做的呢?

行列混合存储模式的做法其实非常简单,它将多行数据定义为一个数据块。假设有 20 万行数据,拆分成 4 个 block,那么每个 block 就有 5 万行数据。然后在 block 内部,数据是按照列式存储的,但多个 block 之间是行式存储的。

我们将上面的图变换一下格式:

基于行列混合存储的特性,配合倒排索引、bitmap索引、范围索引、预排序、各种的 Cache 机制、读写分离等,便可以做到既满足大批量高性能写入、又满足上亿量级数据的毫秒级别响应,很适合大数据的实时写入,实时分析的场景。

而使用行列混合存储的文件格式有很多,最流行的便是 ORC 文件和 Parquet 文件。本篇文章就来介绍 Parquet 文件的存储原理,以及如何用 Python 去处理它。

Parquet 文件的由来

Parquet 是 Apache 的顶级项目,它是由 Twitter 和 Cloudera 合作开发的。其设计和数据模型、计算框架、编程语言均没有关系,可以与任意项目集成,因此应用广泛,并且目前已经是 Hadoop 生态圈列式存储的事实标准。

再来说说 Parquet 的由来,根据推特官方的说法,并不是所有存储在 Hadoop 中的数据都是一个简单的二维表,还有很多包含复杂的嵌套关系的数据。也就是说,推特想在 Hadoop 上面设计一种新的列式存储格式,这种格式可以保存包含嵌套结构的数据。

所以一言以蔽之,Parquet 文件格式试图解决的问题,就是列式存储一个类型包含嵌套结构的数据集。

Parquet 文件的数据模型

如果想深入地了解 Parquet 的存储格式,首先要理解它的数据模型。Parquet 采用了一种类似 Google Protobuf 的协议来描述存储数据的 schema。

比如我们要存储用户的电话簿,那么首先要将数据的 scheme 定义出来。

message AddressBook {
  required string owner;
  repeated string ownerPhoneNumbers;
  repeated group contacts {
    required string name;
    optional string phoneNumber;
  }
}

解释一下里面的字段含义:

  • owner:用户的名字;
  • ownerPhoneNumbers:用户的手机号,可以有多个;
  • contacts:联系人组成的数组,每个联系人对应一个对象,对象里面保存了联系人的名字和手机号;

然后注意里面的标识符,required 表示字段是必需的,optional 表示字段是可选的,repeated 表示字段可以接收多个值,group 用来表示数据的嵌套结构。

结构非常清晰,而像 Map、Set 等复杂类型也可以用 repeated + group 来表达,因此也就不用再单独定义这些类型。

假设我们要存储 1000 个用户的电话簿信息,其中每个用户的电话簿信息大概就是下面这样。

{ 
  "owner": "古明地觉",
  "ownerPhoneNumbers": ["0103456", "0101234"],
  "contacts": [
    {
      "name": "芙兰朵露",
      "phoneNumber": "0221324"
    },
    {
      "name": "八意永琳",
      "phoneNumber": "0226559"
    }
  ]
}

为了阅读方便,这里我们写成 JSON 的形式,但 Parquet 底层肯定不是用 JSON 格式存储的。

ownerPhoneNumbers 字段是一个数组,而 contacts 字段更是一个包含对象的数组。所以这个类型就不能用简单的二维表来存储,因为它包含了嵌套结构。

那么 Parquet 是怎么做的呢?很简单,在 Parquet 里面,保存嵌套结构的方式是把所有字段扁平化以后顺序存储。

什么意思呢?以电话簿的例子来说,真正有数据的其实只有4列:

  • owner;
  • ownerPhoneNumbers;
  • contacts.name;
  • contacts.phoneNumber;

所以只需要把原始数据看作是一个 4 列的表即可,假设有两个用户的电话簿记录,数据如下:

{ 
  "owner": "古明地觉",
  "ownerPhoneNumbers": ["0103456", "0101234"],
  "contacts": [
    {"name": "芙兰朵露", "phoneNumber": "0221324"},
    {"name": "八意永琳", "phoneNumber": "0226559"},
  ]
},

{ 
  "owner": "古明地恋",
  "ownerPhoneNumbers": ["0101785"],
  "contacts": [
    {"name": "琪露诺", "phoneNumber": "0321447"},
    {"name": "八意永琳", "phoneNumber": "0226559"},
  ]
}

以列式保存之后,就会变成这样:

"古明地觉"
"古明地恋"
"0103456"
"0101234"
"0101785"
"芙兰朵露"
"八意永琳"
"琪露诺"
"八意永琳"
"0221324"
"0226559"
"0321447"
"0226559"

前两行是owner,第三行到第五行是ownerPhoneNumbers,第六行到第九行是contacts.name,最后四行是contacts.phoneNumber。

但很明显,这么做有一个问题,因为每条记录(record)的 ownerPhoneNumbers 和 contacts 都是不定长的,如果只是把数据按顺序存放,那么就无法区分 record 之间的边界,也就不知道每个值究竟属于哪条 record 了。所以简单的扁平化是不可行的。

而为了解决这个问题,Parquet 的设计者引入了两个新的概念:repetition level 和 definition level。这两个值会保存额外的信息,可以用来重构出数据原本的结构。关于这两个概念,我们放到后面说,这里先来看看 Parquet 的存储模型。

假定我们需要将一张二维表数据存储在一个 Parquet 文件中,那么这个 Parquet 文件在磁盘上是如何分布的呢?

如果你直接看这张图的话很容易晕,我们简化一下,然后对比来看就清晰了。

里面出现了很多的概念,别着急,我们来一点点地剖析它。

Row Group

要存储的数据集可能会包含上亿条记录(record),因此要进行水平切分,也就是沿着水平方向来几刀。这样整个数据集就会被切分成多份,每一份叫做一个 Row Group。

Parquet 文件很多都存储在 HDFS 上,而 HDFS 是有默认的块大小的,如果文件超过了 128M,那么 HDFS 也会对它进行切分。所以 Parquet 官方建议,将 HDFS 的块大小设置为 1G,Parquet 的 Row Group 大小也设置为 1G,目的就是让一个 Row Group 刚好存在一个 HDFS block 里面。

Column chunk

假设一个 Parquet 文件里面有 1 亿条记录,现在切成了 10 个 Row Group,那么每个 Row Group 里面就是 1000W 条记录。

然后 Row Group 里面对各自的 1000W 条记录会采用列式存储,对于嵌套结构则是扁平化以后拆分成多列。这里为了方便,我们就假设每条记录都只包含 name、age、gender 三个字段。

每个 Row Group 里面的数据会采用列式存储,而每一列的数据就叫做 Column chunk,显然对于上面这张图来说,每个 Row Group 里面都有 3 个 Column chunk,并且它们顺序存储在一起。

Page

再来看看 Page,其实到 Column chunk 这一步就已经非常简单了,但 Parquet 会对 Column chunk 再进行一次水平切分,得到的就是一个个的 Page。

所以每个 Column chunk 会对应多个 Page,每个 Page 的大小默认是 1M。而之所以要进一步切分成 Page,主要是为了让数据读取的粒度足够小,便于单条数据和小批量数据的查询。

因为 Page 是 Parquet 文件的最小读取单位,同时也是压缩单位,如果没有 Page,压缩就只能对整个 Column Chunk 进行。而如果整个 Column Chunk 被压缩,就无法从中间读取数据,只能把 Column Chunk 全部读出来之后解压,才能读到其中的数据。

Parquet 这类列式存储有着更高的压缩比,结合 Parquet 的嵌套数据类型,可以通过高效的编码和压缩方式降低存储空间并提高 IO 效率。

Header

Header,Index 和 Footer 都属于元数据,先来看看 Header。

Header 的内容很少,只有 4 个字节,本质是一个 magic number,用来指示文件类型。这个 magic number 目前有两种变体,分别是 "PAR1" 和 "PARE"。其中 "PAR1" 代表的是普通的 Parquet 文件,"PARE" 代表的是加密过的 Parquet 文件。

Index

Index 是 Parquet 文件的索引块,主要为了支持谓词下推功能。谓词下推是一种优化查询性能的技术,简单地来说就是把查询条件发给存储层,让存储层可以做初步的过滤,把肯定不满足查询条件的数据排除掉,从而减少数据的读取和传输量。

举个例子,对于 csv 文件,因为不支持谓词下推,只能把整个文件的数据全部读出来,然后通过 where 条件一条一条比对,来对数据进行过滤。而如果是 Parquet 文件,因为自带索引,比如 Max-Min 索引,这样就可以根据每个 Page 的最大值和最小值,选择是否要跳过这个 Page,从而直接避免读取无用数据,减少 IO 开销。

目前 Parquet 的索引有两种,一种是 Max-Min,一种是 BloomFilter。其中 Max-Min 索引是对每个 Page 都记录它所含数据的最大值和最小值,这样某个 Page 是否满足查询条件就可以通过该 Page 的最大值和最小值来判断。BloomFilter 索引则是对 Max-Min 索引的补充,对于那些 value 比较稀疏,范围比较大的列,用 Max-Min 索引的效果就不太好,而 BloomFilter 可以克服这一点,同时也可以用于单条数据的查询。

Footer

大部分的元数据都存在 Footer 里,比如 schema,Row Group 的 offset 和 size,Column Chunk 的 offset 和 size。另外读取 Parquet 文件的第一步就是先读取里面的 Footer,拿到元数据之后,再根据元数据跳到指定的 Row Group 和 Column chunk 中,读取真正的数据。

这里可能有人会好奇,一般来说元数据应该放在真实数据的前面,而 Parquet 却写在后面。之所以这么做,是为了让数据可以一次性顺序写到文件里,因为很多元数据的信息需要等真实数据写完以后才知道,例如总行数,各个 Row Group 的 offset 等等。

如果要写在文件开头,就必须 seek 回文件的初始位置,但大部分文件系统并不支持这种写入操作(例如 HDFS)。而如果写在文件末尾,那么整个写入过程就不需要任何回退。

然后 Footer 还包含了一个 Footer Length 和一个 Magic Number,Length 占 4 字节,用于表示整个 Footer 的大小,帮助找到 Footer 的起始指针位置。而 Magic Number 则和 Header 是一样的,占 4 字节。

记录的边界要如何确定

了解完 Parquet 文件的结构之后,再来回顾之前遗留的问题,那就是记录的边界该怎么确定呢?

以里面的 contacts.name 为例,前两个属于第一条 record,后两个属于第二条,那么问题来了,Parquet 是如何区分的呢?答案是通过 repetition level。

数组的每个元素都有一个 repetition level,如果只考虑每个数组的第一个元素,那么它们的 repetition level 就是从 0 开始依次递增的。至于其它元素的 repetition level 则直接和数组的个数保持一致,可能用文字描述的话不太好理解,我们画一张图就清晰了。

因为有 5 条记录,所以数组的个数就是 5。每个数组的第一个元素的 repetition level 从 0 开始依次递增,所以 a1、b1、c1、d1、e1 的 repetition level 分别对应 0、1、2、3、4,至于其它元素的 repetition level 则直接和数组的个数保持一致,因为这里有 5 条记录,所以就都是 5。

说完了 repetition level,再来看看 definition level,后者主要是表示 null 值。因为 Parquet 文件不会显式地存储 null,所以需要用 definition level 来判断某个值是否为 null。

message TestDefinitionLevel {
  optional group a {
    optional group b {
      optional string c;
    }
  }
}

该结构如果转换成列式,那么它只有一列 a.b.c,由于所有 field 都是 optional 的,都可能是 null。如果 c 有定义,那么 a 和 b 作为它的上层,也一定是有定义的。但当 c 为 null 时,可能是因为它的某一级 parent 为 null 才导致 c 是 null 的。

这时为了记录嵌套结构的状况,我们就需要保存最先出现 null 的那一层的深度了。这里一共嵌套三层,所以 definition level 最大是 3。

这里 definition level 不会大于3,等于 3 的时候,表示所有层级都有定义;等于 0、1、2 的时候,指明了 null 出现的层级.

需要说明的是,对于 required 字段是不需要 definition level 的,只有那些 optional 字段才需要,举个例子:

message TestDefinitionLevel {
  optional group a {
    required group b {
      optional string c;
    }
  }
}

如果将 b 改成 required,那么 definition level 最大就是 2,因为 b 不需要 definition level。

以上就是 repetition level 和 definition level,还是不难理解的。但可能有人觉得每个值都保存这俩玩意,是不是太浪费空间了。所以 Parquet 又做了优化,对非数组类型的字段不保存 repetition level,对 required 字段不保存 definition level。

并且实际存储这两个字段时,还会通过 bit-packing + RLE 来进行压缩。

Python 操作 Parquet 文件

Parquet 文件的原理我们稍微了解一下就好,重点是如何操作它。需要说明的是,不光是嵌套结构,二维表结构也一样可以用 Parquet 文件存储。

import pandas as pd

df = pd.DataFrame({
    "name": ["satori", "koishi", "marisa", "cirno"],
    "age": [17, 16, 18, 40],
    "gender": ["female"] * 4
})

df.to_parquet(
    "girl.parquet.gz",
    # 需要 pip install pyarrow
    engine="pyarrow",
    # 压缩方式,可选择:'snappy', 'gzip', 'brotli', None
    # 默认是 'snappy'
    compression="gzip",
    # 是否把 DataFrame 自带的索引写进去,默认写入
    # 但要注意的是,索引会以 range 对象的形式写入到元数据中
    # 因此不会占用太多空间,并且速度还更快
    index=False
)

写完之后,在我本地就会生成一个 gz 文件,那么要如何读取它呢?

import pandas as pd

df = pd.read_parquet("girl.parquet.gz",
                     engine="pyarrow")
print(df)
"""
     name  age  gender
0  satori   17  female
1  koishi   16  female
2  marisa   18  female
3   cirno   40  female
"""

结果没有问题,如果你只想要部分字段,那么通过 columns 参数指定想要的字段即可。

根据列进行分区

然后我们还可以根据指定的列进行分区,举个例子:

import pandas as pd

df = pd.DataFrame({
    "name": ["satori", "koishi", "marisa", "cirno"] * 2,
    "age": [17, 16, 18, 40] * 2,
    "gender": ["female"] * 8
})

df.to_parquet(
    "girl.parquet.gz",
    engine="pyarrow",
    compression="gzip",
    # 按照 "name" 字段分区
    partition_cols=["name"]
)

执行之前先将刚才生成的文件删掉,执行之后会发现生成了一个目录:

此时的 girl.parquet.gz 就不再是文件了,而是一个目录,然后目录里面会出现 4 个子目录。因为我们是按照 name 分区的,而 name 有 4 个不同的值。

所以从这里可以看出,只有那些专门用于分类、元素重复率非常高的字段,才适合做分区字段,最典型的就是日期。

然后我们来读取它。

import pandas as pd

df = pd.read_parquet("girl.parquet.gz",
                     engine="pyarrow")
print(df)
"""
   age  gender    name
0   40  female   cirno
1   40  female   cirno
2   16  female  koishi
3   16  female  koishi
4   18  female  marisa
5   18  female  marisa
6   17  female  satori
7   17  female  satori
"""
# 默认全部读出来了
# 但也可以选择读取部分记录,比如只读取 name='satori' 的记录
df = pd.read_parquet("girl.parquet.gz/name=satori",
                     engine="pyarrow")
print(df)
"""
   age  gender
0   17  female
1   17  female
"""

在读取 name='satori' 的记录时,我们看到居然没有 name 字段,原因也很简单,我们是按照 name 字段分区的。那么每个分区的 name 字段的值一定是相同的,所以读取出来之后手动添加即可。

df = pd.read_parquet("girl.parquet.gz/name=satori",
                     engine="pyarrow")
df["name"] = "satori"
print(df)
"""
   age  gender    name
0   17  female  satori
1   17  female  satori
"""

当然啦,如果你觉得一个分区字段不够,那么也可以指定多个分区字段。

import pandas as pd
import numpy as np

df = pd.DataFrame({
    "p1": ["a"] * 4 + ["b"] * 4 + ["c"] * 4,
    "p2": ["X", "X", "Y", "Y"] * 3,
    "p3": np.random.randint(1, 100, size=(12,))
})

df.to_parquet(
    "test.parquet.gz",
    engine="pyarrow",
    compression="gzip",
    # 按照 "p1" 和 "p2" 字段分区
    partition_cols=["p1", "p2"]
)

先按照 p1 分区,在 p1 内部再按照 p2 分区,执行之后目录结构如下:

非常清晰,所以 pandas 已经封装的非常好了,你根本不需要理解 Parquet 文件的原理,直接用就完事了。

然后我们来读取:

import pandas as pd

df = pd.read_parquet("test.parquet.gz",
                     engine="pyarrow")
print(df)
"""
    p3 p1 p2
0   86  a  X
1   72  a  X
2   64  a  Y
3   53  a  Y
4   90  b  X
5   60  b  X
6   58  b  Y
7   30  b  Y
8   17  c  X
9   74  c  X
10  81  c  Y
11  24  c  Y
"""

df = pd.read_parquet("test.parquet.gz/p1=b",
                     engine="pyarrow")
print(df)
"""
   p3 p2
0  90  X
1  60  X
2  58  Y
3  30  Y
"""

df = pd.read_parquet("test.parquet.gz/p1=b/p2=X",
                     engine="pyarrow")
print(df)
"""
   p3
0  90
1  60
"""

结果也没有问题,然后 pandas 的一个强大之处就在于,它不仅可以读取本地的 Parquet 文件,还可以读 s3 上面的,甚至是 http、ftp 也支持。

小结

以上我们就介绍了 Parquet 文件的原理以及如何用 Python 去操作它,这里需要再补充的一点是 pyarrow,它是 Apache Arrow 的 Python 实现。Apache Arrow 是一个高效的列式数据格式,用于加快数据的处理速度,并且是跨语言的。

而 pandas 在 2.0 的时候,可以采用 pyarrow 作为后端。在此之前,Pandas 的数据在内存中基本都是以 Numpy 数组的形式存在,每一列数据都以向量的形式存储,内部用 BlockManager 去管理这些向量。

但 Numpy 本身并不是为 DataFrame 而设计,对于一些数据类型的支持并不是很好。最尴尬就是缺失值,结果就搞出来 NaN,NaT,pd.NA 等等,让人头皮发麻,甚至一些公司在某些场景下都禁用 pandas。

但 Arrow 的引入可以完美地解决问题,不需要 Pandas 内部对每一种类型都设计一套实现,更契合的内存数据结构省了很多麻烦。不仅速度更快,也更省内存。

import pandas as pd
import pyarrow

# Pyarrow 是 Apache Arrow 的 Python 实现
# 通过 pyarrow.array() 即可生成 Arrow 格式的数据
arr1 = pyarrow.array([1, 2, 3])
print(arr1.__class__)
"""
<class 'pyarrow.lib.Int64Array'>
"""
arr2 = pyarrow.array([1, 2, 3], type=pyarrow.int32())
print(arr2.__class__)
"""
<class 'pyarrow.lib.Int32Array'>
"""

# pandas 在存储数据时默认使用 Numpy Array
s1 = pd.Series([1, 2, 3], dtype="int64")
print(s1)
"""
0    1
1    2
2    3
dtype: int64
"""

# 指定 Pyarrow,表示使用 Arrow 格式来存储数据
s2 = pd.Series([1, 2, 3], dtype="int64[pyarrow]")
print(s2)
"""
0   1
1   2
2   3
dtype: int64[pyarrow]
"""
# 在 2.0 的时候,通过设置 dtype_backend
# 可以让 pandas 默认选择 pyarrow 作为后端
# pd.options.mode.dtype_backend = 'pyarrow'

pandas 还有一个让人诟病的地方,就是它的字符串处理效率不高,因为 Numpy 主要是用于数值计算的,字符串并不擅长。所以在 pandas 里面存储字符串的时候,本质上存储的还是泛型指针,那这样就和 Python 动态处理字符串没什么两样了。

而使用 Arrow 之后,字符串就不再需要通过指针来查找了,它们是连续的一段内存,这样在内存占用和处理速度上都有非常大的提升。

所以我们可以得出如下结论:

  • 1)Apache Arrow 是一种跨语言的高效的列式数据格式,用于加速数据处理;
  • 2)pyarrow 库是 Apache Arrow 的 Python 实现,调用 pyarrow 的 array 函数,即可创建 Arrow 格式的数据。至于它的一些 API 以后有机会再说,不过有 pandas,这些 API 你完全可以不用了解,在使用 pandas 的时候,让它在背后为你默默工作就行;
  • 3)pandas 在创建 Series 或 DataFrame 的时候,在类型后面加上 [pyarrow],即可将数据格式从 Numpy 的数组换成 Arrow,然后在处理数据的时候会以 pyarrow 作为后端;

但 pyarrow 除了可以创建 Arrow 格式的数据之外,它还提供了一系列读取文件的方法,比如读取 ORC 文件、Parquet 文件、CSV 文件、本地文件、HDFS 文件等等。但这些方法你同样不需要了解,因为我们有 pandas,只需要在使用 pandas 读取文件的时候,将参数 engine 指定为 pyarrow 即可。

非常方便,并且速度快了不止一点半点。

注:将 engine 指定为 pyarrow,表示用 pyarrow 去读取 CSV 文件,但数据格式仍然使用 Numpy Array。而在 pandas2.0 的时候新增一个参数 dtype_backend,也要指定为 pyarrow,表示数据读取进来之后使用 Arrow 格式。

以上就是本文的内容,我们聊了 Parquet 文件,然后聊到了如何用 Python 去处理它,最后又介绍了 pyarrow。


本文参考自: