☰
Parquet和Iceberg底层原理:文件格式、表格式与大数据性能优化
2026/9/29 15:41:52 网站建设 项目流程

前阵子有个同事拿着一张大表性能问题来找我:一张用Parquet格式存储的Hive表,每天全量更新一次,查询时只要过滤某个非分区字段,Spark就会把几千个小文件全部扫一遍,跑一次要20分钟。单纯从文件格式角度看,Parquet其实已经做得很好了,但问题出在它只是“文件格式”,没有人帮它管理“哪批文件是一张表”。这恰好是Iceberg这类表格式要解决的事。这篇文章就把Parquet和Iceberg的关系、底层原理、日常打开和排错方式讲清楚,也会顺带聊聊DataX读Parquet那些坑。

1. Parquet和Iceberg不是一个层面的东西:文件格式与表格式的分工

很多初学者会把Parquet和Iceberg放在一起比,问“到底哪个性能更好”。这个提问方式本身就有点问题,因为这两个东西压根不在同一个层次。Parquet是文件格式,Iceberg是表格式,两者是协作关系,不是竞争关系。

1.1 集装箱和港口调度

打个比方:Parquet像是集装箱,Iceberg像是港口调度系统。集装箱本身很能装,内部空间规划得也不错,但如果没有调度系统记录“哪个箱子放在哪个堆场、属于哪艘船、什么时候进港”,港口就会失控。你要找货时,只能把所有集装箱都打开翻一遍。Parquet解决的是“单个文件内部的数据怎么存、怎么压缩、怎么读更快”,Iceberg解决的是“哪些Parquet文件属于同一张表、这张表的schema怎么演化、哪些数据对哪个查询可见、怎么原子地替换一批文件”。

所以真正的关系是:Iceberg把一张表抽象成一组数据文件(通常是Parquet)加一份元数据,Parquet负责底层列存,Iceberg负责表级管理。两者叠加,才能既享受列存的高性能,又拥有事务、时间旅行、分区演进这些表级能力。

1.2 目录当元数据的时代:Hive表为什么越跑越慢

过去最流行的Hive表存储方式,是把表结构信息放在Hive Metastore里,但Metastore里只记录到“表+分区”级别。至于一个分区下到底有哪些文件,Hive在查询时要实时去HDFS目录里列一遍。也就是说,HDFS目录既是存储位置,又兼职当了元数据。

问题就出在这里:

  • 小文件多的时候,每次查询都要列几千上万个路径,性能自然拉垮。
  • 对非分区字段做过滤时,无法提前知道哪些文件可能命中,只能全量扫。
  • 只要有人把文件挪个位置,或者文件名写得不规范,表就读不到了。
  • 多个任务并发写同一个分区,没有任何事务控制,很容易读到写入一半的数据。

我处理过最典型的案例是凌晨全量刷新任务:先删掉当天分区再写入,结果中间有几分钟查询端看到的是空表。业务方就会来报“凌晨数据丢了”。当时Hive分区表配上Parquet文件确实很好,但问题是目录级别的元数据模型太脆弱,这些问题完全不是换个文件格式能解决的。

1.3 Iceberg把“表”变成了一层可以查询的元数据

Iceberg对表的定义和Hive完全不同。一张Iceberg表在某个时刻的状态,由一个**当前快照(current snapshot)**描述。快照再指向一个Manifest List,Manifest List里是若干Manifest文件,每个Manifest文件再指向一批真正的数据文件(Parquet)。

这张“表到底包含哪些文件、文件里每列的统计信息、分区表达式是什么、schema版本是多少”,全部记录在Iceberg自己的元数据文件里。查询引擎不需要去扫HDFS目录,而是先读Catalog定位表,再看元数据,最后定位到具体的Parquet文件。

这样,写任务不再通过修改Hive Metastore来更新表结构,而是用类似提交新快照的方式原子更新表状态。Parquet文件本身保持不变,只是“哪些文件属于表”的指针变了。Iceberg让表变成了一层可以被程序化管理的元数据,而Parquet老老实实当数据存储就够。

2. 拆开一个Parquet文件:Row Group、Footer与打开工具

要理解Iceberg为什么能把Parquet的性能再放大,得先明白Parquet文件内部是怎么组织的。很多人的认知停留在“列存,所以快”,但真正让它快的是文件末尾那一小段Footer,里面藏了大量用来裁剪数据的统计信息。

2.1 列式存储快在哪

行式存储的典型代表是普通CSV和关系库的堆表。一张表20列,查询只取3列,行式存储为了那3列也不得不把整行数据读出来。Parquet的做法是把文件水平切成多个Row Group,每个Row Group内部再按列切分成Column Chunk,每一列再按Page为单位压缩存储。

查询时,引擎可以只解压需要的列对应的Page,完全跳过其他列。比如用户表有20列,你只要查name和city,Parquet的Column Pruning机制会只读取这两列的数据块。这就是为什么在宽表场景下,Parquet能跑出比行存高一个量级的扫描性能。

类比的话,行式存储相当于你要读整张报纸才能找到体育版,列式存储相当于直接告诉你体育版在第8版,翻过去只看那一版。

2.2 Footer:Parquet的“目录册”

Parquet文件的结构不是从头顺序读到尾,而是倒着读的。文件末尾存了4字节的长度信息,指向Footer的位置。Footer里包含:

  • 文件schema;
  • 每个Row Group的偏移量和大小;
  • 每个Column Chunk的类型、压缩编码、编码方式;
  • 每个Column Chunk里的统计信息,比如min和max;
  • Page Index的偏移信息。

所以一个典型的Parquet读取过程是这样的:

  1. 打开文件,先读末尾4字节拿到Footer长度;
  2. 定位并读取完整Footer;
  3. 从Footer里找到需要的列块偏移量和大小;
  4. 跳到对应位置,只读取和解压那部分Page。

这也直接把“Parquet文件怎么打开”这个问题解释清楚了:它是二进制格式,你用文本编辑器直接打开看到的全是乱码,这是正常的。要打开它,必须用支持Parquet的引擎或工具。

Footer里的统计信息价值极高。比如你在SQL里写WHERE age > 30,如果某个Row Group里age列的max值已经小于等于30,整个Row Group就可以直接跳过,一个字节都不用读。Iceberg在Manifest里也存了类似统计信息,相当于把这种裁剪能力从“文件内部”扩大到了“文件之间”。

2.3 parquet文件怎么打开:三种实用姿势

如果你是第一次拿到.parquet文件,不想被乱码吓到,以下三种方式足够日常使用。

用Python的pyarrow打开:

import pyarrow.parquet as pq table = pq.read_table("example.parquet") print(table.schema) print(table.num_rows) df = table.to_pandas() print(df.head())

用DuckDB直接做SQL查询:

SELECT * FROM 'example.parquet' LIMIT 10;

用parquet-tools查看schema和抽样数据:

# 查看文件schema parquet-tools schema example.parquet # 查看前20行数据 parquet-tools head -n 20 example.parquet # 查看每个列的统计信息 parquet-tools meta example.parquet

还有一个值得记住的提醒:如果这个Parquet文件属于一张Iceberg表,不要直接凭路径去找Parquet文件打开。因为Iceberg的新写入不会修改旧文件,而你通过HDFS路径随便翻出来的某个文件,很可能是某个历史快照里的数据,并不代表这张表的当前状态。正确做法是先通过Spark/Flink/Trino的Catalog读取表,而不是绕过表语义去摸文件。

3. Iceberg管Parquet的三层元数据:Catalog、Manifest与快照

Iceberg最核心的价值是让一批Parquet文件具备数据库表才有的语义。要做到这一点,它在数据文件和表之间加了三层元数据。

3.1 一张Iceberg表的物理布局

创建一张Iceberg表后,在HDFS或S3上你会看到类似这样的目录结构:

warehouse/lake/db/tbl/ ├── metadata/ │ ├── v1.metadata.json │ ├── snap-1111111111111111111.avro │ ├── snap-2222222222222222222.avro │ ├── manifest-list-0001.avro │ └── manifest-0001.avro └── data/ ├── dt=2024-01-01/ │ └── 00000-0-xxx.parquet └── dt=2024-01-02/ └── 00000-0-yyy.parquet

metadata目录下,v1.metadata.json是整个表的“总控制台”,记录当前快照ID、schema版本、分区spec列表、snapshot历史等。每次提交都会生成新的元数据文件。Manifest List文件记录一次快照涉及的所有Manifest文件,每个Manifest文件再记录一批Parquet数据文件的路径、分区值、列统计信息。

查询引擎的工作链路是这样的:先通过Catalog找到表的metadata.json,拿到当前快照,再读Manifest List,然后按需读Manifest,最终定位到具体Parquet文件。这个链路听起来比Hive直接扫目录多跳了几步,但每步读的都是小文件,而且Manifest里已经带了统计信息,可以精准跳过大量数据文件。数据量越大,收益越明显。

3.2 快照与时间旅行:写一次,旧数据还在

Iceberg的每次写操作(INSERT、UPDATE、DELETE、MERGE等)都会生成一个新快照。关键在于,新快照不会改写旧的数据文件,只是新增一批Parquet文件,并让Manifest List指向新的文件集合。旧快照仍然完整可用。

这意味着同一张表可以“回到过去”。在Spark SQL里可以这样查:

-- 回到某个快照ID SELECT * FROM lake.db.tbl VERSION AS OF 49126299542712345; -- 回到某个时间点 SELECT * FROM lake.db.tbl TIMESTAMP AS OF '2024-01-01 10:00:00';

我做跑批时最常用的一招是:重跑任务之前先记录当前快照ID,万一跑出来的数据不对,直接把快照指针回滚到那个ID,几秒钟内恢复原状,完全不需要手动找回删除的文件。这就是Parquet单文件无法提供的表级保障。

时间旅行还有一层隐藏价值:读写隔离。Flink正在写入Iceberg表时,Spark查询读到的是写入前的快照,不会看到半成品。这比Hive里“先删分区再写文件”的脏读体验强太多。

3.3 隐藏分区与分区演进:不是分区字段,却能用分区裁剪

Hive表的分区规则非常“死板”:用户必须在表上指定一个分区字段,写入时自己算好分区值,查询时也必须手动带上这个字段才能走分区裁剪。Iceberg则把分区逻辑做成了表的属性,由分区spec定义。

举个例子,订单表可以这样加一个分区分桶:

ALTER TABLE lake.db.orders ADD PARTITION FIELD bucket(user_id, 16);

之后Iceberg在写入时会自动根据user_id的哈希值决定数据文件放到哪个目录,查询时不管SQL里有没有显式写user_id条件,Iceberg都会把查询谓词翻译成分区表达式来做裁剪。用户完全不需要知道数据文件到底是怎么分层的。

更厉害的是分区演进。Hive的分区结构一旦定下来,改分区字段基本等于重建表。Iceberg允许你随时新增分区字段,历史数据继续用旧spec,新数据用新spec,所有spec的历史都记录在元数据里。老文件不用迁移,新查询也能正确过滤新旧两类文件。这类灵活性,是Parquet文件本身完全给不了的。

4. 从DataX到Iceberg:Parquet文件读不懂才是常态

最近看到不少人在搜“DataX HDFSReader支持Parquet”,说明大家在数据集成时遇到了实际障碍。DataX在关系库、日志、HDFS之间做离线同步很方便,但碰到Parquet时,事情并没有想象中顺利。

4.1 HDFSReader对Parquet的支持到底到什么程度

需要先认清现状:不同DataX分支对HDFSReader的fileType支持不完全一样。开源社区版里,HDFSReader的fileType通常支持ORC、TEXT、RC、SEQ、CSV,Parquet并不在所有版本中都“开箱即用”。有些企业维护的发行版或二次开发版加上了Parquet支持,但在提交任务前,一定要确认你用的那个DataX分支真正支持。

为什么官方支持慢?因为Parquet读取依赖parquet-mr和Hive的SerDe,DataX定位是轻量同步框架,不希望引入太重的依赖。另外Parquet的类型系统比普通文本复杂太多,嵌套结构、Decimal精度、时间戳旧格式,处理起来都是额外工作。

我的建议是分两步验证:

  1. 找你当前DataX版本的源码,看HdfsReader里枚举的fileType列表;
  2. 如果没有Parquet,不要硬改Reader参数,也不要幻想换个后缀名就行。Parquet是二进制格式,靠粗暴改配置读不了。

4.2 分片与类型映射的典型坑

即使你的DataX分支支持Parquet,也还有两个经典坑等着你。

第一个坑是分片问题。Parquet文件按Row Group组织,不能简单按字节范围切分。很多同步框架切分任务时,是按HDFS块或文件大小估算的,如果直接按字节切一个Parquet文件,很可能从Row Group中间掰开,读出来就是报错或乱数据。疯狂调大split大小、降低并发,只能缓解,不是根本解法。

第二个坑是类型映射。Parquet里有INT96这种老式时间戳类型,还有带精度的Decimal。DataX的列类型映射表不一定覆盖完整,常见的报错包括:

UnsupportedOperationException: Unsupported type: INT96 ClassCastException: LongWritable cannot be cast to ...

遇到这类报错,建议先不要和Reader参数死磕。更高效的做法是先用Spark或DuckDB把Parquet转换成目标引擎能识别的中间格式,或者直接让Spark完成“读Parquet+写Iceberg”的链路,绕开DataX在这段的能力短板。

4.3 推荐的数据同步链路:Parquet -> Iceberg

如果你的目标表是Iceberg,我最推荐直接让Spark或Flink承担Parquet到Iceberg的同步。

Spark的例子:

val df = spark.read.parquet("hdfs://nameservice1/user/hive/warehouse/src_tbl/dt=2024-01-01/*.parquet") df.writeTo("lake.db.tbl") .using("iceberg") .append()

Flink SQL也可以,前提是配好Iceberg Catalog,然后直接INSERT INTO lake.db.tbl SELECT ... FROM parquet_source。

DataX在这个链路里的定位,应该是“上游数据源到HDFS Parquet”这一段。比如从MySQL抽数到HDFS生成Parquet,之后再交给Spark/Flink进入Iceberg。这样每段都用最适合的工具,而非让DataX一杆子插到底。

值得提前预防的是小文件问题。DataX或流式任务写出来的Parquet往往很小,几十KB到几MB不等。小文件直接进Iceberg,会造成元数据膨胀、查询时文件打开开销大增。所以接入Iceberg后,要把Compaction当成日常运维的一部分,而不是出了问题再想。

5. 选型与调优:不是所有Parquet表都要上Iceberg

看到这里,你可能会觉得Iceberg无所不能,恨不得把所有Parquet表都迁过去。但我的态度是:先看场景,后谈技术。Parquet本身在单文件读写效率上已经非常能打,Iceberg是解决表级管理问题的,不是用来替代Parquet性能的。

5.1 三种信号,说明你值得上Iceberg

第一种信号:多个引擎同时访问同一张表。比如Flink实时写入、Spark离线清洗、Trino做OLAP分析。Hive表在这种场景下很容易出现读写冲突和数据可见性的问题,Iceberg的快照隔离能力能直接缓解。

第二种信号:每天要做全量覆盖或大规模UPDATE。以前用Hive全量表是“先删分区再写”,这中间有查询空窗期。用Iceberg后,新快照提交之前,所有查询看到的还是旧快照;提交之后,马上看到新数据。表切换是原子的。

第三种信号:跑批出错要回滚。Parquet文件一旦写错,最原始的办法是根据备份恢复,费时费力。Iceberg可以直接把表回滚到上一个正常快照,把“数据恢复”变成几秒钟的元数据操作。

如果你所在的团队正在搞湖仓一体,数据要支撑BI、算法、实时检索多种用途,Iceberg的这套表语义几乎是为这个场景量身定做的。

5.2 哪些场景可以先不上

Iceberg不是银弹。有些场景加上它反而是负担。

  • 如果你只有Spark一个引擎,做的是一次性的ETL,结果表就几十GB,直接读Parquet目录也没问题,没必要引入额外的元数据组件。
  • 如果团队对元数据、目录结构、清理策略不熟悉,没有运维Iceberg的能力,仓促上线大概率会制造更多故障。
  • 如果只是纯日志追加存储,表不更新、不删除、不做回滚,Parquet加分区目录足够简单可靠。

选型这个事,我一直坚持“复杂度和收益匹配”原则。Iceberg解决的是真实存在的并发、事务、演化问题。没有这些问题时,它就是徒增的迁移成本。

5.3 上线后最值得做的三件优化

如果你决定让Iceberg管理你的Parquet文件,上线之后别急着炫技,先把这三件事做扎实。

第一件事:定期合并小文件。Iceberg的Spark SQL扩展提供了rewrite_data_files操作:

CALL lake.db.tbl.rewrite_data_files( target_file_size_in_bytes => 134217728 );

这个操作会把大量小Parquet文件重写成128MB左右的较大文件。文件数量降下来,Manifest更小,查询时打开文件的时间也会明显缩短。

第二件事:对高筛选字段做排序或Z-order。Parquet的Row Group裁剪依赖列统计信息,但统计信息只有在数据按该列有序时才有用。如果字段值随机分布,min和max范围大得几乎覆盖全表,裁剪等于失效。

CALL lake.db.tbl.rewrite_data_files( strategy => 'sort', sort_order => 'zorder(user_id)' );

Z-order的好处是让多个过滤字段同时受益,比单纯按一个字段排序更均衡。对用户ID、订单ID这类常用于Join和Filter的字段,效果立竿见影。

第三件事:处理好快照过期和清理。快照太多会让元数据目录膨胀,也增加表状态解析的开销。可以设置必要的保留策略:

ALTER TABLE lake.db.tbl SET TBLPROPERTIES ( 'history.expire.min-snapshots-to-keep' = '3', 'history.expire.max-snapshot-age-ms' = '604800000' );

快的快照保留最近3个,超过7天的自动过期清理。这样既保留时间旅行的能力,又不会让元数据无限膨胀。具体属性名可能随版本有小差异,但思路是一致的:历史快照要控制生命周期。

最后说一点个人体会:Parquet和Iceberg的关系,像钢筋混凝土和建筑图纸。钢筋水泥只是材料,没有图纸,你堆出来的只是个散货堆;有了图纸,才能盖出随时可以拆改但仍保持稳定的房子。如果你现在正被一堆Parquet文件管理问题困扰,先别急着换文件格式,试着用Iceberg把表这层“图纸”补起来,很多坑会自动被填平。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询