例如有一张hive表叫做activity。
cache表,数据放内存,数据被广播到Executor,broadcast,将数据由reduce side join 变map side join。效果都是查不多的,基本表达的都是一个意思。
具体效果体现:读数据次数变小;df(dataframe)执行过一次就已经有值,不用重新执行前方获取df的过程。
将多份数据进行关联是数据处理过程中非常普遍的用法,不过在分布式计算系统中,这个问题往往会变的非常麻烦,因为框架提供的 join 操作一般会将所有数据根据 key 发送到所有的 reduce 分区中去,也就是 shuffle 的过程。造成大量的网络以及磁盘IO消耗,运行效率极其低下,这个过程一般被称为 reduce-side-join。
如果其中有张表较小的话,我们则可以自己实现在 map 端实现数据关联,跳过大量数据进行 shuffle 的过程,运行时间得到大量缩短,根据不同数据可能会有几倍到数十倍的性能提升,这个过程是map-side-join。
reduce-side-join 的缺陷在于会将key相同的数据发送到同一个partition中进行运算,大数据集的传输需要长时间的IO,同时任务并发度收到限制,还可能造成数据倾斜。
reduce-side-join 运行图如下
map-side-join 运行图如下
broadcast效果:
map-side-join 运行图如下
样本数据(2000w)性能测试对比
小表没有进行broadcast
进行了broadcast,可以看到连shuffle过程都省略了
broadcast代码:
写法1:
val people_info = sc.parallelize(Array(("110","lsw"),("222","yyy"))).collectAsMap()
val people_bc = sc.broadcast(people_info)
写法2:
grouped_voca_df_01 = raw_data.join(F.broadcast(raw_full_df_flat),cond,'inner').select(raw_data.key,raw_full_df_flat.voca_day,
*col_name_list).groupBy('key','voca_day').agg(*need_smooth_col)
一、几种缓存方法
1.CACHE TABLE
//缓存全表
sqlContext.sql("CACHE TABLE activity")
//缓存过滤结果
sqlContext.sql("CACHE TABLE activity_cached as select * from activity where ...")
CACHE TABLE是即时生效(eager)的,如果你想等到一个action操作再缓存数据可以使用CACHE LAZY TABLE,这样操作会直到一个action操作才被触发,例如count(*)
sqlContext.sql("CACHE LAZY TABLE ...")
取消hive表缓存数据
sqlContext.sql("UNCACHE TABLE activity")
具体事例:
Spark相对于Hadoop MapReduce有一个很显著的特性就是“迭代计算”(作为一个MapReduce的忠实粉丝,能这样说,大家都懂了吧),这在我们的业务场景里真的是非常有用。
假设我们有一个文本文件“datas”,每一行有三列数据,以“\t”分隔,模拟生成文件的代码如下:
执行该代码之后,文本文件会存储于本地路径:/tmp/datas,它包含1000行测试数据,将其上传至我们的测试Hadoop集群,路径:/user/yurun/datas,命令如下:
查询一下它的状态:
我们通过Spark SQL API将其注册为一张表,代码如下:
表的名称为source,它有三列,列名分别为:col1、col2、col3,类型都为字符串(str),测试打印其前10行数据:
假设我们的分析需求如下:
(1)过滤条件:col1 = ‘col1_50',以col2为分组,求col3的最大值;
(2)过滤条件:col1 = 'col1_50',以col3为分组,求col2的最小值;
注意:需求是不是很变态,再次注意我们只是模拟。
通过情况下我们可以这么做:
每一个collect()(Action)都会产生一个Spark Job,
因为这两个需求的处理逻辑是类似的,它们都有两个Stage:
可以看出这两个Job的数据输入量是一致的,根据输入量的具体数值,我们可以推断出这两个Job都是直接从原始数据(文本文件)计算的。
这种情况在Hive(MapReduce)的世界里是很难优化的,处理逻辑虽然简单,却无法使用一条SQL语句表述(有的是因为分析逻辑复杂,有的则因为各个处理逻辑的结果需要独立存储),只能一个需求对应一(多)条SQL语句(如上示例),带来的问题就是全量原始数据多次被分析,在海量数据的场景下必然带来集群资源的巨大浪费。
其实这两个需求有一个共同点:过滤条件相同(col1 = 'col1_50'),一个很自然的想法就是将满足过滤条件的数据缓存,然后在缓存数据之上执行计算,Spark为我们做到了这一点。
依然是两个Job,每个Job仍然是两个Stage,但这两个Stage的输入数据量(Input)已发生变化:
Job1的Input(数据输入量)仍然是63.5KB,是因为“cacheTable”仅仅在RDD(cacheRDD)第一次被触发计算并执行完成之后才会生效,因此Job1的Input是63.5KB;而Job2执行时“cacheTable”已生效,直接输入缓存中的数据即可,因此Job2的Input减少为3.4KB,而且因为所需缓存的数据量小,可以完全被缓存于内存中,因此效率极高。
我们也可以从Spark相关页面中确认“cache”确实生效:
我们也需要注意cacheTable与uncacheTable的使用时机,cacheTable主要用于缓存中间表结果,它的特点是少量数据且被后续计算(SQL)频繁使用;如果中间表结果使用完毕,我们应该立即使用uncacheTable释放缓存空间,用于缓存其它数据(示例中注释uncacheTable操作,是为了页面中可以清楚看到表被缓存的效果)。
2.将dataFrame注册成表并缓存
val df = sqlContext.sql("select * from activity")
df.registerTempTable("activity_cached")
sqlContext.cacheTable("activity_cached")
Tip:cacheTable操作是lazy的,需要一个action操作来触发缓存操作。
对应的uncacheTable可以取消缓存
sqlContext.uncacheTable("activity_cached")
3.缓存dataFrame
val df = sqlContext.sql("select * from tableName")
df.cache()
二.缓存结果
缓存时看到如下提示:
Added rdd_xx_x in memory on ...
如果内存不足,则会存入磁盘中,提示如下:
Added rdd_xx_x on disk on ...
缓存数据后可以在Storage上看到缓存的数据
三.一些参数
spark.sql.autoBroadcastJoinThreshold
该参数默认为10M,在进行join等聚合操作时,将小于该值的表broadcast到每台worker,消除了大量的shuffle操作。
spark.rdd.compress true
将rdd存入mem或disk前再进行一次压缩,效果显著,我使用cacheTable了一张表,没有开启该参数前总共cache了54G数据,开启这个参数后只34G,可是执行速度并没有收到太大的影响。
spark.sql.shuffle.partitions
这个参数默认为200,是join等聚合操作的并行度,如果有大量的数据进行操作,造成单个任务比较重,运行时间过长的时候,会报如下的错误:
org.apache.spark.shuffle.FetchFailedException: Connection from /192.168.xx.xxx:53450 closed
这个时候需要提高该值。
四、注意:
- cache 的表不一定会被广播到Executor,执行map side join!!!
- 有另外一个参数:spark.sql.autoBroadcastJoinThreshold 会判断是否将该表广播;
- spark.sql.autoBroadcastJoinThreshold参数官方解释:
Configures the maximum size in bytes for a table that will be broadcast to all worker nodes when performing a join. By setting this value to -1 broadcasting can be disabled. Note that currently statistics are only supported for Hive Metastore tables where the command
- spark.sql.autoBroadcastJoinThreshold参数默认值是10M,所以只有cache的表小于10M的才被广播到Executor上去执行map side join,因此要特别要注意,因此在选择cache表的时候,要注意表的大小和spark.sql.autoBroadcastJoinThreshold参数的调整。如果内存比较充足,建议调大该参数。
五、详细原理与测试:
背景
spark-sql或者hive-sql 很多业务场景都会有表关联的的操作,在hive中有map side join优化,对应的在spark-sql中也有map side join。spark中如果在参与join的表中存在小表,可以采用cache broadcast的方式进行优化,避免数据的shuffle,从而一定程度上可以避免数据倾斜,增加spark作业的执行速度。
本文主要阐述怎么使用spark sql的map side join进行优化,及使用过程需要注意的内容,同时mark自己研究spark的过程。
—— [ 败八 ]
避免使用shuffle类型的操作
如果有可能的话,要尽量避免使用shuffle类算子。因为Spark作业运行过程中,最消耗性能的地方就是shuffle过程。shuffle过程,简单来说,就是将分布在集群中多个节点上的同一个key,拉取到同一个节点上,进行聚合或join等操作。比如reduceByKey、join等算子,都会触发shuffle操作。
shuffle过程中,各个节点上的相同key都会先写入本地磁盘文件中,然后其他节点需要通过网络传输拉取各个节点上的磁盘文件中的相同key。而且相同key都拉取到同一个节点进行聚合操作时,还有可能会因为一个节点上处理的key过多,导致内存不够存放,进而溢写到磁盘文件中。因此在shuffle过程中,可能会发生大量的磁盘文件读写的IO操作,以及数据的网络传输操作。磁盘IO和网络数据传输也是shuffle性能较差的主要原因。
因此在我们的开发过程中,能避免则尽可能避免使用reduceByKey、join、distinct、repartition等会进行shuffle的算子,尽量使用map类的非shuffle算子。这样的话,没有shuffle操作或者仅有较少shuffle操作的Spark作业,可以大大减少性能开销。
举例分析
ipTable:需要进行关联的几千条ip数据(70k)和hist:历史数据(百亿级别)
直接join将会对所有数据进行shuffle,需要大量的io操作,相同的key会在同一个partition中进行处理,任务的并发度也收到了限制。使用broadcast将会把小表分发到每台执行节点上,因此,关联操作都在本地完成,基本就取消了shuffle的过程,运行效率大幅度提高。
实践
测试代码
val spark = SparkSession
.builder()
.appName("broadcast join test")
.config("hive.exec.scratchdir", s"/user/$user/hive-$user")
.config("spark.sql.warehouse.dir", s"/user/$user/warehouse")
.enableHiveSupport()
.getOrCreate()
//使用broadcast join 首先要cache表
val table_a= sql(
s"""
|CACHE TABLE table_a AS
| select
| value,
| max(name) as name
| from
| table_test
| where
| dt='${date}'
| group by
| value
""".stripMargin)
val sql_strs =
|s"""
| select * from
| table_b as ti
| left outer join table_a as t3
| on t1.interest_id=t3.value
sql(sql_strs)
spark.stop()
六、broadcast join 源码解析
在Spark-1.6.2中,执行相同join查询语句,broadcast join模式下,DAG和执行时间如下图所示:
1、broadcast join
(1)DAG
(2)执行时间
122 rows selected (22.709 seconds)
- 1
2、非broadcast join
(1)DAG
(2)执行时间
122 rows selected (55.512 seconds)
对于broadcast join模式,会将小于spark.sql.autoBroadcastJoinThreshold
值(默认为10M)的表广播到其他计算节点,不走shuffle过程,所以会更加高效。
一、Spark源码解析
源码中的基本流程如下所示:
1、org.apache.spark.sql.execution.SparkStrategies类
决定是否使用broadcast join的逻辑在SparkStrategies类中,
/**
* Matches a plan whose output should be small enough to be used in broadcast join.
*/
object CanBroadcast {
def unapply(plan: LogicalPlan): Option[LogicalPlan] = plan match {
case BroadcastHint(p) => Some(p)
case p if sqlContext. conf.autoBroadcastJoinThreshold > 0 &&
p.statistics.sizeInBytes <= sqlContext.conf.autoBroadcastJoinThreshold => Some(p)
case _ => None
}
}
这里面sqlContext.conf.autoBroadcastJoinThreshold
由参数spark.sql.autoBroadcastJoinThreshold
来设置,默认为10 * 1024 * 1024
Bytes(10M)。上面这段逻辑是说,如果该参数值大于0,并且p.statistics.sizeInBytes
的值比该参数值小时,就会认为该表比较小,在做join时会broadcast到各个executor上,从而避免了shuffle过程。
2、org.apache.spark.sql.hive.HiveMetastoreCatalog类
p.statistics.sizeInBytes
的值,查看HiveMetastoreCatalog类文件,如下所示
@transient override lazy val statistics: Statistics = Statistics(
sizeInBytes = {
val totalSize = hiveQlTable.getParameters.get(StatsSetupConst.TOTAL_SIZE)
val rawDataSize = hiveQlTable .getParameters.get(StatsSetupConst.RAW_DATA_SIZE)
// TODO: check if this estimate is valid for tables after partition pruning.
// NOTE: getting `totalSize` directly from params is kind of hacky, but this should be
// relatively cheap if parameters for the table are populated into the metastore. An
// alternative would be going through Hadoop's FileSystem API, which can be expensive if a lot
// of RPCs are involved. Besides `totalSize`, there are also `numFiles`, `numRows`,
// `rawDataSize` keys (see StatsSetupConst in Hive) that we can look at in the future.
BigInt(
// When table is external,`totalSize` is always zero, which will influence join strategy
// so when `totalSize` is zero, use `rawDataSize` instead
// if the size is still less than zero, we use default size
Option (totalSize).map(_.toLong).filter(_ > 0)
.getOrElse(Option (rawDataSize).map(_.toLong).filter(_ > 0)
.getOrElse(sqlContext.conf.defaultSizeInBytes)))
}
)
会从hive的tblproperties
属性中优先取出totalSize
的数值,如果该值不大于0,则取rawDataSize
的值,如果该值也不大于0,那么就取sqlContext.conf.defaultSizeInBytes
。
3、org.apache.spark.sql.SQLConf类
那么sqlContext.conf.defaultSizeInBytes
又是多少呢?这个值配置在SQLConf类中,如下所示。
private[spark] def defaultSizeInBytes: Long =
getConf(DEFAULT_SIZE_IN_BYTES , autoBroadcastJoinThreshold + 1L )
- 1
- 2
取DEFAUTL_SIZE_IN_BYTES
的值,这个值一般需要设置的比spark.sql.autoBroadcastJoinThreshold
大,以避免其他表被broadcast出去了。可以看到,默认值为autoBroadcastJoinThreshold
值加1。
上面这一段的意思是,按顺序取hive的表统计信息中的totalSize
属性和rawDataSize
属性,直到取到一个大于零的值为止。如果这两个值都不大于零,那么就默认该表不能被broadcast出去。
二、问题
从上面的过程可以看出,确定是否广播小表的决定性因素是hive的表统计信息一定要准确。并且,由于视图是没有表统计信息的,所以所有的视图在join时都不会被广播。
假如遇到这种需求:有一张全量快照分区表A,需要有一张表B永远指向表A的最新分区,但是又不能影响表B的broadcast join功能。
1、问题1:直接insert会慢
最直接的做法是使用insert overwrite table B select * from A where partition='20170322'
来进行,在插入数据到B表后,会更新该表的totalSize
和rawDataSize
属性。
但是每次进行insert是一个很耗时的过程。
2、问题2:set location不更新表统计信息
如果想要避免这一过程的话,是不是可以用alter table B set location 'PATH_TO_TABLE_A/partition=20170322'
的形式呢?可以这样做,但是这样又不会每次更新totalSize
和rawDataSize
属性了。
如果需要更新表统计信息的话,测试过了alter table B set tblproperties('totalSize'='123')
语句,也不能生效。
如下所示,在更新前后,不仅totalSize没有变化,反而将rawDataSize给置为了-1。并且多了一些之前没有的默认值
0: jdbc:hive2://client:10000> show tblproperties shop_info;
numFiles 1
COLUMN_STATS_ACCURATE true
transient_lastDdlTime 1490283932
totalSize 102030
numRows 2000
rawDataSize 100030
0: jdbc:hive2://client:10000> alter table shop_info set tblproperties('totalSize'='1234');
0: jdbc:hive2://client:10000> show tblproperties shop_info;
numFiles 1
last_modified_by hadoop
last_modified_time 1490284040
transient_lastDdlTime 1490284040
COLUMN_STATS_ACCURATE false
totalSize 102030
numRows -1
rawDataSize -1
只能执行analyze table B compute statistics
语句来跑map-reduce任务来统计了,这一过程也会比较耗时。
3、问题3:external table set location后会清除表统计信息
并且最好将表B建成external table形式,避免删除表B时将表A的数据删除掉。
在实践中发现,每次对external tale进行set location
操作后,即使重新统计过表信息,它的totalSize
和rawDataSize
仍然会被清除掉。
# 首先按照shop_info表格式创建一个外部表,
0: jdbc:hive2://client:10000> create external table test_broadcast like shop_info;
# 查看表信息
0: jdbc:hive2://client:10000> show tblproperties test_broadcast;
EXTERNAL TRUE
transient_lastDdlTime 1490284194
# 重定向location
0: jdbc:hive2://client:10000> alter table test_broadcast set location 'hdfs://m000/user/hive/warehouse/tianchi.db/user_pay_train';
# 查看表信息
0: jdbc:hive2://client:10000> show tblproperties test_broadcast;
numFiles 0
EXTERNAL TRUE
last_modified_by hadoop
last_modified_time 1490284413
COLUMN_STATS_ACCURATE false
transient_lastDdlTime 1490284413
numRows -1
totalSize 0
rawDataSize -1
# 统计表信息
0: jdbc:hive2://client:10000> analyze table test_broadcast compute statistics;
# 查看表信息
0: jdbc:hive2://client:10000>show tblproperties test_broadcast;
numFiles 0
EXTERNAL TRUE
last_modified_by hadoop
last_modified_time 1490284413
transient_lastDdlTime 1490284751
COLUMN_STATS_ACCURATE true
numRows 65649782
totalSize 0
rawDataSize 2098020423
# 再次重定向
0: jdbc:hive2://client:10000> alter table test_broadcast set location 'hdfs://m000/user/hive/warehouse/tianchi.db/user_pay';
# 查看表信息
0: jdbc:hive2://client:10000>show tblproperties test_broadcast;
numFiles 0
EXTERNAL TRUE
last_modified_by hadoop
last_modified_time 1490284790
transient_lastDdlTime 1490284790
COLUMN_STATS_ACCURATE false
numRows -1
totalSize 0
rawDataSize -1
三、解决办法
遇到这个问题有没有觉得很棘手。
1、问题分析
其实方法已经在上面展示过了。我们来看一下org.apache.spark.sql.hive.HiveMetastoreCatalog
类中关于statistics
的注释:
// NOTE: getting totalSize directly from params is kind of hacky, but this should be
// relatively cheap if parameters for the table are populated into the metastore. An
// alternative would be going through Hadoop’s FileSystem API, which can be expensive if a lot
// of RPCs are involved. Besides totalSize, there are also numFiles, numRows,
// rawDataSize keys (see StatsSetupConst in Hive) that we can look at in the future.
这里说,直接获取totalSize并不是一个友好的办法,直接从这里获取totalSize只是相对比较快而已。其实可以通过HDFS的FileSystem API来获取该表在HDFS上的文件大小的。
看到这里,应该已经有了一个基本方法了。那就是将表B建为external table,并且每次遇到external table,直接去取hdfs上的文件大小。每次set location即可。这里唯一需要注意的是,通过HDFS FileSystem API获取文件大小是否耗时。这个可以通过下面的代码测试一下。
2、性能测试
测一下HDFS FileSystem API获取文件大小的耗时。
object HDFSFilesystemTest {
def main(args: Array[String ]) {
val conf = new Configuration()
conf.set("" , "hdfs://m000:8020")
val hiveWarehouse = "/user/hive/warehouse"
val path = new Path(hiveWarehouse)
val fs: FileSystem = path.getFileSystem(conf)
val begin = System.currentTimeMillis()
val size = fs.getContentSummary(path).getLength
println( s"$hiveWarehouse size is: $size Bytes")
val end = System.currentTimeMillis()
println( s"time consume ${end - begin} ms")
}
}
输出结果如下,统计hive warehouse路径大小,耗时132毫秒。
/user/hive/warehouse size is: 4927963752 Bytes
time consume 132 ms
3、源码修改
那么接下来修改一下org.apache.spark.sql.hive.HiveMetastoreCatalog
中获取表大小的逻辑就可以了。
由于外部表会被重定向路径,或者指向路径的文件可以直接被put上来,所以统计的totalSize或者rawDataSize一般不准确。因此如果是外部表,直接获取hdfs文件大小。如果是非外部表,则按顺序取totalSize,rawDataSize的值,如果都不大于0,则通过HDFS FileSystem API获取hdfs文件大小了。
@transient override lazy val statistics: Statistics = Statistics(
sizeInBytes = {
BigInt (if ( hiveQlTable.getParameters.get("EXTERNAL" ) == "TRUE") {
try {
val hadoopConf = sqlContext.sparkContext.hadoopConfiguration
val fs: FileSystem = hiveQlTable.getPath.getFileSystem(hadoopConf)
fs.getContentSummary(hiveQlTable.getPath).getLength
} catch {
case e: IOException =>
logWarning("Failed to get table size from hdfs." , e)
sqlContext.conf.defaultSizeInBytes
}
} else {
val totalSize = hiveQlTable .getParameters.get(StatsSetupConst. TOTAL_SIZE)
val rawDataSize = hiveQlTable .getParameters.get(StatsSetupConst. RAW_DATA_SIZE)
// TODO: check if this estimate is valid for tables after partition pruning.
// NOTE: getting `totalSize` directly from params is kind of hacky, but this should be
// relatively cheap if parameters for the table are populated into the metastore. An
// alternative would be going through Hadoop's FileSystem API, which can be expensive if a lot
// of RPCs are involved. Besides `totalSize`, there are also `numFiles`, `numRows`,
// `rawDataSize` keys (see StatsSetupConst in Hive) that we can look at in the future.
if (totalSize != null && totalSize.toLong > 0L) {
totalSize.toLong
} else if (rawDataSize != null && rawDataSize.toLong > 0L) {
rawDataSize.toLong
} else {
try {
val hadoopConf = sqlContext.sparkContext.hadoopConfiguration
val fs: FileSystem = hiveQlTable.getPath.getFileSystem(hadoopConf)
fs.getContentSummary(hiveQlTable.getPath).getLength
} catch {
case e: IOException =>
logWarning("Failed to get table size from hdfs." , e)
sqlContext.conf.defaultSizeInBytes
}
}
})
}
)
改为之后,看了一下Spark-2中的源码,发现在org.apache.spark.sql.hive.MetastoreRelation
中已经对此次进行了修改,如下所示:
@transient override lazy val statistics: Statistics = {
catalogTable.stats.getOrElse(Statistics(
sizeInBytes = {
val totalSize = hiveQlTable.getParameters.get(StatsSetupConst.TOTAL_SIZE)
val rawDataSize = hiveQlTable.getParameters.get(StatsSetupConst.RAW_DATA_SIZE)
// TODO: check if this estimate is valid for tables after partition pruning.
// NOTE: getting `totalSize` directly from params is kind of hacky, but this should be
// relatively cheap if parameters for the table are populated into the metastore.
// Besides `totalSize`, there are also `numFiles`, `numRows`, `rawDataSize` keys
// (see StatsSetupConst in Hive) that we can look at in the future.
BigInt(
// When table is external,`totalSize` is always zero, which will influence join strategy
// so when `totalSize` is zero, use `rawDataSize` instead
// when `rawDataSize` is also zero, use `HiveExternalCatalog.STATISTICS_TOTAL_SIZE`,
// which is generated by analyze command.
if (totalSize != null && totalSize.toLong > 0L) {
totalSize.toLong
} else if (rawDataSize != null && rawDataSize.toLong > 0) {
rawDataSize.toLong
} else if (sparkSession.sessionState.conf.fallBackToHdfsForStatsEnabled) {
try {
val hadoopConf = sparkSession.sessionState.newHadoopConf()
val fs: FileSystem = hiveQlTable.getPath.getFileSystem(hadoopConf)
fs.getContentSummary(hiveQlTable.getPath).getLength
} catch {
case e: IOException =>
logWarning("Failed to get table size from hdfs.", e)
sparkSession.sessionState.conf.defaultSizeInBytes
}
} else {
sparkSession.sessionState.conf.defaultSizeInBytes
})
}
))
}
4、效果展示
对一张文件大小小于spark.sql.autoBroadcastJoinThreshold
的external table进行表信息统计,将totalSize和rawDataSize置为不大于0的值后。分别在改源码前后进行测试。
(1)改动前
执行join语句,DAG执行计划如下图所示:
执行时间如下所示:
122 rows selected (58.862 seconds)
- 1
(2)改动后
执行join语句,DAG执行计划如下图所示:
执行时间如下图所示:
122 rows selected (27.484 seconds)
- 1
可以看到,改动源码后,走了broadcast join,并且执行时间明显缩短。