spark几乎所有的读写功能都由BlockManager模块实现,且所有的BlockManager受BlockManagerMaster协调管理,它们的大致关系如下图所示(这里并没有把BlockManagerMaster和BlockManager中的所有子模块都罗列出来,这里只是罗列了和我们疑问有关联的模块):
driver上启动BlockManagerMaster、BlockManager,其存储有所有数据块的元信息;
executor上启动BlockManager,负责具体的读写实现;
MemoryStore:负责内存数据的读写
DiskStore:负责磁盘数据的读写
BlockTransferService:负责远程数据的读写
我们虽然在大致理论上知道了spark数据读写的过程,但是对于BlockManagerMaster和BlockManager具体何时启动还不知道,而且对于数据具体的读写也不是很明白,所以想通过源码的阅读来获取这些答案,因为调用blockManager进行读写的场景比较多,所以我们就不从各个场景下追踪其读写的总过程,而是仅仅关注BlockManager对各个场景的读写如何支持,所以为了防止像无头苍蝇一样阅读源码,这里我们还是先提出自己想了解的点,然后再去有目的性的阅读源码:
1)application启动的过程中,具体什么时候启动的BlockManagerMaster和BlockManager
2)BlockManager如何将数据写入内存
3)BlockManager如何将数据写入本地磁盘
4)BlockManager如何读写远程数据
下面开始进入正文:
1)什么时候启动的BlockManagerMaster和BlockManager
直接启动一个application,源码追踪中看下driver中BlockManagerMaster和executor中BlockManager构建位置。
这里留意下左下角的方法调用栈可以看到完整的BlockManagerMaster的构建流程,其在SparkContext初始化的时候会创建SparkEnv环境,而在创建spark具体环境的时候会创建BlockManagerMaster对象。
以此类推,当创建executor时也会有一个sparkEnv,在其中则会创建BlockManager,下面我们到executor中去验证下。读过spark源码(四)可以知道当前的测试demo下Executor对应的实现类是CoarseGrainedExecutorBackend,所以我们直接到其类中去看,在该类的主流程中我们可以看到下面的代码:
到这我们基本上知道了BlockManager和BlockManagerMaster都是在SparkEnv创建时创建的,不过这里还有一个概念容易迷惑的地方,就是driver和executor的SparkEnv创建最终调用的都是相同的方法,都创建了BlockManager和BlockManagerMaster。这是咋回事呢?
其实无论是driver还是executor都会创建BlockManager和BlockManagerMaster,只不过这里的BlockManagerMaster是一个节点引用,引用的节点是BlockManagerMasterEndpoint,该节点也是在sparkEnv创建的时候创建的,只不过方法内会根据当前环境是driver还是executor来进行不同的处理,代码如下:
可以看到只有在driver才会生成该节点,在executor上只会持有该节点的引用。而这个节点就是driver和其它节点进行数据通信的点。
至此我们第一个问题解决了,接下来我们看看第二个问题。
2)BlockManager如何将数据写入内存 —— MemoryStore
BlockManager中有专门的模块负责内存数据的读写,这个模块就是MemoryStore,所以我们接下来直接到该模块中看下其读写相关的源码,首先是写:
def putBytes[T: ClassTag](
blockId: BlockId,
size: Long,
memoryMode: MemoryMode,
_bytes: () => ChunkedByteBuffer): Boolean = {
//确认该block块数据尚未被存储过
require(!contains(blockId), s"Block $blockId is already present in the MemoryStore")
//重点一:通过内存管理器memoryManager申请指定大小的内存,如果申请到再进行存储操作,申请不到则直接返回false
if (memoryManager.acquireStorageMemory(blockId, size, memoryMode)) {
//调用传入的参数方法,获取block的数据
val bytes = _bytes()
//确认获取到的数据大小和声明的大小相同
assert(bytes.size == size)
//重点二:将block数据封装成entry对象存储
val entry = new SerializedMemoryEntry[T](bytes, memoryMode, implicitly[ClassTag[T]])
entries.synchronized {
//将entry对象存到LinkedHashMap集合中,即存到内存里
entries.put(blockId, entry)
}
logInfo("Block %s stored as bytes in memory (estimated size %s, free %s)".format(
blockId, Utils.bytesToString(size), Utils.bytesToString(maxMemory - blocksMemoryUsed)))
true
} else {
false
}
}
可以看到写代码很简单,不过有两个重点需要留意下,第一个就是内存管理器memoryManager模块,这是一个很重要的模块,其管理我们内存的使用与清理。虽然这里不细讲它,但它真的很重要。第二个重点是将数据封装成entry对象存储,entry有两个实现类,分别是SerializedMemoryEntry和DeserializedMemoryEntry,表示序列化和反序列化后的entry信息,从这可以看出,spark内存存储默认都是要序列化的,序列化后会放在LinkedHashMap集合中。
接下来我们看下内存读取的源码:
def getBytes(blockId: BlockId): Option[ChunkedByteBuffer] = {
//通过blockId从LinkedHashMap内存中获取entry对象
val entry = entries.synchronized { entries.get(blockId) }
entry match {
case null => None
case e: DeserializedMemoryEntry[_] =>
throw new IllegalArgumentException("should only call getBytes on serialized blocks")
//通过模式匹配,验证entry类型,并提取序列化类中的数据信息
case SerializedMemoryEntry(bytes, _, _) => Some(bytes)
}
}
读取很简单,就是根据blockId从map中获取数据。
3)BlockManager如何将数据写入本地磁盘——DiskStore
BlockManager中同样有专门的模块负责磁盘数据的读写,这个模块就是DiskStore,所以我们还是老样子直接到该模块中看下其读写相关的源码,首先是写:
def putBytes(blockId: BlockId, bytes: ChunkedByteBuffer): Unit = {
put(blockId) { channel =>
bytes.writeFully(channel)
}
}
我们继续深入put方法看一下:
def put(blockId: BlockId)(writeFunc: WritableByteChannel => Unit): Unit = {
//已经存储过则抛异常
if (contains(blockId)) {
throw new IllegalStateException(s"Block $blockId is already present in the disk store")
}
logDebug(s"Attempting to put block $blockId")
val startTimeNs = System.nanoTime()
//重点一:获取要存储的文件对象,注意这块的File可以看做是一个逻辑上的文件,并没有数据写入呢
val file = diskManager.getFile(blockId)
//创建文件输出流,封装输出流的channel并返回
val out = new CountingWritableChannel(openForWrite(file))
var threwException: Boolean = true
try {
//重点二:通过channel将数据写入file文件
writeFunc(out)
//记录磁盘存储的block块信息
blockSizes.put(blockId, out.getCount)
threwException = false
} finally {
try {
out.close()
} catch {
case ioe: IOException =>
if (!threwException) {
threwException = true
throw ioe
}
} finally {
if (threwException) {
remove(blockId)
}
}
}
logDebug(s"Block ${file.getName} stored as ${Utils.bytesToString(file.length())} file" +
s" on disk in ${TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTimeNs)} ms")
}
可以看到磁盘写逻辑也很好理解,就是通过FileOutputStream的channel将数据写入到磁盘的文件中,唯一需要留意的就是获取File文件时,我们实际上是在对应目录下创建一个逻辑file,并没有存储数据,只有通过channel将数据写入后,这个file才能算是一个实实在在的文件。
接下来我们看一下读取的源码:
def getBytes(blockId: BlockId): BlockData = {
//通过diskManager获取blockId对应的文件信息和大小
getBytes(diskManager.getFile(blockId.name), getSize(blockId))
}
def getBytes(f: File, blockSize: Long): BlockData = securityManager.getIOEncryptionKey() match {
case Some(key) =>
// 加密的块数据不直接返回,而是返回一个封装后的对象,其可以解密提取块中数据
new EncryptedBlockData(f, blockSize, conf, key)
case _ =>
// 返回封装的磁盘数据块
new DiskBlockData(minMemoryMapBytes, maxMemoryMapBytes, f, blockSize)
}
可以看到这里返回的磁盘数据并不是最终的字节或者文件数据,而是一种封装的数据块对象。这里我们就不在继续深入讲解该数据块的使用了,等后续有具体场景时再详细讲讲。
4)BlockManager如何读写远程数据——BlockTransferService
相同的,BlockManager中同样有专门的模块负责远程数据的读写,这个模块就是BlockTransferService。这个模块是个抽象类,它的实现类是NettyBlockTransferService,即底层是通过netty来实现数据的上传和下载(数据的读写其实就是远程数据的上传和下载),我们先来看下数据的下载:
override def fetchBlocks(
host: String,
port: Int,
execId: String,
blockIds: Array[String],
listener: BlockFetchingListener,
tempFileManager: DownloadFileManager): Unit = {
logTrace(s"Fetch blocks from $host:$port (executor id $execId)")
try {
//创建远程块数据下载的启动模块,并实现启动方法
val blockFetchStarter = new RetryingBlockFetcher.BlockFetchStarter {
override def createAndStart(blockIds: Array[String],
listener: BlockFetchingListener): Unit = {
try {
//创建传输客户端,用于连接远程节点
val client = clientFactory.createClient(host, port)
//启动一对一的数据块获取
new OneForOneBlockFetcher(client, appId, execId, blockIds, listener,
transportConf, tempFileManager).start()
} catch {
case e: IOException =>
Try {
driverEndPointRef.askSync[Boolean](IsExecutorAlive(execId))
} match {
case Success(v) if v == false =>
throw new ExecutorDeadException(s"The relative remote executor(Id: $execId)," +
" which maintains the block data to fetch is dead.")
case _ => throw e
}
}
}
}
//只有最大重试次数大于0,才会走封装的重试类,否则直接是开启一对一下载
val maxRetries = transportConf.maxIORetries()
if (maxRetries > 0) {
new RetryingBlockFetcher(transportConf, blockFetchStarter, blockIds, listener).start()
} else {
blockFetchStarter.createAndStart(blockIds, listener)
}
} catch {
case e: Exception =>
logError("Exception while beginning fetchBlocks", e)
blockIds.foreach(listener.onBlockFetchFailure(_, e))
}
}
可以看到,根据是否开启最大重试,数据的获取有两种逻辑,但是底层调用的方法都是一对一的数据块获取方法,只不过重试逻辑里面封装了失败重试功能,我们接着看一对一的数据块处理逻辑:
public void start() {
//想远程节点发送rpc请求,并在回调函数中监听远程节点的响应
client.sendRpc(message.toByteBuffer(), new RpcResponseCallback() {
@Override
public void onSuccess(ByteBuffer response) {
try {
//创建流处理器处理远程节点返回的数据
streamHandle = (StreamHandle) BlockTransferMessage.Decoder.fromByteBuffer(response);
logger.trace("Successfully opened blocks {}, preparing to fetch chunks.", streamHandle);
// 遍历获取远程节点提供的block数据
for (int i = 0; i < streamHandle.numChunks; i++) {
if (downloadFileManager != null) {
client.stream(OneForOneStreamManager.genStreamChunkId(streamHandle.streamId, i),
new DownloadCallback(i));
} else {
client.fetchChunk(streamHandle.streamId, i, chunkCallback);
}
}
} catch (Exception e) {
logger.error("Failed while starting block fetches after success", e);
failRemainingBlocks(blockIds, e);
}
}
@Override
public void onFailure(Throwable e) {
logger.error("Failed while starting block fetches", e);
failRemainingBlocks(blockIds, e);
}
});
}
一对一接收方法中就是向远程节点发现rpc请求获取数据,然后在回调函数中等待接收数据。直接这么理解可能有点懵,不要急,我们再来看看数据的上传就明白了,上传的源码如下:
override def uploadBlock(
hostname: String,
port: Int,
execId: String,
blockId: BlockId,
blockData: ManagedBuffer,
level: StorageLevel,
classTag: ClassTag[_]): Future[Unit] = {
val result = Promise[Unit]()
val client = clientFactory.createClient(hostname, port)
// 序列化元数据
val metadata = JavaUtils.bufferToArray(serializer.newInstance().serialize((level, classTag)))
// 如果上传的数据量超过一定量则通过流式处理器上传
val asStream = blockData.size() > conf.get(config.MAX_REMOTE_BLOCK_SIZE_FETCH_TO_MEM)
//上传成功或者失败的回调函数
val callback = new RpcResponseCallback {
override def onSuccess(response: ByteBuffer): Unit = {
logTrace(s"Successfully uploaded block $blockId${if (asStream) " as stream" else ""}")
result.success((): Unit)
}
override def onFailure(e: Throwable): Unit = {
logError(s"Error while uploading $blockId${if (asStream) " as stream" else ""}", e)
result.failure(e)
}
}
//根据是否需要流处理进而走不通的逻辑
if (asStream) {
//如果是流式处理,则封装流处理器,然后分批上传
val streamHeader = new UploadBlockStream(blockId.name, metadata).toByteBuffer
client.uploadStream(new NioManagedBuffer(streamHeader), blockData, callback)
} else {
// 如果数据量比较小,则一次性传输完,而不需要分批处理
val array = JavaUtils.bufferToArray(blockData.nioByteBuffer())
client.sendRpc(new UploadBlock(appId, execId, blockId.name, metadata, array).toByteBuffer,
callback)
}
result.future
}
结合前面远程数据的下载,这块的逻辑应该清晰一点了,比如我要获取远程节点的数据,我就会通过rpc发送数据的下载请求,远程节点接收消息后就会准备数据并通过上传接口把数据以字节流的形式发送出去。不过这块内容我只看了大致了流程,对于内部详细的功能实现和功能类的配合以及rpc请求发送的具体逻辑都没有详细研究过,后面有时间的话再写个专门的文章进行讲解介绍。
前面三大模块的读写其实还有很多其它方法,我们只介绍了最简单的,感兴趣的可以再研究研究。而且实际的读写比上述复杂的多,涉及到的模块也更多。而且这些介绍只是单纯的技术介绍,并没有把场景接进来,因为读写的场景很多,而且还要保证并发安全性,不好一一列举介绍,后续如果遇到相关的问题,我再从场景作为切入口,具体讲解对应场景的数据读写流程。
另外再重申一下,实际的spark存储管理体系比这复杂的多的多,读写时往往是多个模块协同作用的结果,而不是像上述源码展示的那样简单、单一。
总结:
1、MemoryStore管理内存存储,默认是将block数据封装成序列化entry存储在LinkedHashMap中
2、写磁盘时,一开始获取的File文件是一种逻辑上的存在,此时并不包含具体数据。
3、远程block数据的上传和下载是两个节点协调配合的结果,他们之间通过rpc方式通信。