楔子
随着大数据时代的发展,每天都要产生大量的数据,而存储这些数据不仅需要更多的机器,怎么存也是一个问题。因为数据不是存起来就完事了,我们还要对数据进行分析、统计,所以一个合理的数据格式也是非常重要的。
而随着数据种类的不同,我们也会选择不同的格式去存储。
数据种类
数据一般可以分为三种:非结构化数据、半结构化数据、结构化数据。
非结构化数据
非结构化数据是指数据结构不规则或不完整,没有预定义的数据模型,不方便用数据库二维表来展现的数据。比如 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 文件,以及如何用 Python 去处理它(二):https://developer.aliyun.com/article/1617386