最近在复习Spark 内容,Spark 数据倾斜方面的优化一直是实际生产环境中比较重要的一点,所以学习编写以下博客
Spark 数据倾斜优化可以从以下几个方面入手:
调整并行度
并行度指的是任务分配给集群中的处理器的数量,如果并行度设置得太高则会造成过多的内存开销、网络通信、CPU开销等问题。反之,则会导致任务执行缓慢,无法充分利用集群资源。因此,需要对并行度进行适当调整,以获得最佳的性能。
使用数据压缩
在一些场景下,数据可能过大,通过数据压缩可以减少网络传输和磁盘存储的开销。同时,压缩数据可以减少shuffle过程中的I/O操作。
数据分区
数据分区可以更好地将数据分发到集群上的多个节点中,提高并行度和运行效率,从而加快任务执行速度。可以根据不同的业务需求,将数据按照不同的规则进行分区,以适应不同的运行模式。
内存调优
Spark 的计算需要依赖于内存,因此,合理配置内存非常重要。可以通过配置executor内存大小、调整内存管理机制等方式来优化内存使用情况。
避免使用笛卡尔积
笛卡尔积操作是 Spark 中最慢且最昂贵的操作之一。因此,尽可能避免在 Spark 中使用笛卡尔积操作,选择更高效的算法,可以大幅提高计算性能。
数据倾斜优化
数据倾斜是 Spark 中常见的性能问题之一,当其中某个分区的数据量远高于其他分区时,会严重影响整体运行效率。可以通过对数据进行合理的预处理、调整数据分区、使用 Spark 提供的 skew join 等方式来解决数据倾斜问题。
使用高级技术
如使用RDD持久化、使用Tungsten引擎、使用Spark Streaming等高级技术也能够显著提升Spark的性能。
Spark 某热点数据过多问题
如果同一个 id 的数据量过大,即使调整分区数和 Executor 数量也难以避免数据倾斜问题。在这种情况下,可以尝试以下两种方法进行优化:
- 使用 Spark 的 skew join 算法
Spark 提供了 skew join 算法来解决数据倾斜问题。在进行 join 操作时,Spark 会对 key 进行采样,并根据采样结果确定哪些 key 是热点 key,然后将热点 key 单独处理,其他 key 按照正常方式处理。这种方式可以有效地避免数据倾斜问题。可以通过设置 spark.sql.join.preferSortMergeJoin
参数为 false
开启 skew join 算法。
- 对数据进行预处理
对于同一个 id 数据量过大的情况,可以采用预处理的方式进行优化。具体来说,可以将数据按照 id 进行分组,将同一个 id 的数据分散到不同的文件或表中。然后,在进行 join 操作时,先读取分散后的数据,再统一进行 join。这种方式能够有效地避免数据倾斜问题,但需要消耗额外的存储资源。
综上所述,对于同一 id 数据量过大的情况,建议首先尝试使用 Spark 的 fskew join 算法来解决问题,如果仍然存在数据倾斜问题,则可以通过对数据进行预处理的方式进行优化。
Spark skew join 算法
首先开启了 skew join
算法,然后通过对表1进行采样,并统计每个 key 的出现次数,将出现次数超过1的 key 认为是热点 key。接着,将热点和非热点 key 分别处理,并最终将结果合并。
需要注意的是,使用 skew join
算法需要消耗额外的资源,因此应根据实际情况来决定是否使用。同时,为了保证 skew join
算法的有效性,应选择合适的采样比例
示例:
// 加载数据
val df1 = spark.read.csv("table1.csv").toDF("id", "value1")
val df2 = spark.read.csv("table2.csv").toDF("id", "value2")
// 在 join 操作前开启 skew join 算法
spark.conf.set("spark.sql.join.preferSortMergeJoin", "false")
// 采样数据并确定热点 key
val sampleSize = Math.min(df1.count() / 10, 1000000)
val hotKeys = df1.sample(false, sampleSize.toDouble / df1.count())
.groupBy("id").count().filter(col("count") > 1)
.select("id")
.collect().map(_.getString(0))
// 将热点 key 单独处理
val hotDf1 = df1.filter(col("id").isin(hotKeys: _*))
val hotDf2 = df2.filter(col("id").isin(hotKeys: _*))
val hotResult = hotDf1.join(hotDf2, Seq("id"), "inner")
// 将非热点 key 正常处理
val coldDf1 = df1.filter(!col("id").isin(hotKeys: _*))
val coldDf2 = df2.filter(!col("id").isin(hotKeys: _*))
val coldResult = coldDf1.join(coldDf2, Seq("id"), "inner")
// 合并结果
val result = hotResult.unionByName(coldResult)
df1.sample(false, sampleSize.toDouble / df1.count())
是 Spark DataFrame API 中的一个函数,用于对 DataFrame 进行采样操作。这个函数接受两个参数:
- 第一个参数表示是否进行放回抽样(true表示放回抽样,false表示简单随机抽样);
- 第二个参数表示采样比例,即需要采样的记录数占总记录数的比例。
在上述代码中,df1.sample(false, sampleSize.toDouble / df1.count())
表示对 df1
进行简单随机抽样,抽样比例为 sampleSize.toDouble / df1.count()
。其中,sampleSize
是在代码中指定的采样数据量,由于不希望采样数据过大,因此会限制在总记录数的10%和100万之间。而 df1.count()
则是获取 df1
的总记录数。
例如,如果 df1.count()
返回的值为 10000000
,sampleSize
为 1000000
,则采样比例为 0.1
。即:抽样出的数据占原数据集的10%。调用完该函数后,返回的是一个新的 DataFrame,里面包含了抽样出的数据。
数据预处理
当数据中存在某些 id
出现频次较高的情况时,可以通过将这些记录分配到不同的分区中来实现负载均衡,下面是一个将 id
均匀分布的示例代码:
object HandleSkewedData {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder()
.appName("HandleSkewedData")
.master("local[*]")
.getOrCreate()
// 生成数据集
val data = List.fill(10000)("A")
++ List.fill(10)("B")
++ List.fill(1)("C")
++ List.fill(1)("D")
import spark.implicits._
val df = spark.sparkContext.parallelize(data).toDF("id")
// 计算 id 的哈希值并添加分区编号
val hashedDf = df.select(col("id"), abs(hash(col("id"))) % 20 as "partition_id")
// 求出每个分区中各个 id 的出现次数
val countDf = hashedDf.groupBy("partition_id", "id").count()
// 对每个 id 的计数进行统计和排序
val counts = countDf.groupBy("id")
.agg(sum("count") as "total_count")
.orderBy(desc("total_count"))
.collect()
// 将每个 id 分配到新的分区中
var partitionId = 0
val newDf = hashedDf
.join(countDf, Seq("id"))
.drop("count")
.groupBy("id")
.agg(min("partition_id") as "min_partition_id")
.orderBy("min_partition_id")
.select(col("id"), dense_rank().over(Window.orderBy("min_partition_id")) as "new_partition_id")
.cache()
// 输出每个新分区中的记录数
newDf.groupBy("new_partition_id").agg(count("*") as "rows").orderBy("new_partition_id").show()
// 将每个新分区的记录写入文件
for (i <- 1 to 20) {
newDf.filter($"new_partition_id" === i)
.write.mode("overwrite").csv(s"./data/partition_$i")
}
}
}
在这个代码中,首先生成一个数据集,其中 A
出现的次数较多,B
出现的次数较少,C
和 D
只出现一次。然后,使用 abs(hash(col("id"))) % 20
的方式计算 id
的哈希值,并添加分区编号。接着,统计每个分区中各个 id
的出现次数,并对每个 id
的计数进行统计和排序。最后,将每个 id
分配到新的分区中,使得每个分区中包含的 id
计数都大致相等。
在实际应用中,还需要根据具体情况进行调整,例如选择合适的哈希函数、分区数、分配策略等。
数据预处理与 repartition 的区别
预处理方案和 repartition()
方法都可以用来解决 Spark 中的数据倾斜问题,但是它们采用了不同的策略和技术。
预处理方案是指在数据进行计算之前,根据数据的特点对数据进行预处理、重分区、重新分布或加权,以尽可能地避免某些 key 或数据造成的数据倾斜。预处理过程中触发的 shuffle 的次数一般比较少,所需的计算资源也相对较少。具体实现时,可以采用诸如广播 join、使用随机前缀等技术来实现。
而 repartition()
方法则是在数据已经加载到内存中之后,通过调整 partition 数量和分区方式来重新分配数据,以达到均衡分配数据的目的。repartition()
操作需要将数据重新洗牌并重新分配到新的 partition 中,因此会触发 shuffle 操作,所需的计算资源相对较大。
在实际应用中,选择合适的方案要视情况而定。如果数据集比较大,且存在极端数据造成的数据倾斜,预处理方案可能更适合。如果数据集比较小或只有少量 key 或数据造成的数据倾斜,使用 repartition()
方法可能更加简单有效。
对于你提出的问题,预处理方案和 repartition()
方法都会触发 shuffle 操作。但是,他们的区别在于何时进行 shuffle。预处理方案的 shuffle 是在数据计算之前完成的,而 repartition()
方法的 shuffle 是在数据加载到内存后进行的。
需要注意的是,在使用预处理方案时,需要考虑到热 key 的数量和分布情况,从而选择合适的技术来解决。具体的实现过程还需要结合具体的业务场景和数据集特点来进行分析和调整。