主流存储格式的特点
文件的存储布局可以分为三种,行式存储,列式存储,以及混合存储,不同的存储布局方案会对上层系统的整体性能有着绝对性的影响,可以直接决定数据加载入数仓的效率,响应用户查询的速度,以及底层存储空间的利用率。
行式存储,即每条记录的各个字段连续存储在一起,每个数据块除了存储的元数据之外,每条记录都以行的方式进行数据压缩后连续存储在一起。行式存储的问题是什么,第一,很多SQL查询读取的记录可能只涉及整个记录的所有字段中的部分字段,行式存储会把全部字段读出后才能取到所需的字段,第二,尽管存储时可以采用数据压缩,对于所有字段只能采用同一种压缩算法,导致数据压缩率不高,磁盘利用率不高。
列式存储,即按照列对所有记录进行垂直划分,将同一列的内容连续存放在一起。列式存储存在一个问题,即如果只是单纯把列的数据连续存储在一起,而一般的大数据任务往往需要遍历每条数据记录,那可能就出现,当不同列的数据分散在不同的数据块、不同机器节点时,为了从列式数据中拼接出完整的记录内容,而需要跨网络地把数据读取并合并,这样就需要大量网络传输。为了缓解这个问题,列式存储引入了列族的解决方案,即将所有列进行分组,将经常一起使用的列分为一组,这样保证被查时可以从同一数据块被查出,避免不必要的网络传输。但是该方法只能缓解问题。 可见下面图中,row layout即为行式存储,第一行的列a1,b1,c1先连续存储,然后才到a2。列式存储则是column layout,先把所有行的列a连续存储,然后是b列。
最后是混合式存储。混合式存储融合了行式和列式的优点,首先将记录按照行进行分组,若干行为一组,存储时则按列将同一列的内容连续存储在一起。这种存储布局,一方面可以像行式存储一样,保证同一行的记录是在同一个机器节点上,另一方面列式的存储可以让不同列采取不同的压缩算法,以及避免读数据时读取无关列的操作。
像ORC,Parquet就是混合式存储。但是注意,一般英文语境下,这些存储格式还是被叫做columar file format,可能会让人以为是纯列式存储。
ORC
ORC文件的格式是这样的,一个ORC文件由若干个stripe文件组成,stripe文件带有原始数据、索引和stripe footer。其中,stripe footer保存了流位置的描述,用于扫描数据时用,索引记录了该stripe文件每列的最大值,最小值,平均值等统计结果,以及部分列的每个值在数据块的位置信息(偏移量),这个需要显示指定才会生成(通过参数"orc.bloom.filter.columns"="cuid"且0.13之后版本才可用),该参数可以用于定位stripe文件,跳到正确的stripe文件位置。最后,整个文件的file footer则记录了所有stripe文件的元数据,比如有多少个stripe文件,每个stripe文件包含的记录数,每列的统计信息,比如count, min, max, and sum。当我们的hive集群设置了"hive.compute.query.using.stats=true"时(该参数默认为true,可用查下你使用的hive集群是否设置为true了),如果我们的查询刚好命中元数据,则不需要经过计算结果就能秒回。大家可以试试在hive client试试查询手上parquet或ORC格式表的某个分区的pv数,某列最大值,看看是不是秒出,结果应该都在2秒内返回。
Parquet
parquet则是存储数据结构分3层,元数据也分3层。每个HDFS文件由若干个row_group(行组)和一个footer组成,每个行组的每个列由若干个列数据块(column chunk)组成,每列的column chunk都配套一个dictionary page字典页,保存该chunk的信息,比如该列的min,max值用于筛选数据,以及储存数据的data page(数据页),Footer则保存了行组,列数据和数据页的元数据,比如每个column chunk的起始位置。到最细最不可拆分的page粒度,也有一个page header带上一些描述page的元数据,比如pv量,编码方式等等。基于parquet在3层数据结构都有元数据,可知如果我们把数据排序,则可以通过路由到正确的column chunk的起始位置来跳过不必要的文件。
查询优化
MIN-MAX查询优化
接下来讲下了解存储格式的基础上如何帮助我们优化查询效率。以ORC为例,我们知道ORC的特点是会保存每个stripe文件的最大最小值,这样当查询时,ORC的Stripe Footer内的最大最小值和列索引可对查询条件中的列做谓词上推,即判断我想读的数据是否在这个文件里,如果不存在,则整个文件会被跳过(dataskiping)。但是,我们需要手动去给指定列进行全局排序,才可能达到最优效果,ORC不会帮你对列排序,毕竟这也是个很耗CPU和内存的工作,需要由开发者自己判断(除非你写数据前最后一步是非广播的join,那样可能保证最终的数据已经对关联键的列排序了)。
在指定对写出数据进行排序前,我们可能得先对数据进行一次混洗和分发,比如你想优化查询的列是A,即你准备把列A给排序,则首先你先distribute by A,这里会把所有行的列A都放到一个哈希函数计算,哈希值一样的数都会被放到同一个reducer同一个数据文件中,这个举措保证了相同的数据会被放到一起,相似的数据也容易被放一块。如果你的任务还会写多个分区,且分区列是B,你可以distribute by B, A,这样能降低文件数。成功把相似数据打到相同文件后,另一个问题出现了,不同列值对应的数据量是不一样的,按列A混洗之后,有的列值对应的pv日常大,则文件非常大,有的很小,即数据倾斜的问题,这个时候我们就需要用解决数据倾斜的方法来解决。目前我司的解决方案配合ORC效果是非常明显的,能在不大幅提高计算量的前提下,将查询时间降到原来的20%左右,大家从min-max的角度想想,什么算法既可以加盐散列去解决数据倾斜,又可以顺便把数据排序给做了。
更上一层楼的z-order
上面提到的优化方法,只能满足“只优化一个列就能使下游大部分任务获得收益”这种场景,但是很可能有些大宽表的每个列都有潜力被查询和分析。那么,有没有一种算法,可以让我们把所有列值都排序,且各列之间的排序仍然相对有效?这就是z-order了,z-order应用于阿里云的Delta Lake,Iceberg也计划去支持z-order,可以说是非常好用了。
空间填充曲线(space-filling curve)是一种降低空间维度的技术,将高维空间数据映射到一维空间,并利用转换后的索引值存储和查询数据。空间填充曲线通过有限次的递归操作将一个多维空间(可以理解成一个多维坐标轴)划分为众多的网格,用一个坐标点表示该网格的位置,再通过一条连续的曲线经过所有的坐标点来表示该线填充了所有的网格。z-order是其中一种空间填充曲线,它总是用Z字的顺序去填充空间(见图1)。
原理大概如下:z-order就是我们把现实中一个多列数据集的多列的值,映射到z-order curve里,即我们表里的一条记录(维度1,维度2,维度3...)的维度值通过某些函数转成二进制,映射到坐标轴的(x1y1z1, x2y2z2, x3y3z3, ....)(见图3,维度1维度2变成了x和y),这里坐标轴的x1y1z1会把多个维度的二进制值拼接起来成为一个新的二进制值,即z-index。我们这么循环往下,按照四方格里Z路线的顺序把一个个坐标点穿起来,最后用这个最早的和最晚的z_index去作为文件的min-max(见图2),筛选条件通过把筛选条件的维度生成z-index来对比是否要跳过该文件。
z-order可以让使用该排序的数据集获得所有做了z-order的列的min-max值,来达到在查询时多跳过文件的效果,不仅如此,z-order让更相似的记录紧挨在一起,相似的记录在被查询时有更高的概率被一块查出,且缓存命中的概率也更高,这也提高了查询的效率(原文:Arranging data in order of a space-filling curve improves spatial locality ==> data will be found in cache ==> improvements at all levels of the memory hierarchy)
下面的图例是一个描述网络的数据集有4个字段,数据被混洗到100个文件,然后分成2个对照组,一个只按照sourceIP做排序,一个全部字段按照z-order排序,就这两个数据集查询筛选条件分别为4个字段的情况,比如先是sourceIP=xxx,然后destIP=xxx。可以很明显看出来,当筛选条件为sourceIP时,第一种排序可以跳过99个文件只读一个,但是当使用其他列做筛选时,就完全没有dataskiping的效果;第二种用z-order的排序,保证了至少有44%的dataskipinp效果,平均54%的筛选效果(第一种平均25%)。
但是有一点需要权衡的就是,把数据进行z-order排序也是挺花时间的(需要排序的字段越多越花时间),需要权衡,加入z-order排序后是否能明显优化下游查询效率(如果都是全表读取那就没必要了),时效性是否也能保证,等等(原文:doing sort opertaion is slow, but it will make all your query fast)。还有就是,做z-order的维度值越多,同一列的相似值就越难临近,所以要谨慎选择需要z-order的字段。
存储优化
涉及压缩算法
VLC(Variable Length Codes)——越是高频的字符/字符串,对应的二进制字符串索引越短(represents letters/words with reference to a unique prefix-free string of bits)
Huffman Encoding——假设我们知道,或者能相对准确地预测一串要压缩的字符串的每个字符在整个字符串的占比(如果不知道,我们可以假设所有字符都有相同出现的概率,然后在读数据的时候动态更新概率,假设下面流过来的数据和已处理的数据的概率一样),则我们可以通过构建哈夫曼树(见下图)来以最少的重复去编码这串字符串。
LZ77——基于字典的算法,将长字符串(也称为短语)编码成短小的标记,用小标记代替字典中的短语,从而达到压缩的目的。也就是说,它通过用小的标记来代替数据中多次重复出现的长串方法来压缩数据。其处理的符号不一定是文本字符,可以是任意大小的符号。
ORC文件的默认的压缩格式是ZLIB,Parquet文件的默认压缩格式是SNAPPY,但是它压缩效率最高的压缩格式是GZIP,而GZIP使用的是ZLIB library。ZLIB使用deflate算法,具体逻辑包含Huffman encoding和LZ77算法。两种算法的原理用最粗糙的解释可以说,用独立且前缀不重复的二进制字符串索引来表示列内的各种字符/字符串,且越是高频的字符/字符串,对应的二进制字符串索引越短,最终通过把相当部分列内的字符用这些二进制字符串索引替代,达到数据压缩的效果。我只在上面讲了2个相对好解释的压缩算法的简单场景,因为实际在ZLIB中的应用很复杂。
如何提高压缩效率
为了追求最好的压缩效果,我们应该尽量让相似的数据被分配到同一个文件,且让这些数据紧挨也就是手动指定排序,这样压缩效果最好,因为LZ77算法会边读数据边处理。当某列可以代表数据的大部分列时,用该字段排序效果最好,因为其他列等于跟着该列搭顺风车也排序了,例子比如dwd_xxx_xxx_xxx_log_hi,该表有30个字段,其中14个是描述设备和app环境的,8个是描述点位的,这时候我们用cuid来把数据排序效果最好。同时还可以考虑最后写文件数对压缩效果的影响,把存在相同值的一部分数据分到10个文件,和存到同一个文件,压缩效果肯定是不一样的,后者可以把10个文件里每个独立列值都用同一组二进制编码表示。但是也要考虑到,如果最终reducer/mapper数太少,可能数据处理变慢,同时文件太大可能影响读取效率,这里需要对压缩效率和查询效率进行一个取舍。下图是几种处理策略后的存储占用空间(GB)。对照组1的原数据是14.6GB(因为是实时ETL写入的默认数据,可以认为是按时间戳排序),在按照某个不代表数据集的列重新混洗后,空间占用直接翻了一倍到28GB,后面又尝试按照比较有代表性的列去混洗,空间利用又降到11-13GB。对照2里,在上一步按照code混洗的基础上,将数据按照具有代表性的列cuid排序后,占用空间从28GB降到了18GB,效果非常明显!这意味着通过这个方法,我们能既按想要优化min-max的列去分发数据,又能保证占用空间不膨胀,第三个数据对比了最后生成不同文件的存储差异,可以看到,数据划分越多,占用空间越大。
--对照1
--14.5947 <-- 不处理
--28.6083 <-- distribute by code
--11.7286 ~ 13.9923 <-- distribute by cuid
--对照2
--28.6083 <-- distribute by code
--17.9894 <--128 reducers(生成128个文件) + distribute by code sort by cuid
--20.3902 <-- 256 reducers(生成256个文件)
复制代码
数据的压缩效率主要受四个因素影响,一、压缩算法,对列选择正确的压缩算法,这个一般由存储library根据列的格式来选择;二、对列进行排序,相似数据的压缩效率更高;三、划分更少文件,文件越少,压缩算法中的索引就能代表更多的值;四、如果有多个列去排序,基数低(可以理解为枚举值少)的先排序,然后是基数高的。
参考资料
《大数据日知录:架构与算法》第8章 8.4
ORC:community.cloudera.com/t5/Communit…
Parquet官网文档:parquet.apache.org/documentati…
为什么sort by能影响存储效率:stackoverflow.com/questions/6…
z-order curve:tildesites.bowdoin.edu/~ltoma/teac…
Delta Lake:databricks.com/wp-content/… z-order部分