我们知道多进程编程中,进程之间可以创建共享内存,这是最快的进程通信的方式。那么,对于分布式系统,如何共享数据呢?Spark提供了两种在Spark集群中创建和使用共享变量的机制:广播变量和累加器。

本文介绍广播变量的基本概念和实现原理。

基本概念

Spark官方对广播变量的说明如下:

广播变量可以让我们在每台计算机上保留一个只读变量,而不是为每个任务复制一份副本。例如,可以使用他们以高效的方式为每个计算节点提供大型输入数据集的副本。Spark也尽量使用有效的广播算法来分发广播变量,以降低通信成本。
另外,Spark action操作会被划分成一系列的stage来执行,这些stage根据是否产生shuffle操作来进行划分的。Spark会自动广播每个stage任务需要的通用数据。这些被广播的数据以序列化的形式缓存起来,然后在任务运行前进行反序列化。也就是说,在以下两种情况下显示的创建广播变量才有用:1)当任务跨多个stage并且需要同样的数据时;2)当以反序列化的形式来缓存数据时。

从以上官方定义我们可以得出Spark广播变量的一些特性:

1)广播变量会在每个worker节点上保留一份副本,而不是为每个Task保留一份副本。这样有什么好处?可以想象,在一个worker有时同时会运行若干的Task,若把一个包含较大数据的变量为Task都复制一份,而且还需要通过网络传输,应用的处理效率一定会受到很大影响。

2)Spark会通过某种广播算法来进行广播变量的分发,这样可以减少通信成本。Spark使用了类似于BitTorrent协议的数据分发算法来进行广播变量的数据分发,该分发算法会在后面进行分析。

3)广播变量有一定的适用场景:当任务跨多个stage,且需要同样的数据时,或以反序列化的形式来缓存数据时。

本文,将围绕官方对广播变量的定义来分析其实现原理。在分析前,先来看一下广播变量的使用。

广播变量的创建和使用

假设你有一个变量: v。要使用该变量来创建一个广播变量时,非常简单,只需要调用SparkContext的broadcast(v)函数即可。在spark-shell下代码如下:



scala> val v = Array(1,2,3,4,5,6)
 v: Array[Int] = Array(1, 2, 3, 4, 5, 6)
 
 scala> val bv = sc.broadcast(v)
 res1: org.apache.spark.broadcast.Broadcast[Array[Int]] = Broadcast(1)
 
 scala> bv
 res9: org.apache.spark.broadcast.Broadcast[Array[Int]] = Broadcast(2)
 // 获取广播变量的值
 scala> bv.value
 res10: Array[Int] = Array(1, 2, 3, 4, 5, 6)
 
 // 销毁广播变量
 scala> bv.destory



我们把一个变量v(一个普通数组)转换成了一个广播变量bv。通过查看bv的类型,可以看出bv是一个Array[Int]类型的广播变量。我们可以通过bv.value来获取广播变量的值。这样,广播变量bv就可以用到以后的数据计算中了。

注意,在创建广播变量时,广播变量的值必须是本地的可序列化的值,不能是RDD。

另外,广播变量一旦创建就不应该再修改,这样可以保证所以的worker节点上的值是一致的。这是因为,现有worker将看不到更新的值,新的worker才可能会看到新的值。

广播变量的实现原理

我们根据广播变量的创建和使用流程来分析广播变量的实现。广播变量的实现过程如下图2所示:




sparksql广播机制 spark 广播_spark 获取广播变量


图2 广播变量的创建和读取过程

注:BlockManager是Spark数据块管理模块,会在后面的文章详细分析。

广播变量的创建

广播变量的创建发生在Driver端,如图2所示,当调用SparkContext#broadcast来创建广播变量时,会把该变量的数据切分成多个数据块,保存到driver端的BlockManger中,使用的存储级别是:MEMORY_AND_DISK_SER。

所以,广播变量的读取也是懒评价的,只有在Executor端需要获取广播变量时才会去获取。此时广播变量的数据只在Driver端存在。

此时的状态如图3所示:


sparksql广播机制 spark 广播_spark_02

图3 创建广播变量


从图3可以看出,此时广播变量被保存在本地,并会把广播变量的值切分成多个数据块进行保存。广播变量数据块的默认大小是4M,数据块太大或太小都不利于数据的传输。

广播变量的读取

当要使用广播变量时,需要先获取广播变量的值,其实现流程如图2所示。获取广播变量调用的是bv.value。其实现逻辑如下:

1)第1步(红色线1):首先从Executor本地的BlockManager中读取广播变量的数据,若存在就直接获取,并返回。不在执行第2步和第3步。若不存在,则执行第2步。

2)第2步(红色线2):从Driver端获取广播变量的状态和位置信息(由于所有的BlockManager slave端都会向Master端汇报数据块状态)。

3)第3步:优先从本地目录(数据块就在本地),或者相同主机的其他Executor中读取广播变量数据块。若在本Executor和同主机其他Executor中都不存在,则只能从远端获取数据。从远端获取数据的原则是:先从同一个机架(rack)的主机的Executor端获取。若不能从其他Executor中获取广播变量,则会直接从Driver端获取。

从以上获取流程可以看出,在执行spark应用时,只要有一个worker节点的Executor从Driver端获取到了广播变量的数据,则其他的Executor就不需要从Driver端获取了。

当某个Executor上的某个数据块被删除,可以从其他Executor直接获取该数据块,然后把数据块保存到自己的Executor的BlockManager中。

这个读取的协议类似于:BitTorrent数据传输协议。其读取过程的示意图4所示:


sparksql广播机制 spark 广播_数据_03

图4 广播变量读取过程

从图4可以看出,Executor4中的任务需要使用广播变量,但它只有该变量的b4数据块。此时,它首先从同主机(worker2节点)的中获取数据,获取到数据块b3;然后分别从不同主机的Executor1和Executor2中读取数据块b1和b2。此时,Executor4就获取到变量b的全部数据块了,然后把这些数据块在自己的BlockManager中保存一份。此时,其他Executor就可以从Executor4中读取数据了。

当完成这些操作后,各个Executor端的BlockManager(slave端)会向Driver端的BlockManager(master端)汇报数据块的状态。

广播变量实现源码分析

broadcast()函数

通过SparkContext#broadcast函数可以创建一个广播变量,该函数的原型如下:


def broadcast[T: ClassTag](value: T): Broadcast[T]


在SparkContext中需要调用broadcast函数来创建一个广播变量,并返回一个org.apache.spark.broadcast.Broadcast对象,这样就可以在分布式函数中来读取广播变量的值。该变量会被发送到Spark集群的每个Executor节点上。

注意:该广播变量一旦创建,就不应该再修改,因为即使修改了该变量的值,也无法让spark集群的执行节点看到改变后的新值。

实现该函数的类实体调用过程如下图。


sparksql广播机制 spark 广播_数据块_04

创建广播变量的调用过程

  • broadcast()函数的实现流程如下:

1)判断需要广播的变量是否是RDD类型的变量,若是则终止函数,报告“不能广播RDD变量,可以通过collect()把数据聚集到driver端再广播”的错误。

2)通过BroadcastManager的newBroadcast函数来创建广播变量,并返回一个Broadcast类的对象,Broadcast是抽象类,所以这里其实是该抽象类唯一实现类:TorrentBroadcast的对象。

3)注册broadcast的cleanup函数,可以用来清除不再使用的broadcast变量。

4)最后,返回新创建的TorrentBroadcast对象

在类SparkContext中,broadcast函数的实现代码如下:


def broadcast[T: ClassTag](value: T): Broadcast[T] = {
     assertNotStopped()
     // 不能直接广播rdd等分布式变量
     require(!classOf[RDD[_]].isAssignableFrom(classTag[T].runtimeClass),
       "Can not directly broadcast RDDs; instead, call collect() and broadcast the result.")
     // 通过BroadcastManager工具类来创建一个BroadcastFactory对象
     val bc = env.broadcastManager.newBroadcast[T](value, isLocal)
     val callSite = getCallSite
     logInfo("Created broadcast " + bc.id + " from " + callSite.shortForm)
     cleaner.foreach(_.registerBroadcastForCleanup(bc))
     // 返回Broadcast对象,这里其实是TorrentBroadcast类的对象
     bc
   }


TorrentBroadcast类

介绍

真正实现广播变量的操作是在TorrentBroadcast类中。当创建广播变量时,实际上是创建了一个该类的对象。也就是说,当执行以下代码时:


val bv = sc.broadcast(v)


实际上会执行:


private[spark] class TorrentBroadcastFactory extends BroadcastFactory {
   ...
   override def newBroadcast[T: ClassTag](value_ : T, isLocal: Boolean, id: Long):
   Broadcast[T] = {
     new TorrentBroadcast[T](value_, id)
   }
   ...
 }


该类实现了以下的机制:

1)驱动程序(driver)将序列化对象分成小块,并将这些块存储在驱动程序(driver)的BlockManager中。

2)在每个executor上,executor首先尝试从其BlockManager中获取对象。 若它不存在,则远程从driver或其他executor(如果可用)中获取对象块。 一旦获得块,它就会将块放在自己的BlockManager中,为其他executor来获取数据做好准备。

3)通过这种方式,可以防止driver成为发送多个广播数据副本的瓶颈(每个executor一个)。

代码实现分析

  • 对象构造过程

1)设置配置信息:setConf(SparkEnv.get.conf)。对于广播变量有几个重要的配置项需要设置。一个是切分广播变量时的数据块大小,该参数是由spark.broadcast.blockSize来设置,默认是4M。另外,若设置了spark.broadcast.compress参数,还需要创建压缩广播变量数据的对象。

2)初始化广播变量的唯一id值:private val broadcastId = BroadcastBlockId(id)

3)调用writeBlocks先把广播变量的值,作为单个对象写入本地BlockManger,然后把它划分成多个数据块,并保存到本地blockManager中。使用的存储级别是:MEMORY_AND_DISK_SER。实现代码截选如下:


private def writeBlocks(value: T): Int = {
     ...
     // 保存单个对象
     if (!blockManager.putSingle(broadcastId, value, 
                                 MEMORY_AND_DISK, 
                                 tellMaster = false)) {
     ...
     }
     // 把广播变量切分成块,然后对每个块进行序列化,并进行压缩
     val blocks =
       TorrentBroadcast.blockifyObject(value, blockSize, 
                                       SparkEnv.get.serializer, 
                                       compressionCodec)
       ...
       blocks.zipWithIndex.foreach { case (block, i) =>
       ...
       // 把广播变量切割成块,并保存到bm中
       if (!blockManager.putBytes(pieceId, bytes, 
                                  MEMORY_AND_DISK_SER, 
                                  tellMaster = true)) {
         ...
       }
     }
     blocks.length
   }


  • 读取广播变量:readBroadcastBlock

通过readBroadcastBlock函数来从新构造广播对象,该函数会先从driver或其他executors中读取数据块。在driver端,若需要value值,它会直接从本地的block manager中读取数据。

readBroadcastBlock函数的实现逻辑如下:

1)从SparkEnv.get.broadcastManager.cachedValues从来获取对应broadcastId的数据块值:broadcastCache.get(broadcastId)

2)从blockManager中获取对应id的广播变量的值:blockManager.getLocalValues(broadcastId)

3)若不能从blockManager中获取值,则调用readBlocks函数来读取数据块。该函数会从driver或其他的executors中读取该变量的数据。

BroadcastManager

该类是一个辅助类,用来统一创建broadcast对外的接口。它提供了创建广播变量的对外接口:newBroadcast;删除广播变量的对外接口:unbroadcast;其实它都是调用了TorrentBroadcastFactory对应函数来实现的。

BroadcastManager对象在SparkEnv中创建,这样在Driver端和Executor端都可以使用。该类的构造函数流程如下:

1)定义了两个私有化变量,并且会为每个广播变量生成一个唯一的id,在创建broadcast变量时会通过nextBroadcastId.getAndIncrement()进行自增,并调用initialize()函数进行初始化:


// 是否已经初始
  private var initialized = false
  private var broadcastFactory: BroadcastFactory = null
  
  initialize()
  
  // 生成广播变量的id,该id是唯一的,这里先初始化,会在创建broadcast变量时进行自增操作
  private val nextBroadcastId = new AtomicLong(0)


2)initialize()函数的实现逻辑如下:

a)初始化broadcastFactory变量,这里创建了TorrentBroadcastFactory对象

b)调用TorrentBroadcastFactory的initialize函数来初始化。在实际的代码中,该类的initialize函数什么都不做。

c)把initialized设置为true,同一个对象只初始化一次


// Called by SparkContext or Executor before using Broadcast
   private def initialize() {
     synchronized {  // 加锁
       if (!initialized) {
         // 初始化broadcastFactory变量,这里创建了TorrentBroadcastFactory对象
         broadcastFactory = new TorrentBroadcastFactory
         // 调用TorrentBroadcastFactory的initialize函数来初始化
         broadcastFactory.initialize(isDriver, conf, securityManager)
         // 把initialized设置为true,同一个对象只初始化一次
         initialized = true
       }
     }
   }


3)从以上分析可以看到,当创建广播变量时,实际上是调用的TorrentBroadcastFactory类的newBroadcast函数来进行创建。

TorrentBroadcastFactory工厂类

该类提供了用来创建TorrentBroadcast对象的工厂函数newBroadcast,并提供了删除规定id的广播变量的对外接口函数:unbroadcast。

该类的代码相对简单,主要代码如下:


private[spark] class TorrentBroadcastFactory extends BroadcastFactory {
   ...
   // 调用创建一个TorrentBroadcast对象
   override def newBroadcast[T: ClassTag](value_ : T, 
                       isLocal: Boolean, id: Long): Broadcast[T] = {
     new TorrentBroadcast[T](value_, id)
   }
   ...
    // 删除给定id的广播变量
   override def unbroadcast(id: Long, removeFromDriver: Boolean, blocking: Boolean) {
     TorrentBroadcast.unpersist(id, removeFromDriver, blocking)
   }
 }


广播变量的销毁

广播变量的销毁可以通过调用bv.destory来完成。从实现层面来说,最终调用TorrentBroadcast#unpersist来实现的。

unpersist()函数

该函数用来删除Driver端和Executor端的广播变量。其实现如下:


def unpersist(id: Long, removeFromDriver: Boolean, blocking: Boolean): Unit = {
     logDebug(s"Unpersisting TorrentBroadcast $id")
     SparkEnv.get.blockManager.master.removeBroadcast(id, removeFromDriver, blocking)
   }


该函数会调用blockManagerMaster的removeBroadcast函数来删除在executor上属于该broadcast变量的所有数据块。 实现过程是:从driver端发送一个RemoveBroadcast消息。在Executor上的BlockManager服务接收该消息,就会把广播变量从BlockManager中删除。

若removeFromDriver设置成True,还会从Driver删除该变量的数据。

总结

本文从实现原理和源码两个方面分析了Spark广播变量的原理。