1. 从一次数据清洗的困境说起:为什么需要Spark
几年前我接手过一个日志分析的小项目,原始数据大概每天200GB左右,格式是半结构化的文本,夹杂着JSON片段和固定分隔符。当时第一反应是用Python脚本配合多进程跑,结果单机跑完一天的数据要将近6个小时,而且中间一旦某个进程崩了,整个任务就得从头再来。后来换成数据库做聚合,导入阶段就卡住了,单表数据量上去之后索引维护的成本高得离谱。那个阶段我试过很多方案,要么是内存扛不住,要么是磁盘IO成为瓶颈,要么是容错机制太弱。
这个困境其实很典型:数据量超过了单机舒适处理的上限,但又没有大到需要动用一整套重型数仓体系。Spark就是在这个区间里最顺手的工具。它最早从加州大学伯克利分校的AMP实验室走出来,核心解决的问题只有一个——让分布式计算变得像写单机代码一样自然,同时把中间结果尽量放在内存里,避免反复读写磁盘。而RDD(Resilient Distributed Dataset,弹性分布式数据集)就是它最底层的抽象,理解了RDD,后面所有的DataFrame、Dataset、Spark SQL、Structured Streaming,你都能顺着这根藤摸下去。
这篇文章适合两类人:一类是刚接触大数据、被Hadoop那一套组件绕晕了想找个更轻快入口的开发者;另一类是用过Spark但只会调API、对背后“为什么这么写”没有概念的工程师。我会从架构的整体设计讲起,把RDD的核心机制拆开,配上我实际踩过的坑和参数选择的思路。读完之后,你应该能自己判断一个任务该不该用Spark、怎么写才不容易OOM、以及遇到卡顿时该往哪个方向排查。
2. Spark架构的整体设计与选型逻辑
2.1 为什么是“内存优先”而不是“磁盘优先”
要理解Spark的架构,得先回到它诞生的年代背景。在它之前,MapReduce是分布式批处理的主流方案。MapReduce的模型很清晰:Map阶段做映射,Reduce阶段做聚合,中间结果落磁盘。这个设计在当年磁盘便宜、内存昂贵的环境下是合理的,但它有个致命问题——迭代式计算。比如机器学习里的梯度下降,需要反复扫描同一份数据几十上百轮,每一轮MapReduce都要把中间结果写磁盘再读回来,IO开销直接把速度拖垮。
Spark的选择是把中间结果尽量留在内存里。注意我说的是“尽量”,不是“全部”。这个区别很关键。Spark的内存管理经历了几个版本的演进,早期用Java堆内存加序列化,后来引入钨丝计划(Tungsten)做堆外内存和二进制格式优化。但核心思想没变:能放内存的绝不落盘,放不下的才溢写到磁盘。这个策略让迭代式任务的性能相比MapReduce有数量级的提升,官方 benchmark 里逻辑回归这类任务能快10倍以上。
但“内存优先”也带来了新的问题:内存不够怎么办?这就引出了Spark架构里几个关键角色的分工。
2.2 Driver、Executor与Cluster Manager的三方协作
Spark的运行时架构是一个典型的主从结构,但比简单的Master-Slave多了一层调度抽象。我用一个实际提交任务的流程来串一遍:
当你在终端敲下spark-submit命令时,首先启动的是Driver进程。Driver是整个应用的“大脑”,它负责解析你的代码、构建DAG(有向无环图)、划分Stage、生成Task,然后把这些Task分发给Executor执行。Driver里还维护着SparkContext和SparkSession这两个入口对象,你写的所有转换操作最终都会注册到Driver的DAG调度器上。
Executor是真正干活的“工人”,它们分布在集群的各个节点上,每个Executor是一组JVM进程,内部有线程池来并发执行Task。Executor有两个核心职责:执行Task并把结果返回Driver,以及通过BlockManager管理内存和磁盘上的数据块。这里有个容易混淆的点:Executor的内存分为执行内存和存储内存,早期版本两者是固定比例的,后来改成统一内存管理,可以动态借用。这个机制直接影响到你会不会遇到OOM,后面讲RDD缓存时会展开。
Cluster Manager是资源调度的“中介”,负责在集群里分配CPU和内存给Executor。Spark支持三种模式:Standalone是自带的简易调度器,适合测试和小规模集群;YARN是Hadoop生态里的资源管理器,企业环境用得最多;Mesos现在基本被Kubernetes模式取代了。选哪种取决于你现有的基础设施——如果已经有YARN集群,直接用YARN最省事;如果是容器化环境,Kubernetes模式更自然。
注意:Driver可以运行在集群内部(cluster模式)也可以运行在提交任务的客户端(client模式)。client模式方便看日志调试,但客户端断网任务就挂了;cluster模式适合生产环境,Driver由集群管理,但日志需要去集群里捞。
2.3 懒执行与DAG:Spark的“拖延症”其实是优点
Spark的转换操作(Transformation)是懒执行的。你写filter、map、flatMap这些操作时,Spark并不会立刻计算,而是把它们记录在DAG里,直到遇到行动操作(Action)如count、collect、saveAsTextFile才真正触发计算。
这个设计的好处在于优化空间。Driver在生成物理执行计划之前,可以对整个DAG做全局优化:比如把多个窄依赖的转换合并成一个Stage,减少数据 shuffle;比如谓词下推,把过滤操作尽量提前执行,减少后续处理的数据量。如果每个转换都立即执行,这些优化就无从谈起了。
我刚开始用Spark时很不习惯这种“写了不跑”的模式,调试时经常忘了加行动操作,看着代码没报错但也没输出,排查半天才发现是懒执行导致的。后来养成习惯:每写完一段转换逻辑,先接一个take(5)或count()验证一下,确认数据流符合预期再继续往下写。
3. RDD核心机制拆解与实操要点
3.1 RDD的五个核心属性
RDD是Spark最基础的抽象,官方文档里列了五个属性,但光看定义很难有体感。我用自己的理解重新翻译一遍:
第一,一组分区(Partition)。这是RDD的物理形态。一个RDD在逻辑上是一个大数据集,物理上被切成若干块,每块就是一个分区。分区数决定了并行度——你有多少个分区,就能有多少个Task同时跑。分区太少,集群资源用不满;分区太多,Task调度开销大。通常建议每个分区处理128MB左右的数据,这个数字和HDFS块大小对齐,能减少跨节点数据传输。
第二,一个计算函数。每个分区上的数据怎么算,由这个函数决定。比如map操作就是把这个函数应用到每个分区的每条记录上。
第三,一组依赖关系。RDD之间通过转换操作形成依赖链。依赖分两种:窄依赖和宽依赖。窄依赖是每个父分区最多对应一个子分区,比如map、filter;宽依赖是多个子分区依赖同一个父分区,典型的是groupByKey、reduceByKey,需要shuffle。宽依赖是划分Stage的依据,也是性能瓶颈的高发区。
第四,一个分区器(可选)。对于Key-Value类型的RDD,分区器决定数据按什么规则分布到各个分区。默认是HashPartitioner,也可以自定义。分区器直接影响shuffle的效率,如果两个RDD的分区器一致,join时可以避免二次shuffle。
第五,一组优先位置(可选)。记录每个分区最理想的计算位置,比如数据在哪个节点上,就尽量把Task调度到那个节点,实现“数据本地性”。本地性从高到低有PROCESS_LOCAL、NODE_LOCAL、RACK_LOCAL、ANY,Spark会尽量选高优先级的,但等待时间过长时会降级。
3.2 窄依赖与宽依赖:性能分水岭
窄依赖和宽依赖的区别,是理解Spark性能问题的钥匙。我用一个生活化的类比:窄依赖像是流水线上每个工人独立加工自己面前的零件,不需要等其他人的结果;宽依赖像是所有人要把零件汇总到一个大桌子上重新分配,这个“汇总再分配”的过程就是shuffle。
Shuffle为什么慢?因为它涉及磁盘IO、网络传输和序列化反序列化。Map端先把数据写到本地磁盘(可能经过分区、排序、合并),Reduce端再通过网络拉取属于自己的那部分数据。这个过程里,磁盘和网络都是瓶颈,而且数据量越大越明显。
所以优化的基本原则是:能不用宽依赖就不用,非用不可时尽量减小shuffle的数据量。举个例子,reduceByKey和groupByKey都能实现按key聚合,但reduceByKey会在Map端先做本地聚合(combine),再发送结果给Reduce端;而groupByKey会把所有原始数据都shuffle过去。同样逻辑下,reduceByKey的网络传输量可能只有groupByKey的十分之一。这个差别在数据量大时就是任务能不能跑完的区别。
3.3 缓存与持久化:什么时候该把RDD存下来
RDD的另一个核心特性是“弹性”——分区丢了可以基于依赖关系重新计算。这个机制保证了容错,但重新计算的代价可能很高。如果一个RDD会被多个行动操作复用,或者它是迭代计算的中间结果,就应该把它缓存起来。
Spark提供了多种存储级别:
| 存储级别 | 说明 | 适用场景 |
|---|---|---|
| MEMORY_ONLY | 只存内存,放不下就丢弃,下次用时重算 | 内存充足、RDD可重算 |
| MEMORY_AND_DISK | 内存放不下溢写磁盘 | 内存中等、不想重算 |
| MEMORY_ONLY_SER | 序列化后存内存,省空间但读取要反序列化 | 内存紧张、CPU有富余 |
| DISK_ONLY | 只存磁盘 | 内存极度紧张 |
| OFF_HEAP | 存堆外内存 | 需要避免GC影响 |
我个人的经验是:默认用MEMORY_AND_DISK,除非你明确知道数据量很小。MEMORY_ONLY看起来省事,但一旦内存不够,部分分区被丢弃,后续行动操作触发重算,可能引发雪崩式的连锁反应。序列化级别能省内存,但Kryo序列化需要注册类,不然性能反而下降。
还有一个坑:缓存不是万能的。如果你缓存了一个RDD,但只用了它一次,那缓存的开销(写内存或磁盘)反而拖慢了任务。判断标准很简单——这个RDD被行动操作引用的次数是否大于1,或者它是否在迭代循环里被反复使用。
4. 从代码到执行:一个完整任务的实操记录
4.1 环境准备与依赖选择
我平时做实验用本地模式最多,启动快、调试方便。依赖上,Spark的包管理用Maven或sbt,核心依赖是spark-core,如果用SQL再加spark-sql。版本选择上,Spark 3.x对性能做了很多优化,比如自适应查询执行(AQE)能动态调整shuffle分区数,建议直接用3.x的最新稳定版。
本地模式提交任务的命令大概长这样:
spark-submit \ --master local[4] \ --driver-memory 2g \ --class com.example.MyApp \ myapp.jarlocal[4]表示用4个线程模拟集群,driver-memory控制Driver的内存。本地模式下Executor和Driver在同一个JVM里,所以内存设置要留够余量,不然容易OOM。
4.2 一个词频统计的完整实现与参数解析
词频统计是Spark的“Hello World”,但里面包含了很多可以展开的细节。我写一个带注释的版本:
from pyspark import SparkConf, SparkContext conf = SparkConf().setAppName("WordCount").setMaster("local[4]") sc = SparkContext(conf=conf) # 读取数据,指定分区数为4 rdd = sc.textFile("input.txt", minPartitions=4) # 扁平化:每行拆成单词 words = rdd.flatMap(lambda line: line.split(" ")) # 映射为(单词, 1)的键值对 pairs = words.map(lambda word: (word, 1)) # 按key聚合,reduceByKey会在map端先做本地combine counts = pairs.reduceByKey(lambda a, b: a + b) # 触发行动操作,收集结果 result = counts.collect() for word, count in sorted(result, key=lambda x: -x[1])[:10]: print(f"{word}: {count}") sc.stop()这段代码里几个值得注意的点:
minPartitions=4不是强制的,但显式指定分区数能让你对并行度有掌控。如果不指定,Spark会根据文件大小和HDFS块大小自动推算,本地文件系统下可能只给1个分区,并行度上不去。
reduceByKey的聚合函数要求满足交换律和结合律,因为数据在不同分区上的聚合顺序是不确定的。如果你写的函数依赖顺序,结果就会出错。
collect()会把所有结果拉到Driver内存里,数据量大时千万别用,换成take(10)或saveAsTextFile。
4.3 分区数怎么定:一个实际的计算过程
分区数的选择没有万能公式,但有一个大致的推算逻辑。假设你的数据是10GB,集群有20个Executor,每个Executor分配4个CPU核心,那么总并行度是80。如果每个分区128MB,10GB需要约80个分区,刚好和并行度匹配。如果分区数远小于并行度,部分CPU会闲置;如果分区数远大于并行度,Task调度和启动的开销会累积。
但实际场景往往更复杂。比如数据有严重倾斜,某个key对应的数据量特别大,即使分区数合理,单个Task也可能跑得特别慢。这时候需要加盐或自定义分区器来打散热点key。我遇到过一个案例:用户ID做groupByKey,结果某个测试账号产生了80%的日志,一个Task跑了两个小时,其他Task十分钟就结束了。后来给这个key加了随机前缀,先局部聚合再去掉前缀,总耗时降到十五分钟。
提示:Spark UI的Stage页面能看到每个Task的耗时分布。如果max耗时远大于中位数,基本可以判定是数据倾斜。
5. 常见问题与排查技巧实录
5.1 OOM了怎么办:从内存模型入手排查
OOM是Spark新手最常遇到的问题,但“OOM”只是一个症状,原因可能有好几种。我按排查顺序列一下:
先看是Driver OOM还是Executor OOM。Driver OOM通常是collect()拉了太多数据,或者广播变量太大。Executor OOM则可能是单个分区数据量过大、缓存级别选择不当、或者shuffle时内存不够。
如果是Executor OOM,第一步是看Spark UI里Stage的Task输入大小。如果某个Task的输入远大于其他Task,就是数据倾斜,需要打散。如果所有Task输入都很大,说明分区数太少,每个分区数据量超标,需要增加分区数。
第二步是检查缓存级别。MEMORY_ONLY在数据量大时很容易触发OOM,换成MEMORY_AND_DISK或序列化级别能缓解。但序列化级别会增加CPU开销,需要权衡。
第三步是调整内存比例。Spark的统一内存管理里,spark.memory.fraction控制执行和存储内存占总堆的比例,默认0.6。如果任务以shuffle为主,可以适当调高;如果以缓存为主,可以调低执行内存的占比。
5.2 任务卡在某个Stage不动:shuffle问题排查
任务卡住不动,最常见的原因是shuffle。表现是Spark UI里某个Stage的Task长时间处于RUNNING状态,或者大量Task处于SHUFFLE_WAIT。
先看shuffle的数据量。如果shuffle write或shuffle read的数据量特别大,说明前面的过滤没做好,或者聚合逻辑有问题。比如该用reduceByKey的地方用了groupByKey,shuffle数据量会大很多。
再看shuffle的分区数。spark.sql.shuffle.partitions默认是200,对于小数据量来说分区太多,每个分区数据很少,Task启动开销占比高;对于大数据量来说分区太少,单个分区数据量大,容易OOM。我一般根据shuffle数据量来调,每分区100MB到200MB比较合适。
还有一个隐蔽的问题:shuffle文件太多。如果分区数设得很大,每个Map Task要为每个Reduce分区生成一个文件,文件数等于map数乘以reduce分区数。文件数过多会导致磁盘IO和文件系统压力。Spark 3.x的AQE能自动合并小分区,缓解这个问题。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| Executor OOM | 单分区数据过大 | Spark UI看Task输入大小 | 增加分区数、打散倾斜key |
| Driver OOM | collect数据过多 | 检查行动操作 | 改用take或saveAsTextFile |
| 任务卡在shuffle | shuffle数据量大 | 看shuffle read/write量 | 优化聚合逻辑、调整分区数 |
| 任务跑得慢但没报错 | 数据倾斜 | 看Task耗时分布 | 加盐、自定义分区器 |
| 缓存后反而变慢 | 缓存级别不当 | 看存储内存使用 | 换序列化级别或去掉缓存 |
| 本地性差 | 数据分布不均 | 看Task的本地性级别 | 调整分区策略、增加副本 |
5.4 几个我踩过的坑
第一个坑是collect()的滥用。刚开始写Spark时,习惯性地把结果collect到本地再处理,数据量小的时候没问题,数据量一上来Driver直接OOM。后来改成尽量用saveAsTextFile或foreachPartition把结果写到外部存储。
第二个坑是序列化。PySpark默认用pickle序列化,性能一般。如果自定义了类,一定要确保可序列化,不然运行时报错很难定位。Scala侧用Kryo序列化需要注册类,不注册的话会退化成Java序列化,性能差很多。
第三个坑是广播变量。小表join大表时,把小表广播出去能避免shuffle,但广播变量太大会撑爆Executor内存。spark.sql.autoBroadcastJoinThreshold默认10MB,超过这个大小的表不会自动广播,需要手动调。但调太大也有风险,要评估Executor内存。
6. 从RDD到DataFrame:后续可以怎么扩展
RDD是Spark的基石,但日常开发里直接写RDD的场景在减少。DataFrame和Dataset在RDD之上提供了schema信息和Catalyst优化器,写起来更简洁,执行效率通常也更高。比如同样的词频统计,用DataFrame API大概只要几行:
df = spark.read.text("input.txt") words = df.selectExpr("explode(split(value, ' ')) as word") counts = words.groupBy("word").count() counts.show()但理解RDD仍然重要,因为DataFrame的底层执行最终还是落到RDD上,Catalyst优化器生成的物理计划里就是各种RDD转换。遇到DataFrame API表达不了的复杂逻辑,还是得用rdd方法降级到RDD层操作。而且排查性能问题时,Spark UI里看到的Stage和Task划分,本质上是RDD的依赖关系决定的。
我个人的学习路径建议是:先把RDD的五个属性和窄宽依赖搞清楚,然后学DataFrame API,再回头看Catalyst优化器是怎么把DataFrame转成RDD的。这个顺序走下来,对Spark的理解会比较扎实,遇到问题也知道往哪一层去找原因。
后续如果要做流处理,Structured Streaming的底层也是基于RDD的微批处理模型(虽然现在有连续处理模式,但微批仍是主流)。理解了RDD的容错机制和状态管理,流处理里的checkpoint和watermark概念会容易很多。