性能调优的必要性

在数据应用场景中,ETL(Extract Transform Load)往往是打头阵的那个,毕竟源数据经过抽取和转换才能用于探索和分析,或者是供养给机器学习算法进行模型训练,从而挖掘出数据深层次的价值。我们今天要举的两个例子,都取自典型 ETL 端到端作业中常见的操作和计算任务。

开发案例 1:数据抽取

第一个例子很简单:给定数据条目,从中抽取特定字段。这样的数据处理需求在平时的 ETL 作业中相当普遍。想要实现这个需求,我们需要定义一个函数 extractFields:它的输入参数是 Seq[Row]类型,也即数据条目序列;输出结果的返回类型是 Seq[(String, Int)],也就是(String, Int)对儿的序列;函数的计算逻辑是从数据条目中抽取索引为 2 的字符串和索引为 4 的整型。

代码片段

//实现方案1 —— 反例
val extractFields: Seq[Row] => Seq[(String, Int)] = {
  (rows: Seq[Row]) => {
    var fields = Seq[(String, Int)]()
    rows.map(row => {
        fields = fields :+ (row.getString(2), row.getInt(4))
    })
  fields
  }
}

代码很简单,乍看上去,这个函数似乎没什么问题。特殊的地方在于,尽管这个数据抽取函数很小,在复杂的 ETL 应用里是非常微小的一环,但在整个 ETL 作业中,它会在不同地方被频繁地反复调用。如果我基于这份代码把整个 ETL 应用推上线,就会发现 ETL 作业端到端的执行效率非常差,在分布式环境下完成作业需要两个小时。

想让etl作业跑的更快?这个函数extractFields 从头到尾无非是从 Seq[Row]到 Seq[(String, Int)]的转换,函数体的核心逻辑就是字段提取,只要从 Seq[Row]可以得到 Seq[(String, Int)],目的就达到了。

改进后的代码

val extractFields: Seq[Row] => Seq[(String, Int)] = {
  (rows: Seq[Row]) => 
    rows.map(row => (row.getString(2), row.getInt(4))).toSeq
}

开发案例 2:数据过滤与数据聚合

先看代码


/**
(startDate, endDate)
e.g. ("2021-01-01", "2021-01-31")
*/
val pairDF: DataFrame = _
 
/**
(dim1, dim2, dim3, eventDate, value)
e.g. ("X", "Y", "Z", "2021-01-15", 12)
*/
val factDF: DataFrame = _
 
// Storage root path
val rootPath: String = _ 

在这个案例中,我们有两份数据,分别是 pairDF 和 factDF,数据类型都是 DataFrame。第一份数据 pairDF 的 Schema 包含两个字段,分别是开始日期和结束日期。第二份数据的字段较多,不过最主要的字段就两个,一个是 Event date 事件日期,另一个是业务关心的统计量,取名为 Value,具体含义并不重要。从数据量来看,pairDF 的数据量很小,大概几百条记录,factDF 数据量很大,有上千万行。

对于这两份数据来说,具体的业务需求可以拆成 3 步:

  1. 对于 pairDF 中的每一组时间对,从 factDF 中过滤出 Event date 落在其间的数据条目;
  2. 从 dim1、dim2、dim3 和 Event date 4 个维度对 factDF 分组,再对业务统计量 Value 进行汇总;
  3. 将最终的统计结果落盘到 hdfs.

按上面的需求,可以简单实现代码


def createInstance(factDF: DataFrame, startDate: String, endDate: String): DataFrame = {
val instanceDF = factDF
.filter(col("eventDate") > lit(startDate) && col("eventDate") <= lit(endDate))
.groupBy("dim1", "dim2", "dim3", "event_date")
.agg(sum("value") as "sum_value")
instanceDF
}
 
pairDF.collect.foreach{
case (startDate: String, endDate: String) =>
val instance = createInstance(factDF, startDate, endDate)
val outPath = s"${rootPath}/endDate=${endDate}/startDate=${startDate}"
instance.write.parquet(outPath)
} 

createInstance这个函数将数据进行分组汇总,然后收集 pairDF 到 Driver 端并逐条遍历,数据处理后写入到hdfs
这份代码看起来是没什么问题的,数据量大的情况下瓶颈在collect这个函数.
可以做下优化,重点还改进collect


val instances = factDF
.join(pairDF, factDF("eventDate") > pairDF("startDate") && factDF("eventDate") <= pairDF("endDate"))
.groupBy("dim1", "dim2", "dim3", "eventDate", "startDate", "endDate")
.agg(sum("value") as "sum_value")
 
instances.write.partitionBy("endDate", "startDate").parquet(rootPath)

小结

这两个案例都来自数据应用的 ETL 场景。第一个案例讲的是,在函数被频繁调用的情况下,函数里面一个简单变量所引入的性能开销被成倍地放大。第二个例子讲的是,不恰当的实现方式导致海量数据被反复地扫描成百上千次。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值