1. 为什么文件存储格式值得单独拎出来讲
做数据仓库这行的人,绕不开Hive。而一旦你开始认真用Hive处理稍微上点规模的数据,迟早会撞上一个问题:同样的SQL,为什么别人跑几分钟,我跑半小时?排除掉数据倾斜、资源排队这些因素之后,一个经常被忽视、但影响巨大的变量就是——文件存储格式。
我在刚接触Hive那会儿,建表从来都是默认的TextFile,觉得能存能查就行。直到有一次处理一批日志数据,单表几百GB,一个简单的聚合查询跑了四十多分钟,而隔壁同事同样体量的表只用了五六分钟。排查下来,SQL逻辑几乎一样,差别就在他建表时指定了ORC格式,而我用的是默认的文本格式。那次之后我才真正意识到,存储格式不是建表时随手一填的选项,它直接决定了你的查询性能、存储成本,甚至影响整个数仓的架构设计。
这篇内容就是围绕Hive的文件存储格式展开的。我会把Hive里几种主流格式——TextFile、SequenceFile、RCFile、ORC、Parquet、Avro——挨个拆开讲清楚:它们各自的数据组织方式是什么样的,为什么有的快有的慢,压缩怎么配合,什么场景该选哪个。不管你是刚上手Hive的新人,还是已经用了一段时间但没系统梳理过存储格式的老手,这篇都值得花时间看完。因为这块知识,属于那种“一旦搞明白,每次建表都用得上”的硬通货。
2. Hive存储格式的底层逻辑:行存与列存的分野
2.1 从一张表的物理存储说起
要理解存储格式的差异,得先知道Hive表的数据在HDFS上到底是怎么放的。Hive本身不存储数据,它只是对HDFS上文件的元数据管理加SQL解析层。你建一张表,数据最终是以文件的形式落在HDFS目录下的。文件存储格式,说的就是这些文件内部的数据怎么组织。
最直观的对比就是行式存储和列式存储。拿一张用户表举例,假设有四个字段:用户ID、姓名、年龄、城市。行式存储会把一条记录的所有字段挨着放:001,张三,28,北京,然后下一条002,李四,32,上海,依次排列。列式存储则反过来,先把所有用户ID放一起:001,002,003...,再把所有姓名放一起:张三,李四,王五...,以此类推。
这个差异看起来简单,但它带来的后果是连锁性的。行存适合整行读取的场景,比如你查某个用户的所有信息,一次就能把连续的数据读出来。列存适合只读部分列的场景,比如你只想统计所有用户的平均年龄,那列存只需要读年龄那一列的数据,其他列碰都不用碰。而数仓场景里,大部分分析查询恰恰就是“读少数几列、扫大量行”,这就是列存格式在数仓里大行其道的根本原因。
2.2 为什么列存能大幅减少IO
我用一个具体的数字来说明。假设一张表有100个字段,总共1亿行,每个字段平均占10字节。行存的话,一行大约1000字节,1亿行就是约100GB。现在你要跑一个查询:SELECT avg(age) FROM users,只用到age这一个字段。
行存模式下,Hive需要把每一行的1000字节全部读进来,再从里面挑出age字段。也就是说,为了拿10字节的age,你得读1000字节的数据,IO放大了100倍。列存模式下,age这一列单独存储,1亿行乘以10字节,大约1GB。你只需要读这1GB就够了,IO量直接降到行存的百分之一。
这个差距在数据量小的时候感知不明显,但当表到了TB级别,列存带来的IO节省就是分钟级和小时级的区别。而且列存还有一个附带好处:同一列的数据类型一致,相似度高,压缩效率天然比行存好。行存里一行包含各种类型的字段,压缩算法很难找到规律;列存里一列全是年龄数字或者全是城市名称,压缩比可以做得非常高。
2.3 压缩在存储格式中的角色
说到压缩,这是存储格式绕不开的搭档。Hive本身不负责压缩和解压,它依赖Hadoop的压缩编解码器。常见的压缩算法有Gzip、Snappy、LZO、Zlib、Bzip2等。选择压缩算法时要在三个维度上权衡:压缩比、压缩解压速度、是否可切分。
可切分这一点特别关键。Hadoop处理大文件时会切成多个block并行处理,如果一个压缩文件不可切分,那就只能由一个Map任务处理整个文件,并行度上不去。Gzip压缩比高但不可切分,Snappy压缩比中等但速度快且可切分,Bzip2压缩比最高但速度慢。在实际数仓环境里,Snappy配合ORC或Parquet是最常见的组合,因为它在速度和压缩比之间取得了比较好的平衡,而且支持切分。
注意:TextFile格式配合Gzip压缩后不可切分,会导致Map并行度受限。如果一定要用TextFile存大文件,建议不压缩或者用支持切分的压缩算法。
3. 六种主流存储格式逐一拆解
3.1 TextFile:最朴素的默认选项
TextFile是Hive的默认存储格式,本质上就是纯文本文件,每行一条记录,字段之间用分隔符隔开。你可以直接用hadoop fs -cat命令查看内容,也可以用任何文本编辑器打开。这种格式最大的优势就是简单、通用、可读。
它的适用场景很明确:数据交换、临时表、外部系统对接。比如你要把Hive里的数据导出给下游的Python脚本处理,或者从其他系统导入数据到Hive,TextFile是最不容易出问题的选择。另外,LOAD DATA导入TextFile格式的表时,Hive不需要做任何转换,直接移动文件即可,速度非常快。
但TextFile的缺点同样明显。首先它不支持列式读取,查询任何字段都要扫描整行。其次压缩效率低,文本数据的压缩比通常不如二进制格式。第三不支持块级索引和谓词下推,即使你只查一行数据,也可能要扫描整个文件。第四,NULL值的存储有歧义,默认用\N表示,如果数据里本身就有\N字符串,就会产生混淆。
我在实际项目里的做法是:TextFile只用于ODS层的原始数据落地和临时中转,绝不用于DW层和ADS层的正式表。因为一旦数据量上来,TextFile的查询性能会成为整个链路的瓶颈。
3.2 SequenceFile:Hadoop原生的二进制键值对
SequenceFile是Hadoop原生支持的一种二进制格式,它的结构是一系列的键值对(key-value)。在Hive里使用时,Hive会把每一行数据作为一个value,key通常为空。SequenceFile支持三种压缩级别:NONE(不压缩)、RECORD(按记录压缩)、BLOCK(按块压缩)。
BLOCK级别的压缩效果最好,因为它会把多条记录攒在一起压缩,压缩比更高,而且支持切分。SequenceFile相比TextFile的优势在于:二进制格式更紧凑、支持块级压缩、可以存储任意二进制数据。它曾经是Hive中比较推荐的中间格式,尤其是在需要存储二进制数据的场景下。
不过SequenceFile的问题在于,它仍然是行式存储,不支持列式读取。而且它的元数据开销比较大,每个文件都有一份header,如果文件数量多,这部分开销不容忽视。现在在新项目中,SequenceFile已经很少作为首选了,更多是被ORC和Parquet取代。但在一些老系统里,你仍然会看到它的身影,所以了解它的原理还是有必要的。
3.3 RCFile:列存的早期探索
RCFile全称Record Columnar File,是Facebook早期为Hive设计的一种列式存储格式。它的设计思路是:先按行水平切分成多个Row Group,然后在每个Row Group内部按列垂直存储。这样既保留了行存的一些特性(比如整行读取时不用跨太多块),又获得了列存的部分优势(按列读取时减少IO)。
RCFile在Hive发展史上是一个重要的里程碑,它证明了列存格式在Hadoop生态里的可行性。但它的局限性也很明显:不支持schema演化(加字段、改字段类型很麻烦),不支持嵌套数据类型,而且随着ORC和Parquet的出现,RCFile在性能和功能上都显得落后了。
现在RCFile基本已经退出了主流选择,除非你在维护一个历史遗留系统,否则不建议在新项目中使用。但理解RCFile的设计思想,有助于你更好地理解ORC和Parquet为什么那样设计。
3.4 ORC:Hive生态的亲儿子
ORC全称Optimized Row Columnar,是Hive社区在RCFile基础上发展出来的列式存储格式,可以说是为Hive量身定制的。它的文件结构分为三层:Stripe、Row Group、Column。每个ORC文件包含多个Stripe,每个Stripe默认约250MB,Stripe内部按列存储,每列又分成多个Row Group。
ORC的核心优势有几个方面。第一是极高的压缩比,ORC支持多种压缩算法(Zlib、Snappy、LZO等),而且因为列式存储的特性,同列数据类型一致,压缩效果非常好。实测下来,同样的数据,ORC配合Zlib压缩,体积通常只有TextFile的十分之一甚至更少。第二是内置索引,ORC在每个Stripe和Row Group级别都有统计信息(最大值、最小值、空值数量等),查询时可以利用这些索引做谓词下推,跳过不满足条件的数据块。第三是支持复杂数据类型,包括struct、array、map、union等。第四是支持ACID事务,这是Hive后来支持行级更新和删除的基础。
ORC的查询性能在Hive里通常是最好的,因为Hive对ORC做了大量优化,比如向量化查询(Vectorized Query Execution)就是专门针对ORC的。如果你用的是Hive且没有跨引擎共享数据的需求,ORC基本是默认的最优选择。
3.5 Parquet:跨生态的通用列存
Parquet是另一个广泛使用的列式存储格式,最初由Twitter和Cloudera联合开发,后来成为Apache顶级项目。它的设计目标就是做一个通用的、跨引擎的列存格式。Parquet的文件结构也类似:行组(Row Group)、列块(Column Chunk)、页(Page),每一层都有统计信息和索引。
Parquet和ORC在技术层面有很多相似之处,都是列存、都支持压缩、都有索引、都支持嵌套类型。它们的主要差异在于生态定位。Parquet在设计上更注重与Spark、Impala、Presto等引擎的兼容性,这些引擎对Parquet的支持都非常好。而ORC虽然也能被这些引擎读取,但优化程度不如Parquet。
在实际选型时,我的经验是:如果你的数仓是纯Hive生态,选ORC;如果涉及Spark、Flink等多引擎混用,选Parquet。因为Parquet在各引擎之间的兼容性和性能一致性更好。另外,Parquet对嵌套数据类型的支持在某些场景下比ORC更灵活,比如处理JSON展开后的复杂结构时,Parquet的表现往往更稳定。
3.6 Avro:面向行的事务型格式
Avro和前面几种格式不太一样,它本质上是行式存储,但它的特点是schema嵌入在文件中,而且支持schema演化。Avro最初是为Hadoop的序列化设计的,后来在数据管道和消息队列场景里用得比较多。
Avro的优势在于:写入速度快、schema管理灵活、支持丰富的数据类型。它特别适合作为数据摄入阶段的格式,比如从Kafka导入数据到HDFS时,Avro可以很好地保留数据结构。但在分析查询场景下,Avro的行式存储特性导致它的查询性能不如ORC和Parquet。
所以Avro在数仓里的定位通常是ODS层的落地格式,而不是DW层的查询格式。你可以用Avro存原始数据,然后通过ETL转换成ORC或Parquet供分析使用。
4. 格式选型的实战决策框架
4.1 一张表看懂各格式的核心差异
| 格式 | 存储方式 | 压缩支持 | 可切分 | 索引 | 复杂类型 | 典型场景 |
|---|---|---|---|---|---|---|
| TextFile | 行存 | 支持(Gzip不可切分) | 取决于压缩 | 无 | 有限 | 数据交换、临时表 |
| SequenceFile | 行存(KV) | 支持(BLOCK可切分) | 是 | 无 | 有限 | 二进制数据、中间存储 |
| RCFile | 列存(混合) | 支持 | 是 | 有(有限) | 不支持 | 历史遗留系统 |
| ORC | 列存 | 支持(Zlib/Snappy等) | 是 | 完善 | 完善 | Hive纯生态分析表 |
| Parquet | 列存 | 支持(Snappy/Gzip等) | 是 | 完善 | 完善 | 多引擎混用分析表 |
| Avro | 行存 | 支持 | 是 | 无 | 完善 | 数据摄入、消息管道 |
这张表建议收藏,建表时拿出来对照一下,基本不会选错方向。
4.2 选型时真正要问自己的三个问题
第一个问题:这张表主要用来做什么?如果是给下游系统导数据、做数据交换,TextFile或Avro更合适;如果是做分析查询,ORC或Parquet是首选。第二个问题:查询模式是什么样的?如果经常只查少数几列、扫大量行,列存优势明显;如果经常要取整行记录,行存和列存的差距就没那么大。第三个问题:生态里还有哪些引擎?如果只有Hive,ORC最优;如果有Spark、Presto等,Parquet更稳妥。
这三个问题看起来简单,但我在实际工作中发现,很多人建表时根本不问这些问题,直接默认TextFile或者跟风用ORC,结果要么性能不行,要么兼容性出问题。选型不是选“最好的”,而是选“最合适的”。
4.3 压缩算法的搭配建议
选完格式之后,压缩算法的搭配也很关键。我整理了一个实用的搭配表:
| 格式 | 推荐压缩 | 理由 |
|---|---|---|
| TextFile | 不压缩或Snappy | Gzip不可切分,影响并行度 |
| ORC | Zlib(默认)或Snappy | Zlib压缩比高,Snappy速度快 |
| Parquet | Snappy | 速度与压缩比平衡,生态支持好 |
| Avro | Snappy或Deflate | 写入速度优先 |
提示:ORC使用Zlib压缩时,压缩比通常比Snappy高20%到30%,但解压速度慢一些。如果查询对延迟敏感,可以选Snappy;如果存储成本敏感,选Zlib。
5. 建表实操:从TextFile迁移到ORC的完整过程
5.1 建表语句的格式指定
在Hive里指定存储格式,是在建表语句的STORED AS子句里完成的。下面是一个典型的ORC建表语句:
CREATE TABLE dw_user_behavior ( user_id BIGINT, item_id BIGINT, category_id INT, behavior_type STRING, ts BIGINT ) PARTITIONED BY (dt STRING) STORED AS ORC TBLPROPERTIES ( "orc.compress" = "SNAPPY", "orc.bloom.filter.columns" = "user_id", "orc.stripe.size" = "268435456" );这里有几个参数值得说明。orc.compress指定压缩算法,默认是ZLIB,改成SNAPPY可以提升读写速度。orc.bloom.filter.columns为指定列建立布隆过滤器,对于高基数的列(比如user_id)做等值查询时能大幅提升性能。orc.stripe.size设置Stripe大小,默认256MB,一般不需要改,但如果你的单行数据特别大或者特别小,可以适当调整。
Parquet的建表语句类似:
CREATE TABLE dw_user_behavior_pq ( user_id BIGINT, item_id BIGINT, category_id INT, behavior_type STRING, ts BIGINT ) PARTITIONED BY (dt STRING) STORED AS PARQUET TBLPROPERTIES ( "parquet.compression" = "SNAPPY" );5.2 已有TextFile表的数据迁移
如果你已经有一张TextFile格式的表,想迁移到ORC,有两种方式。第一种是通过中间表转换:
-- 创建ORC格式的新表 CREATE TABLE dw_user_behavior_orc (...) STORED AS ORC; -- 从旧表导入数据 INSERT INTO TABLE dw_user_behavior_orc SELECT * FROM dw_user_behavior_text; -- 验证数据量一致后,重命名表 ALTER TABLE dw_user_behavior_text RENAME TO dw_user_behavior_text_bak; ALTER TABLE dw_user_behavior_orc RENAME TO dw_user_behavior;第二种是利用Hive的CREATE TABLE AS SELECT(CTAS):
CREATE TABLE dw_user_behavior_orc STORED AS ORC AS SELECT * FROM dw_user_behavior_text;CTAS更简洁,但要注意它不支持分区表的直接创建,分区表还是得用第一种方式。另外,迁移过程中要关注数据量,如果表特别大,建议按分区逐步迁移,避免一次性跑太久影响集群稳定性。
5.3 迁移后的效果验证
迁移完成后,怎么验证效果?我通常会做三件事。第一,对比数据量,用SELECT count(*)确认新旧表行数一致。第二,对比查询耗时,选几个典型查询分别在旧表和新表上跑一遍,记录执行时间。第三,查看文件大小,用hadoop fs -du -h对比迁移前后的存储占用。
我自己的实测数据是:一张约500GB的TextFile表迁移到ORC(Snappy压缩)后,存储占用降到约80GB,压缩比超过6倍。一个包含GROUP BY和WHERE条件的聚合查询,执行时间从原来的约12分钟降到约2分钟。这个提升幅度在数据量更大的场景下会更明显。
注意:迁移前一定要确认下游任务是否依赖原表的存储格式。如果有下游任务直接读取HDFS文件(而不是通过Hive表),格式变更可能导致任务失败。
6. 踩坑记录与常见问题排查
6.1 小文件问题:列存格式的隐形杀手
ORC和Parquet虽然查询性能好,但它们对小文件问题特别敏感。因为每个ORC或Parquet文件都有自己的元数据和索引,如果表目录下有大量小文件,元数据的开销会急剧上升,查询时打开文件、读取元数据的时间可能比读数据本身还长。
我遇到过最夸张的情况是:一张表有几十万个几KB的小文件,查询时光初始化就花了好几分钟。解决办法有几个:在写入时控制Reduce数量,避免产生过多小文件;定期做小文件合并,用ALTER TABLE ... CONCATENATE(ORC专用)或者重写数据;在ETL阶段用DISTRIBUTE BY控制输出文件数。
-- ORC表小文件合并 ALTER TABLE dw_user_behavior PARTITION (dt='2024-01-01') CONCATENATE; -- 通过INSERT OVERWRITE重写来合并 INSERT OVERWRITE TABLE dw_user_behavior PARTITION (dt='2024-01-01') SELECT * FROM dw_user_behavior WHERE dt='2024-01-01' DISTRIBUTE BY cast(rand() * 10 as int);6.2 模式演化:加字段时的坑
列存格式在加字段时有一些需要注意的地方。ORC和Parquet都支持在文件末尾追加新列,但旧数据文件里没有新列的值,读取时会返回NULL。这本身没问题,但如果你在加字段后立刻用新字段做过滤或关联,可能会因为大量NULL导致结果不符合预期。
我的建议是:加字段后,先跑一个回填任务把历史数据的默认值补上,再让下游任务使用新字段。另外,ORC的schema演化在某些Hive版本里支持不完善,如果遇到读取报错,可能需要重建表并重新导入数据。
6.3 压缩算法不匹配导致的读取失败
这个问题比较隐蔽。如果你建表时指定了某种压缩算法,但写入数据时用了另一种,或者文件本身没有按指定的算法压缩,查询时就会报错。比如建表时写了orc.compress=SNAPPY,但数据是用Zlib压缩的,Hive读取时可能抛出解压异常。
排查方法是:用hive --orcfiledump命令查看ORC文件的元数据,确认实际的压缩算法。Parquet可以用parquet-tools查看。确保建表参数和实际文件一致,是避免这类问题的关键。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 查询突然变慢 | 小文件过多 | 查看表目录文件数 | 合并小文件或重写数据 |
| 读取报解压错误 | 压缩算法不匹配 | 用orcfiledump查看元数据 | 统一建表参数与实际压缩 |
| 新加字段全为NULL | 历史数据未回填 | 检查旧文件schema | 回填默认值或重写历史分区 |
| ORC表无法更新 | 未开启ACID | 检查表属性 | 建表时开启事务支持 |
| Parquet读取乱码 | 字符编码问题 | 检查源数据编码 | 统一使用UTF-8 |
7. 一些个人经验与后续扩展方向
聊了这么多格式的细节,最后说几个我在实际项目里总结出来的经验。第一,不要一刀切。我见过有的团队为了省事,所有表统一用ORC,结果ODS层的数据交换经常出问题。不同层用不同格式,ODS层用TextFile或Avro保证兼容性,DW层用ORC或Parquet保证性能,这才是合理的做法。第二,格式选型要趁早。表建好了再改格式,迁移成本很高,尤其是分区表和历史数据多的表。所以在项目初期就定好规范,比后期补救省事得多。第三,关注社区动态。Hive的存储格式也在演进,比如ORC的索引机制、Parquet的页级统计,新版本往往会带来性能提升,定期升级和测试是值得的。
这个内容后续还可以往几个方向扩展:一是向量化查询,Hive对ORC的向量化执行能带来数倍的性能提升,值得单独讲;二是Hive ACID与ORC的结合,涉及行级更新和Delta文件合并,是实时数仓场景的关键;三是不同引擎对同一格式的读取差异,比如Spark读ORC和Hive读ORC在性能上的区别。这些话题每一个都能展开成独立的一篇,后面有机会再慢慢聊。