算上这一篇,“开源组件系列”已经写到了第十二篇。前面聊过不少存储和调度相关的组件,这篇来把计算引擎这块的核心拼图补上:Spark。说实话,现在出去面试或者接大数据相关的项目,Spark基本上是跳不过去的一环。很多人对Spark的理解停留在“比MapReduce快很多的内存计算框架”,但真要问到它为什么快、DAG模型到底解决了什么问题、一个作业从提交到出结果在集群里经历了什么,能讲清楚的人其实不多。
这篇不打算照着官方文档翻译一遍。我想从“为什么要用DAG来组织计算”这个底层逻辑开始,把Spark的作业调度、运行机制、三套核心API的区别、部署形态以及我在实际项目中踩过的调优和排坑经验串起来聊。适合两类人看:一类是刚接触Spark、想系统建立认知框架的开发者,另一类是已经写过不少Spark代码、但总觉得调度和优化部分隔着一层窗户纸的同学。
1. Spark靠什么接过了大数据计算的大旗
1.1 在Spark出现之前,批处理是怎么跑的
要理解Spark的价值,得先回到它之前那个时代。早期的大数据批处理,主流方案是基于MapReduce模型的。MapReduce把计算过程强行拆成两个阶段:Map阶段和Reduce阶段,中间隔着一次落盘和网络传输。Map产出的中间结果必须写到本地磁盘,等Reduce阶段启动后再去拉取。这种设计最大的问题在于,一个复杂的分析任务通常需要串联很多个MapReduce作业,前一个作业的输出是后一个作业的输入,每一轮都要经过“写磁盘、网络传输、再读磁盘”的过程。
单纯从这个流程就能看出来:磁盘IO和序列化开销会占据整个作业执行时间相当高的比例。如果是一个需要十几轮迭代的算法,比如一些图计算或者机器学习类的任务,每一轮都把中间结果刷到磁盘,性能会非常难看。这本质上不是MapReduce模型本身算错了,而是它对“中间数据”的处理方式太浪费。
1.2 DAG把“一步一落盘”变成了“内存里接力”
Spark对这件事最核心的改动有两点:引入有向无环图(DAG)来组织计算流程,以及把中间结果尽量驻留在内存中。
先说DAG。一个数据处理任务,从数据源到最终结果,中间涉及的各种转换操作,比如过滤、映射、聚合、关联,这些操作之间的依赖关系天然就能表达成一张图。Spark把这整张DAG交给调度引擎,调度引擎对图进行解析,找出哪些计算可以放在同一个阶段(Stage)里串行执行,哪些阶段之间存在shuffle依赖必须分开,然后以最优的方式把任务分发到集群里的各个节点上去执行。
这跟MapReduce那种“机械地拆成固定两段”完全不同,DAG的灵活性在于,它允许一个阶段的结果直接被下一个阶段消费,中间省去了大量落盘。配合内存缓存机制,那些需要反复迭代的计算,就能在内存里连续接力完成。这也是Spark在迭代式计算和交互式查询场景下,比老方案快出几个数量级的根本原因。
1.3 内存计算不是越猛越好
谈到这里需要纠正一个常见的误解:Spark并不是完全不需要磁盘。当内存不足以存放全部中间数据时,Spark仍然会把溢出部分写到磁盘,也就是常见的spark.shuffle.spill机制。内存计算的意义在于“减少不必要的落盘”,而不是“完全消灭落盘”。理解了这点,后续做内存参数调优时就不容易走极端。
另外有一点很多人在初学阶段容易忽略:Spark虽然比老方案快,但它不是一个“内存数据库”那样的系统。它的核心依然是一个分布式计算框架,只是通过DAG调度和内存复用来大幅缩短了作业的执行时间。搞清楚这层定位,对后面我们讨论资源参数配置会很有帮助。
2. DAG计算模型的底层拆解
2.1 RDD:DAG的节点与边
Spark的DAG不是凭空产生的,它的构建基础是RDD(弹性分布式数据集)。我们可以把RDD理解为一个“带分区信息的不可变数据集”,它记录了数据分布在哪些节点上,以及对这个数据集已经施加过哪些转换操作。
在Spark里,用户的程序基本上就是在不断地创建新的RDD并对其进行转换,比如从一个文本文件创建RDD,然后map、filter、join,每一步操作都会生成一个记录了“我这个数据集是从哪个父数据集、经过什么计算得到的”的RDD。这一系列RDD之间的依赖关系串起来,就是一张血缘关系图(Lineage Graph)。Spark的DAG调度器拿到的,正是这张血缘图。
这里值得强调的是“不可变”这个特性。RDD一旦创建就不会被修改,任何转换操作都生成新的RDD,而不是改动原有数据。这个设计带来一个巨大的红利:如果某个分区的计算失败或者数据丢失,Spark可以沿着血缘关系回溯,只重新计算受影响的那个分区,而不是把整个作业从头跑一遍。这就是容错机制的基础。
2.2 窄依赖与宽依赖:划分Stage的标尺
光有图还不够,调度器要执行这张图,必须搞清楚哪些计算能“就地串联”,哪些计算必须“停下来等待数据重排”。这就需要区分两类依赖:
窄依赖是指父RDD的每个分区最多被子RDD的一个分区使用。典型的操作就是map、filter、union。窄依赖场景下,父分区的计算和子分区的计算可以在同一个任务里连续完成,不需要跨节点传数据,效率极高。
宽依赖是指父RDD的每个分区可能被子RDD的多个分区使用,典型的就是groupByKey、reduceByKey、join这一类会触发shuffle的操作。宽依赖存在时,必须等父阶段的全部任务执行完,然后按照key重新分区和传输数据,才能开始下一阶段的计算。
DAG调度器划分Stage的规则很简单粗暴:从后往前回溯,遇到宽依赖就把依赖链切断,切出来的每一段就是一个Stage。换句话说,Stage的边界就是shuffle发生的位置。
2.3 一张图看懂执行顺序
下面用一个小场景把这几个概念串起来。假设有这样的一个计算逻辑:读取两份数据集,一份做过滤并聚合,另一份做映射,然后两者做关联,最后排序输出。
在实际执行时:Spark先生成DAG,然后DAG调度器发现关联操作(join)是一个宽依赖,因此它被设为Stage边界;关联之前的所有操作会被压缩到前一个Stage里,关联之后直到排序输出则属于后一个Stage。同一个Stage内部的任务之间不需要数据重排,可以尽可能地利用内存和CPU流水线式执行。
这个流程实际对应的执行计划是:
| DAG操作 | 是否触发shuffle | Stage归属 |
|---|---|---|
| 读取源数据 | 否 | Stage 0 |
| 过滤、映射、局部聚合 | 否 | Stage 0 |
| 关联操作 | 是 | Stage 0与Stage 1的边界 |
| 排序与结果输出 | 是 | Stage 1 |
2.4 DAG相比两阶段模型的优势
到这里可以做个明确的对比了。MapReduce模型中,每个业务逻辑都被强行套进Map和Reduce两个阶段里,复杂任务需要手动拆成多个作业串行执行,层层落盘。DAG模型中,计算图在调度层是全局可见的,调度器能自动识别哪些计算可以合并执行,哪些必须分开,然后把尽可能多的计算放进同一个Stage内完成。
这种“全局视角”带来的直接收益是:对于多步骤任务,作业数量从原来的“每个MapReduce作业都要完整走一遍提交、调度、执行、落盘的流程”,变成了“一次提交,内部自动划分阶段”。作业粒度和调度开销都大幅下降,尤其适合那些需要几十步转换的复杂ETL逻辑。
3. 一次Spark作业在集群里的完整旅程
3.1 Driver、Executor和集群管理器的三角关系
理解DAG调度模型之后,有必要看看一个作业提交后到底经过了哪些环节。Spark采用经典的主从架构:Driver负责解析代码、构建DAG、划分Stage、调度Task;Executor负责真正执行Task,并在内存里缓存数据。集群管理器(可以是自带的Standalone模式,也可以对接YARN或者Kubernetes)负责资源的分配和回收。
有个概念容易混淆:Driver和集群管理器各自管什么。Driver管的是“计算任务的编排”,它知道要执行哪些Task、每个Task的数据在哪;集群管理器管的是“计算资源的分配”,它只知道有多少个Executor可以启动,每个Executor给多少CPU和内存,至于Task在Executor内部怎么跑,它不关心。
3.2 作业、阶段、任务:三层调度结构
Spark的调度是典型的三层模型。一个完整的作业(Job)是用户提交的一个动作,比如一次count操作或者一个SQL查询;一个作业会被拆成多个阶段(Stage),阶段之间以宽依赖为界;每个阶段对应多个任务(Task),Task的数量取决于分区数,每个分区由一个Task负责处理。
这三层关系值得多说明一下。Task是实际在Executor上运行的单元,一个Executor默认情况下可以并发运行多个Task,具体取决于分配的CPU核心数。比如分配了4个核心,那这个Executor上就能同时跑4个Task。分区数和并行度直接挂钩,这个数设置太小会导致资源利用率不足,设置太大又会增加调度开销。
3.3 数据本地性:让计算主动靠近数据
Spark调度还有一个容易被忽略但影响很大的策略:数据本地性。它的基本原则是“能不传数据就不传数据,尽量让Task到数据所在的节点上去执行”。因为网络传输的代价通常比在节点本地读数据要高得多,尤其对于TB级别的大数据集,数据的移动会造成巨大的IO和网络开销。
本地性由高到低分为几个等级:进程本地性(PROCESS_LOCAL,数据就在同一个Executor里)、节点本地性(NODE_LOCAL,数据在同一个节点的其他Executor里)、机架本地性(RACK_LOCAL,数据在同一个机架的其他节点上)等。调度器会先尽量满足高等级本地性,如果等待超时还拿不到对应位置的资源,会降级执行,从其他节点拉取数据。
3.4 容错不靠备份,靠重新计算
Spark的容错策略也是源于DAG模型的一个巧妙设计。对于一个执行失败的分区,最直接的做法是重新运行那个Task。如果重新运行仍然失败,Spark会检查程序是否提供了重试机制之外的恢复途径。
这里的关键在于血缘关系。当某个分区数据丢失时,Spark会用它所在Stage的血缘链,从检查点或者初始数据源开始,把丢失的分区重新计算出来。这比传统的“对每个中间结果做备份副本”策略节省了大量存储开销。代价是如果血缘链很深,重算成本会很高,所以实际工程中,对于血缘特别长且中间结果很贵的场景,我们会手动插入checkpoint,把中间结果持久化到可靠存储,截断血缘链。
4. RDD、DataFrame、DataSet三套API怎么选
4.1 三套API的定位差异
先梳理一下三套API的核心区别,因为很多新手都会在这里纠结。
RDD是最底层的抽象,它强调函数式编程,任何逻辑都可以通过map、filter、flatMap、reduce等算子组合出来。它的优点是灵活,几乎能表达任何计算逻辑;缺点是运行时自由度太高,引擎很难做自动优化。所有优化都必须开发者自己手动来控制。
DataFrame是在RDD之上引入Schema信息后的产物,它把数据组织成一张带列名的表。有了Schema,引擎就能在计算之前对执行计划做深度优化,比如谓词下推、列剪枝、常量折叠等。这个优化器就是大家常说的Catalyst。
DataSet则是DataFrame的强类型版本,它在编译时就能校验类型正确性,在某些需要类型安全的场景下(比如写一个长期维护的应用)价值很大。在Spark 2.x以后,DataFrame本质上是DataSet[Row]的特例,两者底层共用同一套执行引擎。
4.2 Catalyst优化器到底优化了什么
Catalyst优化器本质上是一个基于规则的执行计划转换引擎。它把SQL或者DataFrame算子转换成逻辑计划,然后应用一系列优化规则。
举两个最常见的优化效果。第一个是谓词下推:如果你在SQL里写了where过滤条件,优化器会尽量把过滤操作“下沉”到数据源读取之后立刻执行,这样后续的join和聚合操作处理的数据量就会大幅减少。第二个是列剪枝:如果你的查询只需要三列数据,优化器会告诉底层的数据源只读取这三列,而不是把整行数据都读进来。
这些优化在RDD层面是做不到的,因为RDD完全不知道每列的名字和类型。这就是为什么官方一直建议优先使用DataFrame和SQL来处理业务逻辑——哪怕你手动对RDD做了很多精细的调优,大多时候也比不上优化器自动产出的执行计划。
4.3 实际选型建议
基于我在多个项目里的实践,这里给出一个比较稳妥的选择策略:
| 场景 | 推荐API | 原因 |
|---|---|---|
| 常规数据清洗与统计 | DataFrame / SQL | 有优化器加持,开发效率高 |
| 复杂ETL逻辑且需要全链路可控 | DataFrame + UDF | 兼顾灵活性和性能 |
| 底层算法开发、自定义分区器 | RDD | 精细控制执行细节 |
| 需要类型安全、长期演进的应用 | DataSet | 编译期检查类型,方便重构重构 |
补充一点:不要为了“炫技”而刻意使用RDD。绝大多数业务场景里,DataFrame的表现已经足够好,而且代码可读性远高于一堆算子串联。RDD的价值更多体现在引擎扩展和底层原语场景。
5. 不止批处理:Spark生态里的SQL、流、图与机器学习
5.1 Spark SQL:数据处理最顺手的入口
Spark SQL是整个生态的入口之一。它既支持纯SQL,也支持编程式API,背后是同一个Catalyst优化器。这意味着你可以用一套代码逻辑处理相同语义的两种写法,执行计划基本上是一样的。
实际使用时,Spark SQL之前最典型的落地场景包括:把一份数据从文件或者外部数据源读入后注册成临时表,然后用标准SQL做复杂的多层嵌套查询。相比直接写RDD算子,SQL的表达成本要低得多,尤其是遇到多表关联和子查询时,可读性优势非常明显。
5.2 从Spark Streaming到Structured Streaming
流处理这部分,早期Spark Streaming是微批处理模式,把实时数据流切分成极小的批次按批处理的方式执行,延迟一般在秒级。后来推出的Structured Streaming则把流数据抽象成“无界表”,每来一条数据就像往表里插入一行,然后你可以在上面做连续查询。
必须说清楚的是:Spark的流处理并不是真正的逐条事件驱动,它依然是一个微批模型。在需要毫秒级延迟的实时场景(比如金融高频交易),Spark并不是最优解。但在秒级延迟能够接受的场景,例如系统日志的实时监控、用户行为指标的秒级聚合,Structured Streaming凭借“一套代码跑批流”的优势,运维成本比维护两套系统低很多。
5.3 MLlib与GraphX的实际定位
MLlib是Spark上的机器学习库,提供了大量经典的分类、回归、聚类、协同过滤算法。GraphX则提供了基于图的计算框架,支持PageRank、连通分量等图算法。
这里要泼一点冷水:这两个库在很多互联网公司里使用率其实没有SQL和流处理那么高。深度学习领域已经被其他更专业的框架占据,MLlib的主场更多在于大规模样本上的传统机器学习特征处理和模型训练。图计算则更常被专门的图数据库和向量化图引擎取代。如果你想在简历上写“熟悉Spark全家桶”,MLlib和GraphX必须知道它们解决什么问题、适合哪些场景,但不必花太长时间深入研究每个算法实现细节。
5.4 什么场景适合上Spark全家桶
简单概括,如果团队的数据处理需求可以统一成“以SQL为中心的离线/准实时加工管道”,那么Spark全家桶几乎是成本最低的选项。这通常意味着我们只需要维护一套数据引擎,就能覆盖日常报表、临时查询、特征抽取、分钟级事件指标加工等需求,整体技术栈非常整洁。
反过来,如果你需要HTAP能力、毫秒级点查响应或者复杂的事务处理,那Spark就不是合适的主角,它更适合在数据管道中承担批处理和流式预计算这两层核心角色。
6. 部署形态与性能调优里绕不开的坑
6.1 四种部署形态如何选
Spark支持本地模式(local)、独立集群模式(Standalone)、YARN模式以及Kubernetes模式。本地模式一般只用于开发测试和学习;Standalone适合小规模集群,部署最简单;YARN模式在传统大数据平台里非常常见,因为它能和已有的HDFS、Hive等组件共用一个资源管理层,运维方便;Kubernetes模式则是云原生环境下越来越主流的选择,动态扩缩容能力更强,对基础设施管理有标准化要求的团队更友好。
选型的逻辑很简单:能少维护一套资源管理系统就少维护一套。如果公司已经有一个大集群在跑HDFS和Hive,那么YARN模式是不需要犹豫的选择;如果基础设施已经容器化,为了统一交付和快速扩缩容,优先考虑Kubernetes。
6.2 核心参数到底应该怎么配
这一节讲调优,我认为最重要的一个认知是:Spark参数没有“万能最优解”,所有最优配置都是针对特定场景的。但如果非要说哪些参数决定了下限,那大概率是下面几个:
首先是spark.executor.memory,它决定了每个Executor的JVM堆内存大小。这个参数会直接影响shuffle和缓存溢出的频率,以及GC压力。一般建议根据单机总内存和并发Executor数来反推。比如一台机器有64GB内存,计划跑2个Executor,且预留15%给系统其他进程使用,那么每个Executor可以给到24GB左右。
其次是spark.executor.cores,它决定了每个Executor上的并发Task数。这个参数不能只看CPU核心数,还要考虑Task的IO密集程度。一个Executor内部并发Task太多,CPU上下文切换开销会明显上升,并发太少则浪费核数。常见的经验值是2到4个核心。
然后是spark.sql.shuffle.partitions,它决定了shuffle后数据的分区数。很多默认配置在生产环境规模下并不合理,比如这个参数默认是200,但对于特别大或者特别小的数据,都需要手动调整。判断方法很简单:看每个Task处理的数据量。一般来说单个Task处理的数据量在几百MB到1GB之间比较合理,据此反推分区数。
最后是spark.default.parallelism,在未显式指定分区数时,它会作为RDD转换的默认并行度参考值。
6.3 数据倾斜、小文件、序列化问题
这三个问题几乎是我在真实项目里见得最多的故障源。
数据倾斜本质上是某些key的数据量远超其他key,导致个别Task压力过大而其他Task早早空闲。最简单的治标手段是给key加随机前缀后分两阶段聚合,或者想办法把热点key单独拆分处理。治本手段则需要从业务逻辑层面寻找更好的分片策略。
小文件问题则与分区数设置直接有关。如果读取了理论上千个HDFS文件切片但在最终写出时分区数设置非常小,生成的文件数量很少但每只很大;反过来如果每个批次的分区数量非常多,输出时就会产生海量小文件,给下游HDFS的NameNode内存和数据读取都带来沉重负担。常用的手段是调整写出阶段的分区数,或者先执行一次重分区操作再做输出。
序列化方面,最常见的坑是使用了Java原生序列化且自定义类没有实现可序列化接口,或者是闭包中引用了无法被序列化的对象。除了改用Kryo序列化并注册对应的类,我建议在开发阶段多做小规模数据量的本地冒烟测试,把这类问题尽早暴露出来。
7. 实战复盘:一个离线日志统计任务的完整落地过程
7.1 业务场景与需求定义
为了把前面的知识点串起来,分享一个我实际做过的模拟项目:某数据平台需要每天对全量用户访问日志做统计,核心指标包括各端口的访问热力分布、用户留存分层、异常请求占比,并产出结果供下游报表和实时看板使用。
这个场景非常适合用Spark SQL来做:输入数据是按小时落地的Parquet文件,体积在数百GB量级,属于典型的“批量大、逻辑不算太重”的场景。下面用一个简化版的核心代码和参数配置来复盘整个过程。
7.2 代码实现与参数配置
数据读取方面,直接读取指定目录下的Parquet文件并以DataFrame注册为临时表。核心统计逻辑需要先对原始事件做去重和清洗,再按多维维度做聚合。为了控制单次作业的规模,使用传入日期作为参数做分区裁剪。
from pyspark.sql import SparkSession spark = SparkSession.builder \ .appName("daily_log_stat") \ .config("spark.sql.shuffle.partitions", "120") \ .config("spark.sql.adaptive.enabled", "true") \ .getOrCreate() input_path = "/data/logs/events/date=2024-05-20" df = spark.read.parquet(input_path) df.createOrReplaceTempView("events") sql = """ SELECT channel, platform, COUNT(DISTINCT user_id) AS uv, COUNT(*) AS pv, SUM(IF(is_abnormal = 1, 1, 0)) AS abnormal_cnt FROM events WHERE dt = '2024-05-20' AND event_type = 'page_view' GROUP BY channel, platform """ result = spark.sql(sql) result.write.mode("overwrite").format("parquet").partitionBy("channel").save("/data/result/daily_log_stat")这段代码看起来很简单,但实际踩了一个不小的坑:COUNT(DISTINCT user_id)在数据量很大的时候会产生严重的shuffle负担,尤其是去重基数特别高的维度组合。为了解决这个问题,做了一次方案调整,把“精确去重”改成了“近似去重”,使用approx_count_distinct函数,从而大幅降低了shuffle数据量,精度控制在可接受范围内。
7.3 运行过程中遇到的坑与调整链路
第一次全量运行时,作业跑了将近40分钟,而且其中有一个阶段明显拖尾,部分Task执行时间超过15分钟,其他Task三分钟就结束了。这是非常典型的节点资源不均加数据倾斜混合问题。
排查思路是先在Spark UI里找到拖尾的Stage,看它的Task数量和数据量分布。结果发现倾斜的key来自某个数据异常的渠道,占整体数据量的约35%。当时没有直接在SQL里强行加盐,而是先跟业务方确认了这个渠道的特殊性,最后决定在清洗阶段把异常渠道单独分流处理,然后再把结果合并。经过这轮调整,作业总时长降到了12分钟左右。
7.4 调优前后的对比
| 指标 | 调优前 | 调优后 |
|---|---|---|
| 总执行时长 | 约38分钟 | 约12分钟 |
| 最长Task时长 | 16分钟 | 4分钟 |
| shuffle数据量 | 约2.1TB | 约0.6TB |
| Executor数 | 40 | 40 |
可以看到,在资源规模完全没变的前提下,仅仅通过优化去重逻辑、拆分倾斜key、合理设置shuffle分区数,就把作业耗时压缩到了原来的三分之一。这也是我想反复强调的:Spark调优不是堆机器,而是真正理解DAG调度和shuffle机制之后,把计算尽可能变成“不需要挪数据”的形态。
在具体项目里写Spark作业时,我还有一个习惯:先关注Spark UI上的三个核心指标——Shuffle Read Size、Task执行时间长尾情况、GC时间占比。这三个指标能快速定位绝大多数性能问题。另外,预算允许的话,开启Spark的Adaptive Query Execution(动态执行计划)特性,它能自动在运行时调整后续Stage的分区数并做广播优化,对SQL类作业的提升非常明显。
大数据计算这块,真正能拉开差距的地方,往往不是用了多新的组件,而是对DAG调度的每一层细节做到心里有数。Spark发展到今天,生态已经非常成熟,但底层的那些概念——血缘、依赖、阶段、任务、shuffle、本地性——依然是理解它的全部钥匙。把这套基础打牢了,不管以后它怎么演进,你都能比较快地看懂新的设计到底改了什么。