做大数据这几年,经常有人问我同一个问题:Spark到底该怎么学。说实话,这个框架名字在简历上出现得比“精通Office”还频繁,但真正能讲清楚它到底在算什么、怎么算的,十个里未必有两个。如果你正在准备入门分布式计算,或者工作中已经被一堆跑不完的“MapReduce式需求”逼得想换工具,那这篇Spark基础教程应该能帮你省下不少自己摸索的时间。我会从最关键的几个概念讲起,再到一套能直接复现的环境搭建和实战代码,最后把我在真实数据处理任务里踩过的坑一并倒出来。
1. 为什么说Spark值得投入学习
1.1 它到底解决了什么问题
先说说这类框架存在的根本原因。单机内存再大,也有装不下当天增量数据的时候;单核计算再快,也扛不住对几十亿行日志做聚合。分布式计算的基本思路,就是把数据切成一块一块,分给多台机器一起算,最后把结果汇总起来。但“分给多台机器”这件事,听上去简单,做起来全是细节:哪台机器算哪块、某台机器挂了怎么办、算到一半数据要不要落盘、机器之间怎么传数据,这些问题如果都让写业务的人自己处理,那活就没法干了。
Spark要解决的,正是这个问题。它把分布式调度、容错、内存管理、数据切分这些底层事情包住,对外提供一套像写单机程序一样的API。你用一套代码处理单机测试数据和多节点集群数据,流程基本一致,Spark负责把计算摊到集群每一台机器上。最早大家用Hadoop的MapReduce也干类似的事,但它每一步计算都要写磁盘,迭代类型的计算(比如机器学习里反复扫同一份数据)性能很难看。Spark把中间结果尽量留在内存里,配合DAG调度器做计算链的优化,所以在需要反复迭代、多步骤拼接的数据处理任务上,优势非常明显。
1.2 哪些场景真的适合用
Spark不是万能药,这一点新手容易误解。我见过有人拿它统计一个几百MB的CSV,结果启动任务的时间比计算本身还长,这就是典型的杀鸡用牛刀。判断一个场景适不适合Spark,核心看三点。
- 数据量是否大到单机装不下,或者单机算起来要等几小时甚至更久。
- 计算逻辑是否能被拆成“分治再汇总”的形式,大多数统计聚合、过滤清洗、排序关联都属于这一类。
- 是否希望同一套代码将来能从实验环境平滑搬到生产集群,省去重写代价。
典型场景包括:用户行为日志的清洗与聚合、推荐系统中的特征批量计算、数仓里的ETL流程、实时场景下用Structured Streaming做微批次消费,以及在上面跑分布式机器学习库(比如MLlib里的ALS推荐模型)。如果是几千行Excel做透视表,那用Pandas就够了,别抬出Spark给自己找事。
1.3 学之前需要哪些基础
很多人卡在Spark入门其实是卡在前置技能上,不是Spark本身多难。你需要最基本的三样东西。
- 一门能写业务逻辑的语言,推荐Python,因为它生态成熟、写起来快,PySpark的API跟原生Python直觉也比较贴合。
- Linux命令行基础,至少知道解压、改环境变量、看进程日志这些操作。
- 对SQL有所了解,因为Spark SQL占了日常使用的半壁江山。
Java和Scala我不建议第一天就啃。Java是Spark底层实现语言,但你写业务时只需要调用接口,不读懂底层源码也能用得很好。Scala是Spark原生开发语言,写起来很香,但对刚入门的人来说,这个门槛容易变成劝退点。先用PySpark把概念吃透,等你真的需要写自定义算子或UDF性能极敏感时,再回头考虑Scala,路径会顺畅很多。
2. 拆开Spark:这些核心概念必须先吃透
2.1 RDD为什么是“设计上的地基”
RDD(弹性分布式数据集)是你绕不开的第一个概念。它本质上是一个分布在多台机器上的不可变数据集合,每条数据是一个记录,你可以对它做两种操作:转换(Transformation)和行动(Action)。转换是对数据集产生新数据集的描述,比如过滤、映射,它惰性的,不会立刻计算;行动是真正触发计算的操作,比如计数、取回结果、写入存储。
理解“惰性”是理解Spark的关键。你可以先把一批转换操作想象成一份做菜的菜谱清单:先切菜,再热锅,最后下料。菜谱写好了但没人去执行,菜不会自动做好。只有你说“开火”(触发Action),前面所有步骤才会真正执行。Spark这样设计,是为了把整个计算链织成一张DAG图,在真正执行前它能通览全局,做优化。比如两个相邻的过滤条件,它能自动给你合并成一个条件去扫数据;比如有些中间结果根本不需要落盘,它就不落。这种全局优化,是手写MapReduce或自己拼Shell脚本很难做到的。
2.2 为什么要用DataFrame和Spark SQL
如果你只用RDD写业务,很快就会觉得繁琐。RDD是Java对象级别的抽象,Spark不知道你的数据结构里有哪些字段,所以优化无从下手,你这边的代码写起来也全是lambda套lambda,阅读和排错都痛苦。
所以在面向日常分析时,我几乎总是用DataFrame和Spark SQL。可以简单地把DataFrame理解成一张分布式的表,它有明确的列名和类型。Spark能看懂这张表的schema,于是可以像数据库查询优化器一样,对过滤条件下推、对不必要的列裁剪,大幅减少扫描和网络传输。更直观的好处是,你可以直接写SQL:
SELECT user_id, COUNT(*) AS pv FROM page_views WHERE dt = '2025-01-01' GROUP BY user_id ORDER BY pv DESC LIMIT 100这段SQL跑在Spark上,跟你用关系型数据库执行差别不大,底层却是分布式计算。从RDD到DataFrame不是简单的API替换,而是从“看成串的Java对象”到“看成带schema的表”的思维升级。掌握了这个,Spark学习的难度一下降一半。
2.3 一次作业在集群里到底怎么跑
搞懂一个任务Spark内部是如何调度执行的,比死记API对你的帮助大得多。我之前给团队新人讲过一遍这套流程,理解之后再看报错日志,不再是从前那种“全是英文但跟我无关”的感觉。
你提交一个Spark作业,顶层是一个Application。Application里因为每一次Action(比如count、save)会切成多个Job。每个Job会根据数据的依赖关系划分成多个Stage,划分的原则是有没有Shuffle。Shuffle你可以理解成“跨节点重新组织数据”:比如按用户ID分组,每台机器上都有同一个用户的部分数据,必须把这些散在各处的数据先汇集到一起才能聚合,这个过程就要Shuffle。Stage内部由一组Task组成,每个Task处理一个数据分片。Executor(集群里真正干活的进程)领取Task,跑完把结果汇报给Driver(你的提交程序的主控端)。
这里面最值得记住的是两个限度:第一,Shuffle是分布式计算里最贵的动作,能避免就避免;第二,一个Stage里的Task是并行执行的,并行度取决于数据分区的数量,而这个数量你可以通过参数和算子干预。懂了这两点,后面的调优才算有了方向。
3. 上手实操:从装环境到第一个能跑的程序
3.1 本机环境搭建
学Spark的第一步,完全不需要一上来就建集群,本地单机模式就足够你把核心概念跑通。
首先确认本机装了Java,Spark运行时依赖Java。留意一点,不同Spark版本对Java版本要求不同,比如3.4版本推荐Java 8或11,你可以先装Java 11,兼容性比较稳。执行java -version能正确输出版本信息即可。
接着从官网下载Spark发行版,注意选“Pre-built for Apache Hadoop 3.3”之类的预编译包,省去自己编译的时间。下载后解压到一个没有中文和空格的路径。解压完目录里的bin文件夹下面的pyspark、spark-shell和spark-submit是后面常用的命令。
如果你用Python,需要确保Python环境可用,并安装PySpark库,注意用和Spark对应版本的PySpark,别差太远:
pip install pyspark然后在终端里运行:
cd spark-3.4.0-bin-hadoop3 ./bin/pyspark看到大Logo和scala>或>>>提示符,就说明环境已经通了。还没完,我强烈建议你跑一个命令验证Job能正常结束:
rdd = sc.parallelize([1, 2, 3, 4, 5]) print(rdd.map(lambda x: x * x).sum())正常会输出55。这一步能排掉一堆环境隐患:Java版本不对、Spark配置没生效、Python版本匹配不上的问题。整个过程大概半小时以内。
3.2 用PySpark写一个最简单的聚合任务
环境通了之后,我建议立刻写一个贴近真实的小任务,而不是一直跑hello world。
假设我们有这样一个user_actions.csv文件,三列:用户ID、动作类型、事件时间。要做的是统计每个用户每天做了多少次动作。
from pyspark.sql import SparkSession spark = SparkSession.builder \ .appName("first_demo") \ .master("local[*]") \ .getOrCreate() df = spark.read \ .option("header", True) \ .option("inferSchema", True) \ .csv("user_actions.csv") agg_df = df.groupBy("user_id", "event_date").count() agg_df.orderBy("user_id").show()master("local[*]")表示用本机所有可用核心来模拟并行执行,local[2]则指定两个核。show()是一个Action,触发整条计算链执行,所以你在屏幕上看到结果。跑完之后,用agg_df.explain()看一遍执行计划,能直观地感受到Spark从DataFrame到实际物理执行之间做的那些事。
3.3 通过Spark UI看懂任务执行情况
本地跑任务时,Spark会开启一个Web界面,默认地址是http://localhost:4040。这个UI是学习Spark最好的“解剖学工具”,最值得关注三个地方。
- Jobs页:看整个Application被拆成哪些Job,每个Job的耗时和状态。
- Stages页:看Job内部的Stage划分,特别是是否有Shuffle,以及每个Task的执行时间。
- SQL页:如果你用了DataFrame API或SQL,这里能看更细的物理执行计划。
我自己的习惯是每写完一个Spark任务,跑完都会去UI上扫一眼Stages。如果在某个Stage看到大量Task的耗时严重不均,或者Shuffle数据量大得离谱,那基本就锁定了性能瓶颈所在。这个习惯对新手尤其重要,因为代码写得清爽和跑得高效是两回事,靠肉眼很难看出后者。
4. 常用算子与一个小型实战案例
4.1 高频算子速查
把常用算子分组整理了一遍,不追求面面俱到,但下面这些是日常出现频率最高的。
| 类别 | 算子 | 作用 | 使用注意 |
|---|---|---|---|
| 转换 | map / flatMap | 每条数据做一对一映射 / 一对多映射 | flatMap适合一行展开成多行 |
| 转换 | filter | 按条件保留数据 | 条件尽量前置,减少下游数据量 |
| 转换 | distinct / dropDuplicates | 去重 | dropDuplicates可按指定列去重 |
| 转换 | groupBy / aggregation | 分组聚合 | 会产生Shuffle,注意数据倾斜 |
| 转换 | join | 关联两个数据集 | 小表转广播变量,大表注意分桶 |
| 行动 | collect / take / show | 把结果取回Driver | collect慎用于大数据集,会内存爆掉 |
| 行动 | count / save | 统计总数 / 写出存储 | count会真正跑一遍计算 |
用Spark时要想清楚自己是“描述计算”还是“触发计算”。上面的转换都是惰性的,只有遇到行动算子才会真正执行。新手最常见的困惑是:我filter完明明感觉数据少了,为什么程序没反应——因为还没触发执行,这很正常,符合设计逻辑。
4.2 实战:统计浏览日志的PV和UV
接下来做一个完整的案例。假设有一份网站访问日志,每行包含用户ID、访问页面、访问时间,字段以逗号分隔。需求是统计每个页面的总访问量(PV)和独立访客数(UV)。
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder \ .appName("pv_uv_demo") \ .master("local[*]") \ .getOrCreate() df = spark.read \ .option("header", True) \ .option("inferSchema", True) \ .csv("page_views.csv") result = df.groupBy("page_url").agg( F.count("user_id").alias("pv"), F.countDistinct("user_id").alias("uv") ).orderBy(F.col("pv").desc()) result.show()真实项目中,日志更可能是Gzip压缩格式或存放在HDFS/对象存储上,Spark都能直接读,路径换成对应的存储路径即可。countDistinct在数据量极大时比较耗资源,因为精确去重需要把分布式数据汇总,如果允许指标有轻微误差,可以对user_id做HyperLogLog估算——Spark原生提供了approx_count_distinct函数,毫秒级换一个可接受的误差。这也是实际项目中常见的取舍。
写完代码后,跑一下看看结果对不对。不要只对着结果开心,强烈建议再执行一次:
result.explain()输出里你能看到Exchange(Shuffle)的迹象、读取的文件大小等。多跑几遍,对比不同写法的执行计划,你对性能的理解会从“感觉”变成“证据”。
4.3 关于算子的几个常见坑
第一,collect()拿去Driver再处理是大忌。曾经有个同事习惯把所有结果collect到本地再用Python处理,几千行数据没事,几千万行直接把内存打爆。分布式框架的价值观是“数据在哪里,计算去哪里”,算子才是首选。
第二,distinct和dropDuplicates不是完全等价。distinct对所有列去重,dropDuplicates可以只按指定列去重,处理相同主键保留不同详情字段的场景时非常有用。
第三,UDF能不用就不用。Python UDF需要把数据在JVM和Python进程之间反复序列化、传输,开销极高。能用内置函数解决的问题,就优先用内置函数。新增列用withColumn,条件逻辑用when、case when,都比UDF高效得多。
5. 调优与排查:真实场景里最常见的坑
5.1 数据倾斜:最典型的性能瓶颈
分布式计算里,一个Job的完成时间由最慢的一个Task决定。正常情况下数据均匀分布,所有Task差不多同一时间结束;但如果某几个Task分到了巨量数据,其他Task早就跑完了,整个Job却卡在那里干等,这种现象就是数据倾斜。
我遇到过一个实际的例子:统计订单表按省份聚合,某一线城市的订单量比其他省份多几个数量级,聚合Reduce阶段大部分Executor空闲,只有一个Executor在那里慢慢算,整个作业跑了快一小时。解决办法并不玄乎:
- 对倾斜严重的key加随机前缀(比如把用户ID拆成多个子key),先分散聚合,再去掉前缀做二次聚合。
- 如果问题出在Join倾斜,把小表做成广播变量,避免大表按大key做Shuffle。
- 提高Shuffle分区数,把单Task的压力摊薄,比如把
spark.sql.shuffle.partitions从200调整到480等,具体取决于集群规模和数据量,需要试。
这些方案听上去三个字就能概括——“打散”,但背后要考虑的是:打散后结果会不会变化?二次聚合怎么保证正确性?所以处理数据倾斜时,一定先小规模验证逻辑正确,再全量跑,不要直接上生产。
5.2 分区数:并行度到底设多少
分区数是Spark性能调优里讨论最多的参数之一。读取时分区数由文件大小和块大小决定,Shuffle后的分区数由spark.sql.shuffle.partitions控制,RDD转换的分区数则看算子参数。
一个常见误区是分区数越多越好。实际上每个分区对应至少一个Task,Task调度、创建、执行都有开销。分区太少,浪费资源;分区太多,调度开销淹没计算收益。经验上,单个分区处理的数据量在100MB到几百MB之间比较合理,你甚至可以用df.repartition(n)来手动调整。注意repartition本身会触发一次全量Shuffle,不是为了后续算子有必要性,别没事随便调。
另外,coalesce和repartition不要混用。coalesce只能在原分区数基础上减少分区,且尽量不触发Shuffle,适合输出小文件时用;repartition可以增加或减少,代价是全量重洗。我记得很清楚,有一天用户导出一份很大的结果,用了repartition(1),结果Shuffle落盘量巨大,改成coalesce(1)之后快了好几倍,因为前者绕了一圈重洗,后者只是把相邻分区的数据合并。
5.3 常见报错速查表
下面整理几类高频报错和排查思路,不全但基本覆盖新手阶段。
| 报错现象 | 可能原因 | 排查建议 |
|---|---|---|
| java.lang.OutOfMemoryError: Java heap space | Executor堆内存不足 | 调大spark.executor.memory;同时检查代码里是否有过大分区或错误collect |
| ExecutorLostFailure或Container killed | 容器内存溢出被NodeManager杀掉 | 看是否发生数据倾斜;检查堆外内存,尝试spark.memory.offHeap.enabled |
| FetchFailedException / Shuffle file not found | Executor丢失导致Shuffle中间文件不可用 | 加大spark.maxRemoteBlockSizeFetchToMem;检查是否有节点负载过高被中断;一般是集群中Executor异常退出的连带反应 |
| Task not serializable | 在闭包里传了不可序列化对象 | 不需要传对象,用局部变量;或者把对象做成静态只读;检查自定义函数是否捕获了外部类 |
| 查询结果乱码 | 字符集不统一 | 读文件时检查编码参数,统一设为UTF-8,别依赖系统默认编码 |
排查报错有一个通用的步骤:先看Spark UI里失败的Stage,定位是哪个环节挂了,再去翻对应Executor的日志。不要一上来就改参数,参数乱改会把问题掩盖,让后面更难定位。更重要的是,给每个作业起个有意义的名字,比如appName("pv_uv_demo"),这样日志、UI、告警里都能顺着名字找到你的作业。没有名字的任务,在集群一堆作业里排查时,真的会疯掉。
5.4 跑作业前和跑作业后的固定动作
分享几个个人习惯,都是踩过坑后养成的。跑之前,一个人先盯着代码确认三个问题:有没有不必要的Shuffle、有没有把大数据集collect回Driver、有没有在循环里多次触发Action。跑之后,去看UI里的三个阶段:Job数量是否合理、Stage划分是否顺畅、Task耗时是否均匀。如果全过,这个作业基本是健康的。
另外,写Spark作业尽量保持“一个Action、一个明确产出”的风格。有人把好几个统计结果打印、写文件都放在同一个作业里,看似少写了代码,但一旦中间某步出错,排查的上下文很难拉通。宁可多写几个独立的Job,让每个任务职责单一,这样日志看得明白,也能单独重跑,尤其适合刚入门的人。
从我自己的学习路径来说,Spark的重点并不是会调用多少API,而是建立一套“数据是分布式的,计算要顺着分布式来想”的思维。本地单机跑demo是第一步,看懂执行计划和UI是关键一步,真正处理过几次数据倾斜、内存溢出之后,你对Spark的理解会有一个质的提升。这个框架已经在大数据处理领域火了很多年,生态很成熟,现在入手一方面环境资料齐全,另一方面这个能力在数据岗位里几乎成了默认要求,早点掌握,自己的选择面也宽一些。最后再提一个小建议:学完这篇文章的demo,找一份真实的大一点的日志数据,跑一遍全流程,那份手感是看任何教程都替代不了的。