业界对于数据湖的定义存在一定争议,个人认为数据湖就是针对传统hive数仓不支持acid、upsert、schema evolution等痛点上,提出的一种数据存储库。
hive的痛点:hive主要特性是提供了sql解析和元数据管理的功能,统一管理了存储在hdfs上数据的shcmea信息。但是设计之初hive并没有考虑支持upsert,schema evolution等特性,基于这些业务痛点,数据湖应运而生。
ACDID即数据库中事务特性,但是数据湖的事务和oltp数据库的数据特性不同,数据湖是粗粒度的事务控制(filegroup级别)。所谓事务,本质就是一个并发问题,本质在于解决读写冲突和写写冲突。以hudi为例,支持行级并发控制和列级别并发控制(通过TimeLine实现)。
并发控制:hudi中的每个commit都被抽象为TimeLine上的一个instance,instance记录了本次操作的行为、时间戳和状态。冲突检查会在 instant 状态变换的两个节点进行,一个是 requested 转 inflight 状态,一个是 inflight 转 completed 状态。其中,后者状态变换时,会进行加锁操作,以实现版本隔离。
冲突检查即是对 instant 创建到状态变化的过程中其他已经完成/正在执行的 instant 之间的进行冲突检查,检查策略分为行列两种。
hudi支持对数据的upsert操作,对于upsert操作的支持是通过hudi的文件组织特性保证的。hudi中分为COW和MOR两种类型的表,upsert操作的时候,会根据每条数据的record key进行定位。
Hudi支持三种查询类型:
hive更改表schema后需要全表回溯数据,是一种很重的操作。而iceberg的schema evolution特性可以支持修改表的schema。
基本原理是将底层parquet文件中的schema信息和iceberg中的schema建立ID映射。parquet文件的footer中会存储文件中的列信息,将parquet文件中的列信息和iceberg metastore中的列信息通过一个唯一ID建立映射关系。当读取文件时,根据iceberg metastore中列的ID信息,在parquet文件filter出对应列数据。写数据时将column ID和数据一起写入文件中,新列赋新ID,删除的ID不复。
读取数据时,用ID做映射,如果数据文件中没有,如果:
partition evolution:iceberg支持更改表的分区字段,如开始为date分区,之后可以改为date、hour分区。因为iceberg数据中包含timestamp列,通过设置partition transform方式,iceberg会记录转换关系,并按需要进行partition evolution
Hudi内部对每个表都维护了一个Timeline,这个Timeline是由一组作用在某个表上的Instant对象组成。Instant表示在某个时间点对表进行操作的,从而达到某一个状态的表示,所以Instant包含Instant Action,Instant Time和Instant State这三个内容,它们的含义如下所示:
根据上图,说明如下:
我们看到,从数据生成到最终到达Hudi系统,可能存在延迟,如图中数据大约在07:00、08:00、09:00时生成,数据到达大约延迟了分别3、2、1小时多,最终生成COMMIT的时间才是Upsert的时间。对于数据到达时间(Arrival Time)和事件时间(Event Time)相关的数据延迟性(Latency)和完整性(Completeness)的权衡,Hudi可以将数据Upsert到更早时间的Buckets或Folders下面。通过使用Timeline来管理,当增量查询10:00之后的最新数据时,可以非常高效的找到10:00之后发生过更新的文件,而不必根据延迟时间再去扫描更早时间的文件,比如这里,就不需要扫描7:00、8:00或9:00这些时刻对应的文件(Buckets)。
为了支持增量查询,Hudi使用时间轴(Timeline)功能来跟踪表的变更历史,并记录每个操作的增量包含的文件路径和时间戳信息。通过时间轴和查询引擎,Hudi可以组合不同的数据文件,查找给定时间戳或时间范围内的所有增量操作。这样,即使在COW表中没有Delta文件,Hudi仍然可以跟踪和查询表的更新历史记录。
COW表的增量查询:
Hudi的事务功能被称为Timeline,因为Hudi把所有对一张表的操作都保存在一个时间线对象里面。Hudi官方文档中对于Timeline功能的介绍稍微有点复杂,不是很清晰。其实从用户角度来看的话,Hudi提供的事务相关能力主要是这些:
hudi基于timeline实现事务,timeline上每个instant会包含当前版本对于的file path list。当client读取数据时,首先会查看timeline里最新的commit是哪个,从最新的commit里获得对应的文件列表,再去这些文件读取真正的数据。
Hudi通过这种方式实现了多版本隔离的能力。当一个client正在读取v1的数据时,另一个client可以同时写入新的数据,新的数据会被写入新的文件里,不影响v1用到的数据文件。只有当数据全部写完以后,v2才会被commit到timeline里面。后续的client再读取时,读到的就是v2的数据。
顺带一提的是,尽管Hudi具备多版本数据管理的能力,但旧版本的数据不会无限制地保留下去。Hudi会在新的commit完成时开始清理旧的数据,默认的策略是“清理早于10个commit前的数据”。
hudi事务和增量查询原理
Hudi相较与传统数仓的TableStructre主要做了以下设计
一个新的 base commit time 对应一个新的 FileSlice,实际就是一个新的数据版本。HUDI 通过 TableFileSystemView 抽象来管理 table 对应的文件,比如找到所有最新版本 FileSlice 中的 base file (Copy On Write Snapshot 读)或者 base + log files(Merge On Read 读)。
通过 Timeline 和 TableFileSystemView 抽象,HUDI 实现了非常便捷和高效的表文件查找。
Hoodie 的每个 FileSlice 中包含一个 base file (merge on read 模式可能没有)和多个 log file (copy on write 模式没有)。
每个文件的文件名都带有其归属的 FileID(即 FileGroup Identifier)和 base commit time(即 InstanceTime)。通过文件名的 group id 组织 FileGroup 的 logical 关系;通过文件名的 base commit time 组织 FileSlice 的逻辑关系。
HUDI 的 base file的包含数据文件头(File Footer)和数据文件元数据(File Metadata)
在 footer 的 meta 中记录了 record key 组成的 BloomFilter,用于在 file based index 的实现中实现高效率的 key contains 检测。只有不在 BloomFilter 的 key 才需要扫描整个文件消灭假阳。
在File Metadata中记录了文件中包含的所有记录的元数据信息,包括每个记录的记录键(Record Key)和时间戳(Timestamp),以及其他与记录相关的信息。通过读取和解析数据文件元数据,Hudi可以确定每个数据文件中包含哪些增量写记录,以及每个记录的更新时间戳和其他相关的元数据信息。
HUDI 的 log (avro 文件)是自己编码的,通过积攒数据 buffer 以 LogBlock 为单位写出,每个 LogBlock 包含 magic number、size、content、footer 等信息,用于数据读、校验和过滤。
Hoodie key (record key + partition path) 和 file id (FileGroup) 之间的映射关系,数据第一次写入文件后保持不变,所以,一个 FileGroup 包含了一批 record 的所有版本记录。Index 用于区分消息是 INSERT还是 UPDATE。
Index创建过程:
索引类型
Bloom Index的具体流程:
Hudi在分布式文件系统的基础路径下将数据表组织成目录结构
.hoodie
目录是 Hudi 表的核心目录,它包含了 Hudi 表的元数据和其他相关文件和目录.temp
目录用于存储正在写入的数据.tmp
目录用于存储已完成写入但尚未提交的数据archive
目录用于存储归档数据metadata
目录包含了所有分区的元数据信息timeline.json
文件包含了表的时间轴信息version
文件包含了表的版本信息write.lock
文件用于控制并发写入.hoodie_partition_metadata
文件包含了该分区的元数据信息,例如分区键、分区路径等。1、COW
2、MOR
COW
MOR
Flink 和 Spark streaming 的 writer 都可以 apply 异步的 compaction 策略,按照间隔 commits 数或者时间来触发 compaction 任务,在独立的 pipeline 中执行。
通过对写流程的梳理我们了解到 HUDI 相对于其他数据湖方案的核心优势:
import org.apache.hudi.{DataSourceReadOptions, DataSourceWriteOptions}
import org.apache.hudi.common.table.HoodieTableConfig
import org.apache.hudi.config.HoodieWriteConfig
import org.apache.spark.sql.functions.{col, concat_ws}
import org.apache.spark.sql.{DataFrame, SaveMode, SparkSession}
import scala.collection.mutable
object HudiApiTest {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder().enableHiveSupport().getOrCreate()
/**
* 写hudi
* BULK_INSERT_OVERWRITE_OPERATION_OPT_VAL指定分区overwrite语意,
* 或者通过DataSourceWriteOptions.PARTITIONPATH_FIELD_OPT_KEY -> "date,hour"指定分区字段
* mode必须是SaveMode.Append,SaveMode.Overwrite会覆盖整个表
*/
val insertDF = spark.sql(
"""
|select md5(concat('','.','dp_compliance','.','hoodie_test')) as id,'' as cluster_name,'dp_compliance' as database_name,'hoodie_test' as table_name,'20230518' as date,'00' as hour
|union all
|select md5(concat('','.','dp_compliance','.','hoodie_test2')) as id,'' as cluster_name,'dp_compliance' as database_name,'hoodie_test2' as table_name,'20230518' as date,'00' as hour
|""".stripMargin)
val configs = new mutable.HashMap[String, String]()
configs += (HoodieTableConfig.HOODIE_TABLE_NAME_PROP_NAME -> "test_bytelake")
configs += (HoodieTableConfig.HOODIE_DATABASE_NAME_PROP_NAME -> "dp_compliance_test")
// 指定分区字段
// configs += (DataSourceWriteOptions.PARTITIONPATH_FIELD_OPT_KEY -> "date,hour")
// 或者动态分区
configs += (DataSourceWriteOptions.OPERATION_OPT_KEY -> DataSourceWriteOptions.BULK_INSERT_OVERWRITE_OPERATION_OPT_VAL)
insertDF.write
.format("hudi")
.options(configs)
.mode(SaveMode.Append) //BULK_INSERT_OVERWRITE_OPERATION_OPT_VAL控制分区overwrite语意
.save()
/**
* 查询数据
*/
spark
.read
.format("hudi")
.load("hdfs://harunava/home/byte_dw_compliance/warehouse/dp_compliance_test.db/test_bytelake/*/*")
.show()
/**
* 更新数据
* 更新数据和insert相同,hudi根据主键recordkey更新数据,保留最新的一条
*/
val updateDF = spark
.sql(
"""
|select md5(concat('','.','dp_compliance','.','hoodie_test2')) as id,'' as cluster_name,'dp_compliance' as database_name,'hoodie_test3' as table_name,'20230518' as date,'00' as hour
|""".stripMargin)
updateDF.write
.format("hudi")
.options(configs)
.mode(SaveMode.Append) //BULK_INSERT_OVERWRITE_OPERATION_OPT_VAL控制分区overwrite语意
.save()
/**
* 增量查询
* 指定数据查询方式,有以下三种:
* val QUERY_TYPE_SNAPSHOT_OPT_VAL = "snapshot" -- 获取最新所有数据 , 默认
* val QUERY_TYPE_INCREMENTAL_OPT_VAL = "incremental" --获取指定时间戳后的变化数据
* val QUERY_TYPE_READ_OPTIMIZED_OPT_VAL = "read_optimized" -- 只查询Base文件中的数据
*/
spark
.read
.format("hudi")
.option(DataSourceReadOptions.QUERY_TYPE_OPT_KEY,DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL)
// 指定查询某个时间戳之前提交的数据,依据_hoodie_commit_time筛选
.option(DataSourceReadOptions.BEGIN_INSTANTTIME_OPT_KEY,"20230520071825")
.load("/home/byte_dw_compliance/warehouse/dp_compliance_test.db/test_bytelake/*/*/")
.show()
spark
.read.format("hudi")
.option(DataSourceReadOptions.QUERY_TYPE_OPT_KEY,DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL)
//指定查询开始时间(不包含),“000”指定为最早时间
.option(DataSourceReadOptions.BEGIN_INSTANTTIME_OPT_KEY, "00000")
//指定查询结束时间(包含)
.option(DataSourceReadOptions.END_INSTANTTIME_OPT_KEY, "20230520071825")
.load("/home/byte_dw_compliance/warehouse/dp_compliance_test.db/test_bytelake/*/*/")
.show()
/**
* 删除数据
* 删除时根据分区和主键定位,都相同时删除数据
*/
val deleteDF = spark.sql(
"""
|select md5(concat('','.','dp_compliance','.','hoodie_test')) as id,'20230518' as date,'00' as hour
|union all
|select md5(concat('','.','dp_compliance','.','hoodie_test2')) as id,'20230518' as date,'01' as hour
|""".stripMargin)
deleteDF.write.format("hudi")
//指定表名,这里的表明需要与之前指定的表名保持一致
.option(HoodieTableConfig.HOODIE_DATABASE_NAME_PROP_NAME,"dp_compliance_test")
.option(HoodieTableConfig.HOODIE_TABLE_NAME_PROP_NAME,"test_bytelake")
//指定操作模式为delete
.option(DataSourceWriteOptions.OPERATION_OPT_KEY,DataSourceWriteOptions.DELETE_OPERATION_OPT_VAL)
//指定分区字段
.option(DataSourceWriteOptions.PARTITIONPATH_FIELD_OPT_KEY,"date,hour")
//设置删除并行度设置,默认1500并行度
.option("hoodie.delete.shuffle.parallelism", "2")
.mode(SaveMode.Append).save()
}
}
Hudi与Spark整合 API