【Hudi】Apache Hudi:不一样的存储、不一样的计算

本篇是来自好友孟尧总的一篇文章。我发现,真正的纯粹是自发的,没有任何外部的干扰,因为我们都不是为了钱、为了生活做技术、写作。只有这样,我们才能真正的洒脱。通过文章能够看到孟总对待技术的态度、思维方式。

一切,是因为我们都有所期待。

一切,是因为我们都有理想。

一切,是因为我们敬畏技术。

一切,是因为我们不光做共同的技术,更有共同技术文化信仰。

虽然相距600公里,技术会让我们重新聚在一起。

期待。

目录

  • Hudi是什么

  • Hudi的应用场景

  • Hudi的核心概念

  • Hudi支持的存储类型

  • Hudi的SparkSQL使用

  • Hudi的WriteClient使用

  • 如何使用索引

  • 生产环境下的推荐配置

1

Hudi是什么

Apache Hudi(Hadoop Upserts Deletes and Incrementals,简称Hudi,发音为Hoodie)由UBer开源,它可以以极低的延迟将数据快速摄取到HDFS或云存储(S3)的工具,最主要的特点支持记录级别的插入更新(Upsert)和删除,同时还支持增量查询。

本质上,Hudi并不是一种新的文件格式,相反,它仅仅是充分利用了开源的列格式/行格式的文件作为数据的存储形式,并在数据写入的同时生成特定的索引,以便于在读取时提供更加快速的查询性能。

Hudi自身无法完成对数据的读写操作,它强依赖于外部的Spark、Flink、Presto和Impala等计算引擎才可以使用,目前尤其对Spark依赖严重(在0.7.0中新增了Flink支持)。

2

Hudi的应用场景

1

近实时摄取

将外部源(点击流日志、数据库BinLog、API)的数据摄取到Hadoop数据湖是一种必要的数据迁移过程,但现有的大多数迁移方案都是通过多种摄取工具来解决的。而Hudi就是一种通用的增量数据处理框架,目标是集成在各种现有的计算引擎中,从而缩短以往冗长的数据摄取链路(各种组件相互配合使用),更加稳定且有效的完成对多种数据源的摄取,如下:

2

近实时分析

SQL on Hadoop解决方案(如Presto和Spark SQL)表现出色,一般可以在几秒钟内完成查询。而Hudi可以提供一个面向实时分析更有效的替代方案,并支持对存储在HDFS中更大规模数据集的实时分析。此外,它是一个非常轻量级的库,没有额外的组件依赖(如专用于实时分析的HBase集群),使用它并不会增加操作开销。

3

增量处理管道

过去的增量处理往往划分成小时的分区为单位,落在此分区内的数据写入完成时,这使得数据的新鲜程度可以有效提高。如果有些数据迟到时,唯一的补救措施是通过重跑来保证正确性,但这又会导致增加整个系统的开销。Hudi支持Record级别的方式从上游消费新数据,从而可以仅处理发生变更的数据到相应的表分区,同时还可以将分区的粒度缩短到分钟级,从而不会导致额外的系统资源开销。

4

HDFS数据分发

一个常见的用例是先在Hadoop体系中进行处理数据,然后再分发回在线服务存储系统,以提供应用程序使用。在这种用例中一般都会引入诸如Kafka之类的队列系统,以防止目标存储系统被压垮。但如果不使用Kafka的情况下,仅将每次运行的Spark管道更新插入的输出转换为Hudi数据集,也可以有效地解决这个问题,然后以增量方式获取数据(就像读取Kafka topic一样)写入服务存储层。

3

Hudi的核心概念

1

时间轴(Timeline)

Hudi的核心是在所有的表中维护了一个包含在不同的即时(Instant)时间对数据集操作(比如新增、修改或删除)的时间轴(Timeline),在每一次对Hudi表的数据集操作时都会在该表的Timeline上生成一个Instant,从而可以实现在仅查询某个时间点之后成功提交的数据,或是仅查询某个时间点之前的数据,有效避免了扫描更大时间范围的数据。同时,还可以高效地只查询更改前的文件(例如在某个Instant提交了更改操作后,仅query某个时间点之前的数据,则仍可以query修改前的数据)。

  • 时间轴(Timeline)的实现类(位于hudi-common-0.6.0.jar中):

  • 注意:

    由于hudi-spark-bundle.jar和hudi-hadoop-mr-bundle.jar属于Uber类型的jar包,已经将hudi-common-0.6.0.jar的所有class打包进去了。

    时间轴相关的实现类位于org.apache.hudi.common.table.timeline包下。

  • 最顶层的接口约定类为:

    HoodieTimeline。

  • 默认使用的时间轴类:

    HoodieDefaultTimeline继承自HoodieTimeline。

  • 活动时间轴类为:

    HoodieActiveTimeline(此类维护最近12小时内的时间,可配置)。

  • 存档时间轴类为:

    HoodieArchivedTimeline(超出12小时的时间在此类中维护,可配置)。

  • 时间轴(Timeline)的核心组件:

图片

2

文件组织形式

Hudi将DFS上的数据集组织到基本路径(HoodieWriteConfig.BASEPATHPROP)下的目录结构中。数据集分为多个分区(DataSourceOptions.PARTITIONPATHFIELDOPT_KEY),这些分区与Hive表非常相似,是包含该分区的数据文件的文件夹。

在每个分区内,文件被组织为文件组,由文件id充当唯一标识。每个文件组包含多个文件切片,其中每个切片包含在某个即时时间的提交/压缩生成的基本列文件(.parquet)以及一组日志文件(.log),该文件包含自生成基本文件以来对基本文件的插入/更新。Hudi采用MVCC设计,其中压缩操作将日志和基本文件合并以产生新的文件切片,而清理操作则将未使用的/较旧的文件片删除以回收DFS上的空间。

3

索引机制(4类6种)

Hudi通过索引机制提供高效的Upsert操作,该机制会将一个RecordKey+PartitionPath组合的方式作为唯一标识映射到一个文件ID,而且,这个唯一标识和文件组/文件ID之间的映射自记录被写入文件组开始就不会再改变。Hudi内置了4类(6个)索引实现,均是继承自顶层的抽象类HoodieIndex而来,如下:

注意:

  • 全局索引:

    指在全表的所有分区范围下强制要求键保持唯一,即确保对给定的键有且只有一个对应的记录。

    全局索引提供了更强的保证,也使得更删的消耗随着表的大小增加而增加(O(表的大小)),更适用于是小表。

  • 非全局索引:仅在表的某一个分区内强制要求键保持唯一,它依靠写入器为同一个记录的更删提供一致的分区路径,但由此同时大幅提高了效率,因为索引查询复杂度成了O(更删的记录数量)且可以很好地应对写入量的扩展。

4

查询视图(3类)

  • 读优化视图 : 直接查询基本文件(数据集的最新快照),其实就是列式文件(Parquet)。并保证与非Hudi列式数据集相比,具有相同的列式查询性能。

  • 增量视图 : 仅查询新写入数据集的文件,需要指定一个Commit/Compaction的即时时间(位于Timeline上的某个Instant)作为条件,来查询此条件之后的新数据。

  • 实时快照视图 : 查询某个增量提交操作中数据集的最新快照,会先进行动态合并最新的基本文件(Parquet)和增量文件(Avro)来提供近实时数据集(通常会存在几分钟的延迟)。

图片

4

Hudi支持的存储类型

1

写时复制(Copy on Write,COW)表

COW表主要使用列式文件格式(Parquet)存储数据,在写入数据过程中,执行同步合并,更新数据版本并重写数据文件,类似RDBMS中的B-Tree更新。

  1. 更新:在更新记录时,Hudi会先找到包含更新数据的文件,然后再使用更新值(最新的数据)重写该文件,包含其他记录的文件保持不变。当突然有大量写操作时会导致重写大量文件,从而导致极大的I/O开销。

2)读取:在读取数据集时,通过读取最新的数据文件来获取最新的更新,此存储类型适用于少量写入和大量读取的场景。

2

读时合并(Merge On Read,MOR)表

MOR表是COW表的升级版,它使用列式(parquet)与行式(avro)文件混合的方式存储数据。在更新记录时,类似NoSQL中的LSM-Tree更新。

  1. 更新:在更新记录时,仅更新到增量文件(Avro)中,然后进行异步(或同步)的compaction,最后创建列式文件(parquet)的新版本。此存储类型适合频繁写的工作负载,因为新记录是以追加的模式写入增量文件中。

  2. 读取:在读取数据集时,需要先将增量文件与旧文件进行合并,然后生成列式文件成功后,再进行查询。

3

COW和MOR的对比

图片

4

COW和MOR支持的视图

图片

5

Hudi的SparkSQL使用

Hudi支持对文件系统(HDFS、LocalFS)和Hive的读写操作,以下分别使用COW和MOR存储类型来操作文件系统和Hive的案例。

1

文件系统操作

基于COW表的LocalFS/HDFS使用


package com.mengyao.hudi

import com.mengyao.Configured
import org.apache.spark.sql.SaveMode._
import org.apache.hudi.DataSourceReadOptions._
import org.apache.hudi.DataSourceWriteOptions._
import org.apache.hudi.common.model.EmptyHoodieRecordPayload
import org.apache.hudi.config.HoodieIndexConfig._
import org.apache.hudi.config.HoodieWriteConfig._
import org.apache.hudi.index.HoodieIndex
import org.apache.spark.SparkConf
import org.apache.spark.sql.functions._
import org.apache.spark.sql.{DataFrame, SparkSession}

import scala.collection.JavaConverters._

/**
 * Spark on Hudi(COW表) to HDFS/LocalFS
 * @ClassName Demo1
 * @Description
 * @Created by: MengYao
 * @Date: 2021-01-11 10:10:53
 * @Version V1.0
 */
object Demo1 {

  private val APP_NAME = Demo1.getClass.getSimpleName
  private val MASTER = "local[2]"
  val SOURCE = "hudi"
  val insertData = Array[TemperatureBean](
    new TemperatureBean("4301",1,28.6,"2019-12-07 12:35:33"),
    new TemperatureBean("4312",0,31.4,"2019-12-07 12:25:03"),
    new TemperatureBean("4302",1,30.1,"2019-12-07 12:32:17"),
    new TemperatureBean("4305",3,31.5,"2019-12-07 12:33:11"),
    new TemperatureBean("4310",2,29.9,"2019-12-07 12:34:42")
  )
  val updateData = Array[TemperatureBean](
    new TemperatureBean("4310",2,30.4,"2019-12-07 12:35:42")// 设备ID为4310的传感器发生修改,温度值由29.9->30.4,时间由12:34:42->12:35:42
  )
  val deleteData = Array[TemperatureBean](
    new TemperatureBean("4310",2,30.4,"2019-12-07 12:35:42")// 设备ID为4310的传感器要被删除,必须与最新的数据保持一致(如果字段值不同时无法删除)
  )

  def main(args: Array[String]): Unit = {
    System.setProperty(Configured.HADOOP_HOME_DIR, Configured.getHadoopHome)

    // 创建SparkConf
    val conf = new SparkConf()
      .set("spark.master", MASTER)
      .set("spark.app.name", APP_NAME)
      .setAll(Configured.sparkConf().asScala)
    // 创建SparkSession
    val spark = SparkSession.builder()
      .config(conf)
      .getOrCreate()
    // 关闭日志
    spark.sparkContext.setLogLevel("OFF")
    // 导入隐式转换
    import spark.implicits._
    import DemoUtils._

    // 类似Hive中的DB(basePath的schema决定了最终要操作的文件系统,如果是file:则为LocalFS,如果是hdfs:则为HDFS)
    val basePath = "file:/D:/tmp"
    // 类似Hive中的Table
    val tableName = "tbl_temperature_cow"
    // 数据所在的路径
    val path = s"$basePath/$tableName"

    // 插入数据
    insert(spark.createDataFrame(insertData.toBuffer.asJava, classOf[TemperatureBean]), tableName, "deviceId", "deviceType", "cdt", path)
    // 修改数据
//    update(spark.createDataFrame(updateData.toBuffer.asJava, classOf[TemperatureBean]), tableName, "deviceId", "deviceType", "cdt", path)
    // 删除数据
//    delete(spark.createDataFrame(deleteData.toBuffer.asJava, classOf[TemperatureBean]), tableName, "deviceId", "deviceType", "cdt", path)
    // 【查询方式1:默认为快照(基于行或列获取最新视图)查询
//    query(spark.read.format(SOURCE).load(s"$path/*/*").orderBy($"deviceId".asc))
//    query(spark.read.format(SOURCE).options(buildQuery(QUERY_TYPE_SNAPSHOT_OPT_VAL)).load(s"$path/*/*").withColumn("queryType", lit("查询方式为:快照(默认)")).orderBy($"deviceId".asc))
    // 【查询方式2:读时优化
//    query(spark.read.format(SOURCE).options(buildQuery(QUERY_TYPE_READ_OPTIMIZED_OPT_VAL)).load(s"$path/*/*").withColumn("queryType", lit("查询方式为:读时优化")).orderBy($"deviceId".asc))
    // 【查询方式3:增量查询
    // 先取出最近一次提交的时间
    val commitTime = spark.read.format(SOURCE).load(s"$path/*/*").dropDuplicates("_hoodie_commit_time").select($"_hoodie_commit_time".as("commitTime")).orderBy($"commitTime".desc).first().getAs(0)
    // 再查询最近提交时间之后的数据
    query(spark.read.format(SOURCE).options(buildQuery(QUERY_TYPE_INCREMENTAL_OPT_VAL,Option((commitTime.toLong-2).toString))).load(s"$path/*/*").withColumn("queryType", lit("查询方式为:增量查询")).orderBy($"deviceId".asc).toDF())

    spark.close()
  }

  /**
   * 新增数据
   * @param df                  数据集
   * @param tableName           Hudi表
   * @param primaryKey          主键列名
   * @param partitionField      分区列名
   * @param changeDateField     变更时间列名
   * @param path                数据的存储路径
   */
  def insert(df: DataFrame, tableName: String, primaryKey: String, partitionField: String, changeDateField: String, path: String): Unit = {
    df.write.format(SOURCE)
      .options(Map(
        // 要操作的表
        TABLE_NAME->tableName,
        // 操作的表类型(默认COW)
        TABLE_TYPE_OPT_KEY -> COW_TABLE_TYPE_OPT_VAL,
        // 执行insert操作
        OPERATION_OPT_KEY->INSERT_OPERATION_OPT_VAL,
        // 设置主键列
        RECORDKEY_FIELD_OPT_KEY->primaryKey,
        // 设置分区列,类似Hive的表分区概念
        PARTITIONPATH_FIELD_OPT_KEY->partitionField,
        // 设置数据更新时间列,该字段数值大的数据会覆盖小的,必须是数值类型
        PRECOMBINE_FIELD_OPT_KEY->changeDateField,
        // 要使用的索引类型,可用的选项是[SIMPLE|BLOOM|HBASE|INMEMORY],默认为布隆过滤器
        INDEX_TYPE_PROP-> HoodieIndex.IndexType.BLOOM.name,
        // 执行insert操作的shuffle并行度
        INSERT_PARALLELISM->"2"
      ))
      // 如果数据存在会覆盖
      .mode(Overwrite)
      .save(path)
  }

  /**
   * 修改数据
   * @param df                  数据集
   * @param tableName           Hudi表
   * @param primaryKey          主键列名
   * @param partitionField      分区列名
   * @param changeDateField     变更时间列名
   * @param path                数据的存储路径
   */
  def update(df: DataFrame, tableName: String, primaryKey: String, partitionField: String, changeDateField: String, path: String): Unit = {
    df.write.format(SOURCE)
      .options(Map(
        // 要操作的表
        TABLE_NAME->tableName,
        // 操作的表类型(默认COW)
        TABLE_TYPE_OPT_KEY -> COW_TABLE_TYPE_OPT_VAL,
        // 执行upsert操作
        OPERATION_OPT_KEY->UPSERT_OPERATION_OPT_VAL,
        // 设置主键列
        RECORDKEY_FIELD_OPT_KEY->primaryKey,
        // 设置分区列,类似Hive的表分区概念
        PARTITIONPATH_FIELD_OPT_KEY->partitionField,
        // 设置数据更新时间列,该字段数值大的数据会覆盖小的
        PRECOMBINE_FIELD_OPT_KEY->changeDateField,
        // 要使用的索引类型,可用的选项是[SIMPLE|BLOOM|HBASE|INMEMORY],默认为布隆过滤器
        INDEX_TYPE_PROP-> HoodieIndex.IndexType.BLOOM.name,
        // 执行upsert操作的shuffle并行度
        UPSERT_PARALLELISM-> "2"
      ))
      // 如果数据存在会覆盖
      .mode(Append)
      .save(path)
  }

  /**
   * 删除数据
   * @param df                  数据集
   * @param tableName           Hudi表
   * @param primaryKey          主键列名
   * @param partitionField      分区列名
   * @param changeDateField     变更时间列名
   * @param path                数据的存储路径
   */
  def delete(df: DataFrame, tableName: String, primaryKey: String, partitionField: String, changeDateField: String, path: String): Unit = {
    df.write.format(SOURCE)
      .options(Map(
        // 要操作的表
        TABLE_NAME->tableName,
        // 操作的表类型(默认COW)
        TABLE_TYPE_OPT_KEY -> COW_TABLE_TYPE_OPT_VAL,
        // 执行delete操作
        OPERATION_OPT_KEY->DELETE_OPERATION_OPT_VAL,
        // 设置主键列
        RECORDKEY_FIELD_OPT_KEY->primaryKey,
        // 设置分区列,类似Hive的表分区概念
        PARTITIONPATH_FIELD_OPT_KEY->partitionField,
        // 设置数据更新时间列,该字段数值大的数据会覆盖小的
        PRECOMBINE_FIELD_OPT_KEY->changeDateField,
        // 要使用的索引类型,可用的选项是[SIMPLE|BLOOM|HBASE|INMEMORY],默认为布隆过滤器
        INDEX_TYPE_PROP-> HoodieIndex.IndexType.BLOOM.name,
        // 执行delete操作的shuffle并行度
        DELETE_PARALLELISM->"2",
        // 删除策略有软删除(保留主键且其余字段为null)和硬删除(从数据集中彻底删除)两种,此处为硬删除
        PAYLOAD_CLASS_OPT_KEY->classOf[EmptyHoodieRecordPayload].getName
      ))
      // 如果数据存在会覆盖
      .mode(Append)
      .save(path)
  }

  /**
   * 查询类型
   *    <br>Hoodie具有3种查询模式:</br>
   *      <br>1、默认是快照模式(Snapshot mode,根据行和列数据获取最新视图)</br>
   *      <br>2、增量模式(incremental mode,查询从某个commit时间片之后的数据)</br>
   *      <br>3、读时优化模式(Read Optimized mode,根据列数据获取最新视图)</br>
   * @param queryType
   * @param queryTime
   * @return
   */
  def buildQuery(queryType: String, queryTime: Option[String]=Option.empty) = Map(
    queryType match {
      // 如果是读时优化模式(read_optimized,根据列数据获取最新视图)
      case QUERY_TYPE_READ_OPTIMIZED_OPT_VAL => QUERY_TYPE_OPT_KEY->QUERY_TYPE_READ_OPTIMIZED_OPT_VAL
      // 如果是增量模式(incremental mode,查询从某个时间片之后的新数据)
      case QUERY_TYPE_INCREMENTAL_OPT_VAL => QUERY_TYPE_OPT_KEY->QUERY_TYPE_INCREMENTAL_OPT_VAL
      // 默认使用快照模式查询(snapshot mode,根据行和列数据获取最新视图)
      case _ => QUERY_TYPE_OPT_KEY->QUERY_TYPE_SNAPSHOT_OPT_VAL
    },
    if(queryTime.nonEmpty) BEGIN_INSTANTTIME_OPT_KEY->queryTime.get else BEGIN_INSTANTTIME_OPT_KEY->"0"
  )

}```

基于MOR表的LocalFS/HDFS使用

```scala
package com.mengyao.hudi

import com.mengyao.Configured
import org.apache.hudi.DataSourceReadOptions._
import org.apache.hudi.DataSourceWriteOptions._
import org.apache.hudi.common.model.EmptyHoodieRecordPayload
import org.apache.hudi.config.HoodieIndexConfig.INDEX_TYPE_PROP
import org.apache.hudi.config.HoodieWriteConfig._
import org.apache.hudi.index.HoodieIndex
import org.apache.spark.sql.{DataFrame, SparkSession}
import org.apache.spark.sql.SaveMode._
import org.apache.spark.SparkConf
import org.apache.spark.sql.functions.lit

import scala.collection.JavaConverters._

/**
 * Spark on Hudi(MOR表) to HDFS/LocalFS
 * @ClassName Demo1
 * @Description
 * @Created by: MengYao
 * @Date: 2021-01-11 10:10:53
 * @Version V1.0
 */
object Demo2 {

  private val APP_NAME: String = Demo1.getClass.getSimpleName
  private val MASTER: String = "local[2]"
  val SOURCE: String = "hudi"
  val insertData = Array[TemperatureBean](
    new TemperatureBean("4301",1,28.6,"2019-12-07 12:35:33"),
    new TemperatureBean("4312",0,31.4,"2019-12-07 12:25:03"),
    new TemperatureBean("4302",1,30.1,"2019-12-07 12:32:17"),
    new TemperatureBean("4305",3,31.5,"2019-12-07 12:33:11"),
    new TemperatureBean("4310",2,29.9,"2019-12-07 12:34:42")
  )
  val updateData = Array[TemperatureBean](
    new TemperatureBean("4310",2,30.4,"2019-12-07 12:35:42")// 设备ID为4310的传感器发生修改,温度值由29.9->30.4,时间由12:34:42->12:35:42
  )
  val deleteData = Array[TemperatureBean](
    new TemperatureBean("4310",2,30.4,"2019-12-07 12:35:42")// 设备ID为4310的传感器要被删除,必须与最新的数据保持一致(如果字段值不同时无法删除)
  )

  def main(args: Array[String]): Unit = {
    System.setProperty(Configured.HADOOP_HOME_DIR, Configured.getHadoopHome)

    // 创建SparkConf
    val conf = new SparkConf()
      .set("spark.master", MASTER)
      .set("spark.app.name", APP_NAME)
      .setAll(Configured.sparkConf().asScala)
    // 创建SparkSession
    val spark = SparkSession.builder()
      .config(conf)
      .getOrCreate()
    // 关闭日志
    spark.sparkContext.setLogLevel("OFF")
    // 导入隐式转换
    import spark.implicits._
    import DemoUtils._

    // 类似Hive中的DB(basePath的schema决定了最终要操作的文件系统,如果是file:则为LocalFS,如果是hdfs:则为HDFS)
    val basePath = "file:/D:/tmp"
    // 类似Hive中的Table
    val tableName = "tbl_temperature_mor"
    // 数据所在的路径
    val path = s"$basePath/$tableName"

    // 插入数据
    insert(spark.createDataFrame(insertData.toBuffer.asJava, classOf[TemperatureBean]), tableName, "deviceId", "deviceType", "cdt", path)
    // 修改数据
//    update(spark.createDataFrame(updateData.toBuffer.asJava, classOf[TemperatureBean]), tableName, "deviceId", "deviceType", "cdt", path)
    // 删除数据
//    delete(spark.createDataFrame(deleteData.toBuffer.asJava, classOf[TemperatureBean]), tableName, "deviceId", "deviceType", "cdt", path)
    // 【查询方式1:默认为快照(基于行或列获取最新视图)查询
//    query(spark.read.format(SOURCE).load(s"$path/*/*").orderBy($"deviceId".asc))
//    query(spark.read.format(SOURCE).options(buildQuery(QUERY_TYPE_SNAPSHOT_OPT_VAL)).load(s"$path/*/*").withColumn("queryType", lit("查询方式为:快照(默认)")).orderBy($"deviceId".asc))
    // 【查询方式2:读时优化
//    query(spark.read.format(SOURCE).options(buildQuery(QUERY_TYPE_READ_OPTIMIZED_OPT_VAL)).load(s"$path/*/*").withColumn("queryType", lit("查询方式为:读时优化")).orderBy($"deviceId".asc))
    // 【查询方式3:增量查询
    // 先取出最近一次提交的时间
    val commitTime:String = spark.read.format(SOURCE).load(s"$path/*/*").dropDuplicates("_hoodie_commit_time").select($"_hoodie_commit_time".as("commitTime")).orderBy($"commitTime".desc).first().getAs(0)
    // 再查询最近提交时间之后的数据
    query(spark.read.format(SOURCE).options(buildQuery(QUERY_TYPE_INCREMENTAL_OPT_VAL,Option((commitTime.toLong-2).toString))).load(s"$path/*/*").withColumn("queryType", lit("查询方式为:增量查询")).orderBy($"deviceId".asc).toDF())

    spark.close()
  }

  /**
   * 新增数据
   * @param df                  数据集
   * @param tableName           Hudi表
   * @param primaryKey          主键列名
   * @param partitionField      分区列名
   * @param changeDateField     变更时间列名
   * @param path                数据的存储路径
   */
  def insert(df: DataFrame, tableName: String, primaryKey: String, partitionField: String, changeDateField: String, path: String): Unit = {
    df.write.format(SOURCE)
      .options(Map(
        // 要操作的表
        TABLE_NAME->tableName,
        // 操作的表类型(使用MOR)
        TABLE_TYPE_OPT_KEY -> MOR_TABLE_TYPE_OPT_VAL,
        // 执行insert操作
        OPERATION_OPT_KEY->INSERT_OPERATION_OPT_VAL,
        // 设置主键列
        RECORDKEY_FIELD_OPT_KEY->primaryKey,
        // 设置分区列,类似Hive的表分区概念
        PARTITIONPATH_FIELD_OPT_KEY->partitionField,
        // 设置数据更新时间列,该字段数值大的数据会覆盖小的,必须是数值类型
        PRECOMBINE_FIELD_OPT_KEY->changeDateField,
        // 要使用的索引类型,可用的选项是[SIMPLE|BLOOM|HBASE|INMEMORY],默认为布隆过滤器
        INDEX_TYPE_PROP-> HoodieIndex.IndexType.BLOOM.name,
        // 执行insert操作的shuffle并行度
        INSERT_PARALLELISM->"2"
      ))
      // 如果数据存在会覆盖
      .mode(Overwrite)
      .save(path)
  }

  /**
   * 修改数据
   * @param df                  数据集
   * @param tableName           Hudi表
   * @param primaryKey          主键列名
   * @param partitionField      分区列名
   * @param changeDateField     变更时间列名
   * @param path                数据的存储路径
   */
  def update(df: DataFrame, tableName: String, primaryKey: String, partitionField: String, changeDateField: String, path: String): Unit = {
    df.write.format(SOURCE)
      .options(Map(
        // 要操作的表
        TABLE_NAME->tableName,
        // 操作的表类型(使用MOR)
        TABLE_TYPE_OPT_KEY -> MOR_TABLE_TYPE_OPT_VAL,
        // 执行upsert操作
        OPERATION_OPT_KEY->UPSERT_OPERATION_OPT_VAL,
        // 设置主键列
        RECORDKEY_FIELD_OPT_KEY->primaryKey,
        // 设置分区列,类似Hive的表分区概念
        PARTITIONPATH_FIELD_OPT_KEY->partitionField,
        // 设置数据更新时间列,该字段数值大的数据会覆盖小的
        PRECOMBINE_FIELD_OPT_KEY->changeDateField,
        // 要使用的索引类型,可用的选项是[SIMPLE|BLOOM|HBASE|INMEMORY],默认为布隆过滤器
        INDEX_TYPE_PROP-> HoodieIndex.IndexType.BLOOM.name,
        // 执行upsert操作的shuffle并行度
        UPSERT_PARALLELISM-> "2"
      ))
      // 如果数据存在会覆盖
      .mode(Append)
      .save(path)
  }

  /**
   * 删除数据
   * @param df                  数据集
   * @param tableName           Hudi表
   * @param primaryKey          主键列名
   * @param partitionField      分区列名
   * @param changeDateField     变更时间列名
   * @param path                数据的存储路径
   */
  def delete(df: DataFrame, tableName: String, primaryKey: String, partitionField: String, changeDateField: String, path: String): Unit = {
    df.write.format(SOURCE)
      .options(Map(
        // 要操作的表
        TABLE_NAME->tableName,
        // 操作的表类型(使用MOR)
        TABLE_TYPE_OPT_KEY -> MOR_TABLE_TYPE_OPT_VAL,
        // 执行delete操作
        OPERATION_OPT_KEY->DELETE_OPERATION_OPT_VAL,
        // 设置主键列
        RECORDKEY_FIELD_OPT_KEY->primaryKey,
        // 设置分区列,类似Hive的表分区概念
        PARTITIONPATH_FIELD_OPT_KEY->partitionField,
        // 设置数据更新时间列,该字段数值大的数据会覆盖小的
        PRECOMBINE_FIELD_OPT_KEY->changeDateField,
        // 要使用的索引类型,可用的选项是[SIMPLE|BLOOM|HBASE|INMEMORY],默认为布隆过滤器
        INDEX_TYPE_PROP-> HoodieIndex.IndexType.BLOOM.name,
        // 执行delete操作的shuffle并行度
        DELETE_PARALLELISM->"2",
        // 删除策略有软删除(保留主键且其余字段为null)和硬删除(从数据集中彻底删除)两种,此处为硬删除
        PAYLOAD_CLASS_OPT_KEY->classOf[EmptyHoodieRecordPayload].getName
      ))
      // 如果数据存在会覆盖
      .mode(Append)
      .save(path)
  }

  /**
   * 查询类型
   *    <br>Hoodie具有3种查询模式:</br>
   *      <br>1、默认是快照模式(Snapshot mode,根据行和列数据获取最新视图)</br>
   *      <br>2、增量模式(incremental mode,查询从某个commit时间片之后的数据)</br>
   *      <br>3、读时优化模式(Read Optimized mode,根据列数据获取最新视图)</br>
   * @param queryType
   * @param queryTime
   * @return
   */
  def buildQuery(queryType: String, queryTime: Option[String]=Option.empty): Map[String, String] = {
    Map(
      queryType match {
        // 如果是读时优化模式(read_optimized,根据列数据获取最新视图)
        case QUERY_TYPE_READ_OPTIMIZED_OPT_VAL => QUERY_TYPE_OPT_KEY->QUERY_TYPE_READ_OPTIMIZED_OPT_VAL
        // 如果是增量模式(incremental mode,查询从某个时间片之后的新数据)
        case QUERY_TYPE_INCREMENTAL_OPT_VAL => QUERY_TYPE_OPT_KEY->QUERY_TYPE_INCREMENTAL_OPT_VAL
        // 默认使用快照模式查询(snapshot mode,根据行和列数据获取最新视图)
        case _ => QUERY_TYPE_OPT_KEY->QUERY_TYPE_SNAPSHOT_OPT_VAL
      },
      if(queryTime.nonEmpty) BEGIN_INSTANTTIME_OPT_KEY->queryTime.get else BEGIN_INSTANTTIME_OPT_KEY->"0"
    )
  }

}

6

Hudi的WriteClient使用

package com.mengyao.hudi;

import com.mengyao.Configured;
import org.apache.hudi.client.HoodieWriteClient;
import org.apache.hudi.client.WriteStatus;
import org.apache.hudi.common.model.HoodieRecord;
import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload;
import org.apache.hudi.config.HoodieIndexConfig;
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.index.HoodieIndex;
import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import scala.collection.JavaConverters;

import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;

/**
 * WriteClient模式是直接使用RDD级别api进行Hudi编程
 *      Application需要使用HoodieWriteConfig对象,并将其传递给HoodieWriteClient构造函数。HoodieWriteConfig可以使用以下构建器模式构建。
 * @ClassName WriteClientMain
 * @Description
 * @Created by: MengYao
 * @Date: 2021-01-26 10:40:29
 * @Version V1.0
 */
public class WriteClientMain {

    private static final String APP_NAME = WriteClientMain.class.getSimpleName();
    private static final String MASTER = "local[2]";
    private static final String SOURCE = "hudi";

    public static void main(String[] args) {
        // 创建SparkConf
        SparkConf conf = new SparkConf()
                .setAll(JavaConverters.mapAsScalaMapConverter(Configured.sparkConf()).asScala());
        // 创建SparkContext
        JavaSparkContext jsc = new JavaSparkContext(MASTER, APP_NAME, conf);
        // 创建Hudi的WriteConfig
        HoodieWriteConfig hudiCfg = HoodieWriteConfig.newBuilder()
                .forTable("tableName")

                .withSchema("avroSchema")
                .withPath("basePath")
                .withProps(new HashMap())
 .withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.BLOOM).build())
                .build();
        // 创建Hudi的WriteClient
        HoodieWriteClient hudiWriteCli = new HoodieWriteClient<OverwriteWithLatestAvroPayload>(jsc, hudiCfg);

        // 1、执行新增操作
        JavaRDD<HoodieRecord> insertData = jsc.parallelize(Arrays.asList());
        String insertInstantTime = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
        JavaRDD<WriteStatus> insertStatus = hudiWriteCli.insert(insertData, insertInstantTime);
        // 【注意:为了便于理解,以下所有判断Hudi操作数据的状态不进行额外的方法封装】
        if (insertStatus.filter(ws->ws.hasErrors()).count()>0) {// 当提交后返回的状态中包含error时
            hudiWriteCli.rollback(insertInstantTime);// 从时间线(insertInstantTime)中回滚,插入失败
        } else {
            hudiWriteCli.commit(insertInstantTime, insertStatus);// 否则提交时间线(insertInstantTime)中的数据,到此,插入完成
        }

        // 2、也可以使用批量加载的方式新增数据
        String builkInsertInstantTime = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
        JavaRDD<WriteStatus> bulkInsertStatus = hudiWriteCli.bulkInsert(insertData, builkInsertInstantTime);
        if (bulkInsertStatus.filter(ws->ws.hasErrors()).count()>0) {// 当提交后返回的状态中包含error时
            hudiWriteCli.rollback(builkInsertInstantTime);// 从时间线(builkInsertInstantTime)中回滚,批量插入失败
        } else {
            hudiWriteCli.commit(builkInsertInstantTime, bulkInsertStatus);// 否则提交时间线(builkInsertInstantTime)中的数据,到此,批量插入完成
        }

        // 3、执行修改or新增操作
        JavaRDD<HoodieRecord> updateData = jsc.parallelize(Arrays.asList());
        String updateInstantTime = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
        JavaRDD<WriteStatus> updateStatus = hudiWriteCli.upsert(updateData, updateInstantTime);
        if (updateStatus.filter(ws->ws.hasErrors()).count()>0) {// 当提交后返回的状态中包含error时
            hudiWriteCli.rollback(updateInstantTime);// 从时间线(updateInstantTime)中回滚,修改失败
        } else {
            hudiWriteCli.commit(updateInstantTime, updateStatus);// 否则提交时间线(updateInstantTime)中的数据,到此,修改完成
        }

        // 4、执行删除操作
        JavaRDD<HoodieRecord> deleteData = jsc.parallelize(Arrays.asList());
        String deleteInstantTime = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
        JavaRDD<WriteStatus> deleteStatus = hudiWriteCli.delete(deleteData, deleteInstantTime);
        if (deleteStatus.filter(ws->ws.hasErrors()).count()>0) {// 当提交后返回的状态中包含error时
            hudiWriteCli.rollback(deleteInstantTime);// 从时间线(deleteInstantTime)中回滚,删除失败
        } else {
            hudiWriteCli.commit(deleteInstantTime, deleteStatus);// 否则提交时间线(deleteInstantTime)中的数据,到此,删除完成
        }

        // 退出WriteClient
        hudiWriteCli.close();
        // 退出SparkContext
        jsc.stop();
    }

}

7

如何使用索引

1

使用SparkSQL的数据源配置

在SparkSQL的数据源配置中,面向读写的通用配置参数均通过options或option来指定,可用的功能包括:定义键和分区、选择写操作、指定如何合并记录或选择要读取的视图类型。

df.write.format("hudi")
  .options(Map(
    // 要操作的表
    TABLE_NAME->tableName,
    // 操作的表类型(默认COW)
    TABLE_TYPE_OPT_KEY -> COW_TABLE_TYPE_OPT_VAL,
    /**
     * 执行insert/upsert/delete操作,默认是upsert
     * OPERATION_OPT_KEY->INSERT_OPERATION_OPT_VAL,
     *             BULK_INSERT_OPERATION_OPT_VAL,
     *             UPSERT_OPERATION_OPT_VAL,
     *             DELETE_OPERATION_OPT_VAL,
     */
    OPERATION_OPT_KEY->INSERT_OPERATION_OPT_VAL,
    // 设置主键列
    RECORDKEY_FIELD_OPT_KEY->primaryKey,
    // 设置分区列,类似Hive的表分区概念
    PARTITIONPATH_FIELD_OPT_KEY->partitionField,
    // 设置数据更新时间列,该字段数值大的数据会覆盖小的,必须是数值类型
    PRECOMBINE_FIELD_OPT_KEY->changeDateField,
    /**
     * 要使用的索引类型,可用的选项是[SIMPLE|BLOOM|HBASE|INMEMORY],默认为布隆过滤器
     * INDEX_TYPE_PROP -> HoodieIndex.IndexType.SIMPLE.name,
     *                    HoodieIndex.IndexType.GLOBAL_SIMPLE.name,
     *                    HoodieIndex.IndexType.INMEMORY.name,
     *                    HoodieIndex.IndexType.HBASE.name,
     *                    HoodieIndex.IndexType.BLOOM.name,
     *                    HoodieIndex.IndexType.GLOBAL_SIMPLE.name,
     */ 
    INDEX_TYPE_PROP -> HoodieIndex.IndexType.BLOOM.name,
    // 执行insert操作的shuffle并行度
    INSERT_PARALLELISM->"2"
  ))
  // 如果数据存在会覆盖
  .mode(Overwrite)
  .save(path)

2

使用WriteClient方式配置

WriteClient是使用基于Java的RDD级别API进行编程的的一种方式,需要先构建HoodieWriteConfig对象,然后再作为参数传递给HoodieWriteClient构造函数。

// 创建Hudi的WriteConfig
HoodieWriteConfig hudiCfg = HoodieWriteConfig.newBuilder()
    .forTable("tableName")
    .withSchema("avroSchema")
    .withPath("basePath")
    .withProps(new HashMap())
    .withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(
        HoodieIndex.IndexType.BLOOM
        // HoodieIndex.IndexType.GLOBAL_BLOOM
        // HoodieIndex.IndexType.INMEMORY
        // HoodieIndex.IndexType.HBASE
        // HoodieIndex.IndexType.SIMPLE
        // HoodieIndex.IndexType.GLOBAL_SIMPLE
    ).build())
    .build();

3

声明索引的关键参数

在Hudi中,使用索引的关键参数主要有2个,即hoodie.index.type和hoodie.index.class两个。这两个参数只需要配置其中一个即可,原因如下:

图片

4

索引参数在源码中的实现

可以在Hudi索引超类HoodieIndex的源码中看到createIndex方法的定义和实现:

public abstract class HoodieIndex<T extends HoodieRecordPayload> implements Serializable {
    protected final HoodieWriteConfig config;

    protected HoodieIndex(HoodieWriteConfig config) {
        this.config = config;
    }

    public static <T extends HoodieRecordPayload> HoodieIndex<T> createIndex(HoodieWriteConfig config) throws HoodieIndexException {
        if (!StringUtils.isNullOrEmpty(config.getIndexClass())) {
            Object instance = ReflectionUtils.loadClass(config.getIndexClass(), new Object[]{config});
            if (!(instance instanceof HoodieIndex)) {
                throw new HoodieIndexException(config.getIndexClass() + " is not a subclass of HoodieIndex");
            } else {
                return (HoodieIndex)instance;
            }
        } else {
            switch(config.getIndexType()) {
            case HBASE:
                return new HBaseIndex(config);
            case INMEMORY:
                return new InMemoryHashIndex(config);
            case BLOOM:
                return new HoodieBloomIndex(config);
            case GLOBAL_BLOOM:
                return new HoodieGlobalBloomIndex(config);
            case SIMPLE:
                return new HoodieSimpleIndex(config);
            case GLOBAL_SIMPLE:
                return new HoodieGlobalSimpleIndex(config);
            default:
                throw new HoodieIndexException("Index type unspecified, set " + config.getIndexType());
            }
        }
    }

    @PublicAPIMethod(
        maturity = ApiMaturityLevel.STABLE
    )
    public abstract JavaPairRDD<HoodieKey, Option<Pair<String, String>>> fetchRecordLocation(JavaRDD<HoodieKey> var1, JavaSparkContext var2, HoodieTable<T> var3);

    @PublicAPIMethod(
        maturity = ApiMaturityLevel.STABLE
    )
    public abstract JavaRDD<HoodieRecord<T>> tagLocation(JavaRDD<HoodieRecord<T>> var1, JavaSparkContext var2, HoodieTable<T> var3) throws HoodieIndexException;

    @PublicAPIMethod(
        maturity = ApiMaturityLevel.STABLE
    )
    public abstract JavaRDD<WriteStatus> updateLocation(JavaRDD<WriteStatus> var1, JavaSparkContext var2, HoodieTable<T> var3) throws HoodieIndexException;

    @PublicAPIMethod(
        maturity = ApiMaturityLevel.STABLE
    )
    public abstract boolean rollbackCommit(String var1);

    @PublicAPIMethod(
        maturity = ApiMaturityLevel.STABLE
    )
    public abstract boolean isGlobal();

    @PublicAPIMethod(
        maturity = ApiMaturityLevel.EVOLVING
    )
    public abstract boolean canIndexLogFiles();

    @PublicAPIMethod(
        maturity = ApiMaturityLevel.STABLE
    )
    public abstract boolean isImplicitWithStorage();

    public void close() {
    }

    public static enum IndexType {
        HBASE,
        INMEMORY,
        BLOOM,
        GLOBAL_BLOOM,
        SIMPLE,
        GLOBAL_SIMPLE;

        private IndexType() {
        }
    }
}

8

生产环境下的推荐配置

spark.driver.extraClassPath=/etc/hive/conf
spark.driver.extraJavaOptions=-XX:+PrintTenuringDistribution -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime -XX:+PrintGCTimeStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/hoodie-heapdump.hprof
spark.driver.maxResultSize=2g
spark.driver.memory=4g
spark.executor.cores=1
spark.executor.extraJavaOptions=-XX:+PrintFlagsFinal -XX:+PrintReferenceGC -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintAdaptiveSizePolicy -XX:+UnlockDiagnosticVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/hoodie-heapdump.hprof
spark.executor.id=driver
spark.executor.instances=300
spark.executor.memory=6g
spark.rdd.compress=true
 
spark.kryoserializer.buffer.max=512m
spark.serializer=org.apache.spark.serializer.KryoSerializer
spark.shuffle.service.enabled=true
spark.sql.hive.convertMetastoreParquet=false
spark.submit.deployMode=cluster
spark.task.cpus=1
spark.task.maxFailures=4
 
spark.yarn.driver.memoryOverhead=1024
spark.yarn.executor.memoryOverhead=3072
spark.yarn.max.executor.failures=100```

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
随着互联网的发展,数据的不断膨胀,从刚开始的关系型数据库到非关系型数据库,再到大数据技术,技术的不断演进最终是随着数据膨胀而不断改变,最初的数据仓库能解决我们的问题,但是随着时代发展,企业已经不满足于数据仓库,希望有更强大的技术来支撑数据的存储,包括结构化,非结构化的数据等,希望能够积累企业的数据,从中挖掘出更大的价值。基于这个背景,数据湖的技术应运而生。本课程基于真实的企业数据湖案例进行讲解,结合业务实现数据湖平台,让大家在实践中理解和掌握数据湖技术,未来数据湖的需求也会不断加大,希望同学们抓住这个机遇。项目中将以热门的互联网电商业务场景为案例讲解,具体分析指标包含:流量分析,订单分析,用户行为分析,营销分析,广告分析等,能承载海量数据的实时分析,数据分析涵盖全端(PC、移动、小程序)应用。Apache Hudi代表Hadoop Upserts anD Incrementals,管理大型分析数据集在HDFS上的存储Hudi的主要目的是高效减少摄取过程中的数据延迟。Hudi的出现解决了现有hadoop体系的几个问题:1、HDFS的可伸缩性限制 2、需要在Hadoop中更快地呈现数据 3、没有直接支持对现有数据的更新和删除 4、快速的ETL和建模 5、要检索所有更新的记录,无论这些更新是添加到最近日期分区的新记录还是对旧数据的更新,Hudi都允许用户使用最后一个检查点时间戳,此过程不用执行扫描整个源表的查询。 本课程包含的技术: 开发工具为:IDEA、WebStorm Flink1.9.0、HudiClickHouseHadoop2.7.5 Hbase2.2.6Kafka2.1.0 Hive2.2.0HDFS、MapReduceSpark、ZookeeperBinlog、Canal、MySQLSpringBoot2.0.2.RELEASE SpringCloud Finchley.RELEASEVue.js、Nodejs、HighchartsLinux Shell编程课程亮点: 1.与企业接轨、真实工业界产品 2.ClickHouse高性能列式存储数据库 3.大数据热门技术Flink4.Flink join 实战 5.Hudi数据湖技术6.集成指标明细查询 7.主流微服务后端系统 8.数据库实时同步解决方案 9.涵盖主流前端技术VUE+jQuery+Ajax+NodeJS 10.集成SpringCloud实现统一整合方案 11.互联网大数据企业热门技术栈 12.支持海量数据的实时分析 13.支持全端实时数据分析 14.全程代码实操,提供全部代码和资料 15.提供答疑和提供企业技术方案咨询企业一线架构师讲授,代码在老师的指导下企业可以复用,提供企业解决方案。  版权归作者所有,盗版将进行法律维权。  
Hudi建筑工作负载配置文件是一种用于配置和管理Hudi工作负载的文件。Hudi是一个用于处理大规模数据更新和增量处理的开源数据管理框架,因此工作负载配置文件对于确保良好的性能和效率非常重要。 工作负载配置文件包含了一系列参数和选项,用于定义Hudi工作负载的行为和属性。其中一些重要的配置包括: 1. 数据存储:可以选择将数据存储在HDFS或云存储中,并指定相应的路径。 2. 数据表类型:可以选择使用Hudi的不同表类型,如Copy on Write(COW)表和Merge on Read(MOR)表。 3. 数据分区:可以根据需要定义数据的分区方式,例如按日期、按地理位置等。 4. 压缩方式:可以选择使用不同的压缩算法来减小数据的存储空间。 5. 写入模式:可以选择使用增量模式或快照模式进行数据写入。 6. 缓存和索引选项:可以选择启用或禁用缓存和索引,以提高数据读取性能。 通过调整这些参数和选项,可以根据具体的需求优化Hudi的性能和效率。例如,如果需要快速的数据写入和查询,可以选择COW表和增量模式,并启用缓存和索引。如果对于数据的一致性和可查询性要求比较高,可以选择MOR表和快照模式,并使用压缩算法来减小存储空间。 除了配置文件,Hudi还提供了其他工具和API来管理工作负载,如数据清理、增量备份和查询优化等。因此,为了实现最佳的性能和效率,需要全面了解Hudi的不同配置和功能,并根据实际情况进行合理的配置。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值