Spark基础知识详解
Apache Spark是⼀种快速通⽤的集计算系统。 它提供Java,Scala,和R中的⾼级API,以及⽀持通⽤执⾏图的优化引擎。 它还⽀持⼀组丰富的⾼级⼯具,包括⽤于SQL和结构化数据处理的Spark SQL,⽤于机器学习的MLlib,⽤于图形处理的GraphX和Spark Streaming。
Spark优点:
减少磁盘I/O:随着实时⼤数据应⽤越来越多,Hadoop作为离线的⾼吞吐、低响应框架已不能满⾜这类需求。HadoopMapReduce的map端将中间输出和结果存储在磁盘中,reduce端⼜需要从磁盘读写中间结果,势必造成磁盘IO成为瓶颈。Spark允许将map端的中间输出和结果存储在内存中,reduce端在拉取中间结果时避免了⼤量的磁盘I/O。
Hadoop Yarn中的ApplicationMaster申请到Container后,具体的任务需要利⽤NodeManager从HDFS的不同节点下载任务所需的资源(如Jar包),这也增加了磁盘I/O。Spark将应⽤程序上传的资源⽂件缓冲到Driver本地⽂件服务的内存中,当Executor执⾏任务时直接从Driver的内存中读取,也节省了⼤量的磁盘I/O。
增加并⾏度:由于将中间结果写到磁盘与从磁盘读取中间结果属于不同的环节,Hadoop将它们简单的通
过串⾏执⾏衔接起来。Spark把不同的环节抽象为Stage,允许多个Stage 既可以串⾏执⾏,⼜可以并⾏执⾏。
避免重新计算:当Stage中某个分区的Task执⾏失败后,会重新对此Stage调度,但在重新调度的时候会过滤已经执⾏成功的分区任务,所以不会造成重复计算和资源浪费。
可选的Shuffle排序:HadoopMapReduce在Shuffle之前有着固定的排序操作,⽽Spark则可以根据不同场景选择在map端排序或者reduce端排序。
灵活的内存管理策略:Spark将内存分为堆上的存储内存、堆外的存储内存、堆上的执⾏内存、堆外的执⾏内存4个部分。Spark既提供了执⾏内存和存储内存之间是固定边界的实现,⼜提供了执⾏内存和存储内存之间是“软”边界的实现。Spark默认使⽤“软”边界的实现,执⾏内存或存储内存中的任意⼀⽅在资源不⾜时都可以借⽤另⼀⽅的内存,最⼤限度的提⾼资源的利⽤率,减少对资源的浪费。Spark由于对内存使⽤的偏好,内存资源的多寡和使⽤率就显得尤为重要,为此Spark的内存管理器提供的Tungsten实现了⼀种与操作系统的内存Page⾮常相似的数据结构,⽤于直接操作操作系统内存,节省了创建的Java对象在堆中占⽤的内存,使得Spark对内存的使⽤效率更加接近硬件。Spark会给每个Task分配⼀个配套的任务内存管理器,对Task粒度的内存进⾏管理。Task的内存可以被多个内部的消费者消费,任务内存管理器对每个消费者进⾏Task内存的分配与管理,因此Spark对内存有着更细粒度的管理。
基于以上所列举的优化,Spark官⽹声称性能⽐Hadoop快100倍,如图3所⽰。即便是内存不⾜需要磁盘I/O时,其速度也是Hadoop的10倍以上。
Hadoop与Spark执⾏逻辑回归时间⽐较
Spark还有其他⼀些特点。
检查点⽀持:Spark的RDD之间维护了⾎缘关系(lineage),⼀旦某个RDD失败了,则可以由⽗RDD重建。虽然lineage可⽤于错误后RDD的恢复,但对于很长的lineage来说,恢复过程⾮常耗时。如果应⽤启⽤了检查点,那么在Stage中的Task都执⾏成功后,SparkContext将把RDD计算的结果保存到检查点,这样当某个RDD执⾏失败后,在由⽗RDD 重建时就不需要重新计算,⽽直接从检查点恢复数据。
易于使⽤。Spark现在⽀持Java、Scala、Python和R等语⾔编写应⽤程序,⼤⼤降低了使⽤者的门槛。⾃带了80多个⾼等级操作符,允许在Scala,Python,R的shell中进⾏交互式查询。
⽀持交互式:Spark使⽤Scala开发,并借助于Scala类库中的Iloop实现交互式shell,提供对REPL(Read-eval-print-loop)的实现。
⽀持SQL查询。在数据查询⽅⾯,Spark⽀持SQL及Hive SQL,这极⼤的⽅便了传统SQL开发和数据
仓库的使⽤者。
⽀持流式计算:与MapReduce只能处理离线数据相⽐,Spark还⽀持实时的流计算。Spark依赖SparkStreaming对数据进⾏实时的处理,其流式处理能⼒还要强于Storm。
可⽤性⾼。Spark⾃⾝实现了Standalone部署模式,此模式下的Master可以有多个,解决了单点故障问题。Spark也完全⽀持使⽤外部的部署模式,⽐如YARN、Mesos、EC2等。
丰富的数据源⽀持:Spark除了可以访问操作系统⾃⾝的⽂件系统和HDFS,还可以访问Kafka、Socket、Cassandra、HBase、Hive、Alluxio(Tachyon)以及任何Hadoop的数据源。这极⼤地⽅便了已经使⽤HDFS、HBase的⽤户顺利迁移到Spark。
丰富的⽂件格式⽀持:Spark⽀持⽂本⽂件格式、Csv⽂件格式、Json⽂件格式、Orc⽂件格式、Parquet⽂件格式、Libsvm⽂件格式,也有利于Spark与其他数据处理平台的对接。
基本概念
  要想对Spark有整体性的了解,推荐读者阅读Matei Zaharia的Spark论⽂。此处笔者先介绍Spark中的⼀些概念:
RDD(resillient distributed dataset):弹性分布式数据集。Spark应⽤程序通过使⽤Spark的转换API可以将RDD封装为⼀系列具有⾎缘关系的RDD,也就是DAG。只有通过Spark的动作API才会将RDD及其DAG提交到DAGScheduler。RDD的祖先⼀定是⼀个跟数据源相关的RDD,负责从数据源迭代读取数据。
DAG(Directed Acycle graph):有向⽆环图。在图论中,如果⼀个有向图⽆法从某个顶点出发经过若⼲条边回到该点,则这个图是⼀个有向⽆环图(DAG图)。Spark使⽤DAG 来反映各RDD之间的依赖或⾎缘关系。
Partition:数据分区。即⼀个RDD的数据可以划分为多少个分区。Spark根据Partition的数量来确定Task的数量。
NarrowDependency:窄依赖。即⼦RDD依赖于⽗RDD中固定的Partition。NarrowDependency分为OneToOneDependency和RangeDependency两种。
ShuffleDependency:Shuffle依赖,也称为宽依赖。即⼦RDD对⽗RDD中的所有Partition都可能产⽣依赖。⼦RDD对⽗RDD各个Partition的依赖将取决于分区计算器
(Partitioner)的算法。
Job:⽤户提交的作业。当RDD及其DAG被提交给DAGScheduler调度后,DAGScheduler会将所有RDD中的转换及动作视为⼀个Job。⼀个Job由⼀到多个Task组成。
Stage:Job的执⾏阶段。DAGScheduler按照ShuffleDependency作为Stage的划分节点对RDD的DAG进⾏Stage划分(上游的Stage将为ShuffleMapStage)。因此⼀个Job可能被划分为⼀到多个Stage。Stage分为ShuffleMapStage和ResultStage两种。
Task:具体执⾏任务。⼀个Job在每个Stage内都会按照RDD的Partition 数量,创建多个Task。Task分为ShuffleMapTask和ResultTask两种。ShuffleMapStage中的Task为ShuffleMapTask,⽽ResultStage中的Task为ResultTask。ShuffleMapTask和ResultTask类似于Hadoop中的 Map任务和Reduce任务。
Scala与Java的⽐较
  ⽬前越来越多的语⾔可以运⾏在Java虚拟机上,Java平台上的多语⾔混合编程正成为⼀种潮流。在混合编程模式下可以充分利⽤每种语⾔的特点和优势,以便更好地完成功能。Spark同时选择了Scala和Java作为开发语⾔,也是为了充分利⽤⼆者各⾃的优势。表1对这两种语⾔进⾏⽐较。
表1  Scala与Java的⽐较
Scala Java
语⾔类型⾯向函数为主,兼有⾯向对象
⾯向对象(Java8也增加了lambda函数
编程)
简洁性⾮常简洁不简洁类型推丰富的类型推断,例如深度和链式的类型推断、 duck type 、隐式类型转换等,但也因此增
注意:虽然Actor是Scala语⾔最初进⾏推⼴时,最吸引⼈的特性之⼀,但是随着Akka更加强⼤的Actor类库的出现,Scala已经在官⽅⽹站宣布废弃Scala⾃⾝的Actor编程模型,转⽽全⾯拥抱Akka提供的Actor编程模型。与此同时,从Spark2.0.0版本开始,Spark却放弃了使⽤Akka,转⽽使⽤Netty实现了⾃⼰的Rpc框架。遥想当年Scala“⿎吹”Actor编程模型优于Java的同步编程模型时,⼜有谁会想到如今这种场⾯呢?
  Scala作为函数式编程的代表,天⽣适合并⾏运⾏,如果⽤Java语⾔实现相同的功能会显得⾮常臃肿。很多介绍Spark的新闻或⽂章经常以Spark内核代码⾏数少或API精炼等内容作为宣传的“法器”,这应该也是选择Scala的原因之⼀。另⼀⽅⾯,由于函数式编程更接近计算机思维,因此便于通过算法从⼤数据中建模,这也更符合Spark作为⼤数据框架的理念吧!
  由于Java适合服务器、中间件开发,所以Spark使⽤Java更多的是开发底层的基础设施或中间件。
模块设计
整个Spark主要由以下模块组成:
Spark Core :Spark 的核⼼功能实现,包括:基础设施、SparkContext (Application 通过SparkContext 提交)、Spark 执⾏环境(SparkEnv )、存储体系、调度系统、计算引擎、部署模式、任务提交与执⾏等。
Spark SQL :提供SQL 处理能⼒,便于熟悉关系型数据库操作的⼯程师进⾏交互查询。此外,还为熟悉Hive 开发的⽤户提供了对Hive SQL 的⽀持。
Spark Streaming :提供流式计算处理能⼒,⽬前⽀持ApacheKafka 、Apache Flume 、Amazon Kinesis 和简单的TCP 套接字等数据源。在早期的Spark 版本中还⾃带对Twitter 、MQTT 、ZeroMQ 等的⽀持,现在⽤户想要⽀持这些⼯具必须⾃⼰开发实现。此外,Spark Streaming 还提供窗⼝操作⽤于对⼀定周期内的流数据进⾏处理。
GraphX :基于图论,实现的⽀持分布式的图计算处理框架。GraphX 的基础是点、边等图论的理论。GraphX 基于图计算的Pregel 模型提供了多种多样的Pregel API ,这些Pregel API 可以解决图计算中
的常见问题。
MLlib :Spark 提供的机器学习库。MLlib 提供了机器学习相关的统计、分类、回归等领域的多种算法实现。其⼀致的API 接⼝⼤⼤降低了⽤户的学习成本。
Spark SQL、Spark Streaming、GraphX、MLlib的能⼒都是建⽴在核⼼引擎之上,如图
Spark各模块依赖关系
Spark 核⼼功能
  Spark Core中提供了Spark最基础与最核⼼的功能,主要包括:
基础设施:在Spark 中有很多基础设施,被Spark 中的各种组件⼴泛使⽤。这些基础设施包括Spark 配置(SparkConf )、Spark 内置的Rpc 框架(在早期Spark 版本中Spark 使⽤的是Akka )、事件总线(ListenerBus )、度量系统。SparkConf ⽤于管理Spark 应⽤程序的各种配置信息。Spark 内置的Rpc 框架使⽤Netty 实现,有同步和异步的多种实现,Spark 各个组件间的通信都依赖于此Rpc 框架。如果说Rpc 框架是跨机器节点不同组件间的通信设施,那么事件总线就是SparkContext 内部各个组件间使⽤事件——模式异步调⽤的实现。度量系统由Spark 中的多种度量源(Source )和多种度量输出(Sink )构成,完成对整个Spark 集中各个组件运⾏期状态的监控。
SparkContext :通常⽽⾔,⽤户开发的Spark 应⽤程序(Application )的提交与执⾏都离不开SparkContext 的⽀持。在正式提交Application 之前,⾸先需要初始化SparkContext 。SparkContext 隐藏了⽹络通信、分布式部署、消息通信、存储体系、计算引擎、度量系统、⽂件服务、Web UI 等内容,应⽤程序开发者只需要使⽤SparkContext 提供的API 完成功能开发。
SparkEnv :Spark 执⾏环境(SparkEnv )是Spark 中的Task 运⾏所必须的组件。SparkEnv 内部封装了Rpc 环境(RpcEnv )、序列化管理器、⼴播管理器
(BroadcastManager )、map 任务输出跟踪器(MapOutputTracker )、存储体系、度量系统(MetricsSystem )、输出提交协调器(OutputCommitCoordinator )等Task 运⾏所需的各种组件。
存储体系:Spark 优先考虑使⽤各节点的内存作为存储,当内存不⾜时才会考虑使⽤磁盘,这极⼤地减少了磁盘I/O ,提升了任务执⾏的效率,使得Spark 适⽤于实时计算、迭代计算、流式计算等场景。在实际场景中,有些Task 是存储密集型的,有些则是计算密集型的,所以有时候会造成存储空间很空闲,⽽计算空间的资源⼜很紧张。Spark 的内存存储空间与执⾏存储空间之间的边界可以是“软”边界,因此资源紧张的⼀⽅可以借⽤另⼀⽅的空间,这既可以有效利⽤资源,⼜可以提⾼Task 的执⾏效率。此外,Spark 的内存空间还提供了Tungsten 的实现,直接操作操作系统的内存。由于Tungsten 省去了在堆内分配Java 对象,因此能更加有效的利⽤系统的内存资源,并且因为直接操作系统内存,空间
的分配和释放也更迅速。在Spark 早期版本还使⽤了以内存为中⼼的⾼容错的分布式⽂件系统Alluxio (Tachyon )供⽤户进⾏选择。Alluxio 能够为Spark 提供可靠的内存级的⽂件共享服务。调度系统:调度系统主要由DAGScheduler 和TaskScheduler 组成,它们都内置在SparkContext 中。DAGScheduler 负责创建Job 、将DAG 中的RDD 划分到不同的Stage 、给Stage 创建对应的Task 、批量提交Task 等功能。TaskScheduler 负责按照FIFO 或者FAIR 等调度算法对批量Task 进⾏调度;为Task 分配资源;将Task 发送到集管理器分配给当前应⽤的Executor 上由Executor 负责执⾏等⼯作。现如今,Spark 增加了SparkSession 和DataFrame 等新的API ,SparkSession 底层实际依然依赖于SparkContext 。
计算引擎:计算引擎由内存管理器(MemoryManager )、Tungsten 、任务内存管理器(TaskMemoryManager )、Task 、外部排序器(ExternalSorter )、Shuffle 管理器
(ShuffleManager )等组成。MemoryManager 除了对存储体系中的存储内存提供⽀持和管理,还外计算引擎中的执⾏内存提供⽀持和管理。Tungsten 除⽤于存储外,也可以⽤于计算或执⾏。TaskMemoryManager 对分配给单个Task 的内存资源进⾏更细粒度的管理和控制。ExternalSorter ⽤于在map 端或reduce 端对ShuffleMapTask 计算得到的中间结果进⾏排序、聚合等操作。ShuffleManager ⽤于将各个分区对应的ShuffleMapTask 产⽣的中间结果持久化到磁盘,并在reduce 端按照分区远程拉取ShuffleMapTask 产⽣的中间结果。Spark 扩展功能
  为了扩⼤应⽤范围,Spark陆续增加了⼀些扩展功能,主要包括:
Spark SQL :由于SQL 具有普及率⾼、学习成本低等特点,为了扩⼤Spark 的应⽤⾯,因此增加了对SQL 及Hive 的⽀持。Spark SQL 的过程可以总结为:⾸先使⽤SQL 语句解析器(SqlParser )将SQL 转换为语法树(Tree ),并且使⽤规则执⾏器(RuleExecutor )将⼀系列规则(Rule )应⽤到语法树,最终⽣成物理执⾏计划并执⾏的过程。其中,规则包括语法分析器(Analyzer )和优化器(Optimizer )。Hive 的执⾏过程与SQL 类似。
Spark Streaming :Spark Streaming 与Apache Storm 类似,也⽤于流式计算。SparkStreaming ⽀持Kafka 、Flume 、Kinesis 和简单的TCP 套接字等多种数据输⼊源。输⼊流接收器(Receiver )负责接⼊数据,是接⼊数据流的接⼝规范。Dstream 是Spark Streaming 中所有数据流的抽象,Dstream 可以被组织为DStreamGraph 。Dstream 本质上由⼀系列连续的RDD 组成。类型推
丰富的类型推断,例如深度和链式的类型推断、 duck type 、隐式类型转换等,但也因此增加了编译时长少量的类型推断可读性
⼀般,丰富的语法糖导致的各种奇幻⽤法,例如⽅法签名、隐式转换好学习成
较⾼⼀般语⾔特
⾮常丰富的语法糖和更现代的语⾔特性,例如 Option 、模式匹配、使⽤空格的⽅法调⽤丰富并发编
使⽤Actor 的消息模型使⽤阻塞、锁、阻塞队列等
GraphX:Spark提供的分布式图计算框架。GraphX主要遵循整体同步并⾏计算模式(Bulk Synchronous Parallell,简称BSP)下的Pregel模型实现。GraphX提供了对图的抽象Graph,Graph由顶点(Vertex)、边(Edge)及继承了Edge的EdgeTriplet(添加了srcAttr和dstAttr⽤来保存源顶点和⽬的顶点的属性)三种结构组成。GraphX⽬前已经封装了最短路径、⽹页排名、连接组件、三⾓关系统计等算法的实现,⽤户可以选择使⽤。
MLlib:Spark提供的机器学习框架。机器学习是⼀门涉及概率论、统计学、逼近论、凸分析、算法复杂度理论等多领域的交叉学科。MLlib⽬前已经提供了基础统计、分类、回归、决策树、随机森林、朴
素贝叶斯、保序回归、协同过滤、聚类、维数缩减、特征提取与转型、频繁模式挖掘、预⾔模型标记语⾔、管道等多种数理统计、概率论、数据挖掘⽅⾯的数学算法。
Spark模型设计
1. Spark编程模型
正如Hadoop在介绍MapReduce编程模型时选择word count的例⼦,并且使⽤图形来说明⼀样,笔者对于Spark编程模型也选择⽤图形展现。
Spark 应⽤程序从编写到提交、执⾏、输出的整个过程如图所⽰。
代码执⾏过程
图5中描述了Spark编程模型的关键环节的步骤如下。
1)⽤户使⽤SparkContext提供的API(常⽤的有textFile、sequenceFile、runJob、stop等)编写Driver application程序。此外,SparkSession、DataFrame、SQLContext、HiveContext及StreamingContext都对SparkContext进⾏了封装,并提供了DataFrame、SQL、Hive及流式计算相关的API。
2)使⽤SparkContext提交的⽤户应⽤程序,⾸先会通过RpcEnv向集管理器(Cluster Manager)注册应⽤(Application)并且告知集管理器需要的资源数量。集管理器
根据Application的需求,给Application分配Executor资源,并在Worker上启动CoarseGrainedExecutorBackend进程(CoarseGrainedExecutorBackend进程内部将创建Executor)。Executor所在的CoarseGrainedExecutorBackend进程在启动的过程中将通过RpcEnv直接向Driver注册Executor的资源信息,TaskScheduler将保存已经分配给应⽤的Executor资源的地址、⼤⼩等相关信息。然后,SparkContext根据各种转换API,构建RDD之间的⾎缘关系(lineage)和DAG,RDD构成的DAG将最终提交给DAGScheduler。DAGScheduler给提交的DAG创建Job并根据RDD的依赖性质将DAG划分为不同的Stage。DAGScheduler根据Stage内RDD的Partition数量创建多个Task并批量提交给TaskScheduler。TaskScheduler对批量的Task按照FIFO或FAIR调度算法进⾏调度,然后给Task分配Executor资源,最后将Task发送给Executor由Executor执⾏。此外,SparkContext还会在RDD转换开始之前使⽤BlockManager和BroadcastManager将任务的Hadoop配置进⾏⼴播。
3)集管理器(Cluster Manager)会根据应⽤的需求,给应⽤分配资源,即将具体任务分配到不同Worker节点上的多个Executor来处理任务的运⾏。Standalone、YARN、Mesos、EC2等都可以作为Spark的集管理器。
4)Task在运⾏的过程中需要对⼀些数据(例如中间结果、检查点等)进⾏持久化,Spark⽀持选择HDFS 、Amazon S3、Alluxio(原名叫Tachyon)等作为存储。
2.RDD计算模型
RDD可以看做是对各种数据计算模型的统⼀抽象,Spark的计算过程主要是RDD的迭代计算过程,如图6所⽰。RDD的迭代计算过程⾮常类似于管道。分区数量取决于Partition数量的设定,每个分区的数据只会在⼀个Task中计算。所有分区可以在多个机器节点的Executor上并⾏执⾏。
RDD计算模型
上图只是简单的从分区的⾓度将RDD的计算看作是管道,如果从RDD的⾎缘关系、Stage划分的⾓度来看,由RDD构成的DAG经过DAGScheduler调度后,将变成下图所⽰的样
⼦。
DAGScheduler对由RDD构成的DAG进⾏调度
上图中共展⽰了A、B、C、D、E、F、G⼀共7个RDD。每个RDD中的⼩⽅块代表⼀个分区,将会有⼀个Task处理此分区的数据。RDD A经过groupByKey转换后得到RDD B。RDD C经过map转换后得
到RDD D。RDD D和RDD E经过union转换后得到RDD F。RDD B和RDD F经过join转换后得到RDD G。从图中可以看到map和union⽣成的RDD与其上游RDD之间的依赖是NarrowDependency,⽽groupByKey和join⽣成的RDD与其上游的RDD之间的依赖是ShuffleDependency。由于DAGScheduler按照ShuffleDependency 作为Stage的划分的依据,因此A被划⼊了ShuffleMapStage 1;C、D、E、F被划⼊了ShuffleMapStage 2;B和G被划⼊了ResultStage 3。
Spark基本架构
从集部署的⾓度来看,Spark集由集管理器(Cluster Manager)、⼯作节点(Worker)、执⾏器(Executor)、驱动器(Driver)、应⽤程序(Application)等部分组成,它们之间的整体关系如下图所⽰。
Spark基本架构图
下⾯结合图8对这些组成部分以及它们之间的关系进⾏介绍。
(1)Cluster Manager
Spark的集管理器,主要负责对整个集资源的分配与管理。Cluster Manager在Yarn部署模式下为ResourceManager;在Mesos部署模式下为Mesos master;在Standalone
部署模式下为Master。Cluster Manager分配的资源属于⼀级分配,它将各个Worker上的内存、CPU等资源分配给Application,但是并不负责对Executor的资源分配。Standalone部署模式下的Master会直接给Application分配内存、CPU以及Executor等资源。⽬前,Standalone、YARN、Mesos、EC2等都可以作为Spark的集管理器。
注意:这⾥提到了部署模式中的Standalone、Yarn、Mesos等模式,读者暂时知道这些内容即可,本书将在第9章对它们详细介绍。
(2)Worker
Spark的⼯作节点。在Yarn部署模式下实际由NodeManager替代。Worker节点主要负责以下⼯作:将⾃⼰的内存、CPU等资源通过注册机制告知Cluster Manager;创建Executor;将资源和任务进⼀步分配给Executor;同步资源信息、Executor状态信息给Cluster Manager等。在Standalone部署模式下,Master将Worker上的内存、CPU以及Executor等资源分配给Application后,将命令Worker启动CoarseGrainedExecutorBackend进程(此进程会创建Executor实例)。
(3)Executor
执⾏计算任务的⼀线组件。主要负责任务的执⾏以及与Worker、Driver的信息同步。
(4)Driver
Application的驱动程序,Application通过Driver与Cluster Manager、Executor进⾏通信。Driver可以运⾏在Application中,也可以由Application提交给Cluster Manager并由Cluster Manager安排Worker运⾏。
(4)Application
⽤户使⽤Spark提供的API编写的应⽤程序,Application通过Spark API将进⾏RDD的转换和DAG的构建,并通过Driver将Application注册到Cluster Manager。Cluster Manager将会根据Application的资源需求,通过⼀级分配将Executor、内存、CPU等资源分配给Application。Driver通过⼆级分配将Executor等资源分配给每⼀个任
scala不是内部或外部命令
务,Application最后通过Driver告诉Executor运⾏任务。
来源: 《Spark内核设计的艺术 架构设计与实现》 --耿嘉安

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。