数据湖是近年来比较火热的领域,Apache Iceberg被誉为数据湖技术“三剑客”(Delta Lake、Hudi、Iceberg)之一,而iceberg高度抽象和优雅的设计成为了它最吸引人的优势,这一点也是我阅读iceberg源码的主要动力。
写这一篇iceberg源码阅读主要目的,是想系统整理下过去Iceberg源码阅读过程中留下的记录,一方面是回顾,另一方面也是方便在之后继续阅读源码的过程中能更高效地查阅,正所谓“书读百遍,其义自现”,因此内容会比较基础,不会面面俱到,也会掺杂一些我个人的理解。当然,对于想要接触Iceberg源码的同学,尤其是对于大数据体系还不太熟悉的同学,这篇内容也能有所帮助。
本篇主要围绕Iceberg文件组织和具体实现展开。
准备工作:
Iceberg的基础介绍推荐Iceberg的官方文档:https://iceberg.apache.org/
Iceberg的源码见github(0.12版本):https://github.com/apache/iceberg
为节省篇幅,文中不会大篇幅引用代码,因此推荐将代码拉取到本地配合阅读。
基础类介绍
一开始阅读源码的主要难点在于放眼望去都是陌生的类,不清楚这些类是什么作用,以及已经应该从哪里入手,或者看完官方的文档后,对文档中功能的实现和代码不能映射起来,这部分主要就是来解决这个问题。
1.Iceberg 文件组织概述
阅读源码之前,首先应该对Iceberg的功能设计做一个系统的了解,这部分官方文档做了比较清晰的介绍,我只对核心部分做一个简要描述,看完后对这部分仍然不太理解的同学建议阅读下官方文档。
在我看来,Iceberg最核心的功能就是对数据文件组织管理,正如Iceberg对自己的介绍:Apache Iceberg is an open table format for huge analytic datasets. 所谓的table format(表格式),其实就是将底层的数据文件以特定的方式组织好,再以表的方式暴露出来。
插一句题外话,从文件组织的功能上来说,这一点和Hive是类似的,只不过Hive对文件的组织比较粗糙,是以目录的形式进行组织,即只是声名了某个分区的文件在哪个目录下,而Iceberg精确到了文件级别,并且有单独的metadata文件来索引数据文件,metadata文件中保存了数据文件的信息,比如最大值、最小值等,这样,只需要读少量的metadata文件,就能过滤掉大量不需要访问的数据文件,这是Iceberg的一个重要特点。
Iceberg的文件组织如下图所示
我们重点关注下面的metadata layer层和data layer层,metadata layer层中的文件就是metadata文件,而data layer层中的文件就是数据文件。
metadata layer层中的文件分为三类:metadata file、manifest list、manifest file。名字比较相似,需要仔细区分,下面会反复出现这些词汇。
metadata file本质上就是Iceberg表的所有信息,可以说从一个metadata file就可以构建出一个完整的Iceberg Table,包括表结构、分区字段、所有快照等等信息,后面介绍的代码的部分会详细说明,每次对Iceberg Table做一次操作,都会产生一个新的metadata file文件。
manifest list文件向上对应的是一个快照(图中的s0、s1),向下对应的是若干个manifest file文件。
manifest file文件是对一系列数据文件的索引,保存了每个数据文件的路径等信息,也就是在这里,Iceberg对文件的组织精确到了文件级别。
补充:
之所以要拆分manifest list和manifest file两层,主要的目的是为了复用历史的manifest file,如图中所示,一个manifest file可以被多个manifest list索引;相反,如果删掉manifest list这一层,只保留manifest file,则可以想象,每个manifest file都要索引到当前快照到全部文件,不同manifest file之间会存在大量重复,这点仔细推演一下,想必可以理解。
在这样的设计下,Iceberg构建了一套快照体系,这是Iceberg支持ACID特性、Time travel等功能的核心。简单来说,对Iceberg数据文件的任何增加或删除,都会产生一个新的快照(伴随产生一个新的manifest list文件),一系列快照组成了一个连续的快照链,可以指定任何快照进行读取(只要快照没有过期)。
为了更具体的展示,我们实际创建一张iceberg表,并append两张数据文件,可以看到文件组织如下所示:
iceberg_table_test1/
├── metadata
│ ├── 4b461a01-cdca-49fe-85bc-cb0702fb76d1-m0.avro
│ ├── snap-2427807540524010093-1-4b461a01-cdca-49fe-85bc-cb0702fb76d1.avro
│ ├── v1.metadata.json
│ ├── v2.metadata.json
│ └── version-hint.text
├── data_file1
└── data_file2
iceberg有一个单独的metadata目录来保存metadata文件
- V1.metadata.json、V2.metadata.json文件即metadata file
- snap-2427807540524010093-1-4b461a01-cdca-49fe-85bc-cb0702fb76d1.avro,这个文件就是manifest list
- manifest file,如4b461a01-cdca-49fe-85bc-cb0702fb76d1-m0.avro
数据文件data_file1、data_file2默认在根目录,实际上可以写在任意目录下。
2.文件组织相关的基础类
1.Table(org.apache.iceberg.Table)
推荐阅读代码的入口,是定义了对Iceberg表的所有操作的接口,实现类先关注org.apache.iceberg.BaseTable。
2.TableMetadata(org.apache.iceberg.TableMetadata)
从逻辑上看:TableMetadata就是表的元数据,这个元数据不仅是最新的数据,也包含了变更历史,比如schema、partition、sortOrders、snapshot的变更历史都记录在TableMetadata中。
从物理上看:映射到磁盘的metadata.json文件(metadata file),每个TableMetadata对象对应一个metadata.json文件;
需要注意的是对表的每次操作都会产生一个新的metadata.json文件,最新的一个metadata.json文件已经包含了完整的信息,包括历史信息在内。因此,理论上只保留最后一个metadata.json文件即可。
对TableMetadata的读写等操作由TableOperations接口封装。
3.ManifestFile(org.apache.iceberg.ManifestFile)
从逻辑上看:ManifestFile是对磁盘上manifest file的指针(索引),记录了manifest file的位置和一些统计信息;
从物理上看:ManifestFile会持久化到磁盘的manifest list文件,每个ManifestFile对象对应manifestList文件中的一行记录;
snapshot对应一个唯一的manifestList文件(待确认描述是否精确);
4.ManifestEntry(org.apache.iceberg.ManifestEntry)
从逻辑上看:ManifestEntry是对磁盘上一个数据文件的指针(索引),记录了文件的位置和一些统计信息,此外还标注了文件的status,snapshot_id,sequence_number
从物理上看:ManifestEntry会持久化到磁盘的manifest file,每个ManifestEntry对象对应manifest file中的一行记录;
补充:
ManifestEntry中的status:entry中标注了文件的三种状态(Status):EXISTING ADDED DELETED,分别表示这个文件是本次提交之前就存在的、本次提交新增的、本次提交删除的,需要注意的是,这里的DELETED和DeleteFile是两个概念,需要区分清楚;
ManifestEntry中的snapshot_id:ADDED和EXISTING,指的是文件第一次ADD时的snapshot_id,之后不会变化;DELETED,文件删除时的snapshot_id;
ManifestEntry中的sequence_number:ADDED和EXISTING,指的是文件第一次ADD时的sequence_number,之后不会变化;DELETED,null;
一个比较细节的疑问:在提交时历史的manifest不一定会被重写,因此单纯通过ManifestEntry中记录的ADDED/EXISTING判断是否是本次提交添加的是不准确的,需要上游manifestlist中的snapshot_id协助判断,才能真正确定文件是不是这次提交添加的。
5.ContentFile(org.apache.iceberg.ContentFile)
从物理上看:ContentFile是上述ManifestEntry中的一个属性,也是ManifestEntry中的核心信息;
首先要明确一点,ContentFile代表的只是数据文件的元数据信息,而不是文件的实际数据。
文件的元数据信息主要包括:文件类型、文件路径、文件格式、分区信息、以及一些统计信息等。
最顶层接口ContentFile
ContentFile分为两种:接口DataFile和接口DeleteFile,这两个接口的共同的实现逻辑在抽象类BaseFile中。
BaseFile主要是实现了IndexedRecord接口,这是一个avro接口,实现了这个接口意味着这个类可以持久化到avro文件中的一行记录,以及反过来从avro文件中构造出原来的对象(使用了反射的方式实现,这一部分和avro的SpecificData.SchemaConstructable的接口声明有关,如果类实现了这个街口,则承诺一定提供一个构造器允许传入avro的schema,用来构造这个类)。
除了DataFile和DeleteFile,还有一个IndexedDataFile是ContentFile的直接实现,IndexedDataFile是ContentFile的装饰器,增加了IndexedRecord的实现,因此,它的作用也是和avro文件进行交互。
此外,ContentFile还有一个方法splitOffset()方法,是这个文件推荐的划分位置点,说明这个文件可以在这些位置点上被划分为多个部分,进行并发读取。
补充:
IndexedRecord接口是avro文件的接口,表示avro文件内的一行记录,且这行记录的各个列的值是可以按照0、1、2、3...随机访问的,因此称为indexed,类似于ArrayList
public interface IndexedRecord extends GenericContainer {
/**
* Set the value of a field given its position in the schema.
*
* This method is not meant to be called by user code, but only by
* {@link org.apache.avro.io.DatumReader} implementations.
*/
void put(int i, Object v);
/**
* Return the value of a field given its position in the schema.
*
* This method is not meant to be called by user code, but only by
* {@link org.apache.avro.io.DatumWriter} implementations.
*/
Object get(int i);
}
IndexedRecord的上级接口是GenericContainer,GenericContainer要求提供Schema。
public interface GenericContainer {
/** The schema of this instance. */
Schema getSchema();
}
类似于IndexedRecord,iceberg也定义了自己的StructLike,相比IndexedRecord,StructLike可以返回列的数量,而且可以在取出值时动态指定值的类型。
public interface StructLike {
int size();
T get(int pos, Class javaClass);
void set(int pos, T value);
}
将StructLike装饰成IndexedRecord的工具类为IndexedStructLike,使用这个工具,需要提供avroSchema,然后就可以将iceberg的StructLike写成avro文件的一行记录。
3.其他基础类
还有一些很重要的基础类,比如和表结构相关的Schema Type,和快照相关的Snapshot等,不放在本篇介绍,本篇聚焦在文件组织上。
文件组织的实现
文件组织实际上就是Iceberg对metadata layer层的操作,这一步操作最终会落实到对metadata file、manifest list、manifest file文件这三类文件的操作。
这些对文件的组织全部在Table接口中暴露出来,包括对文件的增加、删除、替换等等,所有这些操作的继承关系如下。
1.顶级接口:PendingUpdate
最上层的接口是PendingUpdate,这个接口逻辑很简单,主要定义了apply和commit两个操作步骤,后来还为监听机制添加了updateEvent接口(可以参考https://github.com/apache/iceberg/pull/939,暂时不在这里讨论)。
2.第二级接口:重点SnapshotUpdate
第二级接口总共包括:ReplaceSortOrder、UpdateLocation、Rollback、UpdateProperties、UpdateSchema、ExpireSnapshots、UpdatePartitionSpec、ManageSnapshots、SnapshotUpdate
当前重点关注的:SnapshotUpdate
SnapshotUpdate是所有新生成snapshot的操作的顶级接口,额外定义了三个操作:
- set:设置snapshot summary
- deleteWith:重新定义删除文件的方式
- stageOnly:参照https://github.com/apache/iceberg/pull/342
3.第三级接口:
SnapshotUpdate的下级接口,首先是功能描述接口,如下:
- ReplacePartitions
- RewriteFiles
- RowDelta
- AppendFiles
- RewriteManifests
- OverwriteFiles
- DeleteFiles
然后是上述功能接口的具体实现:SnapshotProducer
SnapshotProducer是SnapshotUpdate的抽象实现,上述功能接口的实现都是基于SnapshotProducer,而这些功能接口的绝大多数实现都是基于SnapshotProducer的抽象子类MergingSnapshotProducer,只有两个具体实现是特例:FastAppend BaseRewriteManifests。
因此,对于实现我们重点来讨论这一对父子:SnapshotProducer和MergingSnapshotProducer。
首先:对于SnapshotProducer
SnapshotProducer提供的通用实现:
- apply
- Commit
SnapshotProducer扩展的方法主要是一些对manifest的通用的操作实现,可供子类调用,比较核心的只有一个apply方法
- protected abstract List
apply(TableMetadata metadataToUpdate);
总的来说,SnapshotProducer只需要子类实现一个核心逻辑:在旧的TableMetadata的基础上实现产生新的manifestFiles,其他问题SnapshotProducer会解决。
接着:对于MergingSnapshotProducer,从类名上就可以看出,这个类要进行merge操作,这个merge指的是manifest file的合并;与此相对照的是FastAppend,FastAppend每次都是生成新的manifest file,不进行manifest file的合并,因此FastAppend提交的代价更低,但是可能引起manifest file的碎片化。除了FastAppend操作之外,其他继承自MergingSnapshotProducer的操作都是要进行manifest合并的,比如和FastAppend相对应的MergeAppend。
MergingSnapshotProducer提供的通用实现:
- apply:实现了上述SnapshotProducer要求的接口,因此MergingSnapshotProducer的子类不需要再实现apply,只有特殊情况下需要额外修改一下apply方法,专指BaseReplacePartitions。
具体看下MergingSnapshotProducer对apply的实现:
围绕4个成员变量:
- filterManager:对data文件的manifest file的过滤,类ManifestFilterManager
- mergeManager:对data文件的manifest file的合并,类ManifestMergeManager
- deleteFilterManager:对delete文件的manifest file的过滤,类ManifestFilterManager
- deleteMergeManager:对delete文件的manifest file的合并,类ManifestMergeManager
先看下ManifestMergeManager ManifestFilterManager的作用,这两个类没有继承关系。
ManifestFilterManager是进行ManifestFile过滤的工具,简单来说,是将(磁盘上)旧的manifest中的需要删除的entry剔除,生成新的manifest file(或者不存在需要删除的entry时,保留旧的manifest file)。
ManifestMergeManager是进行ManifestFile合并操作的工具,简单来说,是将(磁盘上)旧的manifest合并成若干个大小不超过8m的新的manifest(合并不是必然触发的,受TableProperties控制)。
合并后写入到新的manifest时,会剔除对旧的Status=DELETED文件的索引,如果manifest上记录的是本次添加的文件,则记录为ADD,如果是之前添加的文件,则记录为EXISTED,如果是本次删除的文件,则记录为DELETED,如果是之前产生的deleteFile,则忽略。
不管是ManifestMergeManager还是ManifestFilterManager都会进行manifest文件的读写,因此都可能是很耗时的操作。
MergingSnapshotProducer没有将上述4个成员变量直接暴露给子类操作,而是暴露了一些语义更加简单的方法,比如添加数据文件、删除数据文件、按照Expression过滤等,这样各种文件组织操作的具体子类,只需要调用这些接口就能实现自己的功能,各类操作的详细实现不再展开讨论。