Repository: summerDG/spark-code-analysis Branch: master Commit: 6aa5e4c0bb40 Files: 14 Total size: 176.2 KB Directory structure: gitextract_kayea2pq/ ├── README.md └── analysis/ ├── core/ │ ├── block_manager.md │ ├── memory_manager.md │ ├── spark_shuffle.md │ ├── spark_shuffle_new.md │ ├── spark_sort_shuffle.md │ └── task_schedule.md └── sql/ ├── spark_sql_execution.md ├── spark_sql_join_1.md ├── spark_sql_join_2.md ├── spark_sql_optimize.md ├── spark_sql_parser.md ├── spark_sql_physicalplan.md └── spark_sql_preparation.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: README.md ================================================ # Spark源码分析 该专题主要分为两个大的章节,第一章是关于spark core的源码分析,第二章是关于spark sql的源码分析。 ## spark core 该章主要从两个角度分析了spark core的源码:计算和存储。 ### 计算 计算方面涉及到的内容主要包含:作业及任务的调度和shuffle。这里默认读者已经了解了RDD的概念和原理,所以并未对RDD作分析。 [Spark基础知识][1]:该节主要介绍了Job,Stage,Task,Dependency等概念,并且从一个简单实例入手,阐述了这些概念间的关系。主要对Job的调度做了分析。最后简单介绍了Shuffle过程中涉及到的概念和流程。 [Spark的Shuffle机制][2]:该节主要分析了Spark中各种Shuffle的实现过程。 [Spark的任务调度机制][3]:该节介绍的是Task的调度机制,包括核心概念和实现流程。 ### 存储 存储管理分两节讲解,第一节是从用户角度,即数据的存储单位Block的角度进行阐述,第二节是从实现方式的角度,即内存管理来阐述。 [Spark Block管理][4]:对于很对涉及到与存储打交道的操作(如Shuffle、broadcast等)来说,都会涉及到BlockManager。本节主要对BlockManager及相关概念进行分析。 [Spark内存管理][5]:BlockManager落实到具体的存储(内存,磁盘等)方面是利用MemoryManager来完成的。所以该节是对Spark的内存管理的实现进行分析。 ## spark sql 该章分为两部分:Catalyst分析和Join详解。 ### Catalyst分析 这部分是完成对Catalyst的分析,从sql语句的解析到logical前半部分是完成对Catalyst的分析,从sql语句的解析到logical plan,再到logical plan优化,然后是physical plan,最后是执行。如下图所示。 ![Catalyst实现][Catalyst] [Spark SQL 基础知识][7]:本节主要是对Spark中涉及到的几个关键概念进行介绍,包括Row,Expression,Attribute,QueryPlan和Tree。 [Spark Catalyst分析阶段][6]:该节从sql语句开始进行分析,所以会涉及到sql语句解析和生成LogicalPlan的内容。 [Catalyst Logical Plan优化][8]:本节是在上一节的基础上对LogicalPlan进行优化,主要分析了部分优化方案。 [Spark SQL Physical Plan][9]:本节是对优化后的Logical Plan进行处理,生成用于执行的Physical Plan。 [Spark SQL 执行阶段][10]:在PhysicalPlan生成之后,就是执行阶段,执行的时候会涉及到部分的优化方案,并且本节对DataSet的原理进行了分析。 ### Join详解 这部分主要是针对Join算子的分析,前半部分的分析涵盖基本的处理思路,但是并不会细致到每个算子都分析,如果相分析具体算子可以沿着前半部分的思路去挖掘。 [Spark SQL Join分析——上][11]:针对具体的Join算子,分析了从SQL语句的AST树解析到LogicalPlan优化完成,也就是生成PhysicalPlan之前,的代码。 [Spark SQL Join分析——下][12]:针对具体的Join算子,从LogicalPlan到Spark core中具体的shuffle操作之前做了分析。 [1]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/core/spark_shuffle.md [2]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/core/spark_sort_shuffle.md [3]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/core/task_schedule.md [4]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/core/block_manager.md [5]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/core/memory_manager.md [6]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_parser.md [7]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_preparation.md [8]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_optimize.md [9]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_physicalplan.md [10]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_execution.md [11]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_join_1.md [12]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_join_2.md [Catalyst]:pic/Catalyst-Optimizer-diagram.png ================================================ FILE: analysis/core/block_manager.md ================================================ # Spark Block Manager管理 Spark中的RDD-Cache、broadcast、ShuffleWriter和ExternalSorter等都是基于BlockManager实现的。BlockManager会运行在每个节点上, 包括driver和executor。其主要是提供接口来检索各种存储(memory,disk和off-heap)中本地或远程的block。 ![BlockManager][block-manager] 上图显示的是BlockManager各个模块间的关系。可以发现Shuffle只使用了DiskBlockManager。MemoryManager通常用于persist、shuffle过程中内存buffer(用于排序)、缓存磁盘数据、存储拉取过来的数据块等(图中未显示)。由于BlockManager实际上对内存数据和磁盘数据用MemoryStore和DiskStore作了封装,在调用者眼里并不会在乎数据来自于哪里。 ## BlockManager创建 BlockManager是在SparkEnv中创建生成的。这里插一句SparkContext和SparkEnv的区别,前者是创建了整个Spark系统的环境变量或者调度器, 而后者是针对每个运行的Spark实体(master或worker)创建了运行时环境对象(如序列化器、RpcEnv、block manager、map output tracker等)。 当然SparkEnv也是在SparkContext中的`create`中创建的。 这里的Block和HDFS中谈到的Block块是有本质区别:HDFS中是对大文件进行分Block进行存储,Block大小固定为512M等;而Spark中的Block是用户 的操作单位, 一个Block对应一块有组织的内存,一个完整的文件或文件的区间端,并没有固定每个Block大小的做法。 private[spark] class BlockManager( executorId: String, rpcEnv: RpcEnv, val master: BlockManagerMaster, serializerManager: SerializerManager, val conf: SparkConf, memoryManager: MemoryManager, mapOutputTracker: MapOutputTracker, shuffleManager: ShuffleManager, val blockTransferService: BlockTransferService, securityManager: SecurityManager, numUsableCores: Int) extends BlockDataManager with BlockEvictionHandler with Logging { } 看BlockManager的构造函数,可以发现其主要包含了:executorId,rpcEnv,blockManagerMaster,serializerManager,SparkConf, [memoryManager][3],[mapOutputTracker][1],[shuffleManager][2],blockTransferService(用于一次拉取一组blocks), securityManager,numUsableCores(可用核数)。 ## Block相关知识 上面谈到,Block是用户的操作单位,而这个操作对应的key就是这里BlockID,value内容为ManagerBuffer。向看一下BlockDataManager 这个特质(接口),BlockManager就继承了它。它对外提供了对Block的操作,获取或者添加。 trait BlockDataManager { def getBlockData(blockId: BlockId): ManagedBuffer def putBlockData( blockId: BlockId, data: ManagedBuffer, level: StorageLevel, classTag: ClassTag[_]): Boolean def releaseLock(blockId: BlockId): Unit } `getBlockData`是从BlockManager中获取对应Id的ManagerBuffer,`putBlockData`是将一个ManagerBuffer机器Id按照存储层级添加到 BlockManager中。 *BlockId*其本质上来说就是一个字符串。只是针对不同的Block命名方式不同。*ManagerBuffer*实际上是对ByteBuffer的封装。 public abstract class ManagedBuffer { public abstract long size(); public abstract ByteBuffer nioByteBuffer() throws IOException; public abstract InputStream createInputStream() throws IOException; public abstract ManagedBuffer retain(); public abstract ManagedBuffer release(); public abstract Object convertToNetty() throws IOException; } 由于Buffer不能长久存在于内存中不进行释放,所以这里有`retain`和`release`两个方法分别用于添加或释放对该Buffer的引用数目。对该Buffer 的对外访问接口就是`createInputStream`和`nioByteBuffer`,前者是将buffer中的数据作为InputStream暴露出来,后者是作为 NIO ByteBuffer暴露。注意这个nioByteBuffer函数是每次调用将会返回一个新的ByteBuffer,对它的操作不影响 真实的Buffer的offset和long。 这个接口有3个实现,FileSegmentManagedBuffer、NettyManagedBuffer、NioManagedBuffer和BlockManagerManagedBuffer,由类名就可以知道,区别在于 获取ByteBuffer的来源不同,FileSegmentManagedBuffer是保存了一个File类型的变量,所以是读取`file`里的内容生成ByeBuffer, NettyManagedBuffer是通过ByteBuf读取的,NioManagedBuffer保存的直接就是ByteBuffer,BlockManagerManagedBuffer是通过ChunkedByteBuffer(spark.util.io)。 ## Block状态维护 首先来看StorageLevel,在Spark中,对应RDD的cache有很多level选择,这里谈到的StorageLevel就是这块内容。首先我们来看存储的级别: * DISK,即文件存储。 * Memory,内存存储,这里的内存指的是Jvm中的堆内存,即onHeap。 * OffHeap,非JVM中Heap的内存存储,其意思是*序列化*并cache到off-heap内存。 对于DISK和Memory两种级别是可以同时出现的。 关于OffHeap这里多说两句:JVM中如果直接进行内存分配都是受JVM来管理,使用的是JVM中内存堆,但是现在有很 多技术可以在JVM代码中访问不受JVM管理的内存,即OffHeap内存。OffHeap最大的好处就是将内存的管理工作从JVM的GC管理器剥离出来由自己 进行管理,特别是大对象,自定义生命周期的对象来说OffHeap很实用,可以减少GC的代销。具体实现是基于Spark的Off-heap实现的(说到底是 通过Java直接调JVM之外的内存,过去是基于Alluxio——前Tachyon——做的,在SPARK-12667中被移除了,该问题的描述见[SPARK-13992][4])。 StorageLevel还提供了另外两个配置项: * _deserialized:是否需要反序列化。 * _replication:副本数目。 存储在BlockManager中的可以是各种对象,是否支持序列化影响了对这个对象的访问以及内存的压缩,`_deserialized`被标记为`true`之后,就不会 对数据进行序列化了。 *** 对于BlockManager中每次`putBlockData`不一定都会成功,每次`getBlockData`不一定可以马上可以返回结果,因为put有等待的过程,而且可能最后还是失败。 BlockManager通过BlockInfo来维护每个Block的状态,在BlockManager中`blockInfoManager`(BlockInfoManager类型)来保存BlockId和BlockInfo的映射, BlockInfoManager其实就是对映射表的又一层封装,只是增加了任务访问锁(写锁和读锁)来保证互斥写、同步读写(不要在读的时候写)。 下面来看一下`putBlockData`,其实际上最终调用的是`doPutBytes` private def doPutBytes[T]( blockId: BlockId, bytes: ChunkedByteBuffer, level: StorageLevel, classTag: ClassTag[T], tellMaster: Boolean = true, keepReadLock: Boolean = false): Boolean = { doPut(blockId, level, classTag, tellMaster = tellMaster, keepReadLock = keepReadLock) { info => ... val size = bytes.size if (level.useMemory) { val putSucceeded = if (level.deserialized) { val values = serializerManager.dataDeserializeStream(blockId, bytes.toInputStream())(classTag) memoryStore.putIteratorAsValues(blockId, values, classTag) match { case Right(_) => true case Left(iter) => iter.close() false } } else { memoryStore.putBytes(blockId, size, level.memoryMode, () => bytes) } if (!putSucceeded && level.useDisk) { diskStore.putBytes(blockId, bytes) } } else if (level.useDisk) { diskStore.putBytes(blockId, bytes) } val putBlockStatus = getCurrentBlockStatus(blockId, info) val blockWasSuccessfullyStored = putBlockStatus.storageLevel.isValid ... }.isEmpty } private def doPut[T]( blockId: BlockId, level: StorageLevel, classTag: ClassTag[_], tellMaster: Boolean, keepReadLock: Boolean)(putBody: BlockInfo => Option[T]): Option[T] = { val putBlockInfo = { val newInfo = new BlockInfo(level, classTag, tellMaster) if (blockInfoManager.lockNewBlockForWriting(blockId, newInfo)) { newInfo } else { ... return None } } var blockWasSuccessfullyStored: Boolean = false val result: Option[T] = try { val res = putBody(putBlockInfo) blockWasSuccessfullyStored = res.isEmpty res } finally { ... } ... result } `doPutBytes`主要是依靠`doPut`来完成的,`doPutBytes`提供对BlockInfo的处理(包括Block的具体存储),`doPut`则用来 生成BlockInfo。`doPutBytes`中会先判断Block的存储层级,然后就是是否需要序列化,最后就是利用具体的BlockStore(`memoryStore` 或`diskStore`,[之后会介绍](## BlockStore))的`putBytes`方法实现Block的存储。上面的代码省略了针对副本和注册等过程。 BlockInfo中的Block的状态是通过锁来控制的,即通过获取当前该Block的reader数量(读的时候写)或writer(写的时候读)来控制读写同步, 通过保证同时只有一个writer来保证互斥写。Block是否存储成功是通过`doPutBytes`中的`blockWasSuccessfullyStored`变量来控制的, 其是通过`getCurrentBlockStatus`来获取对应Id的Block的存储状态(就是该Block内存存了多少,磁盘存了多少,以及什么存储层级)来判断的。 ## BlockStore BlockStore即Block真正的存储器。但在Spark中,BlockStore既不是一个特质也不是一个类,这里只是用于统称。共分为MemoryStore和DiskStore 两个类型。 ### MemoryStore MemoryStore中有两张映射表:`onHeapUnrollMemoryMap`和`offHeapUnrollMemoryMap`。前者是从任务尝试的Id到*“Unroll”*一个Block所用内存的映射, 后者意思相同,不同的是前者是on-heap模式,后者的是off-heap模式。那么什么是“Unroll”?因为有的时候内存中的数据也会进行 序列化存储以减少对内存的消耗,但是最终还是要反序列化,由于序列化之后占用的的内存小,反序列化之后必然会膨胀,那么就要预留出一部分内存 来保证反序列化之后的数据有存储空间,所以用于预留反序列化的内存就是“Unroll”内存,其对应于一块特定的内存([对应的是Spark1.3][5])。在Spark 2.0.0中情况就是Block是以流的形式一部分一部分读入的,反序列化的操作都不是马上完成的,所以要判断反序列化“展开”该Block所需的内存(自己的理解)。怎么才能保证不会出现OOM呢?方法就是周期性地检查是否有足够 的空余内存,如果满足“Unroll”的内存要求,就在“转换”到内存期间使用临时的“Unroll”内存。实际上2.0.0中“Unroll”内存就是用于Block反序列化“展开”,这两个映射中“UnrollMemory”指的是Block反序列化“展开”需要的内存。所以个人认为*“Unroll”内存(Spark 1.3,特定区域)和用于Block“Unroll”的内存(Spark 2.0.0)稍有不同*。 下面来看怎么存储数据(代码太长,简而言之),在将数据反序列化存入memory时要检查是否有足够的内存,那过程中为什么还要不断查看可用内存呢?还是前面提到的原因,因为反序列化不会一次性完成,开始给定的内存也不一定能满足完整的反序列化的工作,所以要时刻检测当前的空闲内存是否满足剩余的反序列化工作。 “Unroll”过程开始时会默认给一部分内存用于Block的“Unroll”,“Unroll”过程中再检测是否有足够内存满足剩余的反序列化工作。 顺着`MemoryStore.reserveUnrollMemoryForThisTask`->`UnifiedMemoryManager.acquireUnrollMemory`->`UnifiedMemoryManager.acquireUnrollMemory`看一下申请内存的函数。 override def acquireStorageMemory( blockId: BlockId, numBytes: Long, memoryMode: MemoryMode): Boolean = synchronized { assertInvariants() assert(numBytes >= 0) val (executionPool, storagePool, maxMemory) = memoryMode match { case MemoryMode.ON_HEAP => ( onHeapExecutionMemoryPool, onHeapStorageMemoryPool, maxOnHeapStorageMemory) case MemoryMode.OFF_HEAP => ( offHeapExecutionMemoryPool, offHeapStorageMemoryPool, maxOffHeapMemory) } if (numBytes > maxMemory) { return false } if (numBytes > storagePool.memoryFree) { val memoryBorrowedFromExecution = Math.min(executionPool.memoryFree, numBytes) executionPool.decrementPoolSize(memoryBorrowedFromExecution) storagePool.incrementPoolSize(memoryBorrowedFromExecution) } storagePool.acquireMemory(blockId, numBytes) } 这里以UnifiedMemoryManager举例,因为其包含了off-heap和on-heap内存的申请,可以发现针对这二者有不同的内存池,当然还可以看到 运行内存,而且我们发现还有off-heap的运行内存(和用于存储的是两个概念,对off-heap内存的使用并不是指运行)。首先判断申请的 内存大小是否超过了上限,如果没超过继续看当前用于存储的内存池中的空间是否满足请求,如果不满足就将用于运行的内存池中的一部 分*空闲内存*拿出来用于存储。进入StorageMemoryPool的`acquireMemory`,发现最后的腾出内存的操作是MemoryStore在`evictBlocksToFreeSpace` 中完成的,方法就是找到某个可以替换块(先利用写锁占有,保证没有reader和writer),删除掉其对应的信息。 在MemoryStore中的`putIteratorAsBytes`申请的off-heap内存是怎么实现的呢? //MemoryStore private[storage] def putIteratorAsBytes[T]( blockId: BlockId, values: Iterator[T], classTag: ClassTag[T], memoryMode: MemoryMode): Either[PartiallySerializedBlock[T], Long] = { ... val allocator = memoryMode match { case MemoryMode.ON_HEAP => ByteBuffer.allocate _ case MemoryMode.OFF_HEAP => Platform.allocateDirectBuffer _ } ... } //Platform public static ByteBuffer allocateDirectBuffer(int size) { try { Class cls = Class.forName("java.nio.DirectByteBuffer"); Constructor constructor = cls.getDeclaredConstructor(Long.TYPE, Integer.TYPE); constructor.setAccessible(true); Field cleanerField = cls.getDeclaredField("cleaner"); cleanerField.setAccessible(true); final long memory = allocateMemory(size); ByteBuffer buffer = (ByteBuffer) constructor.newInstance(memory, size); Cleaner cleaner = Cleaner.create(buffer, new Runnable() { @Override public void run() { freeMemory(memory); } }); cleanerField.set(buffer, cleaner); return buffer; } catch (Exception e) { throwException(e); } throw new IllegalStateException("unreachable"); } 看到`allocateDirectBuffer`中的`cleaner`和`freeMemory`方法了吧。因此是在这里完成off-heap内存申请的。所以这里返 回的`allocator`已经是一个off-heap内存的ByteBuffer了。 ### DiskStore DiskStore即基于文件来存储Block。基于Disk来存储,首先必须要解决一个问题就是磁盘文件的管理:磁盘目录结构的组成,目录的清理等, 在Spark对磁盘文件的管理是通过DiskBlockManager来进行管理的,因此对DiskStore进行分析之前,首先必须对DiskBlockManager进行分析。 在Spark的配置信息中,通过"SPARK_LOCAL_DIRS"可以配置Spark运行过程中临时目录。有几点需要强调一下: * `SPARK_LOCAL_DIRS`配置的是集合,即可以配置多个LocalDir,用","分开;这个和Hadoop中的临时目录等一样,可以在多个磁盘中创建localdir,从而分散磁盘的读写压力。 * spark运行过程中生成的子文件过程不可估计,这样很容易就会出现一个localDir中子文件过多,导致读写效率很差,针对这个问题,Spark在每个LocalDir中 创建了64个子目录,来分散文件。具体的子目录个数,可以通过`spark.diskStore.subDirectories`进行配置。 DiskBlockManager通过hash来分别确定localDir以及subdir。 def getFile(filename: String): File = { // Figure out which local directory it hashes to, and which subdirectory in that val hash = Utils.nonNegativeHash(filename) val dirId = hash % localDirs.length val subDirId = (hash / localDirs.length) % subDirsPerLocalDir // Create the subdirectory if it doesn't already exist val subDir = subDirs(dirId).synchronized { val old = subDirs(dirId)(subDirId) if (old != null) { old } else { val newDir = new File(localDirs(dirId), "%02x".format(subDirId)) if (!newDir.exists() && !newDir.mkdir()) { throw new IOException(s"Failed to create local dir in $newDir.") } subDirs(dirId)(subDirId) = newDir newDir } } new File(subDir, filename) } DiskBlockManager的核心工作就是这个,即提供`getFile`接口,根据filename确定一个文件的路径。剩下来的就是目录清理等工作。都比较简单这里就不进行详细分析。 至于DiskStore,就是将序列化后的数据(利用BlockManager进行序列化)写到DiskBlockManager获取到的文件位置中。 ## BlockManager的服务结构 BlockManager分散在各个节点上,所以要有一个Master来收集各个节点的Block信息。这里依然利用RPC调用(每个节点保留一个对Master的引用,想其发送信息)。 ### BlockManagerMaster服务 其是在SparkEnv中创建的,其功能通过函数名可以一目了然。主要作用有: * 移除死Executor、某RDD的所有Block、某Shuffle的所有Block、特定Block和某广播变量的所有Block。 * 获取每个BlockManager Id所管理的内存状态或存储状态。 * 更新某BlockManager的中某Block的状态。 * 注册Blockmanager。 * 获取某Block的位置。 前面说BlockManager是对应于Executor的,那么是什么时候以及在哪里启动的呢?流程就是: 1. 任务分配到各节点; 2. Executor初始化,其中就包含了BlockManager的初始化; 3. BlockManager初始化中,先根据主机名、端口名和Executor Id生成BlockManagerId,然后调用BlockManagerMaster的`registerBlockManager`来注册本机的BlockManager; 4. `registerBlockManager`中通过RPC调用来向Master注册BlockManager。 ### BlockManagerSlaveEndpoint 每个BlockManager中都有一个BlockManagerSlaveEndpoint类型的变量`slaveEndpoint`用于和Master通信,作用无非就是收到Master的消息进行相应处理,这里不再赘述。 ### BlockTransferService 会发现在BlockManager中还有一个BlockTransferService类型的变量`blockTransferService`,其作用是从远程主机上把数据迁移过来。 主要的函数有 * `fetchBlocks`:从指定的远程的主机上将指定的Block拉过来。Shuffle过程会用到。 * `uploadBlock`:把本台主机上的一个Block传送到指定的远程主机。 * `fetchBlockSync`和`uploadBlockSync`与上面两个方法作用一样,但是会阻塞。 具体的文件拉取操作在OneForOneBlockFetcher的`start`方法(被NettyBlockTransferService的`fetchBlocks`调用)中。发送操作在NettyBlockTransferService的`uploadBlock`中。 ## 与任务和persist操作的关系 下图显示了RDD如何在任务中实现一层层的计算。 ![RDD计算][rddCompute] 到这里我们只分析了Block管理的部分,那么它与Task的关系是什么样的呢?Excutor中有对应的BlockManager,在运行函数`run`中会调用。下面就是调用关系图: ![Task与BlockManager的关系][task-block] 至于persist操作,其实这个操作只是将对应的RDD标记为persist(cache)到哪里,实际上并没有马上执行相关的BlockStore操作。只是在取用的时候,会调用BlockManager的 `getOrElseUpdate`方法,如果这个块本还没有被persist,那么才会调用`doPutIterator`来执行BlockStore操作。这也就是为什么调用`persist`之后还要有具体操作来触发。 [1]:https://github.com/summerDG/spark-code-analysis/tree/master/analysis/core/spark_shuffle.md [2]:https://github.com/summerDG/spark-code-analysis/tree/master/analysis/core/spark_sort_shuffle.md [3]:https://github.com/summerDG/spark-code-analysis/tree/master/analysis/core/memory_manager.md [4]:https://github.com/apache/spark/pull/11805 [5]:https://0x0fff.com/spark-architecture/ [task-block]:../../pic/task-block.png [block-manager]:../../pic/blockManager.png [rddCompute]:../../pic/rddCompute.png ================================================ FILE: analysis/core/memory_manager.md ================================================ # Spark的内存管理 Spark中的内存管理最近几个版本一直有变化。Spark1.6中将过去的内存管理全部放在了`StaticMemoryManager`中,其名字起得很好,因为过去的的内存 分配确实是静态的(详情见[Spark Architecture][1]),即各个区块的大小是固定的。可以通过配置`spark.memory.useLegacyMode`为`true`来使用。自从1.6版本开始就着力推出新的内存管理,这是[Tungtsen][2]中重要的一个目标。实际上1.6只是一个过渡,其和2.0.0的内存管理还是有一定的区别(在off-heap方面)。[1.6的内存管理见][3]。 ## UnifiedMemoryManager Overview 新的内存管理策略在有这个类进行负责,这里借用[Alexey Grishchenko分析Spark1.6的内存管理][3]的图(有修改)来说明其整体的策略。 ![Spark内存管理][Spark-Memory-Management-1.6.0] 1. Reserved Memory: 这部分空间大小是固定的(可以通过`spark.testing.reservedMemory`设置,需重新编译Spark),300MB。这意味着有300MB的内存不会用于Spark。这部分内存的目的是为了保证有 空间来运行其他系统,并且也可以用于限制设置的内存大小。所以如果你有1GB的JVM,那么用于“运行”(概念稍有不同,稍后解释)和“存储”的内存就是_(1024MB - 300MB) * spark.memory.storageFraction_。 此外,需要注意的是,Spark的Executor和Diver的内存大小至少要有_1.5 * Reserved Memory = 450MB_。否则就会提示通过`--driver-memory`或`--executor-memory`增加内存。 2. User Memory: 这部分空间是属于Spark管理的内存,其用于存储你自己的数据结构、Spark的内部元数据,以及对大而稀疏的数据规模的预测。令除去Reserved Memory之后 的内存为`usableMemory`,那么这部分的内存大小就是_usableMemory * (1 - spark.memory.storageFraction)_。 3. Spark Memory: 这部分内存的大小就是_usableMemory * spark.memory.storageFraction_。其共分为两部分一部分是Execution Memory和Storage Memory。虽然会设置`spark.memory.storageFraction`,但两者的边界并不是固定的,任何一部分内存都可以向另一部分去借内存。当然可以设置`spark.memory.offHeap.enabled`为`true`,并设置`spark.memory.offHeap.size`,即off-heap Memory的大小。这种配置下,在off-heap Memory上的Execution Memory和Storage Memory也一样遵循前文的策略。 * Execution Memory表示的是用于shuffles、join、sorts和 aggregations的内存。Execution Memory当然也可以向Storage Memory借空闲内存。Execution Memory向Storage Memory借内存的情况有两种: 1. 当Storage Memory中有空闲内存,那么就最多能借空闲内存的总量。 2. 当Storage Memory已经超过了初始值,那么Execution Memory最多能踢出Storage Memory越界的那部分内存。 但是Execution Memory的非空闲内存__永远都不会__被腾出来用于Storage Memory存储,所以Storage Memory借不到内存的时候就会把已存块按照存储层级踢出去(踢到磁盘或者直接不存)。 * Storage Memory表示用于persist(cache)、跨集群传递内部数据的内存,以及临时用于序列化数据“unroll”的内存。而且所有广播变量的数据 都作为cached blocks(存储层级为`MEMORY_AND_DISK`)存储在这里。当这部分内存不足的时候,它可以去向Execution Memory申请 __空闲__内存。但是当Execution Memory需要收回这部分内存的时候,要踢出部分cached blocks来满足Execution Memory的请求。 > 那么设置Storage Memory的占比有什么意义呢?它指明了Execution Memory不可以无限制剔除Storage Memory,其不能踢出初始值以内的非空闲的Storage Memory。 e.g. 1. 当Storage Memory内存默认大小为1G,有200MB的空闲内存,而Execution Storage默认1G,需要300MB的内存用于运行,那么向Storage Memory最多借200MB。 2. 当Storage Memory之前向Execution Memory 借了200MB内存,即1.2G,那么Execution Storage若需要300MB内存,那么它最多踢掉200MB用于自己运行。 ## Spark Tungtsen的内存思路 与C等直接面向内存的编程语言不同,Java业务逻辑操作内存是JVM堆内存,分配释放以及引用关系都由JVM进行管理,new返回的只是一个对象引用,而不是该对象在进程空间的绝对地址。但是由于堆内存的使用严重依赖JVM的GC器,对于大内存的使用,JavaER都想脱离JVM的管理,而自行和内存进行打交道,即堆外内存。 目前Java提供了ByteBuffer.allocateDirect函数可以分配堆外内存,但是分配大小受MaxDirectMemorySize配置限制。分配堆外内存另外一个方法就是通过Unsafe的allocateMemory函数,相比前者,它完全脱离了JVM限制,与传统C中的malloc功能一致。这两个函数还有另外一个区别:后者函数返回是进程空间的实际内存地址,而前者被ByteBuffer进行包装。 堆外内存使用高效,节约内存(基于字节,不需要存储繁琐的对象头等信息),堆内内存使用简单,但是对于Spark来说,很多地方会有大数组大内存的需求,内存高效是必须去追求的,它对整个程序运行性能影响极大,因此Spark也提供了堆外内存的支持,从而可以优化Spark运行性能。 对于堆内存,对象的引用为对象在堆中“头指针”,熟悉对象在堆中组织方式以后(比如对象头大小),就可以通过引用+Offset偏移量的方式来操作对象的成员变量;对于堆外内存,我们直接拥有的就是指针,基于Offset可以直接内存操作。通过这种机制,Spark利用MemoryLocation,MemoryBlock来对堆内和堆外内存进行抽象,以LongArray等数据结构对外提供统一的内存入口。类似下图。 ![HashMap][new-memory-offheap] 这种设计可以极大地提高数组、Map等操作的效率。Tungtsen中已经利用这种设计替换了之前的原生数据结构。 ## UnifiedMemoryManager源码分析 首先分析其父类MemoryManager private[spark] abstract class MemoryManager( conf: SparkConf, numCores: Int, onHeapStorageMemory: Long, onHeapExecutionMemory: Long) extends Logging { @GuardedBy("this") protected val onHeapStorageMemoryPool = new StorageMemoryPool(this, MemoryMode.ON_HEAP) @GuardedBy("this") protected val offHeapStorageMemoryPool = new StorageMemoryPool(this, MemoryMode.OFF_HEAP) @GuardedBy("this") protected val onHeapExecutionMemoryPool = new ExecutionMemoryPool(this, MemoryMode.ON_HEAP) @GuardedBy("this") protected val offHeapExecutionMemoryPool = new ExecutionMemoryPool(this, MemoryMode.OFF_HEAP) ... } 上面列举出其最重要的4个成员变量,分别是on-heap和off-heap版本的StorageMemoryPool和ExecutionMemoryPool。 ### StorageMemoryPool 先分析StorageMemoryPool。 private[memory] class StorageMemoryPool( lock: Object, memoryMode: MemoryMode ) extends MemoryPool(lock) with Logging { private[this] var _memoryUsed: Long = 0L private var _memoryStore: MemoryStore = _ } `lock`的作用是同步对该变量的操作,同步是由MemoryManager来完成的。`memoryMode`指定了该内存池的属性,off-heap还是on-heap。 然后就是重要的两个成员变量:`_memoryUsed`和`_memoryStore`。前者表示该部分已经使用的内存,后者就是该内存池对应的用于存储操作的接口([Spark Block Manager管理][4]有介绍)。 这里着重介绍`_memoryStore`,它的目的就是用于必要时将某些块踢到磁盘。它的调用点只有一个就是`acquireMemory`。如果当前的 空闲内存不够的时候,就会调用`memoryStore.evictBlocksToFreeSpace`来踢cached blocks。这个函数主要是将满足条件的块踢出去, 条件有两点:一是准备存储的块和被踢块的存储层级相同;而是一个块不能替换本RDD中的块。如果被选中的满足条件的块的总大小已经满足替换空间的要求,就停止查找,然后去调用`dropBlock`函数将对应BlockId的块踢出去,这个函数具体时间调用了`blockEvictionHandler`变量的 `dropFromMemory`,而该函数就是在BlockManager中实现的(因为BlockManager是BlockEvictionHandler的子类)。该函数中首先会去查看这个块是否使用了`useDisk`这个属性,如果有就先将序列化后的数据写到磁盘上(已经序列化的就直接写到磁盘)。然后就是利用MemoryStore的`remove`函数释放对应块的存储空间并且通知更新块信息。下面看`remove`函数。中会通过MemoryManager的`releaseStorageMemory`释放空间(仅仅是改变`_memoryUsed`的大小),真正从物理上释放空间的操作。 //MemoryStore def remove(blockId: BlockId): Boolean = memoryManager.synchronized { val entry = entries.synchronized { entries.remove(blockId) } if (entry != null) { entry match { case SerializedMemoryEntry(buffer, _, _) => buffer.dispose() case _ => } memoryManager.releaseStorageMemory(entry.size, entry.memoryMode) logDebug(s"Block $blockId of size ${entry.size} dropped " + s"from memory (free ${maxMemory - blocksMemoryUsed})") true } else { false } } 其中会通过MemoryManager的`releaseStorageMemory`释放空间(仅仅是改变`_memoryUsed`的大小),真正从物理上释放空间的操作是 `buffer.dispose()`,`buffer`是一个ChunkedByteBuffer类型的对象。ChunkedByteBuffer是可以看做是一组ByteBuffer,但每个ByteBuffer 的offset必须为0,由于是只读的,所以这组ByteBuffer的数据只能通过copy来供调用者使用。 //ChunkedByteBuffer def dispose(): Unit = { if (!disposed) { chunks.foreach(StorageUtils.dispose) disposed = true } } //StorageUtils def dispose(buffer: ByteBuffer): Unit = { if (buffer != null && buffer.isInstanceOf[MappedByteBuffer]) { logTrace(s"Unmapping $buffer") if (buffer.asInstanceOf[DirectBuffer].cleaner() != null) { buffer.asInstanceOf[DirectBuffer].cleaner().clean() } } } 可以发现最终会调用Cleaner的`clean`方法来释放这部分空间(sun.misc)。不过至此我们还没有分析怎么向off-heap中写数据,实际上向off-heap中写数据只存在于MemoryStore的`putIteratorAsBytes`中,进一步往回找,发现只有在BlockManager的`doPutIterator`中hi调用此函数,而且该函数只会被 BlockManager的`getOrElseUpdate`调用,也就是触发persist(cache)操作的时候。所以off-heap只会用于persist操作。 ### ExecutionMemoryPool 接下来分析ExecutionMemoryPool,该类除了像StorageMemoryPool存储数据(只是用途不同),还提供了一组策略来保证每个任务都可以得到一部分 合理的内存。 假设有N个任务,它保证每个任务在溢出之前有至少1/2N的内存,至多1/N的内存,就是说每个任务的内存持有量是[1/N, 1/2N]。 由于N是动态变化的,所以会一直跟踪活跃任务,重新在等待任务中计算1/2N和1/N。 任务会向ExecutionMemoryPool竞争发起申请。针对为每个任务,ExecutionMemoryPool都记录了它们当前申请的内存大小,同时在申请过程中,为了保证task分配的内存总大小位于[1/2N,1/N]之间,如果可申请大小达不到1/2N,将会阻塞申请(让锁等待),等待其他task释放相应的内存。这部分代码在ExecutionMemoryPool的`acquireMemory`中。ExecutionMemoryPool中还有一个重要的方法就是`releaseMemory`,就是释放对应任务的指定大小的内存。这两个函数的代码很容易懂,所以不贴了。 如果想全面理解ExecutionMemoryPool的原理,对Spark-Shuffle到深度理解是很有必要(见[Spark基础及Shuffle实现][5]和[Spark的shuffle机制分析][6])。 ExecutionMemoryPool,只是维护一个可用内存指标,接受指标的申请与回收,实际负责内存管理的是TaskMemoryManager,它的工作单位是Task,即一个Executor里面会有多个TaskMemoryManager。 他们共同引用一个ExecutionMemoryPool,以竞争的方式申请可用的内存指标,申请指标的主体被表示为MemoryConsumer,即内存的消费者。在[Spark的shuffle机制分析][6]中提到“Deserialized sorting”和“serialized sorting”两种Sorter,其都属于MemoryConsumer。它核心的功能就是支持内存的申请以及在内存不够的时候,可以被动的进行Spill,从而释放自己占用的内存。因此两种Sort支持插入新的数据,也支持将已经Sorter数据Spill到磁盘。 具体来说,是每个任务有一个TaskMemoryManager变量,对于ShuffleMapTask来说,有ShuffleWriter,其中UnsafeShuffleWriter和SortShuffleWriter, 这二者都会包含ExternalSorter类型变量`sorter`,写数据的时候会调用该变量的`insertRecordIntoSorter`方法,然后调用`insertRecord`, 该函数中会判断是否需要spill(没有足够内存用于该任务),如果是就调用MemoryConsumer的`spill`方法将一些数据spill到到磁盘来释放内存。 了解了Shuffle与ExecutionMemoryPool的关系之后,现在分析一下TaskMemoryManager。 #### TaskMemoryManager 这个类很复杂,其中大部分是将off-heap地址转换成64-bit的long型。在off-heap模式下,内存可以直接用64-bit的long值处理。 在on-heap模式下,内存可以通过对象引用和一个64-bit的long值的offset来处理(最初想法)。当想存储其他数据结构中包含的数据结构指针时,例如hashmap或已排序buffer,那么这种方式就会有问题,因为地址完全可以大于这个范围。所以对于on-heap模式,使用64-bit中的高13位来存储页好,低51位来存储页内偏移量(offset)。页号被存储在TaskMemoryManager的页表数组中。所以允许存储_2 ^ 13 = 8192_页,页大小受限于long[]数组(最大2^31),所以可以处理_8192 * 2^31 * 8 bytes_的数据,16TB内存。 分析TaskMemoryManager的页表数组`pageTable`,MemoryBlock[]数组,先来分析MemoryBlock和MemoryLocation。 public class MemoryLocation { @Nullable Object obj; long offset; } public class MemoryBlock extends MemoryLocation { private final long length; public int pageNumber = -1; public static MemoryBlock fromLongArray(final long[] array) { return new MemoryBlock(array, Platform.LONG_ARRAY_OFFSET, array.length * 8L); } public void fill(byte value) { Platform.setMemory(obj, offset, length, value); } } MemoryLocation表示内存位置(on-heap模式),只有两个成员变量,就是对象引用和offset。MemoryBlock(省略了构造函数)是一块连续的内存,从 MemoryLocation位置开始,长度为`length`。其还包含该段内存的页号。由`fromLongArray`可以了解到如何将long[]数组转化为 MemoryBlock,Platform.LONG_ARRAY_OFFSET表示了数组的起始位置(因为Java对象和C++不同,会有头信息),数组所存数据总长度就是_个数 * 64bit_。填充这块内存的操作类似于C语言。 下面来看一些有意思的成员变量。`allocatedPages`,用一个BitSet来表示空闲页(为什么用BitSet,我想应该是,首先页号是连续的,所以BitSet不会造成太大的空间浪费,其次就是BitSet查询插入很快)。`tungstenMemoryMode`就是用于判断off-heap和on-heap,因为二者的内存寻址不同。 `consumers`就是前面提到的Sorter,但是这里为什么是一个HashSet,每个任务不应该只有一个么?其实这里的`consumers`并不是指这个 TaskMemoryManager对应的`consumers`,而是向其申请内存的`consumers`。`acquiredButNotUsed`表示申请了但没有使用的的内存。 来关注几个比较重要的方法。 //TaskMemoryManager public long acquireExecutionMemory(long required, MemoryConsumer consumer) { ... MemoryMode mode = consumer.getMode(); synchronized (this) { long got = memoryManager.acquireExecutionMemory(required, taskAttemptId, mode); if (got < required) { // Call spill() on other consumers to release memory for (MemoryConsumer c: consumers) { if (c != consumer && c.getUsed() > 0 && c.getMode() == mode) { try { long released = c.spill(required - got, consumer); if (released > 0) { ... got += memoryManager.acquireExecutionMemory(required - got, taskAttemptId, mode); if (got >= required) { break; } } } catch (IOException e) { ... } } } } // call spill() on itself if (got < required) { try { long released = consumer.spill(required - got, consumer); if (released > 0) { ... got += memoryManager.acquireExecutionMemory(required - got, taskAttemptId, mode); } } catch (IOException e) { ... } } consumers.add(consumer); return got; } } public MemoryBlock allocatePage(long size, MemoryConsumer consumer) { ... long acquired = acquireExecutionMemory(size, consumer); if (acquired <= 0) { return null; } final int pageNumber; synchronized (this) { pageNumber = allocatedPages.nextClearBit(0); if (pageNumber >= PAGE_TABLE_SIZE) { releaseExecutionMemory(acquired, consumer); } allocatedPages.set(pageNumber); } MemoryBlock page = null; try { page = memoryManager.tungstenMemoryAllocator().allocate(acquired); } catch (OutOfMemoryError e) { ... } page.pageNumber = pageNumber; pageTable[pageNumber] = page; return page; } 为了方便理解,删去大量细节代码。首先来看`allocatePage`,MemoryConsumer申请一定大小的空闲页(申请大小不能大于最大页长)。 其实还是调用`acquireExecutionMemory`来申请特定大小内存,当然页号也不能超出最大页号。关于Allocator在[Spark Block Manager管理][4]中 略微提到。针对on-heap和off-heap有不同的Allocator,其实这里只是利用Allocator进行生成页的信息(页号、起始位置和长度)。 然后再来看`acquireExecutionMemory`,首先就是调用`MemoryManager`的`acquireExecutionMemory`来申请内存,这个函数之前没讲过,但是和 `acquireStorageMemory`总体类似,区别在于申请StorageMemory的时候不可以踢ExecutionMemory,但申请ExecutionMemory的时候可以踢掉__超出`storageRegionSize`的StorageMemory__。但加入申请到的内存不能满足需求,就需要将其他借过内存的`consumer`的内存数据spill到磁盘。那么释放出来的空间就可以供这个`consumer`使用,如果这种情况还不够使用,那就只能spill本任务的内存数据到磁盘了(一种妥协策略,就是尽量满足当前使用,假设该任务之前申请到的内存存得已经是冷数据了)。 最后就是把这个`consumer`加入列表,说明其曾经向本任务请求过内存,必要的时候向他们**讨还**内存(谁找我借的,我就找谁要)。 释放页和释放内存的操作类似(像是C语言或者C++风格)。 内存已经分配了,那怎么想这块内存中写数据呢?可以去看Spark自己实现的LongArray(org.apache.spark.unsafe.array),构造函数就是传入一个 MemoryBlock,也就是一页,因为页中有偏移量,长度,所以可以按序写入(利用Platform的写操作),并且不会越界。 剩下的就是`encodePageNumberAndOffset`、`decodePageNumber`、`decodeOffset`,这几个很容易理解。`encodePageNumberAndOffset`是针对on-heap和off-heap中的页和页内偏移进行编码的。`getPage`是获得页的引用(只针对on-heap有效,因为off-heap并没有页引用)。这些函数在UnsafeExternalSorter和新数据结构中有大量应用,这里不再赘述。 ## JIT编译 实际上Tungtsen借用`sun.misc.Unsafe`管理内存后,其内存操作(申请、插入、释放)都是原生的,即直接通过JIT编译编译成机器指令,而无需JVM将 Java字节码解释后再运行。所以执行速度也会有提升。 [1]:https://0x0fff.com/spark-architecture/ [2]:https://databricks.com/blog/2015/04/28/project-tungsten-bringing-spark-closer-to-bare-metal.html [3]:https://0x0fff.com/spark-memory-management/ [4]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/block_manager.md [5]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/spark_shuffle.md [6]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/spark_sort_shuffle.md [Spark-Memory-Management-1.6.0]:../../pic/Spark-Memory-Management-1.6.0.png [new-memory-offheap]:../../pic/new-memory-offheap.png ================================================ FILE: analysis/core/spark_shuffle.md ================================================ # Spark基础及Shuffle实现 ## Job,Stage,Task,Dependency ### 从一般Job(非Shuffle)开始 Job可以看作是一组transformation操作和一个action操作的集合,换句话说每个action操作会触发一个Job。查看源码 可以发现每个action操作的最后总是调用`sc.runJob(this,...)`,即SparkContext下的runJob方法。其实所有的runJob的 重载方法最终都会调用如下的过程, def runJob[T, U: ClassTag]( rdd: RDD[T], func: (TaskContext, Iterator[T]) => U, partitions: Seq[Int], resultHandler: (Int, U) => Unit): Unit = { if (stopped.get()) { throw new IllegalStateException("SparkContext has been shutdown") } val callSite = getCallSite val cleanedFunc = clean(func) logInfo("Starting job: " + callSite.shortForm) if (conf.getBoolean("spark.logLineage", false)) { logInfo("RDD's recursive dependencies:\n" + rdd.toDebugString) } dagScheduler.runJob(rdd, cleanedFunc, partitions, callSite, resultHandler, localProperties.get) progressBar.foreach(_.finishAll()) rdd.doCheckpoint() } `resultHandler`参数是由之前的runJob传入的,主要作用是利用回调函数来返回结果,注意这里并不是利用方法返回值实现结果返回。 `func`参数是针对每个partition运行action操作的函数。`partitions`是各个partition编号构成的数组。dagScheduler返回Spark最外 层的调度器,通过名字也可以知道Job是一个DAG图,`dagScheduler.runJob`是一个堵塞操作,Job完成之后,rdd会进行checkpoint。 继续深入DAGScheduler.runJob def runJob[T, U]( rdd: RDD[T], func: (TaskContext, Iterator[T]) => U, partitions: Seq[Int], callSite: CallSite, resultHandler: (Int, U) => Unit, properties: Properties): Unit = { val start = System.nanoTime val waiter = submitJob(rdd, func, partitions, callSite, resultHandler, properties) val awaitPermission = null.asInstanceOf[scala.concurrent.CanAwait] waiter.completionFuture.ready(Duration.Inf)(awaitPermission) waiter.completionFuture.value.get match { case scala.util.Success(_) => logInfo("Job %d finished: %s, took %f s".format (waiter.jobId, callSite.shortForm, (System.nanoTime - start) / 1e9)) case scala.util.Failure(exception) => logInfo("Job %d failed: %s, took %f s".format (waiter.jobId, callSite.shortForm, (System.nanoTime - start) / 1e9)) // SPARK-8644: Include user stack trace in exceptions coming from DAGScheduler. val callerStackTrace = Thread.currentThread().getStackTrace.tail exception.setStackTrace(exception.getStackTrace ++ callerStackTrace) throw exception } } 可以发现该方法是堵塞的,具体的就是`waiter = submitJob(rdd, func, partitions, callSite, resultHandler, properties)`,即提交作业后其实 就已经堵塞了。 //DAGScheduler def submitJob[T, U]( rdd: RDD[T], func: (TaskContext, Iterator[T]) => U, partitions: Seq[Int], callSite: CallSite, resultHandler: (Int, U) => Unit, properties: Properties): JobWaiter[U] = { // Check to make sure we are not launching a task on a partition that does not exist. val maxPartitions = rdd.partitions.length partitions.find(p => p >= maxPartitions || p < 0).foreach { p => throw new IllegalArgumentException( "Attempting to access a non-existent partition: " + p + ". " + "Total number of partitions: " + maxPartitions) } val jobId = nextJobId.getAndIncrement() if (partitions.size == 0) { // Return immediately if the job is running 0 tasks return new JobWaiter[U](this, jobId, 0, resultHandler) } assert(partitions.size > 0) val func2 = func.asInstanceOf[(TaskContext, Iterator[_]) => _] val waiter = new JobWaiter(this, jobId, partitions.size, resultHandler) eventProcessLoop.post(JobSubmitted( jobId, rdd, func2, partitions.toArray, callSite, waiter, SerializationUtils.clone(properties))) waiter } 前两句是判断rdd的partition和传入的partitions的数目一致才可以处理,之后就是这个Job创建一个JobId,如果该rdd没有 partition会直接判定为处理成功(直接返回一个JobWaiter对象),反之利用JobId和该rdd创建一个JobWaiter对象,然后向 `eventProcessLoop`,即DAG调度器,发送一个JobSubmitted的消息。由于现在的版本的Spark已经用Netty代替了Akka,所以 并不是Akka风格的写法。这里的JobWaiter也被一起发送了出去,`waiter`对象封装了很多信息,包括分区数和用于接收结果的 回调函数。`eventProcessLoop`是一个DAGSchedulerEventProcessLoop对象,其在接收到一个event后会调用DAGScheduler的 handleJobSubmitted方法。 private[scheduler] def handleJobSubmitted(jobId: Int, finalRDD: RDD[_], func: (TaskContext, Iterator[_]) => _, partitions: Array[Int], callSite: CallSite, listener: JobListener, properties: Properties) { var finalStage: ResultStage = null try { // New stage creation may throw an exception if, for example, jobs are run on a // HadoopRDD whose underlying HDFS files have been deleted. finalStage = createResultStage(finalRDD, func, partitions, jobId, callSite) } catch { case e: Exception => logWarning("Creating new stage failed due to exception - job: " + jobId, e) listener.jobFailed(e) return } val job = new ActiveJob(jobId, finalStage, callSite, listener, properties) submitStage(finalStage) } 这里删去部分没用的代码(关于log信息和web统计页面),该方法的主要工作就是根据rdd和和jobId生成finalStage,并且 调用`submitStage(finalStage)`将其提交运行。这里涉及到stage,每个Job只有一个finalStage,它是整个DAG最后一个Stage, 但是中间依据情况会有多个(或0个)ShuffleStage(严格说是ShuffleMapStage),ShuffleStage是涉及到宽依赖才会有的。 进入createResultStage private def createResultStage( rdd: RDD[_], func: (TaskContext, Iterator[_]) => _, partitions: Array[Int], jobId: Int, callSite: CallSite): ResultStage = { val parents = getOrCreateParentStages(rdd, jobId) val id = nextStageId.getAndIncrement() val stage = new ResultStage(id, rdd, func, partitions, parents, jobId, callSite) stageIdToStage(id) = stage updateJobIdStageIdMaps(jobId, stage) stage } private def getOrCreateParentStages(rdd: RDD[_], firstJobId: Int): List[Stage] = { getShuffleDependencies(rdd).map { shuffleDep => getOrCreateShuffleMapStage(shuffleDep, firstJobId) }.toList } private[scheduler] def getShuffleDependencies( rdd: RDD[_]): HashSet[ShuffleDependency[_, _, _]] = { val parents = new HashSet[ShuffleDependency[_, _, _]] val visited = new HashSet[RDD[_]] val waitingForVisit = new Stack[RDD[_]] waitingForVisit.push(rdd) while (waitingForVisit.nonEmpty) { val toVisit = waitingForVisit.pop() if (!visited(toVisit)) { visited += toVisit toVisit.dependencies.foreach { case shuffleDep: ShuffleDependency[_, _, _] => parents += shuffleDep case dependency => waitingForVisit.push(dependency.rdd) } } } parents } 首先是调用`getOrCreateParentStages(rdd, jobId)`来生成父Stage,可以发现在`getOrCreateParentStages`中是父Stage是依据 shuffle依赖生成的。进入`getShuffleDependencies`,可以发现只有在ShuffleDependency,即宽依赖,的情况下才会将其作为父依赖,其他 情况(也就是窄依赖)只是追溯其父依赖(ShuffleDependency),而且这里只会追溯一层依赖,例如:A<--B<--C表示C依赖(Shuffle依赖)B, B依赖A,这里只会解析到B<--C。`getOrCreateParentStages`中为每个Shuffle依赖创建一个ShuffleStage,也就是ShuffleMapStage。 ShuffleMapStage有两个比较重要的函数,`isAvailable`判断当前Stage是否运行完成,判断条件一目了然。`addOutputLoc`则主要是对 `_numAvailableOutputs`进行修改,`outputLocs`是利用partition编号作为下标,每个编号对应的可能MapStatus(因为一个partition可能运行多次), MapStatus会在之后进行介绍,该函数主要是在`createShuffleMapStage`中调用,每有一个partition有shuffle输出,就会调用一次,当所有partition输出 都完成时,该stage也就完成了。 def isAvailable: Boolean = _numAvailableOutputs == numPartitions def addOutputLoc(partition: Int, status: MapStatus): Unit = { val prevList = outputLocs(partition) outputLocs(partition) = status :: prevList if (prevList == Nil) { _numAvailableOutputs += 1 } } 接下来分析MapStatus, private[spark] sealed trait MapStatus { def location: BlockManagerId def getSizeForBlock(reduceId: Int): Long } 其只包含一个对象location,即这个任务运行的位置,也就是Map任务的输出位置。还有对reduceId对应的Block的预测大小,其实预测的重点目的是针对预测值为0 的Block不进行计算。对于ResultStage是否完成的判断由如下两部分完成 //ResultStage def activeJob: Option[ActiveJob] = _activeJob private[spark] class ActiveJob( val jobId: Int, val finalStage: Stage, val callSite: CallSite, val listener: JobListener, val properties: Properties) { /** * Number of partitions we need to compute for this job. Note that result stages may not need * to compute all partitions in their target RDD, for actions like first() and lookup(). */ val numPartitions = finalStage match { case r: ResultStage => r.partitions.length case m: ShuffleMapStage => m.rdd.partitions.length } /** Which partitions of the stage have finished */ val finished = Array.fill[Boolean](numPartitions)(false) var numFinished = 0 } ResultStage中用activeJob获取该Job,然后Job中有一个finished变量表示各个partition是否完成,对于finish的修改是在 `DAGScheduler.handleTaskCompletion(event: CompletionEvent)`中完成的。 接下来继续`submitJob`分析 private def submitStage(stage: Stage) { val jobId = activeJobForStage(stage) if (jobId.isDefined) { logDebug("submitStage(" + stage + ")") if (!waitingStages(stage) && !runningStages(stage) && !failedStages(stage)) { val missing = getMissingParentStages(stage).sortBy(_.id) logDebug("missing: " + missing) if (missing.isEmpty) { logInfo("Submitting " + stage + " (" + stage.rdd + "), which has no missing parents") submitMissingTasks(stage, jobId.get) } else { for (parent <- missing) { submitStage(parent) } waitingStages += stage } } } else { abortStage(stage, "No active job for stage " + stage.id, None) } } private def getMissingParentStages(stage: Stage): List[Stage] = { val missing = new HashSet[Stage] val visited = new HashSet[RDD[_]] // We are manually maintaining a stack here to prevent StackOverflowError // caused by recursively visiting val waitingForVisit = new Stack[RDD[_]] def visit(rdd: RDD[_]) { if (!visited(rdd)) { visited += rdd val rddHasUncachedPartitions = getCacheLocs(rdd).contains(Nil) if (rddHasUncachedPartitions) { for (dep <- rdd.dependencies) { dep match { case shufDep: ShuffleDependency[_, _, _] => val mapStage = getOrCreateShuffleMapStage(shufDep, stage.firstJobId) if (!mapStage.isAvailable) { missing += mapStage } case narrowDep: NarrowDependency[_] => waitingForVisit.push(narrowDep.rdd) } } } } } waitingForVisit.push(stage.rdd) while (waitingForVisit.nonEmpty) { visit(waitingForVisit.pop()) } missing.toList } 首先判断该Stage是否是等待(父Stage没有完成)、运行、失败等状态。然后用`getMissingParentStages(stage)`获取 finalStage的所有未处理的父Stage,并且迭代提交父Stage,并且把该finalStage加入waitingStage,如果没有父Stage, 那么就利用`submitMissingTasks(stage, jobId.get)`提交该Stage。 `getCacheLocs(rdd)`是获取该Stage处理的RDD的所有Partition的cache位置,主要就是借助`cacheLocs`变量,其表示的 是RDD和其partirion的cache位置的映射,它是一个HashMap,key是RDD的id,val是一个数组,下标为partition的id,内 容是cache位置。如果有没有处理完的partition,那么判断该RDD的依赖对应的Stage是否完成,如果没有完成,就把没有完 成的父Stage加入`missing`。 接下来分析`submitMissingTasks` private def submitMissingTasks(stage: Stage, jobId: Int) { //step1 stage.pendingPartitions.clear() val partitionsToCompute: Seq[Int] = stage.findMissingPartitions() val properties = jobIdToActiveJob(jobId).properties runningStages += stage //step 2 val taskIdToLocations: Map[Int, Seq[TaskLocation]] = try { stage match { case s: ShuffleMapStage => partitionsToCompute.map { id => (id, getPreferredLocs(stage.rdd, id))}.toMap case s: ResultStage => partitionsToCompute.map { id => val p = s.partitions(id) (id, getPreferredLocs(stage.rdd, p)) }.toMap } } catch { ... } stage.makeNewStageAttempt(partitionsToCompute.size, taskIdToLocations.values.toSeq) listenerBus.post(SparkListenerStageSubmitted(stage.latestInfo, properties)) //step3 var taskBinary: Broadcast[Array[Byte]] = null try { // For ShuffleMapTask, serialize and broadcast (rdd, shuffleDep). // For ResultTask, serialize and broadcast (rdd, func). val taskBinaryBytes: Array[Byte] = stage match { case stage: ShuffleMapStage => JavaUtils.bufferToArray( closureSerializer.serialize((stage.rdd, stage.shuffleDep): AnyRef)) case stage: ResultStage => JavaUtils.bufferToArray(closureSerializer.serialize((stage.rdd, stage.func): AnyRef)) } taskBinary = sc.broadcast(taskBinaryBytes) } catch { ... } //step4 val tasks: Seq[Task[_]] = try { stage match { case stage: ShuffleMapStage => partitionsToCompute.map { id => val locs = taskIdToLocations(id) val part = stage.rdd.partitions(id) new ShuffleMapTask(stage.id, stage.latestInfo.attemptId, taskBinary, part, locs, stage.latestInfo.taskMetrics, properties) } case stage: ResultStage => partitionsToCompute.map { id => val p: Int = stage.partitions(id) val part = stage.rdd.partitions(p) val locs = taskIdToLocations(id) new ResultTask(stage.id, stage.latestInfo.attemptId, taskBinary, part, locs, id, properties, stage.latestInfo.taskMetrics) } } } catch { ... } //step5 if (tasks.size > 0) { stage.pendingPartitions ++= tasks.map(_.partitionId) taskScheduler.submitTasks(new TaskSet( tasks.toArray, stage.id, stage.latestInfo.attemptId, jobId, properties)) stage.latestInfo.submissionTime = Some(clock.getTimeMillis()) } else { // Because we posted SparkListenerStageSubmitted earlier, we should mark // the stage as completed here in case there are no tasks to run markStageAsFinished(stage, None) submitWaitingChildStages(stage) } } 该过程大体分为5步。 第1步是指明该Stage所需计算的partition(因为可能重新计算,所以存在部分partition已经算完)。 然后第2步就是针对不同的Stage选择该任务执行的位置,接着就是请求运行(每运行一个Stage都会请求),并且向`listenerBus`发送 `SparkListenerStageSubmitted`消息,只有在接收到这个消息后才可以测试任务是否可序列化。这里的`listenerBus`仅用于监测系统,即用于度量 各种系统指标,`listenerBus`就是向监听器发送消息,收集这些指标的。但是并不是所有的JobListener都会在listener进行注册。 第3步是将任务运行所需的信息进行序列化并且发送给各个任务,针对不同的Stage发送的信息稍有不同,首先ShuffleStage和ResultStage都需要RDD信息,所以每个 任务都有一份RDD引用的拷贝,但是ShuffleStage需要额外发送依赖(ShuffleDependency),而ResultStage则必须有`func`信息。 class ShuffleDependency[K: ClassTag, V: ClassTag, C: ClassTag]( @transient private val _rdd: RDD[_ <: Product2[K, V]], val partitioner: Partitioner, val serializer: Serializer = SparkEnv.get.serializer, val keyOrdering: Option[Ordering[K]] = None, val aggregator: Option[Aggregator[K, V, C]] = None, val mapSideCombine: Boolean = false) 可以发现ShuffleDependency包含了partitioner告诉我们要按照什么分区函数将Map分Bucket进行输出, 有serializer告诉我们怎么 对Map的输出进行 序列化, 有keyOrdering和aggregator告诉我们怎么按照Key进行分Bucket,已经怎么进行合并,以及mapSideCombine 告诉我们是否需要进行Map端reduce。 第4步就是针对每个计算的分片构造Task对象,分别是`ShuffleMapTask`和`ResultTask`,二者都接受任务的运行位置、partition数据、 该Stage的信息、还有前面生成的广播信息等。进入具体的`ShuffleMapTask`类中,主要函数就是`runTask`,对广播对象进行反序列化,然后利用 具体ShuffleManager的具体ShuffleWriter对数据进行处理(这部分会在[Spark的shuffle机制分析][1]中分析)。 而`ResultTask`的`runTask`的处理相对简单,除了反序列化广播信息外,剩余操作就是调用`func`来计算该partition的结果。 第5步是将所有任务通过任务调度器(具体分析见[Task调度机制][2])进行提交。如果该Stage的任务已经完毕,那么就将其标记为完成, 并且开始调度等待的子Stage。 现在假设任务已经运行完毕,开始分析如何将任务的运行结果传递给之前分析的`waiter`。 private[scheduler] def handleTaskCompletion(event: CompletionEvent) { val task = event.task val taskId = event.taskInfo.id val stageId = task.stageId val taskType = Utils.getFormattedClassName(task) listenerBus.post(SparkListenerTaskEnd( stageId, task.stageAttemptId, taskType, event.reason, event.taskInfo, taskMetrics)) val stage = stageIdToStage(task.stageId) event.reason match { case Success => stage.pendingPartitions -= task.partitionId task match { case rt: ResultTask[_, _] => // Cast to ResultStage here because it's part of the ResultTask // TODO Refactor this out to a function that accepts a ResultStage val resultStage = stage.asInstanceOf[ResultStage] resultStage.activeJob match { case Some(job) => if (!job.finished(rt.outputId)) { updateAccumulators(event) job.finished(rt.outputId) = true job.numFinished += 1 // If the whole job has finished, remove it if (job.numFinished == job.numPartitions) { markStageAsFinished(resultStage) cleanupStateForJobAndIndependentStages(job) listenerBus.post( SparkListenerJobEnd(job.jobId, clock.getTimeMillis(), JobSucceeded)) } // taskSucceeded runs some user code that might throw an exception. Make sure // we are resilient against that. try { job.listener.taskSucceeded(rt.outputId, event.result) } catch { ... } } case None => ... } ... } } } 这里删去了很多逻辑,基本只剩成功处理ResultTask的情况。不过,这段代码里并没有waiter的信息,实际上这里的`job.listener`就是每个Job对应的Waiter,因为JobWaiter是JobListener的 子类,那waiter是怎么传给Job的呢?其实在之前的`handleJobSubmitted`函数分析中,就可以发现变成了listener,而且利用finalStage 和listener生成了对应的ActiveJob对象。 首先向由`listenerBus`发送任务完成的信息,针对ResultTask,那么说明该Job也完成了,所以将Job标记为完成。 向`listenerBus`发送Job完成的信息。注意这里的`listenerBus`并不会给JobWaiter发送消息,这也就是之前说的,并不是所有的JobListener都会在`listenerBus`注册,`listenerBus`针对的是度量系统,所以这里要把这两者的关系分清。最后`job.listener.taskSucceeded(rt.outputId, event.result)`将任务的结果返回给`listener`(即`waiter`)。 JobWaiter的`taskSucceeded`方法中调用`resultHandler`,即利用所说的回调函数完成结果的返回(如下面的代码所示)。 override def taskSucceeded(index: Int, result: Any): Unit = { // resultHandler call must be synchronized in case resultHandler itself is not thread safe. synchronized { resultHandler(index, result.asInstanceOf[T]) } if (finishedTasks.incrementAndGet() == totalTasks) { jobPromise.success(()) } } ### Shuffle Job执行过程 Shuffle包含两个过程:Shuffle Map和Shuffle reduce,类似于MapReduce中的map和reduce。Shuffle Map就是ShuffleMapStage, ShuffleMapTask将数据写到相应文件中,并把文件位置以MapOutput返回给DAGScheduler,并更新Stage信息。Reduce是利用不同类型的RDD 来实现的。 #### Shuffle Map过程 首先介绍DAGScheduler中的`mapOutputTracker`(MapOutputTrackerMaster对象)。MapOutputTracker用于记录每个Stage map输出的位置(MapStatus对象), 相当于是存储元数据信息,由于Driver端和Executor端有不同的实现,所以分为MapOutputTrackerMaster和MapOutputTrackerWorker。Master端用于 注册ShuffleId和MapOutput信息,而Executor端用于拉取这些信息。 private[spark] class MapOutputTrackerMaster(conf: SparkConf, broadcastManager: BroadcastManager, isLocal: Boolean) extends MapOutputTracker(conf) { def registerShuffle(shuffleId: Int, numMaps: Int) { if (mapStatuses.put(shuffleId, new Array[MapStatus](numMaps)).isDefined) { throw new IllegalArgumentException("Shuffle ID " + shuffleId + " registered twice") } shuffleIdLocks.putIfAbsent(shuffleId, new Object()) } def registerMapOutputs(shuffleId: Int, statuses: Array[MapStatus], changeEpoch: Boolean = false) { mapStatuses.put(shuffleId, Array[MapStatus]() ++ statuses) if (changeEpoch) { incrementEpoch() } } def getSerializedMapOutputStatuses(shuffleId: Int): Array[Byte] = { var statuses: Array[MapStatus] = null var retBytes: Array[Byte] = null var epochGotten: Long = -1 shuffleIdLock.synchronized { val (bytes, bcast) = MapOutputTracker.serializeMapStatuses(statuses, broadcastManager, isLocal, minSizeForBroadcast) // Add them into the table only if the epoch hasn't changed while we were working epochLock.synchronized { if (epoch == epochGotten) { cachedSerializedStatuses(shuffleId) = bytes if (null != bcast) cachedSerializedBroadcast(shuffleId) = bcast } else { logInfo("Epoch changed, not caching!") removeBroadcast(bcast) } } bytes } } } Master的内容比较多,这里只取出3个比较重要的函数,`registerShuffle`和`registerMapOutputs`就是分别注册ShuffleId和对应的 MapOutput信息。`getSerializedMapOutputStatuses`是用于序列化MapOutput信息(这里删去了从cache了的信息中查找的过程)。 其中`registerShuffle`和`getSerializedMapOutputStatuses`是在创建ShuffleMapStage的时候调用的,即之前提到的 `createShuffleMapStage`函数。`registerMapOutputs`是在任务结束后调用的,即`handleTaskCompletion`函数中。 private[scheduler] def handleTaskCompletion(event: CompletionEvent) { ... event.reason match { case Success => stage.pendingPartitions -= task.partitionId task match { ... case smt: ShuffleMapTask => val shuffleStage = stage.asInstanceOf[ShuffleMapStage] updateAccumulators(event) val status = event.result.asInstanceOf[MapStatus] val execId = status.location.executorId if (failedEpoch.contains(execId) && smt.epoch <= failedEpoch(execId)) { ... } else { shuffleStage.addOutputLoc(smt.partitionId, status) } if (runningStages.contains(shuffleStage) && shuffleStage.pendingPartitions.isEmpty) { markStageAsFinished(shuffleStage) mapOutputTracker.registerMapOutputs( shuffleStage.shuffleDep.shuffleId, shuffleStage.outputLocInMapOutputTrackerFormat(), changeEpoch = true) clearCacheLocs() if (!shuffleStage.isAvailable) { submitStage(shuffleStage) } else { // Mark any map-stage jobs waiting on this stage as finished if (shuffleStage.mapStageJobs.nonEmpty) { val stats = mapOutputTracker.getStatistics(shuffleStage.shuffleDep) for (job <- shuffleStage.mapStageJobs) { markMapStageJobAsFinished(job, stats) } } submitWaitingChildStages(shuffleStage) } } } } } RPC通信的工作是在MapOutputTrackerMasterEndpoint类中实现的,它提供了一个消息收发的处理方式,因为MapOutputTracker 对象本身就有一个MapOutputTrackerMasterEndpoint类型的成员变量`trackerEndpoint`,所以可以将其看作是一个访问入口。 该入口的设置是在SparkEnv中的create方法中注册完成的(由`createDriverEnv`和`createExecutorEnv`调用)。至于Executor是 如何RPC拉去数据的,就是通过MapOutputTracker中的`getStatuses(shuffleId: Int)`方法实现的,具体过程不再赘述。 ##### Shuffle机制 Map按照什么规则输出数据是由ShuffleManager决定的。 private[spark] class BaseShuffleHandle[K, V, C]( shuffleId: Int, val numMaps: Int, val dependency: ShuffleDependency[K, V, C]) extends ShuffleHandle(shuffleId) private[spark] trait ShuffleManager { def registerShuffle[K, V, C]( shuffleId: Int, numMaps: Int, dependency: ShuffleDependency[K, V, C]): ShuffleHandle def getWriter[K, V](handle: ShuffleHandle, mapId: Int, context: TaskContext): ShuffleWriter[K, V] def getReader[K, C]( handle: ShuffleHandle, startPartition: Int, endPartition: Int, context: TaskContext): ShuffleReader[K, C] def unregisterShuffle(shuffleId: Int): Boolean def shuffleBlockResolver: ShuffleBlockResolver def stop(): Unit } 首先看上面的ShuffleHandle的实现, 它只是一个shuffleId, numMaps和ShuffleDep的封装; 再看ShuffleManager提供的接口。 + registerShuffle/unregisterShuffle:提供了Shuffle的注册和注销的功能,和上面谈到的MapOutputTracker一致,然后 返回一个ShuffleHandle对象,来对shuffle进行封装。 + getWriter:MapTask调用,用于输出数据。 + getReader:Reduce过程进行调用,即ShuffleRDD调用,用于读取Map输出的内容。这里设置了起始位置,是因为每个Map输出的 文件其实只有一个,只是针对不同的Reduce输出的偏移量不同,这部分在[Spark的shuffle机制分析][1]讨论。 运行过程已经在上面的ShuffleMapTask的runTask中介绍过了。 #### Shuffle Reduce实现 Reduce对应的应该是之前讲的ResultTask中runTask的内容,其中操作的RDD包括:CoGroupedRDD、CustomShuffledRDD、ShuffledRDD、 ShuffledRowRDD和SubtractedRDD,以ShuffleRDD为例,其compute方法不同与一般的非Shuffle RDD。 override def compute(split: Partition, context: TaskContext): Iterator[(K, C)] = { val dep = dependencies.head.asInstanceOf[ShuffleDependency[K, V, C]] SparkEnv.get.shuffleManager.getReader(dep.shuffleHandle, split.index, split.index + 1, context) .read() .asInstanceOf[Iterator[(K, C)]] } [1]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/spark_sort_shuffle.md [2]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/task_schedule.md ================================================ FILE: analysis/core/spark_shuffle_new.md ================================================ # Spark Shuffle --- ## 1. SortShuffleWriter Shuffle中map端输出的数据要先写到磁盘,然后由reduce进行拉取。 > Hash Shuffle最早的是每个map-reduce对应一个文件,那么有n个map和m和reduce就会产生n*m个文件,由于可能产生很多小文件,每次打开文件和关闭文件会有很多开销,所以后面改进为合并成一个大文件。最初的优化是将所有小文件做一次合并,然后减少文件打开关闭的次数。 SortShuffleWriter的策略是,同一个map任务的shuffle块数据存储在一个 统一的**数据文件**中。数据块在数据文件中的偏移量存储在不同的**索引文件**中。那么n个map只会产生2n个文件。文件按照partition_id从小到大顺序写入。 Shuffle过程中map端的输出首先要依据partition ID进行排序。排序工作只能在内存中执行。如果排序的数据太多,内存中放不下,那么就将当前内存排序好的数据输出到磁盘文件。当所有shuffle数据都经过排序处理之后,对spill到磁盘的文件和当前内存中的数据进行合并。由于每个文件中每个partition的长度都已记录,而内存中的partition长度也有记录,所以merge过程如下。 ```scala for id <- 0 to partitions then 将每个spill文件和内存buffer中id对应的partition输出到合并文件 end ``` SortShuffleWriter和UnsafeShuffleWriter核心思想基本相同,只是排序数据存放的位置不同。SortShuffleWriter存放在堆内,UnsafeShuffleWriter存放在堆外。 ## 2. UnsafeShuffleWriter ![UnsafeShuffleWriter原理图][1] 这种Shuffle策略的核心思想就是**将shuffle中待排序的数据放到堆外内存**,而堆内只存储堆外每条数据对应的地址。 和SortShuffleWriter不同的是,UnsafeShuffleWriter合并文件的操作是由本对象完成的(而非Sorter对象)。而且其有两种合并方式:1. **基于FileStream的方式**;2. **基于NIO TransferTo的方式**。由于输出文件可能采用压缩方式来减小文件大小(降低网络通信量),所以合并操作会面临解压的过程。基于FileStream的方式每轮会先减压spill文件对应的partition(reduce_id),然后再写入到输出文件。而第二种方式通常要比第一种方式性能更好,其基于NIO的TransferTo,无需中间复制(很多操作系统支持直接从文件系统缓存直接传输到指定channel)。但是第二种方式需要文件的压缩方式和序列化方式支持,保证对原始数据的连接操作不会导致错误。 ## 3. BypassMergeSortShuffleWriter 该策略就是过去的Hash Shuffle。核心思想就是每个map-reduce对应一个数据文件,然后根据配置将这些配置文件进行合并,最后每个map同样也是生成两个文件,一个数据文件,一个索引文件。 **对比** 1. 该策略适用于reduce数目较少的情况,文件数目较少的情况下,合并文件的速度还是比较快的。但是reduce数目太多,会导致文件的打开关闭操作过于频繁,从而降低性能。 2. 前两种策略的耗时操作在于排序,如果该策略可以避免排序。但是如果shuffle操作需要排序或者聚集操作,那么就应该直接选用前两种策略。 [1]: http://on-img.com/chart_image/598fbfffe4b0b83fa25e3d89.png ================================================ FILE: analysis/core/spark_sort_shuffle.md ================================================ # Spark的shuffle机制分析 ## SortShuffle spark2.0.0开始后,HashShuffleManager被移除了,但实际上HashShuffle通过改变特定的ShuffleWriter来实现了,因 为这两个Shuffle的ShuffleReader是相同的,即reduce获取数据的方式相同。所以这里只分析SortShuffleManager。 SortShuffleManager的主要功能是依靠IndexShuffleBlockResolver、BlockStoreShuffleReader和ShuffleWriter的3个 子类(UnsafeShuffleWriter、bypassMergeSortShuffleWriter和BaseShuffleWriter)实现的。 在看下面的源码之前先看一下总体的思路,见[各种ShuffleWriter][1]。 ### IndexShuffleBlockResolver分析 IndexShuffleBlockResolver用于生成并维护逻辑块到物理文件位置的映射。同一个map任务的shuffle块数据存储在一个 统一的数据文件中。数据块在数据文件中的偏移量存储在不同的索引文件中。数据文件的命名方式为shuffle_shuffleID_ mapId_reduceId.data,但是reduceId是被设置为0的,索引文件只是后缀为.index。 private[spark] class IndexShuffleBlockResolver( conf: SparkConf, _blockManager: BlockManager = null) extends ShuffleBlockResolver with Logging { override def getBlockData(blockId: ShuffleBlockId): ManagedBuffer = { // The block is actually going to be a range of a single map output file for this map, so // find out the consolidated file, then the offset within that from our index val indexFile = getIndexFile(blockId.shuffleId, blockId.mapId) val in = new DataInputStream(new FileInputStream(indexFile)) try { ByteStreams.skipFully(in, blockId.reduceId * 8) val offset = in.readLong() val nextOffset = in.readLong() new FileSegmentManagedBuffer( transportConf, getDataFile(blockId.shuffleId, blockId.mapId), offset, nextOffset - offset) } finally { in.close() } } def writeIndexFileAndCommit( shuffleId: Int, mapId: Int, lengths: Array[Long], dataTmp: File): Unit = { val indexFile = getIndexFile(shuffleId, mapId) val indexTmp = Utils.tempFileWith(indexFile) val out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(indexTmp))) Utils.tryWithSafeFinally { // We take in lengths of each block, need to convert it to offsets. var offset = 0L out.writeLong(offset) for (length <- lengths) { offset += length out.writeLong(offset) } } { out.close() } val dataFile = getDataFile(shuffleId, mapId) // There is only one IndexShuffleBlockResolver per executor, this synchronization make sure // the following check and rename are atomic. synchronized { val existingLengths = checkIndexAndDataFile(indexFile, dataFile, lengths.length) if (existingLengths != null) { // Another attempt for the same task has already written our map outputs successfully, // so just use the existing partition lengths and delete our temporary map outputs. System.arraycopy(existingLengths, 0, lengths, 0, lengths.length) if (dataTmp != null && dataTmp.exists()) { dataTmp.delete() } indexTmp.delete() } else { // This is the first successful attempt in writing the map outputs for this task, // so override any existing index and data files with the ones we wrote. if (indexFile.exists()) { indexFile.delete() } if (dataFile.exists()) { dataFile.delete() } if (!indexTmp.renameTo(indexFile)) { throw new IOException("fail to rename file " + indexTmp + " to " + indexFile) } if (dataTmp != null && dataTmp.exists() && !dataTmp.renameTo(dataFile)) { throw new IOException("fail to rename file " + dataTmp + " to " + dataFile) } } } } 实际上,这里的reduceId由于一直保持0,所以并不是每个map和reduce都对应一个data和index文件。从上面的代码可以 看出获取索引文件靠的只是shuffleId和mapId,然后通过`ByteStreams.skipFully(in, blockId.reduceId * 8)`将in的 指针偏移量移动`blockId.reduceId * 8`,这样就可以找到该reduceId对应的块索引,通过获取前后两个块索引(偏移量), 就可以知道数据块的范围。writeIndexFileAndCommit会将每个块的偏移量写入索引文件,并且commit数据文件和索引文件, `lengths`表示当前shuffle的数据块大小。我们可以看到每个map都只有一个data文件和index文件。这里在写数据文件的 时候首先检查索引文件与数据文件是否匹配,并且对于已经成功写入map输出文件情况,使用的是已有的分块长度,并且 要删除临时的map输出(包括数据文件和索引文件)。对于不匹配的情况(通常是每个task第一次尝试),要用临时的map输 出进行覆盖。 ### ShuffleWriter分析 下面分析ShuffleWriter的逻辑。 private[spark] class SortShuffleWriter[K, V, C]( shuffleBlockResolver: IndexShuffleBlockResolver, handle: BaseShuffleHandle[K, V, C], mapId: Int, context: TaskContext) extends ShuffleWriter[K, V] with Logging { /** Write a bunch of records to this task's output */ override def write(records: Iterator[Product2[K, V]]): Unit = { sorter = if (dep.mapSideCombine) { require(dep.aggregator.isDefined, "Map-side combine without Aggregator specified!") new ExternalSorter[K, V, C]( context, dep.aggregator, Some(dep.partitioner), dep.keyOrdering, dep.serializer) } else { // In this case we pass neither an aggregator nor an ordering to the sorter, because we don't // care whether the keys get sorted in each partition; that will be done on the reduce side // if the operation being run is sortByKey. new ExternalSorter[K, V, V]( context, aggregator = None, Some(dep.partitioner), ordering = None, dep.serializer) } sorter.insertAll(records) // Don't bother including the time to open the merged output file in the shuffle write time, // because it just opens a single file, so is typically too fast to measure accurately // (see SPARK-3570). val output = shuffleBlockResolver.getDataFile(dep.shuffleId, mapId) val tmp = Utils.tempFileWith(output) val blockId = ShuffleBlockId(dep.shuffleId, mapId, IndexShuffleBlockResolver.NOOP_REDUCE_ID) val partitionLengths = sorter.writePartitionedFile(blockId, tmp) shuffleBlockResolver.writeIndexFileAndCommit(dep.shuffleId, mapId, partitionLengths, tmp) mapStatus = MapStatus(blockManager.shuffleServerId, partitionLengths) } } 首先分析默认的ShuffleWriter,SortShuffleWriter,其实现依赖于ExternalSorter,即外部排序,看源码的注释, 原理是基桶排序,先将key分到不同的partition中,然后每个partition中单独进行排序,并且这里有合并参数, 控制是否对相同key的value进行合并。经过排序后将不同partition的数据写到文件中。 final class BypassMergeSortShuffleWriter extends ShuffleWriter { public void write(Iterator> records) throws IOException { assert (partitionWriters == null); if (!records.hasNext()) { partitionLengths = new long[numPartitions]; shuffleBlockResolver.writeIndexFileAndCommit(shuffleId, mapId, partitionLengths, null); mapStatus = MapStatus$.MODULE$.apply(blockManager.shuffleServerId(), partitionLengths); return; } final SerializerInstance serInstance = serializer.newInstance(); final long openStartTime = System.nanoTime(); partitionWriters = new DiskBlockObjectWriter[numPartitions]; partitionWriterSegments = new FileSegment[numPartitions]; for (int i = 0; i < numPartitions; i++) { final Tuple2 tempShuffleBlockIdPlusFile = blockManager.diskBlockManager().createTempShuffleBlock(); final File file = tempShuffleBlockIdPlusFile._2(); final BlockId blockId = tempShuffleBlockIdPlusFile._1(); partitionWriters[i] = blockManager.getDiskWriter(blockId, file, serInstance, fileBufferSize, writeMetrics); } // Creating the file to write to and creating a disk writer both involve interacting with // the disk, and can take a long time in aggregate when we open many files, so should be // included in the shuffle write time. writeMetrics.incWriteTime(System.nanoTime() - openStartTime); while (records.hasNext()) { final Product2 record = records.next(); final K key = record._1(); partitionWriters[partitioner.getPartition(key)].write(key, record._2()); } for (int i = 0; i < numPartitions; i++) { final DiskBlockObjectWriter writer = partitionWriters[i]; partitionWriterSegments[i] = writer.commitAndGet(); writer.close(); } File output = shuffleBlockResolver.getDataFile(shuffleId, mapId); File tmp = Utils.tempFileWith(output); partitionLengths = writePartitionedFile(tmp); shuffleBlockResolver.writeIndexFileAndCommit(shuffleId, mapId, partitionLengths, tmp); mapStatus = MapStatus$.MODULE$.apply(blockManager.shuffleServerId(), partitionLengths); } } BypassMergeSortShuffleWriter就是过去的HashShuffle,从代码中可以看到`partitionWriters`会为每个map和 reduce生成一个partition文件writer,但是由于这会造成大量的小文件,所以这里会将这些小文件合并成一个大 文件,然后reduce在读取的时候同样是利用偏移量进行读取。`partitionWriterSegments`可以看作partition文件 的集合,然后调用`partitionLengths = writePartitionedFile(tmp)`将其彻底合并为一个文件,`partitionLengths` 对应的则是各个分片的大小,所以利用同样的原理生成索引文件。这种shuffle方式只有在特定的条件下会发挥不错, 具体可以看代码注释。 public class UnsafeShuffleWriter extends ShuffleWriter { public void write(scala.collection.Iterator> records) throws IOException { // Keep track of success so we know if we encountered an exception // We do this rather than a standard try/catch/re-throw to handle // generic throwables. boolean success = false; try { while (records.hasNext()) { insertRecordIntoSorter(records.next()); } closeAndWriteOutput(); success = true; } finally { if (sorter != null) { try { sorter.cleanupResources(); } catch (Exception e) { // Only throw this error if we won't be masking another // error. if (success) { throw e; } else { logger.error("In addition to a failure during writing, we failed during " + "cleanup.", e); } } } } } @VisibleForTesting void insertRecordIntoSorter(Product2 record) throws IOException { assert(sorter != null); final K key = record._1(); final int partitionId = partitioner.getPartition(key); serBuffer.reset(); serOutputStream.writeKey(key, OBJECT_CLASS_TAG); serOutputStream.writeValue(record._2(), OBJECT_CLASS_TAG); serOutputStream.flush(); final int serializedRecordSize = serBuffer.size(); assert (serializedRecordSize > 0); sorter.insertRecord( serBuffer.getBuf(), Platform.BYTE_ARRAY_OFFSET, serializedRecordSize, partitionId); } void closeAndWriteOutput() throws IOException { assert(sorter != null); updatePeakMemoryUsed(); serBuffer = null; serOutputStream = null; final SpillInfo[] spills = sorter.closeAndGetSpills(); sorter = null; final long[] partitionLengths; final File output = shuffleBlockResolver.getDataFile(shuffleId, mapId); final File tmp = Utils.tempFileWith(output); try { partitionLengths = mergeSpills(spills, tmp); } finally { for (SpillInfo spill : spills) { if (spill.file.exists() && ! spill.file.delete()) { logger.error("Error while deleting spill file {}", spill.file.getPath()); } } } shuffleBlockResolver.writeIndexFileAndCommit(shuffleId, mapId, partitionLengths, tmp); mapStatus = MapStatus$.MODULE$.apply(blockManager.shuffleServerId(), partitionLengths); } private long[] mergeSpills(SpillInfo[] spills, File outputFile) throws IOException { final boolean compressionEnabled = sparkConf.getBoolean("spark.shuffle.compress", true); final CompressionCodec compressionCodec = CompressionCodec$.MODULE$.createCodec(sparkConf); final boolean fastMergeEnabled = sparkConf.getBoolean("spark.shuffle.unsafe.fastMergeEnabled", true); final boolean fastMergeIsSupported = !compressionEnabled || CompressionCodec$.MODULE$.supportsConcatenationOfSerializedStreams(compressionCodec); try { if (spills.length == 0) { new FileOutputStream(outputFile).close(); // Create an empty file return new long[partitioner.numPartitions()]; } else if (spills.length == 1) { // Here, we don't need to perform any metrics updates because the bytes written to this // output file would have already been counted as shuffle bytes written. Files.move(spills[0].file, outputFile); return spills[0].partitionLengths; } else { final long[] partitionLengths; if (fastMergeEnabled && fastMergeIsSupported) { // Compression is disabled or we are using an IO compression codec that supports // decompression of concatenated compressed streams, so we can perform a fast spill merge // that doesn't need to interpret the spilled bytes. if (transferToEnabled) { logger.debug("Using transferTo-based fast merge"); partitionLengths = mergeSpillsWithTransferTo(spills, outputFile); } else { logger.debug("Using fileStream-based fast merge"); partitionLengths = mergeSpillsWithFileStream(spills, outputFile, null); } } else { logger.debug("Using slow merge"); partitionLengths = mergeSpillsWithFileStream(spills, outputFile, compressionCodec); } writeMetrics.decBytesWritten(spills[spills.length - 1].file.length()); writeMetrics.incBytesWritten(outputFile.length()); return partitionLengths; } } catch (IOException e) { if (outputFile.exists() && !outputFile.delete()) { logger.error("Unable to delete output file {}", outputFile.getPath()); } throw e; } } } UnsafeShuffleWriter与SortShuffleWriter最大的不同在于二者使用的排序对象。前者使用的是ShuffleExternalSorter, 其排序使用的是ShuffleInMemorySorter,但该Sorter本身使用的排序有两种可供选择:RadixSort和TimSort。而且排序是 基于堆外记录的地址(不是Java对象),这会减少堆内内存开销和GC。 与ExternalSorter不同的是,其不会对溢出(内存装不下)数据进行合并,而是将合并工作转交给UnsafeShuffleWriter, 由于特殊的处理过程,可以省去额外的序列化和反序列化的操作。可以发现write的操作主要是调用insertRecordIntoSorter 插入数据,然后也是调用`sorter.insertRecord(...)`来完成,进一步探索就可以看到其只是在数据太大(内存装不下)的 时候将一部分数据放到磁盘上,减轻内存压力。然后在UnsafeShuffleWriter中调用mergeSpills完成对溢出数据的合并,基于 溢出文件数和IO压缩编解码来选择最快的合并策略。这需要序列化器允许序列化后的记录可以不用反序列化就可以连接合并。 此外对堆外内存数据的排序仅需要对其在堆内的地址映射进行排序,堆外的对象无需移动,所以ShuffleExternalSorter仅需要依据key对其地址(堆内的数组里)进行排序。有两种快速合并溢出文件的方式,FileStream和TransferTo。这两种方式直 接在序列化记录上操作,而不需要在合并的时候反序列化(其区别在于压缩方式是否支持之间合并)。而且如果压缩编解码支持压缩数据连接操作,那么就可以直接将文 件连接起来(但依据不同的压缩方式而定)。所以这一切都得益于编码和压缩,这种合并并不适应所有情况,如aggregation, 输出有序,因为而这会涉及到全局的数据信息,所以还需解压,这种情况下,就会退化成SortShuffleManager。 insertRecordIntoSorter中先是利用`serOutputStream`将record加入序列化流,但这里还有一个`serBuffer`是byte数组形式 的输出流,这二者的关系在open方法中有体现,`serBuffer`实际上是一个ByteArrayOutputStream的对象,只是它对外暴露了 自己的`buf` private void open() throws IOException { assert (sorter == null); sorter = new ShuffleExternalSorter( memoryManager, blockManager, taskContext, initialSortBufferSize, partitioner.numPartitions(), sparkConf, writeMetrics); serBuffer = new MyByteArrayOutputStream(1024 * 1024); serOutputStream = serializer.serializeStream(serBuffer); } 所以在 sorter.insertRecord( serBuffer.getBuf(), Platform.BYTE_ARRAY_OFFSET, serializedRecordSize, partitionId); 这句中可以看到,利用`serBuffer`直接将buffer中的内容传到了sorter中,由其进行排序。然后在closeAndWriteOutput方法中 对数据进行写文件的操作,前面的部分类似于其他Writer,不同的是`partitionLengths = mergeSpills(spills, tmp);`,而spills 则是sorter的输出,spills的类型是`SpillInfo[]`,这个类其实就是记录了溢出数据的文件,以及包含的分片长度数组和块id。 转入mergeSpills,可以发现压缩编解码的内容,但是并没有发现和sorter或者spills的联系,其实压缩编解码的过程是在`SerializerManager` 中完成的,其实也就是序列化的过程,该类的作用就是依据配置自动选择序列化方式和压缩方式。然后就是依据不同的压缩配置 来选择不同的合并方式,`mergeSpillsWithTransferTo`是基于NIO转换的合并方式,速度快,但当压缩和序列化支持连接操作时。 `mergeSpillsWithFileStream`更适用压缩不支持直接合并的情况,因为该过程需要先解压缩,然后再进行压缩,所以速度相对慢一点。 ### ShuffleReader分析 SortShuffleManager依赖的ShuffleReader是BlockStoreShuffleReader,其作用就是从其他节点将该reduce所要读取的数据段拉过来。 其只包含方法read private[spark] class BlockStoreShuffleReader[K, C]( handle: BaseShuffleHandle[K, _, C], startPartition: Int, endPartition: Int, context: TaskContext, serializerManager: SerializerManager = SparkEnv.get.serializerManager, blockManager: BlockManager = SparkEnv.get.blockManager, mapOutputTracker: MapOutputTracker = SparkEnv.get.mapOutputTracker) extends ShuffleReader[K, C] with Logging { override def read(): Iterator[Product2[K, C]] = { val blockFetcherItr = new ShuffleBlockFetcherIterator( context, blockManager.shuffleClient, blockManager, mapOutputTracker.getMapSizesByExecutorId(handle.shuffleId, startPartition, endPartition), SparkEnv.get.conf.getSizeAsMb("spark.reducer.maxSizeInFlight", "48m") * 1024 * 1024, SparkEnv.get.conf.getInt("spark.reducer.maxReqsInFlight", Int.MaxValue)) // Wrap the streams for compression based on configuration val wrappedStreams = blockFetcherItr.map { case (blockId, inputStream) => serializerManager.wrapForCompression(blockId, inputStream) } val serializerInstance = dep.serializer.newInstance() // Create a key/value iterator for each stream val recordIter = wrappedStreams.flatMap { wrappedStream => serializerInstance.deserializeStream(wrappedStream).asKeyValueIterator } // An interruptible iterator must be used here in order to support task cancellation val interruptibleIter = new InterruptibleIterator[(Any, Any)](context, metricIter) val aggregatedIter: Iterator[Product2[K, C]] = if (dep.aggregator.isDefined) { if (dep.mapSideCombine) { // We are reading values that are already combined val combinedKeyValuesIterator = interruptibleIter.asInstanceOf[Iterator[(K, C)]] dep.aggregator.get.combineCombinersByKey(combinedKeyValuesIterator, context) } else { // We don't know the value type, but also don't care -- the dependency *should* // have made sure its compatible w/ this aggregator, which will convert the value // type to the combined type C val keyValuesIterator = interruptibleIter.asInstanceOf[Iterator[(K, Nothing)]] dep.aggregator.get.combineValuesByKey(keyValuesIterator, context) } } else { require(!dep.mapSideCombine, "Map-side combine without Aggregator specified!") interruptibleIter.asInstanceOf[Iterator[Product2[K, C]]] } // Sort the output if there is a sort ordering defined. dep.keyOrdering match { case Some(keyOrd: Ordering[K]) => // Create an ExternalSorter to sort the data. Note that if spark.shuffle.spill is disabled, // the ExternalSorter won't spill to disk. val sorter = new ExternalSorter[K, C, C](context, ordering = Some(keyOrd), serializer = dep.serializer) sorter.insertAll(aggregatedIter) context.taskMetrics().incMemoryBytesSpilled(sorter.memoryBytesSpilled) context.taskMetrics().incDiskBytesSpilled(sorter.diskBytesSpilled) context.taskMetrics().incPeakExecutionMemory(sorter.peakMemoryUsedBytes) CompletionIterator[Product2[K, C], Iterator[Product2[K, C]]](sorter.iterator, sorter.stop()) case None => aggregatedIter } } } 为了简洁,这里删去部分代码,前两句是获取打包后的数据(因为不同节点对应的map输出并不在一起),第一句使用到 `mapOutputTracker`,也就是reduce必须通过它才能知道自己应该拉取的数据在哪台物理节点上。第4句是用于反序列化 数据。后面的内容是针对aggregate操作和输出排序的。 [1]: ./spark_shuffle_new.md ================================================ FILE: analysis/core/task_schedule.md ================================================ # Spark的任务调度机制 ## 从TaskScheduler开始 TaskScheduler的主要作用就是获得需要处理的任务集合,并将其发送到集群进行处理。并且还有汇报任务运行状态的作用。 所以其是在Master端。具体有以下4个作用: * 接收来自Executor的心跳信息,使Master知道该Executer的BlockManager还“活着”。 * 对于失败的任务进行重试。 * 对于stragglers(拖后腿的任务)放到其他的节点执行。 * 向集群提交任务集,交给集群运行。 ### TaskScheduler创建 对于调度器,都是在SparkContext中进行创建的。 // SparkContext初始化块中 val (sched, ts) = SparkContext.createTaskScheduler(this, master, deployMode) _schedulerBackend = sched _taskScheduler = ts `createTaskScheduler(this, master, deployMode)`是利用部署方式创建具体的TaskScheduler,这里我们发现有一个`sched` 变量,SchedulerBackend类型,该类型表示不同的后端调度,什么是后端调度,例如:Local,Standalone,Yarn、Mesos等。 对于外部的(与Standalone对应)的后端,其都继承自CoarseGrainedSchedulerBackend,命名表明这只是个粗粒度的后端,具体的 细粒度的实现在不同后端系统模块有实现。所有外部调度器和后端的生成是依赖ClusterManager来完成的,外部的ClusterManager 都继承自ExternalClusterManager。通过ExternalClusterManager的`createTaskScheduler`和`createSchedulerBackend`来完成 对TaskScheduler和还有SchedulerBackend的生成。具体可以以mesos为例,去mesos模块下去看实现。 至于TaskScheduler,其主要的实现就是TaskSchedulerImpl,Local模式、Standalone模式和Mesos模式都是用的这个实现。这里列举 部分组合方式: * Local模式:TaskSchedulerImpl+LocalSchedulerBackend * Spark Standalone集群模式:TaskSchedulerImpl+StandaloneSchedulerBackend * Mesos集群模式:TaskSchedulerImpl+MesosFineGrainedSchedulerBackend或MesosCoarseGrainedSchedulerBackend * Yarn集群模式:YarnClusterScheduler+YarnClusterSchedulerBackend TaskScheduler和SchedulerBackend对象生成之后,会利用`scheduler.initialize(backend)`将二者配套。 这里有个问题没有处理,就是之前说过TaskScheduler中会接收心跳信息,那么通信过程是在哪里实现的呢?答案是HeartbeatReceiver中, HeartbeatReceiver是一个SparkListener,并且是一个RPC通信的入口,在其`receiveAndReply`中会有 case TaskSchedulerIsSet => scheduler = sc.taskScheduler context.reply(true) 这句将其TaskScheduler进行初始化,那么心跳的接收以及处理就实现了。同时对于丢失Executor的处理也是在HeartbeatReceiver中完成的。 ### TaskScheduler启动 TaskScheduler的启动也是在SparkContext初始化块中完成的。以TaskSchedulerImpl和StandaloneSchedulerBackend为例,分析器启动过程。 TaskScheduler的启动函数首先会启动`backend`,并启动一个`speculationScheduler`守护线程,用于检测当前Job的speculatable tasks。 speculatable tasks的应用场景是,如果一个任务执行时间过长,那么就将其判断为speculatable tasks(拖后腿了),然后将这个任务分配在 另外一台机器上,这时同一个任务会有两个执行,其中一台机器成功返回之后,会通知另外一台机器结束任务。 进入StandaloneSchedulerBackend的`start`函数,首先通过配置生成Dirver的rpc访问点。SchedulerBackend的作用如下: * 向cluster manager请求启动新的Executor,或关闭特定的Executor。 * 关闭特定Executor上的特定任务。 * 收集Worker信息,比如任务的尝试次数,计算资源(就是CPU核数)等。 TaskScheduler则是通过收集的`backend`收集的资源信息针对任务作出合理的资源分配。在利用`backend`初始化的时候,其已经利用不同 的调度模式建立了对应的任务池(Pool),可以将其简单理解为一组遵循特定调度算法的任务。 ### TaskScheduler提交任务集 由于TaskScheduler的函数很多,但最为重要的毕竟是提交任务给集群进行处理。 override def submitTasks(taskSet: TaskSet) { val tasks = taskSet.tasks this.synchronized { val manager = createTaskSetManager(taskSet, maxTaskFailures) val stage = taskSet.stageId val stageTaskSets = taskSetsByStageIdAndAttempt.getOrElseUpdate(stage, new HashMap[Int, TaskSetManager]) stageTaskSets(taskSet.stageAttemptId) = manager val conflictingTaskSet = stageTaskSets.exists { case (_, ts) => ts.taskSet != taskSet && !ts.isZombie } schedulableBuilder.addTaskSetManager(manager, manager.taskSet.properties) ... backend.reviveOffers() } 首先注意到它会针对每个任务集(一个Stage的所有任务)生成对应的TaskSetManager,该类会跟踪每个任务,如果其失败会 进行重试,以及对于该任务集通过延迟调度来使其尽量保证是本地调度。该类的主要方法就是`resourceOffer`,其主要是判断 task的locality并最终调用`addRunningTask`来添加运行的任务,并调用DAGScheduler来启动任务,以下是源码: def resourceOffer( execId: String, host: String, maxLocality: TaskLocality.TaskLocality) : Option[TaskDescription] = { if (!isZombie) { val curTime = clock.getTimeMillis() var allowedLocality = maxLocality dequeueTask(execId, host, allowedLocality) match { case Some((index, taskLocality, speculative)) => val task = tasks(index) val taskId = sched.newTaskId() copiesRunning(index) += 1 val attemptNum = taskAttempts(index).size val info = new TaskInfo(taskId, index, attemptNum, curTime, execId, host, taskLocality, speculative) taskInfos(taskId) = info taskAttempts(index) = info :: taskAttempts(index) // Serialize and return the task val startTime = clock.getTimeMillis() val serializedTask: ByteBuffer = try { Task.serializeWithDependencies(task, sched.sc.addedFiles, sched.sc.addedJars, ser) } catch { ... } addRunningTask(taskId) sched.dagScheduler.taskStarted(task, info) return Some(new TaskDescription(taskId = taskId, attemptNumber = attemptNum, execId, taskName, index, serializedTask)) case _ => } } None } 这里出现了TaskLocality,那它是如何定义的呢?其实是一个枚举类,共分为PROCESS_LOCAL,NODE_LOCAL,NO_PREF, RACK_LOCAL,ANY,分别表示同个进程里,同个node(即机器)上,同机架,随意,很明显优先级已达到小。`dequeueTask` 是使任务从等待的任务队列中出列,返回任务的索引、locality(因为传入了Executor Id和节点Id,所以可以判断)和是 否是speculative任务。然后生成对应的任务信息,并把任务进行序列化。将该任务加入正在运行的任务集合中之后,通过 DAGScheduler来启动任务。但是这里的启动仅仅指的是Driver端认为其启动了。真正和Slave是如何通信的呢?其实利用的 就是`backend`,下面我们一步步分析。 `submitTasks`中创建TaskSetManager中之后会将其加入`schedulableBuilder`中,其实就是`schedulableBuilder`的`rootPool` 中。这样TaskSetManager就算提交了,那什么时候才会触发执行操作呢? 顺着`rootPool`->`TaskSchedulerImpl.resourceOffers`->`CoarseGrainedSchedulerBackend.launchTasks`->`CoarseGrainedSchedulerBackend.makeOffers`->`CoarseGrainedSchedulerBackend.receive`。 //CoarseGrainedSchedulerBackend override def receive: PartialFunction[Any, Unit] = { case StatusUpdate(executorId, taskId, state, data) => scheduler.statusUpdate(taskId, state, data.value) if (TaskState.isFinished(state)) { executorDataMap.get(executorId) match { case Some(executorInfo) => executorInfo.freeCores += scheduler.CPUS_PER_TASK makeOffers(executorId) ... } } case ReviveOffers => makeOffers() ... } 发现是在任务的状态更新之后或者收到`ReviveOffers`消息之后,这个消息是通过`reviveOffers`方法发送的,正好我们在`submitTasks` 的最后一句发现有`backend.reviveOffers()`,所以也就是这时触发了申请资源的操作。 进入`makeOffers` //CoarseGrainedSchedulerBackend private def makeOffers() { // Filter out executors under killing val activeExecutors = executorDataMap.filterKeys(executorIsAlive) val workOffers = activeExecutors.map { case (id, executorData) => new WorkerOffer(id, executorData.executorHost, executorData.freeCores) }.toSeq launchTasks(scheduler.resourceOffers(workOffers)) } 首先将活跃的节点及上面的空闲核数等信息提取出来,申请资源,生成任务信息,传入`launchTasks`。先分析资源申请的过程。 //TaskSchedulerImpl def resourceOffers(offers: Seq[WorkerOffer]): Seq[Seq[TaskDescription]] = synchronized { // Randomly shuffle offers to avoid always placing tasks on the same set of workers. val shuffledOffers = Random.shuffle(offers) // Build a list of tasks to assign to each worker. val tasks = shuffledOffers.map(o => new ArrayBuffer[TaskDescription](o.cores)) val availableCpus = shuffledOffers.map(o => o.cores).toArray val sortedTaskSets = rootPool.getSortedTaskSetQueue var launchedTask = false for (taskSet <- sortedTaskSets; maxLocality <- taskSet.myLocalityLevels) { do { launchedTask = resourceOfferSingleTaskSet( taskSet, maxLocality, shuffledOffers, availableCpus, tasks) } while (launchedTask) } if (tasks.size > 0) { hasLaunchedTask = true } return tasks } 上面的代码只保留了核心代码,首先该方法会对可用的Worker打乱顺序,因为这可避免每次都把任务分配给数组前面的Worker, 然后生成对应的任务描述信息,利用`rootPool`中的调度方式对任务进行排序,然后进入`resourceOfferSingleTaskSet`针对 每个任务集申请资源。 private def resourceOfferSingleTaskSet( taskSet: TaskSetManager, maxLocality: TaskLocality, shuffledOffers: Seq[WorkerOffer], availableCpus: Array[Int], tasks: Seq[ArrayBuffer[TaskDescription]]) : Boolean = { var launchedTask = false for (i <- 0 until shuffledOffers.size) { val execId = shuffledOffers(i).executorId val host = shuffledOffers(i).host if (availableCpus(i) >= CPUS_PER_TASK) { try { for (task <- taskSet.resourceOffer(execId, host, maxLocality)) { tasks(i) += task ... } } catch { ... } } } if (!launchedTask) { taskSet.abortIfCompletelyBlacklisted(executorIdToHost.keys) } return launchedTask } 该方法主要是调用上面提到的TaskSetManager的`resourceOffer`,所以会返回序列化后的任务信息。然后加入到`tasks`中, 这样TaskSchedulerImpl的`resourceOffers`也完成了。现在来看`launchTasks`的处理过程。 //CoarseGrainedSchedulerBackend private def launchTasks(tasks: Seq[Seq[TaskDescription]]) { for (task <- tasks.flatten) { val serializedTask = ser.serialize(task) if (serializedTask.limit >= maxRpcMessageSize) { ... } else { val executorData = executorDataMap(task.executorId) ... executorData.executorEndpoint.send(LaunchTask(new SerializableBuffer(serializedTask))) } } } 首先取出序列化任务的信息,然后取出分配给该任务的Executor,然后向该Executor发送启动任务的消息。该消息被Executor端的 CoarseGrainedExecutorBackend接收到 //CoarseGrainedExecutorBackend override def receive: PartialFunction[Any, Unit] = { ... case LaunchTask(data) => if (executor == null) { exitExecutor(1, "Received LaunchTask command but executor was null") } else { val taskDesc = ser.deserialize[TaskDescription](data.value) executor.launchTask(this, taskId = taskDesc.taskId, attemptNumber = taskDesc.attemptNumber, taskDesc.name, taskDesc.serializedTask) } ... } 收到启动任务的信息后,会先进行任务信息的反序列化,然后该`executor`启动任务,Executor有一组线程组成。 //Executor def launchTask( context: ExecutorBackend, taskId: Long, attemptNumber: Int, taskName: String, serializedTask: ByteBuffer): Unit = { val tr = new TaskRunner(context, taskId = taskId, attemptNumber = attemptNumber, taskName, serializedTask) runningTasks.put(taskId, tr) threadPool.execute(tr) } 该方法会从线程池中选择可用线程运行任务。 然后就是序列化任务结果,将其交给`ExecutorBackend`(调用CoarseGrainedExecutorBackend.statusUpdate),由`ExecutorBackend` 将结果返回给Driver端的SchedulerBackend(CoarseGrainedSchedulerBackend.receive),其收到会更新状态,即`statusUpdate` 方法。 def statusUpdate(tid: Long, state: TaskState, serializedData: ByteBuffer) { var failedExecutor: Option[String] = None synchronized { try { ... taskIdToTaskSetManager.get(tid) match { case Some(taskSet) => if (TaskState.isFinished(state)) { ... } if (state == TaskState.FINISHED) { taskSet.removeRunningTask(tid) taskResultGetter.enqueueSuccessfulTask(taskSet, tid, serializedData) } else if (Set(TaskState.FAILED, TaskState.KILLED, TaskState.LOST).contains(state)) { ... } case None => ... } } ... } ... } 这里仅保留任务成功结束的处理情况,可以发现首先是移除掉任务编号,然后取出任务结果。 def enqueueSuccessfulTask( taskSetManager: TaskSetManager, tid: Long, serializedData: ByteBuffer): Unit = { getTaskResultExecutor.execute(new Runnable { override def run(): Unit = Utils.logUncaughtExceptions { try { val (result, size) = serializer.get().deserialize[TaskResult[_]](serializedData) match { case directResult: DirectTaskResult[_] => if (!taskSetManager.canFetchMoreResults(serializedData.limit())) { return } directResult.value() (directResult, serializedData.limit()) case IndirectTaskResult(blockId, size) => ... } ... scheduler.handleSuccessfulTask(taskSetManager, tid, result) } catch { ... } } }) } 取出任务结果后,交给TaskScheduler来处理成功的任务,其实是调用TaskSetManager的`handleSuccessfulTask`方法。该方法 调用DAGScheduler的`taskEnded`方法来发送CompletionEvent类型的消息(简而言之就是TaskSetManager向DAGScheduler发送消息), DAGScheduler收到该消息后就会调用`handleTaskCompletion`方法进行处理(见[Spark基础及Shuffle实现][1])。 至此任务的提交处理的整个过程就走通了。 [1]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/spark_shuffle.md ================================================ FILE: analysis/sql/spark_sql_execution.md ================================================ # Spark SQL 执行阶段 本文是从生成PhysicalPlan之后开始说起。 //QueryExecution lazy val executedPlan: SparkPlan = prepareForExecution(sparkPlan) lazy val toRdd: RDD[InternalRow] = executedPlan.execute() 主要针对以上两条语句。首先`executedPlan`是对已生成的PhysicalPlan做一些处理,主要是插入shuffle操作和internal row的格式转换。 //QueryExecution protected def prepareForExecution(plan: SparkPlan): SparkPlan = { preparations.foldLeft(plan) { case (sp, rule) => rule.apply(sp) } } protected def preparations: Seq[Rule[SparkPlan]] = Seq( python.ExtractPythonUDFs, PlanSubqueries(sparkSession), EnsureRequirements(sparkSession.sessionState.conf), CollapseCodegenStages(sparkSession.sessionState.conf), ReuseExchange(sparkSession.sessionState.conf), ReuseSubquery(sparkSession.sessionState.conf)) ## Preparations规则 和Python相关的不介绍。 ### PlanSubqueries case class PlanSubqueries(sparkSession: SparkSession) extends Rule[SparkPlan] { def apply(plan: SparkPlan): SparkPlan = { plan.transformAllExpressions { case subquery: expressions.ScalarSubquery => val executedPlan = new QueryExecution(sparkSession, subquery.plan).executedPlan ScalarSubquery( SubqueryExec(s"subquery${subquery.exprId.id}", executedPlan), subquery.exprId) case expressions.PredicateSubquery(query, Seq(e: Expression), _, exprId) => val executedPlan = new QueryExecution(sparkSession, query).executedPlan InSubquery(e, SubqueryExec(s"subquery${exprId.id}", executedPlan), exprId) } } } 1. 将ScalarSubquery生成SparkPlan,其实回顾上一篇[Spark SQL Physical Plan][1],并没有对ScalarSubquery做处理。原因何在?实际上如果解析的是SQL语句,是不会产生这个节点的, 只有在人为产生LogicalPlan的时候才可能会出现。所以Subquery之前是一直没有被转化的,这里就进行转化,其对应的SparkPlan节点是ScalarSubquery(execution),和之前的ScalarSubquery(expressions)是不同的,二者属于不同的包,只是同名。 2. 将PredicateSubquery生成SparkPlan。何谓PredicateSubquery?该种子查询会检查其子查询结果中是否存在某个值,现在只允许将谓词表达式放在Filter Plan中(WHERE或HAVING块中)。实际上该查询和ScalarSubquery一样,如果直接解析SQL语句,是不会出现这个类的。 其实Subquery类的子类中只有这两类是SQL语句不会生成的,其他两个是Exist和ListQuery(即IN包含的块)。该规则最后生成InSubquery,该类就是检查查询中的记录包不包含谓词表达式的结果,所以该节点的结果有true、false、null三种。 ### EnsureRequirements //EnsureRequirements def apply(plan: SparkPlan): SparkPlan = plan.transformUp { case operator @ ShuffleExchange(partitioning, child, _) => child.children match { case ShuffleExchange(childPartitioning, baseChild, _)::Nil => if (childPartitioning.guarantees(partitioning)) child else operator case _ => operator } case operator: SparkPlan => ensureDistributionAndOrdering(operator) } 1. 将ShuffleExchange类型的节点做处理,这个类只会出现在repartition的时候,所以如果该节点的子节点已经可以保证partition,那么就用子节点代替该节点。 //EnsureRequirements private def ensureDistributionAndOrdering(operator: SparkPlan): SparkPlan = { val requiredChildDistributions: Seq[Distribution] = operator.requiredChildDistribution val requiredChildOrderings: Seq[Seq[SortOrder]] = operator.requiredChildOrdering assert(requiredChildDistributions.length == operator.children.length) assert(requiredChildOrderings.length == operator.children.length) def createShuffleExchange(dist: Distribution, child: SparkPlan) = ShuffleExchange(createPartitioning(dist, defaultNumPreShufflePartitions), child) var (parent, children) = operator match { case PartialAggregate(childDist) if !operator.outputPartitioning.satisfies(childDist) => val (mergeAgg, mapSideAgg) = AggUtils.createMapMergeAggregatePair(operator) (mergeAgg, createShuffleExchange(requiredChildDistributions.head, mapSideAgg) :: Nil) case _ => // Ensure that the operator's children satisfy their output distribution requirements: val childrenWithDist = operator.children.zip(requiredChildDistributions) val newChildren = childrenWithDist.map { case (child, distribution) if child.outputPartitioning.satisfies(distribution) => child case (child, BroadcastDistribution(mode)) => BroadcastExchangeExec(mode, child) case (child, distribution) => createShuffleExchange(distribution, child) } (operator, newChildren) } def requireCompatiblePartitioning(distribution: Distribution): Boolean = distribution match { case UnspecifiedDistribution => false case BroadcastDistribution(_) => false case _ => true } ... children = withExchangeCoordinator(children, requiredChildDistributions) ... parent.withNewChildren(children) } 2. 其他类型的节点的处理函数如上。 * DIstribution表示数据的分布方式,其分布方式有两个方面,集群分布和单个partition内的分布,分别表示数据如何被分布到集群上和每个partition内部的分布情况。`requiredChildDistribution`会有不同的实现,简单看了一下Aggregation操作的实现,其实就是尽量将有联系的tuple放在一起(用表达式判断联系)。作用单位是一个SparkPlan节点。 * SortOrder,一个表达式可以用于对tuple进行排序,作用单位是Expression。 可以发现`requiredChildDistributions`和`requiredChildOrderings`长度都是该SparkPlan的子节点个数,也就是每个子节点可能有不同的分布和排序。 然后利用PartialAggregate提取出该Plan第一个子节点的分布方式(同时判断是否支持partial aggregations,该操作类似于combiner,但适用范围更广,不支持该特性的操作不多,在[上一篇][1]中有介绍),并且其输出不满足该分布,那么就为其生成一个map端的aggregation节点和一个合并节点,并且利用map端的aggregation节点生成ShuffleExchange节点。 **简而言之,如果一个Aggregation操作需要Shuffle并且支持partial aggregations,先在map端进行部分aggregations,然后shuffle,最后合并。** 对于其他节点,就是如果子节点的输出已经满足该子节点对应的分布情况,就直接用子节点代替(省去了重新分布,即shuffle的过程)。如果子节点的分布模式是`BroadcastDistribution`,就为其生成一个`BroadcastExchangeExec`类型的节点。其他情况就直接生成ShuffleExchange节点。 省略的代码很长,主要功能就是当该SparkPlan有多个子节点的时候,并且指定了子节点的分布,那么子节点的输出分区一定要相互兼容。兼不兼容的判断条件是,分区数不同一定不兼容,分区数相同的情况下分区看策略是否相同。 如果不兼容,首先判断子节点的输出分区是否都满足各自的分布策略,如果是就不做处理(因为`requiredChildDistributions`中的分布是兼容的,因此这种情况下子节点输出分区也相互兼容)。 如果已不能用现有分区,先确定新的分区数,策略是如果所有子节点输出的分区与对应分布策略都不兼容,那么就用默认分区数目,反之则用这些节点中最大的分区数目。最后扫描各节点,如果与新的分布策略不匹配,那么就对其输出重新Shuffle(分布策略依然用对应分区策略,分区数目就是新确定的),形成新的分区。 接下来的工作就是为子节点增加ExchangeCoordinator,该类表示一个协调器,现在是想的作用就是确定Shuffle后partition的数目。 下面省略的代码表示为子节点增加必要的排序方式,策略类似于前面,就是如果子节点输出的排序已经满足对应的排序,即直接用子节点,否则就在其包裹一层排序节点。最后一句就是合并。其整体结构如下: ![add shuffle and sort][ensureDistributionAndOrdering] ### CollapseCodegenStages 该规则是在支持codegen的节点顶端插入WholeStageCodegen。可参考[Apache Spark as a Compiler: Joining a Billion Rows per Second on a Laptop][2]。 //WholeStageCodegenExec.scala private def insertWholeStageCodegen(plan: SparkPlan): SparkPlan = plan match { // For operators that will output domain object, do not insert WholeStageCodegen for it as // domain object can not be written into unsafe row. case plan if plan.output.length == 1 && plan.output.head.dataType.isInstanceOf[ObjectType] => plan.withNewChildren(plan.children.map(insertWholeStageCodegen)) case plan: CodegenSupport if supportCodegen(plan) => WholeStageCodegenExec(insertInputAdapter(plan)) case other => other.withNewChildren(other.children.map(insertWholeStageCodegen)) } private def supportCodegen(plan: SparkPlan): Boolean = plan match { case plan: CodegenSupport if plan.supportCodegen => val willFallback = plan.expressions.exists(_.find(e => !supportCodegen(e)).isDefined) val hasTooManyOutputFields = numOfNestedFields(plan.schema) > conf.wholeStageMaxNumFields val hasTooManyInputFields = plan.children.map(p => numOfNestedFields(p.schema)).exists(_ > conf.wholeStageMaxNumFields) !willFallback && !hasTooManyOutputFields && !hasTooManyInputFields case _ => false } 只有一种条件(第二个case)会插入WholeStageCodegen。第一种条件表明输出类型是特定类型并且仅输出一个属性。那么什么算是支持codegen呢?首先必须继承了CodegenSupport特质,并且`supportCodegen`为true, 很多节点都实现了该trait,但是是否支持的判断条件各不相同,这里不赘述。这里有对`supportCodegen`的重载,`willFallBack`用于判断是否有表达式的代码生成支持回滚,为什么回滚的不能生成WholeStageCodegen? 大概是因为实现了该trait的类本身没有相应的执行方式,其操作太复杂或者有些第三方组件无法集成到生成的代码中,所以没有用java(scala)代码生成。`hasTooManyOutputFields`保证输出的数据类型不能有太多的field。 而且`hasTooManyInputFields`表明每个子节点的输入数据也都不能超出上限(conf.wholeStageMaxNumFields)。 ### ReuseExchange 该规则是针对重复的Exchange的。Exchange是一个抽象类,所有子类都和多线程或进程的数据交换有关,之前提到的ShuffleExchange和BroadcastExchangeExec是它仅有的两个子类。如果之前有相同类型的Exchange,并且输出结果相同(条件就是判断递归输出类型,参数个数,子树大小是否相同,基本就是说树结构相同,输出结果就相同), 那么就用之前生成的节点替换,即重用之前生成的节点。该规则逻辑比较简单清晰,这里就不贴代码了。 ### ReuseSubquery 处理基本同ReuseExchange,只是操作对象换成了ExecSubqueryExpression。 ## execute方法介绍 每个SparkPlan都有一个`execute`方法,其调用`doExecute`方法,各个子类会实现具体的doExecute方法。这里以select XXX from XXX where XXX为例介绍。该语句涉及到的SparkPlan节点很少,只有FilterExec,ProjectExec和InMemoryTableScanExec(假设是InMemoryRealtion)。 ![execution example][execution] ### ProjectExec执行 //ProjectExec protected override def doExecute(): RDD[InternalRow] = { child.execute().mapPartitionsInternal { iter => val project = UnsafeProjection.create(projectList, child.output, subexpressionEliminationEnabled) iter.map(project) } } 执行过程很简单就是针对子节点的输出RDD[InternalRow],以partition为单位执行投影操作,注意这里生成的并不是最后的数据,而是RDD[InternalRow],我们之前说明InternalRow并不保存实际数据。也就是execute是transform操作,而非action操作。 但是RDD[InternalRow]有一个很重要的作用就是知道每步操作的对象。例如:Row[InternalRow]记录了这一步是对第x个单元的数据加一,x是一个变量,但是最后执行的时候,这部分代码只是作用于真是的数据。简而言之RDD[InternalRow]用于生成相应代码。 上面函数中的`project`就相当于一个投影操作的代码生成函数。 ### FilterExec执行 //FilterExec protected override def doExecute(): RDD[InternalRow] = { val numOutputRows = longMetric("numOutputRows") child.execute().mapPartitionsInternal { iter => val predicate = newPredicate(condition, child.output) iter.filter { row => val r = predicate(row) if (r) numOutputRows += 1 r } } } `predicate`相当于一个函数用于判断该行满不满足条件,不过和上面一样是代码生成的函数。 ### InMemoryTableScanExec执行 该节点的`doExecute`方法比较复杂。但思路就是操作具体列(因为经过前面的优化后,没必要输出所有列),作为输出。 ##与具体数据的联系 以`collect`操作为例。 //DataSet def executeCollect(): Array[InternalRow] = { val byteArrayRdd = getByteArrayRdd() val results = ArrayBuffer[InternalRow]() byteArrayRdd.collect().foreach { bytes => decodeUnsafeRows(bytes).foreach(results.+=) } results.toArray } private def getByteArrayRdd(n: Int = -1): RDD[Array[Byte]] = { execute().mapPartitionsInternal { iter => var count = 0 val buffer = new Array[Byte](4 << 10) // 4K val codec = CompressionCodec.createCodec(SparkEnv.get.conf) val bos = new ByteArrayOutputStream() val out = new DataOutputStream(codec.compressedOutputStream(bos)) while (iter.hasNext && (n < 0 || count < n)) { val row = iter.next().asInstanceOf[UnsafeRow] out.writeInt(row.getSizeInBytes) row.writeToStream(out, buffer) count += 1 } out.writeInt(-1) out.flush() out.close() Iterator(bos.toByteArray) } } `byteArrayRdd`就是该DataSet对应的RDD[Array[Byte]]。`getByteArrayRdd`就是利用`execute`生成的RDD[InternalRow]。RDD[InternalRow]和数据联系的桥梁是两个:Interator[InternalRow]和UnsafeRow。 ### Iterator[InternalRow]和UnsafeRow InternalRow虽然不包含数据,但是并不妨碍Iterator[InternalRow]可以输出数据,因为`hasNext`,`next`等函数可以被重写。 上面在[InMemoryTableScanExec][###InMemoryTableScanExec执行]中没有提到的是其`execute`调用了`GenerateColumnAccessor.generate(columnTypes)`,而该函数又调用了`GenerateColumnAccessor.create(columnTypes)`。 `create`的主要功能就是生成代码(由于是字符串,所以IDE根本检测不到)。生成的代码主要是实现了SpecificColumnarIterator类,该类是ColumnarIterator(继承自Interator[InternalRow])的子类。其中就有`buffers`,`rowWriter`等变量。 //GenerateColumnAccessor protected def create(columnTypes: Seq[DataType]): ColumnarIterator = { ... val codeBody = s""" import ... public SpecificColumnarIterator generate(Object[] references) { return new SpecificColumnarIterator(); } class SpecificColumnarIterator extends ${classOf[ColumnarIterator].getName} { private ByteOrder nativeOrder = null; private byte[][] buffers = null; private UnsafeRow unsafeRow = new UnsafeRow($numFields); private BufferHolder bufferHolder = new BufferHolder(unsafeRow); private UnsafeRowWriter rowWriter = new UnsafeRowWriter(bufferHolder, $numFields); private MutableUnsafeRow mutableRow = null; private int currentRow = 0; private int numRowsInBatch = 0; private scala.collection.Iterator input = null; private DataType[] columnTypes = null; private int[] columnIndexes = null; ... public void initialize(Iterator input, DataType[] columnTypes, int[] columnIndexes) { this.input = input; this.columnTypes = columnTypes; this.columnIndexes = columnIndexes; } ${ctx.declareAddedFunctions()} public boolean hasNext() { ... } public InternalRow next() { currentRow += 1; bufferHolder.reset(); rowWriter.zeroOutNullBytes(); ${extractorCalls} unsafeRow.setTotalSize(bufferHolder.totalSize()); return unsafeRow; } }""" val code = CodeFormatter.stripOverlappingComments( new CodeAndComment(codeBody, ctx.getPlaceHolderToComments())) ... CodeGenerator.compile(code).generate(Array.empty).asInstanceOf[ColumnarIterator] } 在这里就会体会到其实在Interator[InternalRow]就会有数据了。这里主要利用到4个变量:`buffer`,`unsafeRow`,`bufferHolder`,`rowWriter`。`buffer`的类型好解释。下面解释其他3个对象的类型。 #### UnsafeRow 该类型继承自InternalRow,但其包含了数据,而且其操作是原生内存操作,所以不是Java对象。其数据保存在`baseObject`对象中,该对象是Object类型。 //UnsafeRow public byte getByte(int ordinal) { assertIndexIsValid(ordinal); return Platform.getByte(baseObject, getFieldOffset(ordinal)); } private long getFieldOffset(int ordinal) { return baseOffset + bitSetWidthInBytes + ordinal * 8L; } 每个Tuple由三部分组成:[null bit set] [values] [variable length portion]。`baseOffset`是一个对象的头部占用的长度。 * [null bit set] 用于null位的跟踪,长度是**8-Byte**(如果没有field,该域长度为0),其会为每个field存储一个**bit**,用0或1表示是否为null。 * [values]域中为每个field存储了8-Byte的内容,当然对于固定长度的原始类型,如long,int,double等,直接存入。对于非原始类型或可变长度的值,存储的是一个相对offset(指向变长field的起始位置)和该field的长度([variable length portion])。 所以UnsafeRow对象可以当做是一个指向原始数据的指针。 #### BufferHolder 该类实际上是用于生成相应UnsafeRow的。 //BufferHolder public BufferHolder(UnsafeRow row, int initialSize) { int bitsetWidthInBytes = UnsafeRow.calculateBitSetWidthInBytes(row.numFields()); if (row.numFields() > (Integer.MAX_VALUE - initialSize - bitsetWidthInBytes) / 8) { ... } this.fixedSize = bitsetWidthInBytes + 8 * row.numFields(); this.buffer = new byte[fixedSize + initialSize]; this.row = row; this.row.pointTo(buffer, buffer.length); } //UnsafeRow public void pointTo(byte[] buf, int sizeInBytes) { pointTo(buf, Platform.BYTE_ARRAY_OFFSET, sizeInBytes); } public void pointTo(Object baseObject, long baseOffset, int sizeInBytes) { assert numFields >= 0 : "numFields (" + numFields + ") should >= 0"; this.baseObject = baseObject; this.baseOffset = baseOffset; this.sizeInBytes = sizeInBytes; } 以上3个方法就是将byte数组存入UnsafeRow中,真正写入的操作是由UnsafeRowWriter启动的。 #### UnsafeRowWriter 针对不同的类型会有不同的写入方法,为了简洁,这里选用原始类型,以说明原理。 //UnsafeRowWriter public void write(int ordinal, long value) { Platform.putLong(holder.buffer, getFieldOffset(ordinal), value); } public long getFieldOffset(int ordinal) { return startingOffset + nullBitsSize + 8 * ordinal; } public void reset() { this.startingOffset = holder.cursor; holder.grow(fixedSize); holder.cursor += fixedSize; zeroOutNullBytes(); } //BufferHolder public void grow(int neededSize) { if (neededSize > Integer.MAX_VALUE - totalSize()) { ... } final int length = totalSize() + neededSize; if (buffer.length < length) { // This will not happen frequently, because the buffer is re-used. int newLength = length < Integer.MAX_VALUE / 2 ? length * 2 : Integer.MAX_VALUE; final byte[] tmp = new byte[newLength]; Platform.copyMemory( buffer, Platform.BYTE_ARRAY_OFFSET, tmp, Platform.BYTE_ARRAY_OFFSET, totalSize()); buffer = tmp; row.pointTo(buffer, buffer.length); } } UnsafeRowWriter的`write`方法就是把数据写到BufferHolder的`buffer`中,然后调用`reset`来触发BufferHolder将数据提交到UnsafeRow的`baseObject`中。BufferHolder的提交过程发生在`grow`方法中, 可以看出BufferHolder实际上只有一个buffer,而且一个Iterator中也只有一个UnsafeRow变量,只是BufferHolder每次生成新的buffer,写入UnsafeRow对象中取出,所以造成一种假象似乎是每个对象都是UnsafeRow。 这部分的逻辑都在代码生成当中,所以很找到,但原理是基本一致的。 ![iterator InternalRow][iterator] [1]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_physicalplan.md [2]:https://databricks.com/blog/2016/05/23/apache-spark-as-a-compiler-joining-a-billion-rows-per-second-on-a-laptop.html [ensureDistributionAndOrdering]:../../pic/sort-distribution.png [execution]:../../pic/example.png [iterator]:../../pic/Iterator.png ================================================ FILE: analysis/sql/spark_sql_join_1.md ================================================ # Spark SQL Join 解析 本文讲的是将from语句块解析成Join的LogicalPlan。 ## 从AST到Unresolved LogicalPlan //AstBuilder override def visitQuerySpecification( ctx: QuerySpecificationContext): LogicalPlan = withOrigin(ctx) { val from = OneRowRelation.optional(ctx.fromClause) { visitFromClause(ctx.fromClause) } withQuerySpecification(ctx, from) } AstBuilder中引入了ParserUtils,所以其[隐式类](http://docs.scala-lang.org/zh-cn/overviews/core/implicit-classes.html)EnhancedLogicalPlan也被引入了,所以这里有`OneRowRelation.optional`的操作,实际上这是隐式类起的作用。这里主要关注`visitFromClause`的处理。 //AstBuilder override def visitFromClause(ctx: FromClauseContext): LogicalPlan = withOrigin(ctx) { val from = ctx.relation.asScala.map(plan).reduceLeft(Join(_, _, Inner, None)) ctx.lateralView.asScala.foldLeft(from)(withGenerate) } protected def plan(tree: ParserRuleContext): LogicalPlan = typedVisit(tree) protected def typedVisit[T](ctx: ParseTree): T = { ctx.accept(this).asInstanceOf[T] } `ctx.accept`之后做了什么操作呢?实际上其最后会调用`visitJoinRelation`。 //AstBuilder override def visitJoinRelation(ctx: JoinRelationContext): LogicalPlan = withOrigin(ctx) { /** Build a join between two plans. */ def join(ctx: JoinRelationContext, left: LogicalPlan, right: LogicalPlan): Join = { val baseJoinType = ctx.joinType match { case null => Inner case jt if jt.FULL != null => FullOuter case jt if jt.SEMI != null => LeftSemi case jt if jt.ANTI != null => LeftAnti case jt if jt.LEFT != null => LeftOuter case jt if jt.RIGHT != null => RightOuter case _ => Inner } // Resolve the join type and join condition val (joinType, condition) = Option(ctx.joinCriteria) match { case Some(c) if c.USING != null => val columns = c.identifier.asScala.map { column => UnresolvedAttribute.quoted(column.getText) } (UsingJoin(baseJoinType, columns), None) case Some(c) if c.booleanExpression != null => (baseJoinType, Option(expression(c.booleanExpression))) case None if ctx.NATURAL != null => (NaturalJoin(baseJoinType), None) case None => (baseJoinType, None) } Join(left, right, joinType, condition) } // Handle all consecutive join clauses. ANTLR produces a right nested tree in which the the // first join clause is at the top. However fields of previously referenced tables can be used // in following join clauses. The tree needs to be reversed in order to make this work. var result = plan(ctx.left) var current = ctx while (current != null) { current.right match { case right: JoinRelationContext => result = join(current, result, plan(right.left)) current = right case right => result = join(current, result, plan(right)) current = null } } result } 上面的方法做了一件什么事呢? > 对于from clause中的每个relation,relation的划分是以逗号为标准的,格式类似于`from relation,relation,...`,因为每个relation中通常会有join操作,所以该方法就是把一个relation中的join操作组织成二叉树。`joinCriteria`就是表示ON或USING语句块。传入join方法`ctx`的用于指明该Join操作的类型,是InnerJoin还是OuterJoin。*NATURAL- JOIN是一种特殊类型的EQUI-JOIN,当两张表的Join keys具有相同名字,并且对应的列数据类型相同,所以这里不能用ON关键字。*将所有的join组织成二叉树的过程就是从左边第一个对象开始,向右递归合并。如下面的例子。 设该relation为(tb1 __join__ tb2 __on__ c1) __join__ (tb3 __join__ (tb4 __join__ tb5 __on__ c4) __on__ c3) __on__ c2。那么会生成如下的二叉树。 ![join tree](../../pic/jointree.png) 必须说明**这里生成的Join二叉树中各个节点的join类型是任意的,包括outer join(left,right,full),semi join等**。 每个relation都会生成一棵Join二叉树,`visitFromClause`中的操作是将这些二叉树再进行合并,只是条件并没有给定,这是因为条件在where语句中。 ## 从Unresolved LogicalPlan到Resolved LogicalPlan Analyzer的Resolution规则集中涉及到resolve Join的规则有:ResolveReferences和ResolveNaturalAndUsingJoin。下面分别说明这三个规则中对Join节点的操作。 //Analyzer.ResolveReferences.apply case j @ Join(left, right, _, _) if !j.duplicateResolved => j.copy(right = dedupRight(left, right)) 这一句的作用是,如果该Join操作有重名属性,即左右子节点的输出属性名集合有重叠,那么就调用`dedupRight`将右子节点对应的Expression用一个新的Expression ID表示,所以虽然同名,但是对应的Expression ID不同,所以可以区分。 > 小技巧:这个新ID的生成是怎么完成的呢?之前以为要自己写一个计数器控制,实际上不是,而是在object NamedExpression中调用`UUID.randomUUID()`就可以生成jvm中全局唯一的ID。 //Analyzer object ResolveNaturalAndUsingJoin extends Rule[LogicalPlan] { override def apply(plan: LogicalPlan): LogicalPlan = plan resolveOperators { case j @ Join(left, right, UsingJoin(joinType, usingCols), condition) if left.resolved && right.resolved && j.duplicateResolved => // Resolve the column names referenced in using clause from both the legs of join. val lCols = usingCols.flatMap(col => left.resolveQuoted(col.name, resolver)) val rCols = usingCols.flatMap(col => right.resolveQuoted(col.name, resolver)) if ((lCols.length == usingCols.length) && (rCols.length == usingCols.length)) { val joinNames = lCols.map(exp => exp.name) commonNaturalJoinProcessing(left, right, joinType, joinNames, None) } else { j } case j @ Join(left, right, NaturalJoin(joinType), condition) if j.resolvedExceptNatural => // find common column names from both sides val joinNames = left.output.map(_.name).intersect(right.output.map(_.name)) commonNaturalJoinProcessing(left, right, joinType, joinNames, condition) } } 从上面的代码可以看出USING块是一种特殊的Natural Join,Natural Join使用的Join key就是两个Relation的相同属性,而USING块是指定了Join key(两个Relation共同拥有)的Natural Join。上面的规则作用是: 1. 对USING块指定的Join key进行验证,可能存在不满足“两个Relation共同拥有”这一条件。 2. 由于Natural Join没有指明Join Key,在这里进行指明。 `commonNaturalJoinProcessing`生成的是Projection节点(包含投影Join后的输出和Join节点),而且增加了`condition`(之前为None),并且解析出了左右Join Key的Attribute对象。 [之前](https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_parser.md)介绍过,这些规则集有负责将属性绑定到真是的数据集上。 ## LogicalPlan Optimize 在Optimizer的优化规则里与Join算子关系较为密切的规则有:PushPredicateThroughJoin、ReorderJoin和EliminateOuterJoin。 本文着重关注InnerJoin,所以EliminateOuterJoin不介绍。 ### PushPredicateThroughJoin 该优化规则是将Filter中的条件下移到Join算子中。 1. 当Filter中的条件只需要Join中的left child或right child的输出属性求出来,就更新该节点。以InnerJoin为例(本文只关注InnerJoin) //PushPredicateThroughJoin.apply case f @ Filter(filterCondition, Join(left, right, joinType, joinCondition)) => val (leftFilterConditions, rightFilterConditions, commonFilterCondition) = split(splitConjunctivePredicates(filterCondition), left, right) joinType match { case Inner => // push down the single side `where` condition into respective sides val newLeft = leftFilterConditions. reduceLeftOption(And).map(Filter(_, left)).getOrElse(left) val newRight = rightFilterConditions. reduceLeftOption(And).map(Filter(_, right)).getOrElse(right) val (newJoinConditions, others) = commonFilterCondition.partition(e => !SubqueryExpression.hasCorrelatedSubquery(e)) val newJoinCond = (newJoinConditions ++ joinCondition).reduceLeftOption(And) val join = Join(newLeft, newRight, Inner, newJoinCond) if (others.nonEmpty) { Filter(others.reduceLeft(And), join) } else { join } ... } 其中`split`方法是将Filter算子(Where块)中的条件分为3部分:1)可以通过Join的左节点直接求值的;2)可以通过有节点直接求值的;3)涉及到两个子节点的属性,必须通过两个节点求值的,或者其他属性(如子查询)。 然后针对前两种情况直接在子节点上面生成Filter节点。 对于第3种情况,首先会将该部分的条件分为两部分,非子查询条件或子查询条件(子查询条件值得是必须等该Join操作完成之后才能执行的过滤条件,e.g.IN,BETWEEN等算子)。这里主要关注非子查询条件,因为这部分和Join有关。非子查询条件说明是涉及到左右两个子节点的属性,所以理应加到Join的连接条件当中。所以这里会将这部分条件与原来Join中的连接条件连接到一起生成新的Join条件。 最后,如果子查询条件为空,就直接返回Join节点,省去了Filter节点(减少了一次算子的运行)。反之将子查询条件作为Filter的新条件,向下连接Join节点。这一步同时处理了fromClause中不同relation之间连接的问题(之前只是生成Join节点,但并没有连接条件)。 2. 只是将Join节点中的连接条件下移到左右子节点。即如果连接条件中的部分条件可以完全由左(右)子节点求值,就没必要将其放到Join的判断条件中,毕竟Join关注的应该是和Shuffle相关的条件才对。这部分代码类似于1中的前半部分,所以就不贴了。 ### ReorderJoin 该规则只对InnerJoin进行重新排序,把所有的条件表达式分配到join的子树中,使每个叶子节点至少有一个条件表达式。 //ReorderJoin in joins.scala def apply(plan: LogicalPlan): LogicalPlan = plan transform { case j @ ExtractFiltersAndInnerJoins(input, conditions) if input.size > 2 && conditions.nonEmpty => createOrderedJoin(input, conditions) } ExtractFiltersAndInnerJoin并不是一个节点,而是一种具体形式的子树,实际上该类的作用就是“解构”子树,并无其他作用,所以其被调用到的方法只有`unapply`。这棵子树的结构就是Filter和InnerJoin交替出现。该规则的作用就是: Filter | inner Join / \ ----> (Seq(plan0, plan1, plan2), conditions) Filter plan2 | inner join / \ plan0 plan1 `conditions`中条件的顺序是bottom-up的,这里的条件表达式包括Join中的连接条件,也包含Filter中的条件,所以这两类条件从底到顶交替出现。 下面看如何对这棵子树中的各个节点排序。 //ReorderJoin in joins.scala def createOrderedJoin(input: Seq[LogicalPlan], conditions: Seq[Expression]): LogicalPlan = { assert(input.size >= 2) if (input.size == 2) { ... } else { val left :: rest = input.toList // find out the first join that have at least one join condition val conditionalJoin = rest.find { plan => val refs = left.outputSet ++ plan.outputSet conditions.filterNot(canEvaluate(_, left)).filterNot(canEvaluate(_, plan)) .exists(_.references.subsetOf(refs)) } // pick the next one if no condition left val right = conditionalJoin.getOrElse(rest.head) val joinedRefs = left.outputSet ++ right.outputSet val (joinConditions, others) = conditions.partition( e => e.references.subsetOf(joinedRefs) && !SubqueryExpression.hasCorrelatedSubquery(e)) val joined = Join(left, right, Inner, joinConditions.reduceLeftOption(And)) // should not have reference to same logical plan createOrderedJoin(Seq(joined) ++ rest.filterNot(_ eq right), others) } } 子plan数目为2的时候,说明最多只有一个个Join,其操作类似于之前PushPredicateThroughJoin的操作,这里省去。如果有多个Join,将plan序列分为两部分:left和rest。从rest中找到第一个与`left`有join关系的plan(即代码中的`right`)。找到后按照是否是这两个节点(`left`和`right`)输出属性的子集,将判断条件分为两部分,一部分是与这两个plan都相关的,另一部分就是其他。最后生成新的Join节点,然后递归操作。那么这个操作作用怎么体现呢?对于普通链式和星形的multi-way join操作似乎没有作用,但是对于环形的操作就会有效。举例:a(x, y, z) <- A(x, z), B(x, y), C(y, z)。 ![reorder join for cycle](../../pic/reorderJoin.png) ================================================ FILE: analysis/sql/spark_sql_join_2.md ================================================ # Spark SQL Join 之前分析过SQL的总体执行过程,但是介绍的是大体思路。本文主要关注的是Join的具体执行。 ## LogicalPlan到PhysicalPlan 从LogicalPlan进行分析,Join操作的LogicalPlan有多种类型,主要包含ExtractEquiJoinKeys,Logical.Join类型。从PhysicalPlan中的JoinSelection入手来看。 //SparkStrategies.JoinSelection def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match { ... case ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, condition, left, right) if RowOrdering.isOrderable(leftKeys) => joins.SortMergeJoinExec( leftKeys, rightKeys, joinType, condition, planLater(left), planLater(right)) :: Nil // --- Without joining keys ------------------------------------------------------------ // Pick BroadcastNestedLoopJoin if one side could be broadcasted case j @ logical.Join(left, right, joinType, condition) if canBuildRight(joinType) && canBroadcast(right) => joins.BroadcastNestedLoopJoinExec( planLater(left), planLater(right), BuildRight, joinType, condition) :: Nil case j @ logical.Join(left, right, joinType, condition) if canBuildLeft(joinType) && canBroadcast(left) => joins.BroadcastNestedLoopJoinExec( planLater(left), planLater(right), BuildLeft, joinType, condition) :: Nil // Pick CartesianProduct for InnerJoin ... } } ExtractEquiJoinKeys主要是用于equi-Join,而没有Join key或者Inner-Join的时候会用Logical.Join。以常见的equi-Join为对象,即ExtractEquiJoinKeys,进行分析。 //pattern.scala object ExtractEquiJoinKeys extends Logging with PredicateHelper { /** (joinType, leftKeys, rightKeys, condition, leftChild, rightChild) */ type ReturnType = (JoinType, Seq[Expression], Seq[Expression], Option[Expression], LogicalPlan, LogicalPlan) def unapply(plan: LogicalPlan): Option[ReturnType] = plan match { case join @ Join(left, right, joinType, condition) => logDebug(s"Considering join on: $condition") // Find equi-join predicates that can be evaluated before the join, and thus can be used // as join keys. val predicates = condition.map(splitConjunctivePredicates).getOrElse(Nil) val joinKeys = predicates.flatMap { case EqualTo(l, r) if canEvaluate(l, left) && canEvaluate(r, right) => Some((l, r)) case EqualTo(l, r) if canEvaluate(l, right) && canEvaluate(r, left) => Some((r, l)) // Replace null with default value for joining key, then those rows with null in it could // be joined together case EqualNullSafe(l, r)... case other => None } val otherPredicates = predicates.filterNot { case EqualTo(l, r) => canEvaluate(l, left) && canEvaluate(r, right) || canEvaluate(l, right) && canEvaluate(r, left) case other => false } if (joinKeys.nonEmpty) { val (leftKeys, rightKeys) = joinKeys.unzip logDebug(s"leftKeys:$leftKeys | rightKeys:$rightKeys") Some((joinType, leftKeys, rightKeys, otherPredicates.reduceOption(And), left, right)) } else { None } case _ => None } } 首先就是针对Join操作中的连接条件进行提取,如果是Equi-Join,就将左右子节点的Join Key都提取出来。这里有两种情况:EqualTo和EqualNullSafe,这两者的区别在于对空值是否敏感。EqualTo对空值是敏感的,也就是说对于空值没有额外的处理,而EqualNullSafe情况下的处理逻辑基本和EqualTo一样,但是它会对空值做处理,即赋予相应类型的默认值。那么什么情况下会使用这两种情况呢?实际是用户指定的,条件表达式为“=”或“==”时使用EqualTo,当为“<=>”使用EqualNullSafe。 `otherPredicates`是记录除了EqualTo类型之外的条件表达式,这里主要是除EqualTo之外的表达式(求值),还有就是EqualNullSafe表达式(这里将EqualNullSafe再次加入`otherPredicates`的目的是)。之后生成的结果就是提取出来的Equi-Join Key,并且把其他连接条件也提取出来。 > Note: Spark SQL暂时没有实现Theta Join(真是高看它了,Hive也没有实现,09年就提出了,PR已提交,但至今没解决),但是范围约束之前的Logical Plan优化是处理过一次的,这里是另一部分,即那些不能马上得出的,如求值表达式(至于IN,BETWEEN等在不在里边,暂时不确定)。 所以`otherPredicates`中的内容基本上可以Shuffle之后在各个数据集上分别处理。 由于在没有特殊设置的情况下会调用SortMergeJoin,所以进入SortMergeJoinExec,传入的参数包括left child和right child,以及对应的join key,还有约束条件。 Shuffle的操作是[执行](https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_execution.md)的时候添加的。在EnsureRequirements的`ensureDistributionAndOrdering`会获取Join的分布策略,该策略是通过`requiredChildDistribution`获取的。 //SortMergeJoinExec override def requiredChildDistribution: Seq[Distribution] = ClusteredDistribution(leftKeys) :: ClusteredDistribution(rightKeys) :: Nil 可以发现每个ClusteredDistribution只包含一个key,并且通过源码,实际上每个child RDD对应一个ClusteredDistribution。所以可以猜想每个ClusteredDistribution应该是用于shuffle时的partition id的计算。 >RDD在进行Shuffle的时候会输入必须是一个Product[K,V]的形式,所以RDD中的记录必须转成这种格式。那么SQL DataSet中的数据类型是InternalRow,哪来的key呢?实际上在DataSet中,这个Key就是Partition Id。在进行真正的Shuffle之前,其实已经计算好了对应的Partition Id。 ## PhysicalPlan到执行 //EnsureRequirements private def ensureDistributionAndOrdering(operator: SparkPlan): SparkPlan = { val requiredChildDistributions: Seq[Distribution] = operator.requiredChildDistribution val requiredChildOrderings: Seq[Seq[SortOrder]] = operator.requiredChildOrdering assert(requiredChildDistributions.length == operator.children.length) assert(requiredChildOrderings.length == operator.children.length) def createShuffleExchange(dist: Distribution, child: SparkPlan) = ShuffleExchange(createPartitioning(dist, defaultNumPreShufflePartitions), child) var (parent, children) = operator match { ... case _ => ... case (child, distribution) => createShuffleExchange(distribution, child) } (operator, newChildren) } } private def createPartitioning( requiredDistribution: Distribution, numPartitions: Int): Partitioning = { requiredDistribution match { case AllTuples => SinglePartition case ClusteredDistribution(clustering) => HashPartitioning(clustering, numPartitions) case OrderedDistribution(ordering) => RangePartitioning(ordering, numPartitions) case dist => sys.error(s"Do not know how to satisfy distribution $dist") } } 回顾EnsureRequirements,这次关注的是ShuffleExchange和Partitioning。构造ShuffleExchange之前首先要构造Partitioning。Partitioning本质上就是一个以数据为输入(InternalRow类型),输出为partition id的表达式。 > 上面的函数还用于生成排序节点,join操作实际上是要先shuffle,然后排序,最后找相同的key,Shuffle操作由ShuffleExchange节点完成,虽说Spark使用的是sortPartition进行Shuffle,但这里的Sort并不会保证partition内部的数据有序,其只是保证Shuffle时各个Partititon之间是有序的。所以这里还要增加SortExec节点,也就是说在原本child节点上面增加了两层父节点,先是把结果传给ShuffleExchange进行Shuffle,然后再传给SortExec节点进行排序。 ShuffleExchange只是合并了Partitioning和对应child RDD。这样RDD就可以通过Partitioning生成对应的partition id了。上面提到额Distribution最主要的作用就是生成对应的Partitioning,真正用于计算Partition id的是Partitioning,而非Distribution,可以把Distribution当做计算Partitioning的参数集合。 //ShuffleExchange def getPartitionKeyExtractor(): InternalRow => Any = newPartitioning match { ... case h: HashPartitioning => val projection = UnsafeProjection.create(h.partitionIdExpression :: Nil, outputAttributes) row => projection(row).getInt(0) ... } Partitoning的子类包含:BroadcastPartitioning,RoundRobinPartitioning,HashPartitionning和RangePartitioning等。前面已经看到,ClusteredDistribution对应的是HashPartitionning。`UnsafeProjection.create`是根据Partitioning生成表达式。 //HashPartitioning def partitionIdExpression: Expression = Pmod(new Murmur3Hash(expressions), Literal(numPartitions)) //Projection.UnsafeProjection def create(exprs: Seq[Expression], inputSchema: Seq[Attribute]): UnsafeProjection = { create(exprs.map(BindReferences.bindReference(_, inputSchema))) } def create(exprs: Seq[Expression]): UnsafeProjection = { val unsafeExprs = exprs.map(_ transform { case CreateStruct(children) => CreateStructUnsafe(children) case CreateNamedStruct(children) => CreateNamedStructUnsafe(children) }) GenerateUnsafeProjection.generate(unsafeExprs) } 大可先将Pmod认为就是mod,输入数据,计算hash value,求模,计算出Partition id。`UnsafeProjection.create`中首先对Pmod类型的Expression做处理,将Pmod中的所有变量同需要输出的Attribute进行绑定,因为只有绑定之后处理的对象才会是RDD中记录的具体位置。然后就是调用`GenerateUnsafeProjection.generate`生成投影表达式,根据输入的row,输出投影后InternalRow。这个函数主要的作用调用`create`codegen对应的代码。 //GenerateUnsafeProjection private def create( expressions: Seq[Expression], subexpressionEliminationEnabled: Boolean): UnsafeProjection = { val ctx = newCodeGenContext() val eval = createCode(ctx, expressions, subexpressionEliminationEnabled) val codeBody = s""" public java.lang.Object generate(Object[] references) { return new SpecificUnsafeProjection(references); } class SpecificUnsafeProjection extends ${classOf[UnsafeProjection].getName} { private Object[] references; ... // Scala.Function1 need this public java.lang.Object apply(java.lang.Object row) { return apply((InternalRow) row); } public UnsafeRow apply(InternalRow ${ctx.INPUT_ROW}) { ${eval.code.trim} return ${eval.value}; } } """ val code = CodeFormatter.stripOverlappingComments( new CodeAndComment(codeBody, ctx.getPlaceHolderToComments())) logDebug(s"code for ${expressions.mkString(",")}:\n${CodeFormatter.format(code)}") val c = CodeGenerator.compile(code) c.generate(ctx.references.toArray).asInstanceOf[UnsafeProjection] } def createCode( ctx: CodegenContext, expressions: Seq[Expression], useSubexprElimination: Boolean = false): ExprCode = { val exprEvals = ctx.generateExpressions(expressions, useSubexprElimination) ... val writeExpressions = writeExpressionsToBuffer(ctx, ctx.INPUT_ROW, exprEvals, exprTypes, holder, isTopLevel = true) val code = s""" $resetBufferHolder $evalSubexpr $writeExpressions $updateRowSize """ ExprCode(code, "false", result) } private def writeExpressionsToBuffer( ctx: CodegenContext, row: String, inputs: Seq[ExprCode], inputTypes: Seq[DataType], bufferHolder: String, isTopLevel: Boolean = false): String = { ... val writeFields = inputs.zip(inputTypes).zipWithIndex.map { case ((input, dataType), index) => ... val writeField = dt match { ... case _ => s"$rowWriter.write($index, ${input.value});" } if (input.isNull == "false") { s""" ${input.code} ${writeField.trim} """ } else { ... } s""" $resetWriter ${ctx.splitExpressions(row, writeFields)} """.trim } 这部分复杂的代码主要功能就是利用表达式(Pmod)生成用于把row(InternalRow)生成下一个InternalRow的代码,简而言之,这是一个类似InternalRow=>InternalRow的函数,不同的是,结果的InternalRow中的对象只有Partition id。 代码只保留最核心的代码,调用关系如下图。 ![partition表达式](../../pic/pmod.png) `$rowWriter.write($index, ${input.value});`这句将表达式计算的结果写入RDD,这里最终每条结果的类型是UnsafeRow,但是只保存一个field,即partition id。所以在ShuffleExchange的`getPartitionKeyExtractor()`中执行`projection(row).getInt(0)`就可以获取到Partition Id。 获取到partition id后,生成ShuffleDependency(在ShuffleExchange的prepareShuffleDependency中)。这个对象之后被传入doExcute中。 //ShuffleExchange protected override def doExecute(): RDD[InternalRow] = attachTree(this, "execute") { // Returns the same ShuffleRowRDD if this plan is used by multiple plans. if (cachedShuffleRDD == null) { cachedShuffleRDD = coordinator match { case Some(exchangeCoordinator) => ... case None => val shuffleDependency = prepareShuffleDependency() preparePostShuffleRDD(shuffleDependency) } } cachedShuffleRDD } private[exchange] def preparePostShuffleRDD( shuffleDependency: ShuffleDependency[Int, InternalRow, InternalRow], specifiedPartitionStartIndices: Option[Array[Int]] = None): ShuffledRowRDD = { ... new ShuffledRowRDD(shuffleDependency, specifiedPartitionStartIndices) } 得知Dependency,并转化为RDD之后,处理过程就和RDD的操作一样了(遇到action操作触发生成RDD)。忙活了大半天,其实就是在生成Dependency。所以Spark SQL绝大部分工作也只是生成RDD的dependency。 ================================================ FILE: analysis/sql/spark_sql_optimize.md ================================================ # Catalyst Logical Plan Optimizer 生成Resolved LogicalPlan之后的工作就是执行Logical Plan Optimize。该阶段的主要任务就是对Logical Plan进行剪枝合并等操作,删除无用计算或者对一些计算的多个步骤进行合并。在[Spark Catalyst 分析阶段][1]中也有对LogicalPlan结构的改变,不过那只是将多个LogicalPlan合并成一个,这里是将在一个LogicalPlan的基础上进行结构变化。 ![logicalPlan optimizer][logicalPlan_optimizer] 关于Optimizer:优化包括RBO(Rule Based Optimizer)/CBO(Cost Based Optimizer),其中Spark Catalyst是属于RBO,即基于一些经验规则(Rule)对Logical Plan的语法结构进行优化;在生成Physical Plan时候,还会基于Cost代价做进一步的优化,比如多表join,优先选择小表进行join,以及根据数据大小,在HashJoin/SortMergeJoin/BroadcastJoin三者之间进行抉择。 优化操作的是在被触发之后才会执行,所以从DataSet的一个Action操作入手,这里就选`collect`。然后调用`QueryExecution.executedPlan`。 QueryExecution中有多个Plan需要Lazy执行,首先是利用`analyzed`生成`withCachedData`,然后利用`withCachedData`生成`optimizedPlan`,这之前的操作都是在LogicalPlan上执行的,并且生成的也都是LogicalPlan。然后就是生成`sparkPlan`,即PhysicalPlan,继而是`executedPlan`。 ## withCachedData 在进入LogicalPlan Optimizer之前,首先需要生成`withCachedData`。其作用就是将LogicalPlan中的某些部分替换成已经cached,从而减少不必要的分析运算。可以说这也是Optimizer的一部分。 //CacheManager def useCachedData(plan: LogicalPlan): LogicalPlan = { plan transformDown { case currentFragment => lookupCachedData(currentFragment) .map(_.cachedRepresentation.withOutput(currentFragment.output)) .getOrElse(currentFragment) } } 操作就是按照前序遍历(`transformDown`是前序遍历对每个节点运行规则,`transformUp`是后序遍历对每个节点运行规则)逐一查询各个LogicalPlan节点的结果是否已经被cached,若是则直接用cached后的结果替换。 //CacheManager def lookupCachedData(plan: LogicalPlan): Option[CachedData] = readLock { cachedData.find(cd => plan.sameResult(cd.plan)) } case class CachedData(plan: LogicalPlan, cachedRepresentation: InMemoryRelation) //QueryPlan def sameResult(plan: PlanType): Boolean = { val left = this.canonicalized val right = plan.canonicalized left.getClass == right.getClass && left.children.size == right.children.size && left.cleanArgs == right.cleanArgs && (left.children, right.children).zipped.forall(_ sameResult _) } 先解释一下InMemoryRelation这个类,其包含了一个内存中的Relation的信息,属性列表,存储层级,以及表名,相应RDD,其RDD中数据的类型是CatchedBatch,这个类型包含该条数据的行号,以及数据Buffer,类型是Array[Array[Byte]],还有一个InternalRow的变量stats指明每条记录中的各个单元是什么类型,因为这里以Array[Byte]来存储每个数据,所以要指明数据类型,从而我们在这里明确InternalRow并没有什么数据,不过就是用于指明数据类型。 `cachedData`类型为ArrayBuffer[CacheData],相当于一个LogicalPlan和InMemoryRelation的映射。在CacheManager中的`cacheQuery`中可以发现,这里的LogicalPlan就是QueryExecution中的`analyzed`的LogicalPlan,也就是说没有经过优化处理的。*这样的处理也是比较合理的,因为经过优化的LogicalPlan可能根据整体查询的差异而导致即使相同的查询也不能匹配,其次优化是需要处理时间的,每个子查询的优化手段并不能像整体优化那样是确定的,这样会带来很大的开销*。 LogicalPlan的`canonicalized`形态就是将子查询的别名去掉,这样的话不会因为两个LogicalPlan仅仅因为子查询别名不同就判断不相同,然后就是一次比较相关信息。 接下来就是利用`_.cachedRepresentation.withOutput(currentFragment.output)`这句替换原有LogicalPlan子树`currentFragment`的输出。 //InMemoryRelation def withOutput(newOutput: Seq[Attribute]): InMemoryRelation = { InMemoryRelation( newOutput, useCompression, batchSize, storageLevel, child, tableName)( _cachedColumnBuffers, batchStats) } 之前的文章介绍过每个LogicalPlan的输出是一组Attribute,这里生成一个新的Relation,但是Attribute经过了替换。这里要强调一下,InMemoryRelation本身也是一个LogicalPlan的子类,所以替换过程就这样完成了。 ## optimizedPlan //QueryExecution lazy val optimizedPlan: LogicalPlan = sparkSession.sessionState.optimizer.execute(withCachedData) //SessionState lazy val optimizer: Optimizer = new SparkOptimizer(catalog, conf, experimentalMethods) //SparkOptimizer class SparkOptimizer( catalog: SessionCatalog, conf: SQLConf, experimentalMethods: ExperimentalMethods) extends Optimizer(catalog, conf) { override def batches: Seq[Batch] = super.batches :+ Batch("Optimize Metadata Only Query", Once, OptimizeMetadataOnlyQuery(catalog, conf)) :+ Batch("Extract Python UDF from Aggregate", Once, ExtractPythonUDFFromAggregate) :+ Batch("User Provided Optimizers", fixedPoint, experimentalMethods.extraOptimizations: _*) } 可以发现优化规则除了父类的之外分为3个主要部分: 1. 用于只需发现partition层面的元数据就可以回答的查询。应用环境是,当所有被扫描的columns都是partition columns(就是列式存储下,column本身就是一个partition)。查询表达式满足以下条件: * 聚合表达式(例中select后面的col)本身就是partition columns,例:SELECT col FROM tbl GROUP BY col; * 聚合操作(count,avg,sum等)作用于DISTINCT过的partition columns,例:SELECT col1, count(DISTINCT col2) FROM tbl GROUP BY col1; * 在partition columns上的聚合操作是`Max`,`Min`,`First`和`Last`,因为不管包不包含DISTINCT,其结果都一样,例:SELECT col1, Max(col2) FROM tbl GROUP BY col1。 2. 用于Python,不关心。 3. 用户提供的优化器,但是实际上SparkSQL内部有提供一个接口,ExperimentalMethods。这个类下面有`extraStrategies`和`extraOptimizations`两个函数用于提取或设置策略和优化规则。 第一类的方案就是把针对Partitioned表(存储的都是元数据文件,如HDFS的元数据文件或者CataLog表,即元数据表)的节点这种直接用相应的partition values替换为数据库支持的类型。仅仅是做了数据转换。比如上面的操作用都可以用每个Partition的元数据信息回答。 一般规则的优化在Optimizer这个类下面,即上面代码中的`super.batches`,这个`batches`包含的优化涉及到很多大类。 首先“Finish Analysis”并不属于优化范畴,而是属于Analyzer的范畴,保证了结果一致性(正确性,例如将所有的CurrentData进行同步),并进行规范化操作(将子查询别名去除)。 ### 优化规则 Batch("Union", Once, CombineUnions) :: Batch("Subquery", Once, OptimizeSubqueries) :: Batch("Replace Operators", fixedPoint, ReplaceIntersectWithSemiJoin, ReplaceExceptWithAntiJoin, ReplaceDistinctWithAggregate) :: Batch("Aggregate", fixedPoint, RemoveLiteralFromGroupExpressions, RemoveRepetitionFromGroupExpressions) :: Batch("Operator Optimizations", fixedPoint, ...) :: Batch("Decimal Optimizations", fixedPoint, DecimalAggregates) :: Batch("Typed Filter Optimization", fixedPoint, CombineTypedFilters) :: Batch("LocalRelation", fixedPoint, ConvertToLocalRelation, PropagateEmptyRelation) :: Batch("OptimizeCodegen", Once, OptimizeCodegen(conf)) :: Batch("RewriteSubquery", Once, RewritePredicateSubquery, CollapseProject) :: Nil 可以看到很多优化规则组成的Batch,[Spark-Catalyst Optimizer][2]中介绍了很大一部分。这里修改部分优化规则介绍,并且补充其他规则。 1. OptimizeIn作用有两点: * 消除IN操作的序列中的相同元素,例如将In (value, seq[Literal])转为In (value, ExpressionSet(seq[Literal])) * 如果消除重复后的序列的元素数目超过`conf.optimizerInSetConversionThreshold`(默认10),转化为INSET操作,即自动将ExpressionSet转换为Hashset,提高IN操作的性能。 2. ColumnPruning涉及到的逻辑很多,这里主要分为: * 若子查询中的Project/Aggregate/Expand操作包含最终Project操作中不包含的属性,剪去多余部分 * 若子操作中包含DeserializeToObject/Aggregate/Expand/Generate中没有的属性,剪去多余部分 * 对于Generate操作,如果其子树不出现在Generate输出中,并且最终的Project的属性集还是Generate输出属性的子集,那么说明最终都没有用到Generate子树的那部分输出,这里就令Generate输出不与输入子树连接。 * 对于Left-Semi-Join,将右边的子查询分量的无关属性去除,即只保留其用于Join的属性 * 对于Project(_, child)操作,只保留child中与Project有关的属性,依据类型不同,部分情况下甚至可以只求child 3. CombineTypedFilters和CombineFilters的操作类似,区别在于TypedFilter属于一种特殊的Filter,其条件函数可以自己定义,只要是一个以行未输入,返回boolean的函数即可,这个规则合并的是条件函数 4. CombineUnions针对多个union操作进行合并,例如:(select a from tb1) union (select b from tb2) union (select c from tb3) => select a,b,c from union(tb1,tb2,tb3) 5. OptimizeSubqueries针对所有子查询做Optimizer中优化规则 6. RemoveLiteralFromGroupExpressions是将所有Group by中的表达式中的常量移除掉,因为其不会影响结果 7. RemoveRepetitionFromGroupExpressions是将Group by中的表达式中相同的表达式移除掉 8. PushProjectionThroughUnion用于将Project向下移动到每个子Union表达式,从而提早缩小范围 9. ReorderJoin,把所有的条件表达式分配到join的子树中,使每个子树至少有一个条件表达式。重排序的顺序依据条件顺序,例如:select * from tb1,tb2,tb3 where tb1.a=tb3.c and tb3.b=tb2.b,那么join的顺序就是join(tb1,tb3,tb1.a=tb3.c),join(tb3,tb2,tb3.b=tb2.b) 10. EliminateOuterJoin,这条规则是尽量将fullOuterJoin转化为left/right outer join,甚至是inner join,**null-unsupplying谓词表示,如果有null作为输入则返回false或null,也就是说改谓词不支持包含null的输入**,包含5种情况: * 对于full outer,如果左边子树的谓词是null-unsupplying,full outer -> left outer;反之,如果右边有这种谓词,full outer -> right outer;若两边都有,full outer -> inner * 对于left outer,若右边子树包含null-unsupplying,left outer -> inner * 对于right outer,若左边子树包含null-unsupplying,right outer -> inner 11. InferFiltersFromConstraints是将总的Filter中相对于子树约束多余的约束删除掉,只支持inter join和leftsemi join,例如:select * from (select * from tb1 where a) as x, (select * from tb2 where b) as y where a, b ==> select * from (select * from tb1 where a) as x, (select * from tb2 where b) as y 12. FoldablePropagation,先对有别名的可折叠表达式中的属性和其别名建立索引,然后将LogicalPlan中的所有该属性的节点用其别名代替 13. ConstantFolding,将可以静态直接求值的表达式直接求出常量,Add(1,2)==>3 14. ReorderAssociativeOperator,将与整型相关的可确定的表达式直接计算出其部分结果,例如:Add(Add(1,a),2)==>Add(a,3);与ConstantFolding不同的是,这里的Add或Multiply是不确定的,但是可以尽可能计算出部分结果 15. RemoveDispensableExpressions,这个规则仅仅去掉没必要的节点,包括两类:UnaryPositive和PromotePrecision,二者仅仅是对子表达式的封装,并无额外操作 16. EliminateSorts,首先明确sort by默认只保证partition有序,只有在设置全局有序的情况下才保证全局有序;该规则是消除没有起作用的排序算子,因为排序算子可能是确定的(语法正确,逻辑有问题),那么这样的排序算子就没有意义,例如:select a from tb1 sort by 1+1 ==> select a from tb1 17. RewriteCorrelatedScalarSubquery,ScalarSubquery指的是只返回一个元素(一行一列)的查询,如果Filter,Project,Aggregate操作中包含相应的ScalarSubquery,就重写之,思想就是因为ScalarSubquery结果很小,可以过滤大部分无用元素,所以优先使用left semi join过滤: * Aggregate操作,例如select max(a), b from tb1 group by max(a) ==> select max(a), b from tb1 left semi join max(a) group by max(a),这样先做join,效率要比先做group by操作效率高 * Project操作,例如select max(a), b from tb1 ==> select max(a), b from tb1 left semi join max(a) * Filter,例如select b from tb1 where max(a) ==> select b (select * from tb1 left semi join max(a) where max(a)) 18. EliminateSerialization,很多操作需要先序列化,然后反序列化,如果反序列化的输出类型和序列化前的输入类型相同,就省略序列化和反序列化的操作 19. RemoveAliasOnlyProject,消除仅仅由子查询的输出的别名构成的Project,例如:select a from (select a from t) ==> select a from t 20. OptimizeCodegen,对于特定的情况,将一部分查询生成整块的代码,而不是通过一个个算子进行操作,参考[Apache Spark as a Compiler: Joining a Billion Rows per Second on a Laptop][3] 21. RewritePredicateSubquery,将EXISTS/NOT EXISTS改为left semi/anti join形式,将IN/NOT IN也改为left semi/anti join形式 22. ConvertToLocalRelation,将Project中的每个无需数据交换的表达式应用于LocalRelation中的相应元素,例如select a + 1 from tb1转为一个属性名为“a+1”的relation,并且tb1.a的每个值都是加过1的 23. PropagateEmptyRelation,针对有空表的LogicalPlan进行变换 * 如果Union的子查询都是空,其结果也为空; * 如果Join的子查询存在空表, - `Inner => empty(p)`, - `LeftOuter | LeftSemi | LeftAnti if isEmptyLocalRelation(p.left) => empty(p)`, - `RightOuter if isEmptyLocalRelation(p.right) => empty(p)`, - `FullOuter if p.children.forall(isEmptyLocalRelation) => empty(p)` * 对于单节点Plan,若其子节点为空表,则整体为空表 24. DecimalAggregates应该是用于加速浮点数计算的,因为浮点数计算要控制精度(往往需要通过扩充长度来保证精度),但是一定范围内可以不控制,即不需要每一步的浮点运算都控制精度 25. PushPredicateThroughJoin是将Filter中的条件下移到Join算子中,详情见[Spark SQL Join 上][4]。 [1]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_parser.md [2]:https://github.com/ColZer/DigAndBuried/blob/master/spark/spark-catalyst-optimizer.md [3]:https://databricks.com/blog/2016/05/23/apache-spark-as-a-compiler-joining-a-billion-rows-per-second-on-a-laptop.html [4]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_join_1.md [logicalPlan_optimizer]:../../pic/Optimizer-diagram.png ================================================ FILE: analysis/sql/spark_sql_parser.md ================================================ # Spark Catalyst 分析阶段 本文从使用者的视角,一步步深入SQL的分析,这部分从SQL语句开始,以LogicalPlan为输出。 ![catalyst-analysis][analysis] 我们以官方的一段代码作为讲解的流程。 import org.apache.spark.sql.SparkSession val spark = SparkSession .builder() .appName("Spark SQL Example") .config("spark.some.config.option", "some-value") .getOrCreate() // For implicit conversions like converting RDDs to DataFrames import spark.implicits._ case class Person(name: String, age: Long) val peopleDF = spark.sparkContext .textFile("examples/src/main/resources/people.txt") .map(_.split(",")) .map(attributes => Person(attributes(0), attributes(1).trim.toInt)) .toDF() peopleDF.createOrReplaceTempView("people") val teenagersDF = spark.sql("SELECT name, age FROM people WHERE age BETWEEN 13 AND 19") ## 解析每条记录的类型 SparkSession是DataSet和DataFrame编程的入口,Builder可以用于在REPL或notebooks中配置环境。第一句的重点是获取到当前环境的SparkSession。 第二步就是引入Spark SQL中的隐式转换和隐式值(RDD.toDF和toDS等操作都是通过隐式转换实现的)。这里虽然是DataFrame,但是之前说过在2.0.0 中DataFrame就是DataSet[Row],所以不影响我们分析DataSet。`toDF`和`toDS`都是使用如下隐式转换。 implicit def rddToDatasetHolder[T : Encoder](rdd: RDD[T]): DatasetHolder[T] = { DatasetHolder(_sqlContext.createDataset(rdd)) } case class DatasetHolder[T] private[sql](private val ds: Dataset[T]) { def toDS(): Dataset[T] = ds def toDF(): DataFrame = ds.toDF() def toDF(colNames: String*): DataFrame = ds.toDF(colNames : _*) } 可以发现是RDD到DataSet的转换调用的是SQLContext的`createDataset`方法。但预先必定没有Encoder[Person]的隐式值,隐式转换是通过 implicit def newProductEncoder[T <: Product : TypeTag]: Encoder[T] = Encoders.product[T] 因为Person是case class,所以是Product类型。进入Encoder的`product`,然后进入ExpressionEncoder的`apply`方法。这里就用到了[Spark SQL 基础知识][1]中的提到的反射机制。 def apply[T : TypeTag](): ExpressionEncoder[T] = { val mirror = typeTag[T].mirror val tpe = typeTag[T].tpe val cls = mirror.runtimeClass(tpe) val flat = !ScalaReflection.definedByConstructorParams(tpe) val inputObject = BoundReference(0, ScalaReflection.dataTypeFor[T], nullable = true) val nullSafeInput = if (flat) { inputObject } else { AssertNotNull(inputObject, Seq("top level non-flat input object")) } val serializer = ScalaReflection.serializerFor[T](nullSafeInput) val deserializer = ScalaReflection.deserializerFor[T] val schema = ScalaReflection.schemaFor[T] match { case ScalaReflection.Schema(s: StructType, _) => s case ScalaReflection.Schema(dt, nullable) => new StructType().add("value", dt, nullable) } new ExpressionEncoder[T]( schema, flat, serializer.flatten, deserializer, ClassTag[T](cls)) } `flat`用于判断这个类是不是完全由成员变量构造,如果是就将各个成员变量解析为属性,反之就抛异常。进入`ScalaReflection.serializerFor`, 看解析过程。针对case class,我们看`serializerFor`(private)的相应情况。 private def serializerFor( inputObject: Expression, tpe: `Type`, walkedTypePath: Seq[String]): Expression = ScalaReflectionLock.synchronized { ... tpe match { ... case t if definedByConstructorParams(t) => val params = getConstructorParameters(t) val nonNullOutput = CreateNamedStruct(params.flatMap { case (fieldName, fieldType) => if (javaKeywords.contains(fieldName)) { throw new UnsupportedOperationException(s"`$fieldName` is a reserved keyword and " + "cannot be used as field name\n" + walkedTypePath.mkString("\n")) } val fieldValue = Invoke(inputObject, fieldName, dataTypeFor(fieldType)) val clsName = getClassNameFromType(fieldType) val newPath = s"""- field (class: "$clsName", name: "$fieldName")""" +: walkedTypePath expressions.Literal(fieldName) :: serializerFor(fieldValue, fieldType, newPath) :: Nil }) val nullOutput = expressions.Literal.create(null, nonNullOutput.dataType) expressions.If(IsNull(inputObject), nullOutput, nonNullOutput) ... } } 通过反射机制,从类型中解析出各个参数的名字和对应的类型,并且判断参数名是否合法。`Invoke`方法,传入被转化为Expression的类、函数名和对应返回类型,从而有效的调用对应函数。 由于Scala中的成员变量名也可以作为函数名传入,所以这里相当于获取成员变量。这其实是一个绑定的过程,即直接从原有类中读取数据,注意这里并没有把这个类型(Person)转化为其他变量, 而只是将属性名和Person的具体成员变量调用函数进行了绑定,所以读取数据依然是从Person中读取。还可以发现这是个递归的过程,因为可能成员变量类型依然不是Int,String等基本类型,那就要继续解析。 `expressions.Literal(fieldName) :: serializerFor(fieldValue, fieldType, newPath) :: Nil`。当某些函数的返回值因为擦除变成Object类型之后,需要生成代码把结果强制转换为运行类型,这部分代码在 Invoke的`doGenCode`方法中。 所以解析完之后,`serializer`就是将类中各成员变量名映射到了自定义类型的成员变量获取函数中,可以通过`serializer`可以将自定义类型中的数据提取出来进行操作。 反之可以猜想到`deserializer`就是将成员变量的赋值函数映射到各成员变量名,从而可以将处理完的数据完整写入到自定义类型中。当然这二者同样具备序列化和反序列化的功能。 > 想想Java就不能处理这样的问题,重点在于Scala支持类型擦除之后还原(TypeTag的功劳),而且类似于Bash脚本的写法,使得代码生成相当方便。不过真的很佩服Spark工程师想到利用反射,以及用Expression绑定 Schema,从而达到支持任意类型的目的。这部分代码很多,但总体思路这里已经介绍清楚。再多说一句,现在版本的Scala支持一个类中最多允许有22个field,多了的话就得继承Product了。 之后就是利用反射生成Schema,同样也是递归过程,与`serializer`和`deserializer`的区别在于其不用绑定读取或复制函数,仅仅是成员变量名和类型名的对应关系。 最后就是利用上面生成的对象构造ExpressionEncoder,每个DataSet[T]中记录的类型T都会有一个ExpressionEncoder。 ## 生成DataSet Encoder生成之后,关注DataSet的生成。 def createDataset[T : Encoder](data: RDD[T]): Dataset[T] = { Dataset[T](self, ExternalRDD(data, self)) } 主要看ExternalRDD,其实这个类的作用就是将RDD转换为LogicalPlan的节点(很明显应该是叶子节点),用于扫描RDD数据。在生成ExternalRDD之前先关注一下`CatalystSerde.generateObjAttr`, 该方法主要是调用AttributeReference的`apply`方法生成一个对属性的引用,这个类型的作用有: 1. 判断两个引用是否指向同一个属性,因为每个属性都只有一个ID; 2. 判断属性是否相同; 3. 更换属性名; 4. 更换修饰符名,例如:tableName.name和subQueryAlias.name中tableName和subQueryAlias都是修饰符名。 ExternalRDD的作用较为简单: 1. 生成本对象的拷贝,但是这个拷贝只是新生成对应AttributeReference的拷贝,对应rdd并不是拷贝。也就是表明这只是用于构建LogicalPlan,因为Plan中可能会多次 用到同一Attribute的引用进行不同操作,甚至改变结构,但这些操作最终都是作用到同一个RDD的; 2. 检验与另一个Plan对应的RDD是否是同一个(似乎仅用于Physical Plan阶段); ExternalRDD本身是一个LogicalPlan节点的子类,并且是叶子节点,这很容易解释通,因为这是没有任何查询逻辑,所以它应该被当做叶子节点来输入数据,以供中间节点进行处理。 到此,DataSet生成完毕。但是实际上所有真正的操作都是Lazy的,只有在触发的时候,才会执行QueryPlan。也就是说这里的`toDF`和`toDS`操作都只是转换操作。 `createTempView`操作是Command操作,所以可以立即执行,就是给这个DataSet起别名(视图名,其生命周期由SparkSession决定),当然该操作会验证这个名字全局唯一。 ## Query语句解析 `spark.sql(...)`方法首先是调用`SparkSqlParser.parsePlan`来解析这条查询语句。实际调用的是`AbstractSqlParser.parse`。 protected def parse[T](command: String)(toResult: SqlBaseParser => T): T = { val lexer = new SqlBaseLexer(new ANTLRNoCaseStringStream(command)) lexer.removeErrorListeners() lexer.addErrorListener(ParseErrorListener) val tokenStream = new CommonTokenStream(lexer) val parser = new SqlBaseParser(tokenStream) parser.addParseListener(PostProcessor) parser.removeErrorListeners() parser.addErrorListener(ParseErrorListener) try { try { parser.getInterpreter.setPredictionMode(PredictionMode.SLL) toResult(parser) } catch { case e: ParseCancellationException => tokenStream.reset() // rewind input stream parser.reset() parser.getInterpreter.setPredictionMode(PredictionMode.LL) toResult(parser) } } catch { ... } } 其实这部分的代码如果了解Antlr 4 的解析过程的话会很容易懂,可以参考[Antlr v4入门教程和实例][2],如果想深入了解参考[ANTLR 4权威参考读书笔记][3]。 Antlr首先对字符串进行词法解析,即`lexer`,这里用Spark本身的`ParseErrorListener`替换了原有的词法错误监听器,其实作用就是将过去的错误类型转化为异常信息。 利用`lexer`生成token流(符号流)。然后利用token流进行`parse`过程,生成语法树。`parse`过程中加入Spark自己的解析器`PostProcessor`针对特殊情况做处理,例如: 将标识符(表名或属性名)中的两个``“`”``换做单个(因为不同数据库操作人员的习惯不同),以及将所有非保留字(select,where等)的token全部当做标识符。 之后设置语法树生成策略(SLL或LL,前者快但能力较弱,没具体了解)。重点进入`toResult`方法,分析其解析过程。 //AbstractSqlParser override def parsePlan(sqlText: String): LogicalPlan = parse(sqlText) { parser => astBuilder.visitSingleStatement(parser.singleStatement()) match { case plan: LogicalPlan => plan case _ => val position = Origin(None, None) throw new ParseException(Option(sqlText), "Unsupported SQL statement", position, position) } } 很明显该过程就会生成LogicalPlan。`parser.singleStatement()`生成对应语法树,`singleStatement`规则文件中最顶层的结构名(自定义)。 进入`ASTBuilder.visitSingleStatement`。 override def visitSingleStatement(ctx: SingleStatementContext): LogicalPlan = withOrigin(ctx) { visit(ctx.statement).asInstanceOf[LogicalPlan] } visit是父函数在运行时生成的,所以代码中没有显示其位置。将singleStatement中的statement提取出来,然后按照层次一层一层解析。 例子`SELECT name, age FROM people WHERE age BETWEEN 13 AND 19`中的变量首先是查询语句。 //AstBuilder override def visitQuerySpecification( ctx: QuerySpecificationContext): LogicalPlan = withOrigin(ctx) { val from = OneRowRelation.optional(ctx.fromClause) { visitFromClause(ctx.fromClause) } withQuerySpecification(ctx, from) } override def visitFromClause(ctx: FromClauseContext): LogicalPlan = withOrigin(ctx) { val from = ctx.relation.asScala.map(plan).reduceLeft(Join(_, _, Inner, None)) ctx.lateralView.asScala.foldLeft(from)(withGenerate) } 第一句获取到from之后语法块(例如表名,View名或者子查询)的LogicalPlan节点,如果有多个对象,会调用Join方法,注意这步并没有进行Join计算,仅仅是确定了其运算逻辑。然后交给`withQuerySpecification`处理。这个函数逻辑比较多,这里只挑例子中对应的成分分析。 //AstBuilder private def withQuerySpecification( ctx: QuerySpecificationContext, relation: LogicalPlan): LogicalPlan = withOrigin(ctx) { import ctx._ // WHERE def filter(ctx: BooleanExpressionContext, plan: LogicalPlan): LogicalPlan = { Filter(expression(ctx), plan) } val expressions = Option(namedExpressionSeq).toSeq .flatMap(_.namedExpression.asScala) .map(typedVisit[Expression]) val specType = Option(kind).map(_.getType).getOrElse(SqlBaseParser.SELECT) specType match { ... case SqlBaseParser.SELECT => ... val withLateralView = ctx.lateralView.asScala.foldLeft(relation)(withGenerate) // Add where. val withFilter = withLateralView.optionalMap(where)(filter) val namedExpressions = expressions.map { case e: NamedExpression => e case e: Expression => UnresolvedAlias(e) } val withProject = if (aggregation != null) { withAggregation(aggregation, namedExpressions, withFilter) } else if (namedExpressions.nonEmpty) { Project(namedExpressions, withFilter) } else { withFilter } ... } } 后面的解析基于前面的节点,例如首先就是解析View量,然后就是过滤语句,之后是属性名(或者Aggregate操作),接着是投影,之后还有having、distinct等操作。 可以发现这个逻辑是很合理的,因为View量(在不代表子查询的情况下,是最初的输入)是数据输入,之后经过过滤确定出处理的属性,然后获取到针对该属性的Aggregate操作(或就是本身), 然后就是投影输出。 在[Spark SQL 基础知识][1]中谈到Generate是将一组数据的分析结果与当前的分析拼接在一起。但是这里子查询还没有Resolve,该Generator是Unresolved的,暂时仅用于拼接。这里会发现在分析FROM块的时候也会用与View名拼接, 那么这里的处理和那里有什么不同呢?举个例子 ...(SELECT * FROM (table1,table2,lateralView1)) lateralView2 ... `visitFromClause`仅用于table1和2的Join结果与lateralView1进行拼接,后者用于外层拼接,也就是将子查询的输出与该lateralView2进行拼接。 然后调用`val withFilter = withLateralView.optionalMap(where)(filter)`,当存在where参数的时候,将WHERE后边的条件块映射到名为`withFilter`的LogicalPlan节点中,并且将`withLateralView`作为子节点为Filter提供输入。 这里的Where块中的booleanExpression是Predicated(谓词,支持BETWEEN、IN、LIKE、RLIKE和IS NULL及其反义操作),所以进入withPredicated可以看具体的解析过程。 ctx.kind.getType match { case SqlBaseParser.BETWEEN => // BETWEEN is translated to lower <= e && e <= upper invertIfNotDefined(And( GreaterThanOrEqual(e, expression(ctx.lower)), LessThanOrEqual(e, expression(ctx.upper)))) ... } 最后我们发现BTWEEN操作被转化为类似于`e >=ctx.lower && e <= ctx.upper`的操作。 之后的投影(Project)操作类似,都可以总结为获取节点中的Expression用于运算,然后连接LogicalPlan节点。 > Expression包含于LogicalPlan节点中,其相当于该节点的计算单元,而LogicalPlan节点之间的联系可以看做是为计算单元提供输入输出接口。 ![relation between expression and logicalPlan][expresion-logical] 生成LogicalPlan之后,就需要传给`DataSet.ofRows`。 //DataSet object def ofRows(sparkSession: SparkSession, logicalPlan: LogicalPlan): DataFrame = { val qe = sparkSession.sessionState.executePlan(logicalPlan) qe.assertAnalyzed() new Dataset[Row](sparkSession, qe, RowEncoder(qe.analyzed.schema)) } 首先就是调用`executePlan`来执行logicalPlan,生成QueryExecution对象,但实际上QueryExecution里边的分析操作都是Lazy的,所以可以说这里返回的就是个“准备就绪的机器”,等待触发。 第二句用于检查是否有不支持的操作,例如分析的table不存在(或没有计算出来),Attribute不存在等,以便提早终止错误代码。 然后调用QueryExecution的分析`analyzed`开始执行。 //QueryExecution lazy val analyzed: LogicalPlan = { SparkSession.setActiveSession(sparkSession) sparkSession.sessionState.analyzer.execute(logical) } `analyzer`本身也是Lazy的,所以会调用Analyzer的`execute`方法将Unresolved的Attitude和Relation,通过CataLog转化为真正操作用的类型对象。 ## Analyzer 首先看什么是Catalog。它是一个SessionCatalog类,用于维护Spark SQL中表和数据库的状态,它可以和外部系统(如Hive)进行连接,从而获取到Hive中的数据库信息。 之前提到的利用`createTempView`生成表名,这个函数中同时将表名(View名)信息注册到了Catalog中。 创建Analyzer对象的时候,可以对`extendedResolutionRules`进行重写,该规则是用户额外添加的规则,用于解析。还有一个可以重写的field,就是`extendedCheckRules`, 其用于添加用于检测合法性的规则。 Analyzer中包含大量的规则,共分为6类,最重要的两类是:替换(Substitution)和解析(Resolution)。规则本身就是一个方法集(工厂类),从而实现对LogicalPlan的转换。 ### Substitution `CTESubstitution`规则是将CTE定义(就是WITH块)的子查询替换为可处理LogicalPlan,由于CTE定义打乱了语法树的结构(从左到右解析根本没本法直接将CTE定义的子块加到语法树),所以此处要将CTE定义的子块重新按照索引加入到整个查询的LogicalPlan中,并且将所有WITH块中生成的relation解析为Resolved状态。 > 例如:`WITH q as(SELECT time_taken FROM idp WHERE time_taken=15) SELECT * FROM q; ` > 在没有运行该条规则的时候会存在两个LogicalPlan树,分别对应WITH块和`SELECT * FROM q`,然后该规则的作用就是以q为链接依据,将这两个LogicalPlan进行合并。那么WITH块中的查询就应该作为后边FROM块中的子树。 `WindowsSubstitution`规则做的工作类似于`CTESubstitution`,只是语法定义与CTE不同。 > Substitution做的工作是对LogicalPlan的结构做改变,而Resolution的工作只是将原有的节点解析为实体(因为语法解析后的表名仅仅是一个名字,并没有真正地与DataSet建立联系)。 ### Resolution 这些规则是有序的,打乱了就很可能导致出错或者短时间内运行不完。 1. 首先第一条规则是`ResolveTableValuedFunctions`,即类似于`range(start, end, step)`的语句。其处理流程就是先匹配函数名(暂时只有range),匹配到之后就可以知道各参数的类型。 然后将之前解析的各参数(或表达式参数)的类型强制转换为expectedType。 2. 第二条规则是`ResolveRelations`,顾名思义,就是将relation的状态解析为resolved,解析为resoleved的过程其实就是将表名变为具体的DataSet实体。 3. 第三条规则是`ResolveReferences`,其将UnresolvedAttribute解析为连向子DataSet中具体属性的引用,即AttributeReference。但AttributeReference本身继承自Unevaluable,所以并未求值。 那么它是怎么将属性名和具体属性联系起来的呢?因为解析每个DataSet的时候会注册很多属性,这个属性是包含具体内容的实体。所以会到注册的表里去查询匹配,从而将UnresolvedAttribute中的属性名与对应Attribute做映射。 具体实现在`resolveAsTableColumn`下。 //LogicalPlan protected def resolve( nameParts: Seq[String], input: Seq[Attribute], resolver: Resolver): Option[NamedExpression] = { var candidates: Seq[(Attribute, List[String])] = { // If the name has 2 or more parts, try to resolve it as `table.column` first. if (nameParts.length > 1) { input.flatMap { option => resolveAsTableColumn(nameParts, resolver, option) } } else { Seq.empty } } if (candidates.isEmpty) { candidates = input.flatMap { candidate => resolveAsColumn(nameParts, resolver, candidate) } } def name = UnresolvedAttribute(nameParts).name candidates.distinct match { case Seq((a, Nil)) => Some(a) case Seq((a, nestedFields)) => val fieldExprs = nestedFields.foldLeft(a: Expression)((expr, fieldName) => ExtractValue(expr, Literal(fieldName), resolver)) Some(Alias(fieldExprs, nestedFields.last)()) ... } } private def resolveAsTableColumn( nameParts: Seq[String], resolver: Resolver, attribute: Attribute): Option[(Attribute, List[String])] = { assert(nameParts.length > 1) if (attribute.qualifier.exists(resolver(_, nameParts.head))) { // At least one qualifier matches. See if remaining parts match. val remainingParts = nameParts.tail resolveAsColumn(remainingParts, resolver, attribute) } else { None } } 输入参数中的`nameParts`表示查询语句中的属性名,为什么是序列类型呢?因为有`tableName.colName.fieldName`的形式。 `attribute`表示可能的属性,因为最先不知道的时候只能根据最开头的名字(表名tableName)一个个去排查。然后调用`resolveAsColumn`去验证是否该`attribute`的名字与colName是否匹配,若是匹配,就返回该`attribute`和需要的其他字段(如fieldName)的映射。 当然还有解析不到的情况,这不是说明这个属性不存在,因为上一步是针对`tableName.colName...`的形式,然后针对直接是`colName...`形式匹配。 最后如果没有其他字段(就是内嵌的属性名)就直接返回该DataSet的Attrubute。反之则将该属性类型具体的成员变量的提取函数与`tableName.colName.fieldName`作联系起来,那么使用`tableName.colName.fieldName`就相当于直接获取`colName`对象具体的成员变量。 其他的规则类似,都是将Unresolved的LogicalPlan节点与具体操作或者实体进行对应。如图所示: ![unresolved-to-resolved][generateLogicalPlan] *** > `execute`方法在RuleExecutor中,主要作用就是利用Analyzer中的规则集合Batches来处理LogicalPlan。处理方式在[Spark SQL 基础知识][1]中有提到,就是不断应用规则达到Fixed Point(可以设置策略来保证有限时间以内)。 [1]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_preparation.md [2]:http://blog.csdn.net/dc_726/article/details/45399371 [3]:http://codemany.com/tags/antlr/ [analysis]:../../pic/Catalyst-analysis.png [expresion-logical]:../../pic/Expression_LogicalPlan.png [generateLogicalPlan]:../../pic/generate_LogicalPlan.png ================================================ FILE: analysis/sql/spark_sql_physicalplan.md ================================================ # Spark SQL Physical Plan 本文介绍Spark SQL的PhysicalPlan的生成,这一部分主要是基于CBO(Cost Based Optimizer)的优化。 ![generate physical plan][physical-plan] lazy val sparkPlan: SparkPlan = { SparkSession.setActiveSession(sparkSession) // TODO: We use next(), i.e. take the first plan returned by the planner, here for now, // but we will implement to choose the best plan. planner.plan(ReturnAnswer(optimizedPlan)).next() } `SparkSession.setActiveSession(sparkSession)`主要是重新设置SparkSession,因为此时的SparkSession已经经过之前的操作过后发生了改变,必须确保不同线程可以获得这个新的SparkSession,而不是初始化创建的那个。 然后就是`ReturnAnswer(optimizedPlan)`,ReturnAnswer其实一个特殊的LogicalPlan节点,其被插入到优化后的logical plan的顶层。 `planner`用于将LogicalPlan转化为physicalPlan。 //QueryPlanner def plan(plan: LogicalPlan): Iterator[PhysicalPlan] = { val candidates = strategies.iterator.flatMap(_(plan)) val plans = candidates.flatMap { candidate => val placeholders = collectPlaceholders(candidate) if (placeholders.isEmpty) { Iterator(candidate) } else { placeholders.iterator.foldLeft(Iterator(candidate)) { case (candidatesWithPlaceholders, (placeholder, logicalPlan)) => val childPlans = this.plan(logicalPlan) candidatesWithPlaceholders.flatMap { candidateWithPlaceholders => childPlans.map { childPlan => candidateWithPlaceholders.transformUp { case p if p == placeholder => childPlan } } } } } } val pruned = prunePlans(plans) assert(pruned.hasNext, s"No plan for $plan") pruned } 首先针对优化后的LogicalPl生成候选PhysicalPlan(就是针对最外层的操作类型生成的PhysicalPlan),这里的候选PhysicalPl针对的是最外层LogicalPlan的转换,对于子查询(LogicalPlan子树)暂时标记为PlanLater。 然后利用`this.plan(logicalPlan)`将标记为PlanLater的子查询递归转化为PhysicalPlan,最后替换掉相应位置的`placeholder`。所以可以发现转化是由顶向下的。这样每层转化都会生成很多PhysicalPlan, 所以总的PhysicalPlan是指数级增加的,越是复杂的SQL语句,产生的可能的PhysicalPlan数量是越多的。所以在这个函数中最后还有一个`prunePlans`用于剪去不合适的plan,从而避免组合爆炸,但是现在还没有实现。 可以发现代码中暂时实现的仅仅是选择这些PhysicalPlan的第一个,并没有进行基于cost的选择,应该不久之后就会改为选择最优的plan了。 ## 生成PhysicalPlan的策略(Strategies) //SparkPlanner def strategies: Seq[Strategy] = extraStrategies ++ ( FileSourceStrategy :: DataSourceStrategy :: DDLStrategy :: SpecialLimits :: Aggregation :: JoinSelection :: InMemoryScans :: BasicOperators :: Nil) `extraStrategies`是由`experimentalMethods.extraStrategies`提供,[Catalyst Logical Plan Optimizer][1]中介绍过该类。DDLStrategy针对DDL语句,DataSourceStrategy和FileSourceStrategy类似,只是针对的数据源是JDBC等外部数据库的数据(非HadoopFsRelation),这里不对以上两个策略做分析。下面一一介绍其他策略。 ### FileSourceStrategy 该策略会扫描文件集合,因为它们可能是按照用于指定的列进行partition(按照给定属性划分,文件名是对应值)或bucket(bucket是另一种划分方式,按照给定属性列划分成多个bucket,文件名是bucket编号)。所以通过代码了解到这个策略仅用于HadoopFsRelation(下面的总结在看完代码分析之后会更清楚)。 > 该策略可能发生的几个阶段为: > 1. 分离filters以用于分别求值,因为不同的filter可能作用于不同的数据; > 2. 基于现有的投影需要的数据来对数据schema进行剪枝。该剪枝现仅用于顶层column; > 3. 通过将filters和schema传递到FileFormat中来构造reader函数; > 4. 使用分片剪枝谓词枚举需要读取的文件; > 5. 增加必须在扫描之后求值的projection或者filters > 依照一下算法将文件分配给任务: > 1. 当表是“bucketed”,按照bucket id把文件组织进正确数目的Partitions; > 2. 当表不是“bucketed”或“bucketing”被关闭了: * 如果文件很大,超出了阈值,将其基于该阈值分成多片; * 按照文件大小降序排列; * 将排序好的文件按照后面的算法装入buckets;如果现有部分在加入下一个文件之后没有超出阈值,那么就添加,反之就生成新的bucket来添加之。 首先来看PhysicalOperation,因为该策略首先需要依靠该类从LogicalPlan中提取确定的project和filter操作。 //patterns.scala private def collectProjectsAndFilters(plan: LogicalPlan): (Option[Seq[NamedExpression]], Seq[Expression], LogicalPlan, Map[Attribute, Expression]) = plan match { case Project(fields, child) if fields.forall(_.deterministic) => val (_, filters, other, aliases) = collectProjectsAndFilters(child) val substitutedFields = fields.map(substitute(aliases)).asInstanceOf[Seq[NamedExpression]] (Some(substitutedFields), filters, other, collectAliases(substitutedFields)) case Filter(condition, child) if condition.deterministic => val (fields, filters, other, aliases) = collectProjectsAndFilters(child) val substitutedCondition = substitute(aliases)(condition) (fields, filters ++ splitConjunctivePredicates(substitutedCondition), other, aliases) case BroadcastHint(child) => collectProjectsAndFilters(child) case other => (None, Nil, other, Map.empty) } 该方法就是找到所有确定的Project和Filter。Expression.deterministic表示当输入固定的时候,输出也是确定的,不会因为多次运行而改变。所以这里先匹配所有Project表达式中确定的投影变量, 并且通过内联或替换把别名变为最原始的Expression。Filter的操作类似,只是针对的是条件表达式,而非投影表达式。而且二者处理稍有区别,对于条件表达式,替换之后的条件表达式与子树的条件表达式要合并起来, 而投影表达式就直接替换,因为子树的投影表达式只可能会比父节点的多,所以不会产生错误的结果。 该方法执行完之后会有返回“确定的”投影表达式和Filter表达式,以及传入的LogicalPlan。 //FileSourceStrategy object FileSourceStrategy extends Strategy with Logging { def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match { case PhysicalOperation(projects, filters, l @ LogicalRelation(fsRelation: HadoopFsRelation, _, table)) => val filterSet = ExpressionSet(filters) val normalizedFilters = filters.map { e => e transform { case a: AttributeReference => a.withName(l.output.find(_.semanticEquals(a)).get.name) } } val partitionColumns = l.resolve( fsRelation.partitionSchema, fsRelation.sparkSession.sessionState.analyzer.resolver) val partitionSet = AttributeSet(partitionColumns) val partitionKeyFilters = ExpressionSet(normalizedFilters.filter(_.references.subsetOf(partitionSet))) logInfo(s"Pruning directories with: ${partitionKeyFilters.mkString(",")}") val dataColumns = l.resolve(fsRelation.dataSchema, fsRelation.sparkSession.sessionState.analyzer.resolver) // Partition keys are not available in the statistics of the files. val dataFilters = normalizedFilters.filter(_.references.intersect(partitionSet).isEmpty) // Predicates with both partition keys and attributes need to be evaluated after the scan. val afterScanFilters = filterSet -- partitionKeyFilters logInfo(s"Post-Scan Filters: ${afterScanFilters.mkString(",")}") val filterAttributes = AttributeSet(afterScanFilters) val requiredExpressions: Seq[NamedExpression] = filterAttributes.toSeq ++ projects val requiredAttributes = AttributeSet(requiredExpressions) val readDataColumns = dataColumns .filter(requiredAttributes.contains) .filterNot(partitionColumns.contains) val outputSchema = readDataColumns.toStructType logInfo(s"Output Data Schema: ${outputSchema.simpleString(5)}") val pushedDownFilters = dataFilters.flatMap(DataSourceStrategy.translateFilter) logInfo(s"Pushed Filters: ${pushedDownFilters.mkString(",")}") val outputAttributes = readDataColumns ++ partitionColumns val scan = new FileSourceScanExec( fsRelation, outputAttributes, outputSchema, partitionKeyFilters.toSeq, pushedDownFilters, table) val afterScanFilter = afterScanFilters.toSeq.reduceOption(expressions.And) val withFilter = afterScanFilter.map(execution.FilterExec(_, scan)).getOrElse(scan) val withProjections = if (projects == withFilter.output) { withFilter } else { execution.ProjectExec(projects, withFilter) } withProjections :: Nil case _ => Nil } * `ExpressionSet`类其实是用于过滤掉规范化后形式相同的表达式,例如"a+1"和"1+a"规范化后的形式是一样的,所以这算是一步去冗余的操作。`normalizedFilters`的作用是将条件表达式中的属性名统一成输出的属性名。 * `partitionColumns`是将HadoopFsRelation的partition schema(就是那些用于Partition分区的key属性)和resolver传入,从而解析出该HadoopFsRelation的partition属性。这里有个疑问,这一步不应该在解析成Realoved LogicalPlan的时候就做了吗?实际上哪一步的操作有些遗漏,就是针对外部系统的表, 当时只是查询了Catalog有没有,并没有将其相应属性进行连接,变为真正的resolved状态。`partitionSet`作用也是去除重复的属性。 * `partitionKeyFilters`是找到条件表达式中**只**包含partition属性的表达式集合。这样在进行过滤时可以直接通过partition编号进行,从而提高操作的效率。 * `dataColumns`对应的是存储数据的属性,主要是非partition属性,但是如果partition属性有数据,那么也会保留。`dataFilters`是那些只包含非partition属性的filter。 * `afterScanFilters`表示的是经过扫描有才能确定的Filter,例如:`partitionKeyFilters`只需要针对与key值对应的partition即可,但是其他情况必须将所有表都扫描完才能确定。 * 所以`requiredAttributes`是需要的属性,`readDataColumn`是最终需要查询属性中的非partition属性。然后输出相应的Schema信息`outputSchema`。 * `pushedDownFilters `是将`dataFilter`中的filter原先的常量替换成Scala的原生类型(因为Catalyst中的类型不能函数操作,其只是用于表示)。 * 实际上扫描表输出的属性除了`readDataColumn`还有`partitionColumns`,即`outputAttributes`。首先需要强调`requiredAttributes`和`outputAttributes`不同,后者包含了所有的Partition column,前者只包含部分。 * 然后创建扫描HadoopFsRelation中数据的PhysicalPlan节点,`scan`。**那么之前的操作说到底都是在做过滤,就是过滤没必要查询的属性列。** > 之前这里一直有个疑问,`afterScanFilters`和`dataColumns`是什么关系?其实后者是前者的子集,后者存在的目的是为之后的扫描缩小范围,前者之所以要包含后者是因为有些操作,只有在第二次扫描的时候才能最终确定。 例如:select * from tb1 where a < c and b < c and b = max(c),令a是partition key,那么第一遍即使传入了b < c and b = max(c)也只是缩小了范围b = max(c)并不能马上计算,因为a < c在之后才能确定,所以由于很难区分`dataFilter`中这些操作,只能统一重新计算,保证正确性。 之后就是将`afterScanFilters`用and连接起来,然后生成新的PhysicalPlan节点`withFilter`,其子节点为`scan`。最后在`withFilter`上加上一个投影节点。 > 可以发现该策略的作用语句很简单,只是select ... from tb1 where ...类型的操作。没有额外的子查询和Aggregate等。 ### SpecialLimits //SparkStrategies object SpecialLimits extends Strategy { override def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match { case logical.ReturnAnswer(rootPlan) => rootPlan match { case logical.Limit(IntegerLiteral(limit), logical.Sort(order, true, child)) => execution.TakeOrderedAndProjectExec(limit, order, None, planLater(child)) :: Nil case logical.Limit( IntegerLiteral(limit), logical.Project(projectList, logical.Sort(order, true, child))) => execution.TakeOrderedAndProjectExec( limit, order, Some(projectList), planLater(child)) :: Nil case logical.Limit(IntegerLiteral(limit), child) => execution.CollectLimitExec(limit, planLater(child)) :: Nil case other => planLater(other) :: Nil } ... } } ReturnAnswer这个类在本文开篇介绍过,只是用与封装。省略掉的其他case的处理包含在该种情况中。 > 这里先普及一个概念,即Window function,之前忽略了,这里补上,因为里面会涉及到GlobalLimit的概念。Window操作是在查询结果集上执行的操作,其相对于过去的聚合操作来说, 粒度更细,而且效率更高。例如:SELECT Row_Number() OVER (partition by xx ORDER BY xxx desc) RowNumber中OVER关键字后面的就是是一个window function,因为最后该语句相当于输出每个partition的行数, 当然如果也可以给window其别名,这里就会用到WINDOW关键字,类似于SELECT sum(salary) OVER w, avg(salary) OVER w FROM empsalary WINDOW w AS (PARTITION BY depname ORDER BY salary DESC);关于window函数的详细知识见[SQL Server中的窗口函数][2]。 GlobalLimit其实就是指window函数中的windows子块,如ROWS,RANGE等。 //logical.Limit def unapply(p: GlobalLimit): Option[(Expression, LogicalPlan)] = { p match { case GlobalLimit(le1, LocalLimit(le2, child)) if le1 == le2 => Some((le1, child)) case _ => None } } 可以发现该unapply方法比较特殊,因为它必须针对GlobalLimit的子查询是LocalLimit的情况,而且这两个Limit的limit表达式还一样,说明有冗余。case 1中还有一个条件是LocalLimit操作的对象是Sort过的,满足这几点才可以执行`execution.TakeOrderedAndProjectExec`。 该函数的作用其实就是按照LocalLimit子查询的顺序来获取前k(Limit后面的数)条记录。这其实是一步优化,就是如果GlobalLimit和LocalLimit的获取的记录条数相同,因为GlobalLimit的操作是在LocalLimit的基础上执行的,那么无论window函数怎么排序,其实都是不会影响到最终的结果。 例如: select count(*) over (sort by b rows 100) from (select * from tb1 sort by c) limit 100 ==> select count(*) from (select * from tb1 sort by c) limit 100 case 2的区别就是,LocalLimit的子查询是作用在排序上面的投影操作,调用函数同case 1,只是限定了输出的属性,对于没有处理的子树,这里都是用`planLater`进行标记。 case 3就是GlobalLimit的子查询并不满足上面的条件,直接生成CollectLimitExec PhysicalPlan节点,注意上面的TakeOrderedAndProjectExec也是PhysicalPlan节点。 ### Aggregation //SparkStrategies object Aggregation extends Strategy { def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match { case PhysicalAggregation( groupingExpressions, aggregateExpressions, resultExpressions, child) => val (functionsWithDistinct, functionsWithoutDistinct) = aggregateExpressions.partition(_.isDistinct) ... val aggregateOperator = if (functionsWithDistinct.isEmpty) { aggregate.AggUtils.planAggregateWithoutDistinct( groupingExpressions, aggregateExpressions, resultExpressions, planLater(child)) } else { ... aggregate.AggUtils.planAggregateWithOneDistinct( groupingExpressions, functionsWithDistinct, functionsWithoutDistinct, resultExpressions, planLater(child)) } aggregateOperator case _ => Nil } } 首先来看PhysicalAggregation,其与LogicalAggregate不同的地方在于: 1. 给无名grouping表达式命名,从而使这些表达式可以在聚合阶段被引用; 2. 出现多次的Aggregation要去重; 3. 各个aggregation操作本身的计算与最终结果分开的。例如:“count + 1”中的“count”是AggregateExpression,但是最终结果是“count.resultAttribute + 1”。 //PhysicalAggregation type ReturnType = (Seq[NamedExpression], Seq[AggregateExpression], Seq[NamedExpression], LogicalPlan) def unapply(a: Any): Option[ReturnType] = a match { case logical.Aggregate(groupingExpressions, resultExpressions, child) => val aggregateExpressions = resultExpressions.flatMap { expr => expr.collect { case agg: AggregateExpression => agg } }.distinct val namedGroupingExpressions = groupingExpressions.map { case ne: NamedExpression => ne -> ne case other => val withAlias = Alias(other, other.toString)() other -> withAlias } val groupExpressionMap = namedGroupingExpressions.toMap val rewrittenResultExpressions = resultExpressions.map { expr => expr.transformDown { case ae: AggregateExpression => ae.resultAttribute case expression => groupExpressionMap.collectFirst { case (expr, ne) if expr semanticEquals expression => ne.toAttribute }.getOrElse(expression) }.asInstanceOf[NamedExpression] } Some(( namedGroupingExpressions.map(_._2), aggregateExpressions, rewrittenResultExpressions, child)) case _ => None } 第一句首先是收集不同的聚合操作,例如select sum(*)+count(*),count(*),count(*)+1 from tb1 group by a中实际上只包含两个聚合操作,sum(*)和count(*)。 第二句是对grouping表达式中非NamedExpression作别名处理。第三句就是形成Map[Expression,NamedExpression]映射。 原始的`resultExpressions`是一组可能引用了聚合函数、grouping column值和常量的表达式的集合,当聚合算子输出后,会利用`resultExpressions`生成投影结果。 因此这里会重写结果表达式,即`rewrittenResultExpressions`,从而使其属性与最后投影的输入行匹配。就是把`resultExpressions`的元素类型换成Attribute。 case 2是当表达式不是AggregateExpression时,要与grouping expression中的表达式进行统一(语义相同视为相同,例如:a+1和1+a语义相同,a+b-c和a-c+b相同),以减少计算。 回到Aggregation,将收集到的不同的AggregateExpression划分为两部分,包含DISTINCT关键字的和不包含的。当然包含DISTINCT的AggregateExpression数量不能超过1个。 所以之后的操作就是针对包含一个DISTINCT的aggregation生成一种类型的PhysicalPlan节点,针对不包含DISTINCT的aggregation生成另一种PhysicalPlan节点。 对于无DISTINCT的aggregation实际上最后会判断聚合函数适合用哪种PhysicalPlan,HashAggregateExec还是SortAggregateExec。判断依据就是如果聚合函数的返回类型都是可变的(基本类型,包括浮点类型,都是可变的)并且都是supportPartialAggregate的(不支持只有3种情况,Collect,Hive UDAF和Window function中的聚合函数),那么就用HashAggregateExec。 反之就用SortAggregateExec。原因这里没有搞懂。 对于含有一个DISTINCT的aggregation,首先也是按照无DISTINCT的操作生成一个PhysicalPlan节点,这一步中将DISTINCT操作一定程度上转化为了Group By操作。 //AggUtils.planAggregateWithOneDistinct val partialAggregate: SparkPlan = { val aggregateExpressions = functionsWithoutDistinct.map(_.copy(mode = Partial)) val aggregateAttributes = aggregateExpressions.map(_.resultAttribute) createAggregateExec( requiredChildDistributionExpressions = Some(groupingAttributes ++ distinctAttributes), groupingExpressions = groupingExpressions ++ namedDistinctExpressions, aggregateExpressions = aggregateExpressions, aggregateAttributes = aggregateAttributes, initialInputBufferOffset = (groupingAttributes ++ distinctAttributes).length, resultExpressions = groupingAttributes ++ distinctAttributes ++ aggregateExpressions.flatMap(_.aggregateFunction.inputAggBufferAttributes), child = child) } 其中`distinctAttributes`表示DISTINCT操作的属性,例如`Max(DISTINCT a)`中的a。可以发现`requiredChildDistributionExpressions`和`groupingExpressions`都分别添加了DISTINCT作用的属性(表达式)。所以可以说DISTINCT操作被转化为了Group By操作。 然后找到包含DISTINCT的聚合函数,即上面例子中的`Max`。生成对应的Expression和Attribute,最后输出的新的PhysicalPlan节点,其子节点就是之前生成的那个节点,只是聚合操作进行了改变,变为最终需要输出的部分。 ### JoinSelection 顾名思义,该策略的目的就是基于Joining key和logical plan的大小选择合适的Physical plan。首先通过ExtractEquiJoinKeys找到equi-join key。然后依照流程选择策略: 1. Broadcast:如果join操作某一侧的表预测大小小于阈值(用户定义SQLConf.AUTO_BROADCASTJOIN_THRESHOLD)或者SQL语句中指明(例如用户可以将`org.apache.spark.sql.functions.broadcast()`应用于DataFrame)要广播,那么就广播这一侧的表。 2. Shuffle hash join:如果每个partition的足够小,适合建立hash表的话,并且没有设置优先使用SortMergeJoin,选择该策略。 3. Sort merge:如果两侧的join key都是可排序的,就用该策略。 如果没有join key,流程如下: 1. BroadcastNestedLoopJoin:如果join操作某侧的表可以被广播。 2. CartesianProduct:用于Inner Join(Inner Join中可以包含除了等号之外的其他比较符,如大于、小于、不等于等),有名字可以猜测应该就是作笛卡尔积。 3. BroadcastNestedLoopJoin:其他。 > 补充:ExtractEquiJoinKeys中会遇到EqualNullSafe,该操作在SQL中就是`<=>`操作,因为有时候无法确保等式两侧的属性在实际操作中会不会出现空,若是这种情况,就用该属性的默认值替换,保证双方都为null的时候返回true,仅一方为null,返回false。 至于表大小的预测方法,总体思路就是根据子树的预测结果进行操作,不同操作的预测方式不同,这里不一一赘述。只讲叶子节点的预测方式,对于LocalRelation,其预测方式就是将输出的每条记录的大小(各单元类型的长度之和)乘以记录数,这个属于元数据可以确定。 对于InMemoryRelation,如果已经实例化就直接partition schema的统计信息计算,反之则直接用一个默认值(可以设置)。这里只介绍这两种。 ### InMemoryScans 该策略主要是用于对InMemoryRelation的扫描操作。主要看。 def pruneFilterProject( projectList: Seq[NamedExpression], filterPredicates: Seq[Expression], prunePushedDownFilters: Seq[Expression] => Seq[Expression], scanBuilder: Seq[Attribute] => SparkPlan): SparkPlan = { val projectSet = AttributeSet(projectList.flatMap(_.references)) val filterSet = AttributeSet(filterPredicates.flatMap(_.references)) val filterCondition: Option[Expression] = prunePushedDownFilters(filterPredicates).reduceLeftOption(catalyst.expressions.And) if (AttributeSet(projectList.map(_.toAttribute)) == projectSet && val scan = scanBuilder(projectList.asInstanceOf[Seq[Attribute]]) filterCondition.map(FilterExec(_, scan)).getOrElse(scan) } else { val scan = scanBuilder((projectSet ++ filterSet).toSeq) ProjectExec(projectList, filterCondition.map(FilterExec(_, scan)).getOrElse(scan)) } } 当不需要复杂的映射操作(即表达式嵌套,如sum(max(a),min(b)),即`AttributeSet(projectList.map(_.toAttribute)) == projectSet`,并且filter所包含的属性时查询(投影)属性的子集。那么只要对Relation进行过滤即可,这样省去了投影操作。 反之,就要在过滤操作上加一个投影节点ProjectExec,不过这里的过滤还包含投影操作涉及到的属性,即`(projectSet ++ filterSet).toSeq`。 ### BasicOperators 直接将基本的操作转化为PhysicalPlan节点,没有额外的策略选择。 > 由于有的LogicalPlan同时满足多种策略,所以通常每层分析会有多种策略可供选择,但每种策略只会返回一种。现在的SQL暂时还没有实现基于Cost的策略选择,而且也没有实现剪枝(去除不好的策略,以免组合爆炸),但是这些都在TODO中。 可以发现PhysicalPlan和LogicalPlan节点是很不一样的,LogicalPlan的节点就是操作逻辑,很好理解,但是PhysicalPlan节点是尽量把LogicalPlan中的多个操作合并在一起进行处理,从而减少实际查询的开销。并且和LogicalPlan不同的是, 并没有PhysicalPlan这个类,代码中出现的PhysicalPlan只是泛型的名称,实际上代表PhysicalPlan的就是SparkPlan。 [1]:https://github.com/summerDG/spark-code-ananlysis/blob/master/analysis/sql/spark_sql_optimize.md [2]:http://www.cnblogs.com/CareySon/p/3411176.html [physical-plan]:../../pic/physical_plan.png ================================================ FILE: analysis/sql/spark_sql_preparation.md ================================================ # Spark SQL 基础知识 本文参考自[Spark Catalyst的实现分析][1]。先上一张所有Catalyst的流程图。虽然本文不会涉及流程,但是之后的分析会以该图为指导。 ![Catalyst][calalyst_flow] 本文着重介绍几个SQL中的几个重要概念,不对其分析进行展开。 ## Row 表示关系运算的一行输出。其是一个Trait,所以有很多具体实现。实际上本质上来说就是一个数组。但是和RDD不同的是, RDD中的类型可以是任意的,而DataFrame中每条数据的类型只能是Row。在Spark1.6之后DataFrame就变成了DataSet[Row]的别名。 Row表示的只能是一行结构化数据,非结构化不合法。Row本身有`schema`,用于指明各个字段的类型和列名。 但是支持的数据结构并不是任意的,而是必须继承自DataType,Sparl SQL中已经实现了数据库字段的基本类型。 其也允许继承UserDefinedType来定义自己的类型,这个类中要实现自己的序列化和反序列化操作。如果不定义`schema` 就会使用泛化的Get操作,并且不可以通过列名进行操作。但是它是类型不安全的,因为数据的类型根本不会受到`schema`的约束。 DataSet是Spark1.6之后版本的概念。DataSet和RDD、DataFrame一样,都是分布式数据结构的概念。 区别在于DataSet可以面向特定类型,也就是其无需将输入数据类型限制为Row,或者依赖`Row.fromSeq`将其他类型转换为Row。 但实际上,DataSet的输入类型也必须是与Row相似的类型(如Seq、Array、Product,Int等),最终这些类型都被转化为Catalyst 内部的InternalRow和UnsafeRow。 DataSet的核心概念就是Encoder,这个工具充分利用了隐式转换和上下文界定(过去不了解上下文界定函数中其实有一个默认的参数就是传入一个相应的隐式值, 获取其本身;其都是作为函数的最后一个参数传入的)。例如: private[sql] implicit val exprEnc: ExpressionEncoder[T] = encoderFor(encoder) 由于从道理上说,泛型可以传入任意类型,但是实际上的而处理函数不可能实现所有可能,所以存在类型界定。在Java中这个功能就比较弱了, 它只能确定类型的上下界。scala中除了可以限定上下界,还可以利用视图界定和上下文界定。后两者的目的是一样的,就是用于限定特定的类型, 不是上下界之内的,而是隐式定义过的。区别在于前者需要定义隐式转换(类似`implicit ev: A => B`),后者需要定义隐式值(类似`implicit ev: B[A]`)。 下面再说一下Scala中TypeTag这个类(很多地方会用到,参考[这里][2])。TypeTag是用于解决Scala的泛型会在编译的时候被擦除的问题。这其实也是Java的问题。 为了不被擦除,就用TypeTag这个类来解决。例如:`typeTag[List[Int]]`运行时的值为`TypeTag[scala.List[Int]]`,`typeTag[List[Int]].tpe`和`typeOf[List[Int]]` 的值一样,是`scala.List[Int]`。与TypeTag类似的有ClassTag,但CLassType只包含运行时给定类的类型信息,例如:`ClassTag[scala.List[Int]]`就是`scala.collection.immutable.List`。 `typeTag[T].mirror`可以获得当前环境下的所有可用类型(类似于classloader)。由于Catalyst用到反射机制来解析类型,所以关于Scala的反射机制参考[Scala doc][3]。 回到Encoder,其作用就是将外部类型转化为DataSet内部的InternalRow。但是这个转换是有类型检查的。另外InternalRow还有一个子类,即MutableRow, 而且UnsafeRow也是MutableRow的子类,它即为可修改的InternalRow,在很多地方都会出现这个,原理很简单,支持set等操作而已。 ## Expression 在SQL语句中,除了SELECT FROM等关键字以外,其他大部分元素都可以理解为Expression,比如SELECT sum(a), a,其中sum(a)和a都为Expression,这其中当然也包含表名。 每一个DataSet在创建的时候都会有一个对应的ExpressionEncoder,而ExpressionEncoder创建必须得有两个和Expression相关的对象:`serializer: Seq[Expression]`和 `deserializer: Expression`,前者用于将表中一条记录中各个分量解析后转化为Calalyst的InternalRow,后者用于将InternalRow转换为对应类型。 所以Expression还可以表示除表达式之外的类型元素,如属性、常量、行。对于任何一个DataSet[T],首先会生成一个ExpressionEncoder的隐式值。 生成该隐式值的流程(在ScalaReflection这个工厂中)为: 1. 解析出类型T,这里应该是一个类似于Row或者Product的类型; 2. 通过该类型解析出对应变量,生成对应与该(类数组)变量的Expression,其是一个CreateNamedStruct类型(继承自Expression), 例如:针对_FUNC_(name1, val1, name2, val2, ...)这样一条数据,该对象就可以有效地表示它,并且可以`flatten`成为一组Expression(对应`serializer`), 每一个Expression用于解析一个(namei,vali); 3. 只要给定目标类型T,那么就一定会生成一个对应的Expression用于将任意的InternalRow转化为该类型的对象; 4. 利用3和4生成的`serializer`和`deserializer`,以及从T获取到的Schema,以及T对应的ClassTag生成ExpressionEncoder对象。 * Expression是一个Tree结构(结构上可以有一个、两个或三个child,也可以没有)。可以通过多级的Child Expression来组合成复杂的Expression。前面提到的对原始数据进行转换就是一个复杂的Expression。 * Expression基本功能是求值,就是`eval`方法,输入InternalRow然后返回结果。 * 既然Expression的功能是求值,那么它就有输入和输出类型的限制。每个Expression都有def dataType: DataType类型变量来表示它的输出类型,以及def checkInputDataTypes(): TypeCheckResult函数来校验当前Expression的输入(为Tree结构,那么它的输入即为Child Expression输出)是否符合类型要求。 * Expression功能是针对Row进行加工,但是可以把加工方法分为以下几种 * 原生的def eval(input: InternalRow = null): Any函数; * 对于包含子表达式的Expression(如:UnaryExpression、BinaryExpression、TernaryExpression等),Expression的计算是基于Child Expression计算结果进行二次加工的, 因此对于这类Expression,对Eval进行默认实现,子类只需要实现函数`def nullSafeEval(input: Any): Any`即可以。 * Expression也可能是不支持eval的,即Unevaluable类型的Expression,一般有三种情况:1)是真的无法求值,比如处于Unresolved状态的Expression; 2)是不支持通过eval进行求值,而需要通过gencode的方式来实现Expression功能,涵盖了对全局操作的Expression,例如:Aggravation、Sorting、Count操作; 3)Expression为RuntimeReplaceable类型(仅有IfNull,NullIf,Nvl和Nvl2),它仅仅是在parser阶段一种临时Expression,在优化阶段,会被替换为别的Expression,因此它本身不需要有执行逻辑,但是得有替换相关的逻辑。 * Projection类型,它本身不是传统意义上的Expression,但是它可以根据N个Expression,对输入row的N个字段分别进行加工,输出一个新的Row,即Expression的容器。 下面对Expression进行分类: **数据输入**:这部分基本都是继承自LeafExpression,即没有子表达式,用于直接产生数据。 | Name | 功能描述 | | --------- | -------- | | Attribute | Catalyst里面最为重要的概念,可以理解为表的属性,在sql处理各个阶段会有不同的形态,比如UnresolvedAttribute->AttributeReference->BoundReference,后面会具体分析 | | Literal | 常量,支持各种类型的常量输入 | | datetimeExpressions | 对当前时间类型常量的统称(并不包含时间操作),包括`CurrentDate`,`CurrentTimestamp` | | randomExpressions | 根据特定的随机分布生成一些随机数,主要包括RDG(生成随机分布)| | 其他一些输入 | 比如获取sql计算过程中的任务对应的InputFileName,SparkPartitionID | **基本计算功能**:这部分基本都包含子表达式,所以基本都是继承自UnaryExpression、BinaryExpression、BinaryOperator和TernaryExpression。 | Name | 求值方式 | 功能描述 | | --------- | :-------: | -------- | | arithmetic | nullSafeEval | 数学Expression,支持`-`,`+`,`abs`, `+`,`-`,`*`,`/`,`%`,`max`,`min`,`pmod`数学运算符 | | bitwiseExpressions | nullSafeEval | 位运算数,支持IntegralType类型的`and`,`or`,`not`,`xor`位运算 | | mathExpressions | nullSafeEval | 数学函数,支持`cos`,`Sqrt`之类30多种,相当于Math包 | | stringExpressions | nullSafeEval | 字符串函数,支持`Substring`,`Length`之类30多种,相当于String包 | | decimalExpressions | nullSafeEval | Decimal类型的支持,支持`Unscaled`,`MakeDecimal`操作 | | datetimeExpressions | nullSafeEval | 时间类型的运算(和上面不同的是,这里指运算)| | collectionOperations | nullSafeEval | 容器的操作,暂时支持容器`ArrayContains`,`ArraySort`,`Size`,`MapKeys`和`MapValues`5种操作 | | cast | nullSafeEval | 支持数据类型的转换 | | misc | nullSafeEval | 功能函数包,支持MD5,crc32之类的函数功能 | **基本逻辑计算功能**:包括与或非、条件、匹配。 | Name | 求值方式 | 功能描述 | | --------- | :-------: | -------- | | predicates | eval/nullSafeEval类型 | 支持子Expression之间的逻辑运算,比如`AND`,`In`,`Or`,输出blooean | | regexpExpressions|nullSafeEval | 支持LIKE相关操作,返回blooean | | conditionalExpressions | eval | 支持Case(分为CaseWhen和CaseWhenCodegen),If四种逻辑判断运算 | | nullExpressions | eval/RuntimeReplaceable | 与NULL/NA相关的判断或者IF判断功能,大部分都为RuntimeReplaceable,会被进行优化处理 | **其他类型** | Name | 求值方式 | 功能描述 | | --------- | :-------: | -------- | | complexTypeCreator | eval | SparkSql支持复杂数据结构,比如Array,Map,Struct,这类Expression支持在sql语句上生成它们,比如select array。常用于Projection类型。 | | Generator | eval | 支持flatmap类似的操作,即将Row转变为多个Row,支持Explode和自定义UserDefinedGenerator两种,其中Explode支持将数组和map拆开为多个Row。| ## Attribute 上面已经介绍过,Attribute其实也是一种Expression,继承自NamedExpression,就是带名字的Expression。 Attribute直译为属性,在SQL中,可以简单理解为输入的Table中的字段,Attribute通过Name字段来进行命名。 SQL语句通过Parse生成AST以后,SQL语句中的每个字段都会解析为UnresolvedAttribute,它是属于Attribute的一个子类,比如SELECT a中的a就表示为UnresolvedAttribute("a")。 SQL语句中的`*`,它表示为Star,继承自NamedExpression,它有两个子类:UnresolvedStar和ResolvedStar,二者在analysis.unresolved文件中,但二者其实并没有转换关系, 前者用于AST分析,后者用于查询。 分析需对query的AST加工过程中很重要的一个步骤就是将整个AST中所有Unresolved的Attribute都转变为resolved状态。这个过程在ASTBuilder和Analyzer中配合完成, 前者用于生成unresolved attribute(包括Star和relation等),后者是通过Logic plan对这些unresolved对这些unresolved attribute解析生成固定的AttributeReference(或relation等,针对不同类型最终不一样,该过程目的就是“固定”)。 此外,resolve操作的主要功能就是关联SQL语句所有位置用到的Attribute,即在Attribute的name基础上,指定一个ID进行唯一标示, **如果一个Attribute在两处被多处被引用,ID即为同一个**。 > Attribute Resolve操作时**从底到顶**来遍历整个AST,每一步都是**根据**底部**已经resloved的Attribute**来给顶部的Attribute赋值,从而保证如果两个Attribute是指向同一个,它们的ID肯定是一样的)。 可以这么理解,做这些事情都是为了优化,物理存储的Table可能有很多Attribute,而通过resolve操作,就指定整个计算过程中需要使用到Attribute,即可以只从物理存储中读取相应字段, 上层各种Expression对这些字段都转变为引用,因此resolve以后的Attribute不是叫做resolvedAttribute,而是叫做AttributeReference。 对于一个中间节点的Expression,如果它对一个Attribute有引用,比如求一个字段值的长度length(a),这里a经过了UnresolvedAttribute到AttributeReference的转化,但是针对一个输入的Row, 进行lengthExpression计算时,还是无法从AttributeReference中读取相应在Row中的值,为什么?虽然AttributeReference也是Expression,但是它是Unevaluable,为了获取属性在输入Row中对应的值, 需要对AttributeReference再进行一次BindReferences的转化,生成BoundReference,这个操作本质就是将Expression和一个输入Scheme进行关联,Scheme由一组AttributeReference,它们之间是有顺序的, 通过Expression中AttributeReference在Schema AttributeReference组中的Index,并生成BoundReference,在对BoundReference进行eval时候,即可以使用该index获取它在相应Row中的值。 ## QueryPlan 如上所言,在SQL语句中,除了SELECT FROM等关键字以外,其他大部分元素都可以理解为Expression,那么用什么来表示剩下的SELECT FROM这些关键字呢?毕竟Expression只是一些Eval功能函数或者代码片段,需要一个东西来串联这些片段,这个东西就是Plan,具体来说是QueryPlan。 QueryPlan就是将各个Expression组织起来,子类有LogicalPlan和PhysicalPlan(源码中没有该类或接口,在plan.physical下有具体形式)。Plan表现形式也是Tree,节点之间的关系可以理解为一种操作次序,比如Plan叶子节点表示从磁盘读取DB文件,而Root节点表示最终数据的输出;下面是Plan最常见的实例截图。 ![QueryPlan][plan_img] 用SQL语句来表示这个Plan即为:`SELECT project FROM table, table WHERE filter`。 直观理解,Expression是除了SELECT FROM之外可以看到的Item,Plan就是将Expression按照一定的执行顺序执行。 Expression功能是对输入Row进行加工,输出可能是Any数据类型。而Plan输出类型为def output: Seq[Attribute]表示的一组Attribute,比如上面的Project和Table肯定是输出一个由Seq[Attribute]类型表示的Row, Filter感觉是输出Ture/False,但是这里说的Plan,而不是Filter类型的Expreesion,Filter类型的Plan会在内部根据Expression计算结果来判断是否返回Row,但是Row返回的类型肯定也是由Seq[Attribute]表示的。 所以说到底Filter还是返回Seq[Attribute]。 同样LogicalPlan从结构上分也有单节点,叶节点,双节点。 Catalyst是对AST树遍历过程中,完成LogicalPlan和所有依赖的Expression的构建,相关逻辑在org.apache.spark.sql.catalyst.parser.AstBuilder以及相关子类中, 整个解析的过程在ParseDriver中,该类中的过程更加宏观清晰。 LogicalPlan也是Tree形结构,其节点分为两种类型:Operator和Command。Command表示无需查询的指令,立即执行,例如:Command可以被用来表示DDL操作。 Operator通常会组成多级的Plan。Operator的类都在basicLogicalOperators下面。这里只取暂时看得懂的。 Name | 功能描述 -------- | -------- |`Project`(projectList: Seq[NamedExpression], child: LogicalPlan)|SELECT语句输出操作,其中projectList为输出对象,每一个都为一个Expression,它们可能是Star,或者很复杂的Expression| |`Filter`(condition: Expression, child: LogicalPlan)|根据condition来对Child输入的Rows进行过滤| |`Join`(left: LogicalPlan,right: LogicalPlan,joinType: JoinType,condition: Option[Expression])|left和right的输出结果进行join操作| |`Intersect`(left: LogicalPlan, right: LogicalPlan)|left和right两个Plan输出的rows进行取交集运算。| |`Except`(left: LogicalPlan, right: LogicalPlan)|在left计算结果中剔除掉right中的计算结果| |`Union`(children: Seq[LogicalPlan])|将一组Childs的计算结果进行Union联合| |`Sort`(order: Seq[SortOrder],global: Boolean, child: LogicalPlan)|对child的输出进行sort排序| |`Repartition`(numPartitions: Int, shuffle: Boolean, child: LogicalPlan)|对child输出的数据进行重新分区操作| |`InsertIntoTable`(table: LogicalPlan,child: LogicalPlan,...)|将child输出的rows输出到table中| |`Distinct`(child: LogicalPlan)|对child输出的rows取重操作| |`GlobalLimit`(limitExpr: Expression, child: LogicalPlan)|对Child输出的数据进行Limit限制| |`Sample`(child: LogicalPlan,....)|根据一些参数,从child输出的Rows进行一定比例的取样| |`Aggregate`(groupingExpressions: Seq[Expression],aggregateExpressions: Seq[NamedExpression],child: LogicalPlan)|对child输出row进行aggregate操作,比如groupby之类的操作| |`Generate`(generator: Generator,join: Boolean,outer: Boolean,ualifier: Option[String],generatorOutput: Seq[Attribute],child: LogicalPlan)|可以用于复杂的查询,将子查询结果以View形式作为输入,输入行以流的形式输入,并以流的形式输出。类似于`flatMap`,但允许将输入与输出连接在一起,也就是将子查询的分析结果作为父查询的输入和部分输出| |`Range`(start: Long,end: Long,step: Long,numSlices: Option[Int],output: Seq[Attribute])|对输出数据的范围进行约束| |`GroupingSets`(bitmasks: Seq[Int],groupByExprs: Seq[Expression],child: LogicalPlan,aggregations: Seq[NamedExpression])|相当于把多个Group By操作合并起来,具体参考[将 GROUP BY 与 ROLLUP、CUBE 和 GROUPING SETS 一起使用][5]。其中的掩码是将各个Expression按照1,2,4,8...顺序进行编号,然后用编号的和来表示集合,类似于linux中的权限设置| |`Expand`(bitmasks: Seq[Int],groupByAliases: Seq[Alias],groupByAttrs: Seq[Attribute],gid: Attribute,child: LogicalPlan)|利用表示集合的掩码以及输入的输入的属性(包括其别名)将,每行数据进行扩展,为保证输出长度统一,集合中不包含的属性用Null表示。主要用于GROUPINGSETS| 下面介绍Command类,这些类都继承自Command,而且数量比Operator多。 | Name | 功能描述 | | :-------- | --------| |`DataBase`操作类|支持ShowDatabase以及UseDatabase以及Create等操作| |`Table`操作类|多达13种,比如Create,Show,Alter等| |`View`操作类|CreateViewCommand支持View的创建| |`Partition`操作类|支持Partition新增删除等操作| |`Resources`操作类|比如AddJar,AddFile之类的资源操作| |`Functions`操作类|支持新增函数,删除函数等操作| |`Cache`操作类|支持对Table进行cache和uncache操作| |`Set`操作|通过SetCommand执行对参数(任务数和Shuffle的Partition数)进行临时修改| LogicalPlan需要被转换为最终的PhysicalPlan才能真正具有可执行的能力,而这些Command类型的Plan都是以`def run(sparkSession: SparkSession): Seq[Row]`函数暴露给Spark SQL, 比如通过调用Table的run函数完成Table的创建等操作。 ## Tree的操作 TreeNode节点本身类型为Product(在Scala中Product是最基本数据类型之一,其子类包含所有Tuple、List、Option和case类等,如果一个`Case Class`继承Product, 那么便可以通过`productElement`函数或者`productIterator`迭代器对`Case Class`的**参数信息**进行索引和遍历),并且所有Expression和Plan都是属于`Product`类型, 因此可以通过TreeNode内部定义的`mapProductIterator`函数对节点参数进行遍历。 对Plan或Expression进行遍历的目的:首先是为了收集一些信息,比如针对Tree进行map/foreach操作;其次是为了对Tree节点内部的信息进行修改, 比如对PlanTree中每个Plan节点内部引用的Attribute进行Revole操作;最后就是为对Tree的数据结构进行修改,比如删除Tree的子节点,以及与子节点进行合并, 比如Catasylt Optitimze就有大量Tree结构的修改。 对Tree进行转换的操作用到的`rule`都是用Scala的偏函数实现的([偏函数使用][4],偏函数主要用于匹配)。 对Expression和LogicalPlan的**操作**通常都会被整理到同一个Object中,这个Object中的aplly方法的输入输出类型相同,且其继承自Rule[T],T标明处理类型(类似于《快学Scala》中的18.12抽象类型中的设计)。 abstract class Rule[TreeType <: TreeNode[_]] extends Logging { val ruleName: String = { val className = getClass.getName if (className endsWith "$") className.dropRight(1) else className } def apply(plan: TreeType): TreeType } 另外可以将一组`Rule`组合为一个`Batch(name: String,rules: Rule[TreeType]*)`并把它封装在`RuleExecutor`中,从而通过`RuleExecutor`将该组`Rule`的可执行接口提供给外部使用, 比如Optimize策略,就是一堆堆的Batch组成。用Batch中的每个Rule(这里想象成对LogicalPlan进行优化)来执行`plan`,直到在到最大允许迭代次数前达到fix point。 但是优化很可能会消耗很长时间,所以每个Batch都有Strategy,其有两个子类Once和FixedPoint,前者表明该Batch只允许执行一次,后者会设定对大迭代次数。 Spark SQL对Plan Tree或者内部Expression Tree的遍历分为几个阶段: 1. 对AST进行Parse操作,生成Unresolve Plan; 2. 对Unresolve Plan进行Analysis(包括Resolve)操作,生成Logical Plan; 3. 对Logical Plan进行Optimize操作,生成Optimized Logical Plan; 4. 以及最后进行Planning操作,生成Physical Plan。 > 这里面的每一阶段都可以简述为应用一组BatchRule来对plan进行加工。 [1]:https://github.com/ColZer/DigAndBuried/blob/master/spark/spark-catalyst.md [2]:http://stackoverflow.com/questions/12218641/scala-what-is-a-typetag-and-how-do-i-use-it [3]:http://docs.scala-lang.org/overviews/reflection/overview.html [4]:http://blog.csdn.net/u010376788/article/details/47206571 [5]:https://technet.microsoft.com/zh-cn/library/bb522495(v=sql.105).aspx [calalyst_flow]:../../pic/Catalyst-Optimizer-diagram.png [plan_img]:../../pic/plan.png