Skip to content

Latest commit

 

History

History
295 lines (186 loc) · 26.6 KB

File metadata and controls

295 lines (186 loc) · 26.6 KB

LEC 15: Big Data: Spark

NotebookLM 生成的双人播客

https://notebooklm.google.com/notebook/46488032-3f8c-4788-9053-02f33a37ecb6/audio

微信链接: https://mp.weixin.qq.com/s/BD_293Ekdx3_x0izLG47TQ

给我留下的较深的印象:

  1. 传统的 MapReduce 需要大量 I/O ,这是难以接受的
  2. RDD 的“只读”更多的是是编程的概念而非运行时的概念?基于 RDD 这个概念和懒加载, Spark 可以实现优化后的计算流程
  3. 考虑到数据的分区(包括容错和最临近空间)),我们将操作分为窄依赖和宽依赖;此外,对过程中的中间数据建图(建立血缘),这样哪个分区机器坏了,就把其之前的图跑一遍,得到同样数据,这点同样很妙

Gemini 2.5 Pro 生成的“Spark 核心技术概要与大数据生态圈的演进之路”

从 MapReduce 的局限到 Spark 的诞生

我们知道,Google 的 MapReduce 框架是大数据处理的开山鼻祖,它将复杂的分布式计算抽象成了简单的 mapreduce 两个阶段,让工程师可以轻松地在商用硬件集群上处理海量数据。但它并非万能药。

MapReduce 的核心问题在于其 无状态基于磁盘 的设计。每两个 MapReduce 作业之间的数据交换,都必须通过一个外部稳定存储系统(比如 HDFS)。这意味着,一个作业的 reduce 输出被写到磁盘,下一个作业的 map 再从磁盘上把它读出来。这个过程涉及到大量的磁盘 I/O、数据复制和序列化开销。

对于只需要“扫一遍”数据的简单 ETL(提取、转换、加载)任务,这没什么问题。但对于那些需要复用中间结果的应用——比如多次迭代的机器学习算法(逻辑回归、K-均值聚类)和交互式数据挖掘——MapReduce 就显得力不从心了。想象一下,一个需要迭代 10 次的 PageRank 算法,用 MapReduce 实现就意味着要执行 10 个独立的 MapReduce 作业,中间结果来来回回在磁盘上读写 9 次,性能之差可想而知。

为了解决这个问题,学术界和工业界提出了各种专用框架,例如用于迭代图计算的 Pregel 和用于迭代 MapReduce 的 HaLoop 。但这些系统往往只针对特定计算模式,缺乏通用性。

正是在这个背景下, Spark 应运而生。它的目标是提供一个 通用 的、 高性能 的计算框架,既能优雅地处理迭代和交互式任务,又能兼容 MapReduce 擅长的批处理场景。Spark 的核心武器,就是一种名为 弹性分布式数据集 (Resilient Distributed Datasets, RDD) 的抽象。

Spark 的诞生本身就是一个传奇故事。它源于加州大学伯克利分校的一个研究项目,其主要贡献者 Matei Zaharia 凭借这项工作赢得了计算机协会 (ACM) 的博士论文奖。对于一个博士生来说,创造出如此规模和影响力的系统是相当了不起的成就。如今,Spark 已经被全球各大公司广泛应用于生产环境,你可以从其商业化公司 Databricks 的客户列表中一窥其影响力。

Spark 的核心:RDD (弹性分布式数据集)

那么,RDD 到底是什么?

从形式上看,一个 RDD 是一个 只读的被分区的 记录集合。你可以把它想象成一个分布在集群成百上千台机器内存中的一个巨大 ListArray,但你不能像操作普通 Array 那样去修改它的某个元素。

这听起来限制很大,但正是这些限制赋予了 Spark 强大的能力。让我们来逐一拆解 RDD 的关键特性:

只读 (Immutable) 与转换 (Transformations)

有人可能会问:“如果 RDD 是只读的,那我们怎么进行计算呢?” 这就引出了 Spark 的核心编程模型: 转换 (transformation)

你不能“修改”一个 RDD,但你可以对一个 RDD 应用一个转换操作(比如 mapfilterjoin),然后生成一个 全新的 RDD 。这就像在函数式编程里,你不会去修改一个传入的 List,而是返回一个新的、经过处理的 List

比如,我们有一个包含了日志文件所有文本行的 lines RDD,我们可以这样操作:

// errors RDD 是通过对 lines RDD 进行 filter 转换得到的
val errors = lines.filter(line => line.startsWith("ERROR"))

在这里,lines RDD 本身没有任何变化,我们得到的是一个全新的、只包含错误信息的 errors RDD。这种“只读”或称为 不可变性 (immutability) 的设计是 Spark 实现廉价、高效容错机制的基石。

分区 (Partitioned)

RDD 在物理上是分布式的,它由多个 分区 (partition) 组成,每个分区是数据集的一部分。比如一个 1TB 的 HDFS 文件,在 Spark 中可以被表示为一个 RDD,这个 RDD 可能有 8000 个分区(例如,每个 HDFS 块对应一个分区)。

这些分区分布在集群的不同 工作节点 (Worker) 上,使得计算可以并行进行。Spark 的调度器会尽可能地将计算任务分配到存储着对应数据分区的节点上,这被称为 数据本地性 (data locality) ,它可以极大减少网络数据传输,提升性能。

应用程序是如何知道一个 RDD 的位置的呢?在驱动程序 (Driver program) 中,我们用 Scala 的变量名来指代 RDD 。而每个 RDD 的元数据中都包含了其分区的位置信息。调度器正是利用这些信息,来将计算任务(比如一个 map 函数)发送到离数据最近的节点上执行。

惰性计算 (Lazy Evaluation) 与行动 (Actions)

在 Spark 中,所有的转换操作都是 惰性 (lazy) 的。什么意思呢?就是当你调用一个 transformation 时(如 filter, map),Spark 并不会立即执行计算。它只是默默地记下你做了什么操作。

例如,下面的代码:

val lines = spark.sparkContext.textFile("hdfs://...")
val errors = lines.filter(_.startsWith("ERROR"))
val hdfsErrors = errors.filter(_.contains("HDFS"))

执行完这三行,集群上什么计算都还没发生。Spark 只是构建了一个计算计划。

那么,计算何时才会真正发生呢?答案是当你调用一个 行动 (action) 操作时。行动操作是那些会真正触发计算并返回一个值给驱动程序,或者将数据写入到外部存储的命令。常见的 action 包括 count() (返回 RDD 的元素个数)、collect() (将 RDD 的所有元素以数组形式返回到驱动程序)、saveAsTextFile() (将 RDD 内容存为文本文件) 等。

当你对 hdfsErrors RDD 调用 count() 时,Spark 会审视整个计算计划,然后说:“哦,原来用户想要计算 hdfsErrors 的数量。要得到它,我得先执行对 errorsfilter,而要得到 errors,我得先对 lines 进行 filter,而 lines 来自于 HDFS 文件。” 于是,它将整个计算流程打包成任务,分发到集群上执行。

这种惰性计算的策略,让 Spark 有机会在执行前对整个计算流程进行优化,比如将多个 filter 操作合并(串联)在一起执行,避免产生不必要的中间数据。

血缘 (Lineage) 与容错

RDD 最精妙的设计在于其容错机制。前面提到,RDD 是只读的,并且只能通过对其他 RDD 进行确定的转换操作来创建。Spark 会记录下这一系列的转换关系,形成一个 血缘关系图 (lineage graph),也叫作 有向无环图 (DAG)

这个血缘图完整地记录了任何一个 RDD 是如何从最原始的输入数据一步步计算得来的。

现在,假设集群中一台机器宕机了,它内存中保存的某个 RDD 分区也随之丢失。怎么办?传统的分布式系统可能需要依赖高成本的数据复制或检查点 (checkpointing) 机制来恢复。

而 Spark 的做法则非常优雅:它根本不需要复制数据来实现容错。它只需要根据血缘图,找到丢失的那个分区是如何计算出来的,然后在另外一个空闲的节点上, 重新执行一遍 当初的计算过程,就能把它恢复出来。因为转换操作是 确定性 (deterministic) 的,所以重新计算的结果和之前会完全一样。

这种基于血缘的恢复方式,开销极小,而且恢复任务可以并行进行,速度很快。这就是 RDD 中“弹性 (Resilient)”一词的由来。

深入 Spark 执行:窄依赖与宽依赖

为了优化执行,Spark 将 RDD 之间的依赖关系分为两类:窄依赖 (narrow dependencies)宽依赖 (wide dependencies) 。理解这个区别至关重要。

  • 窄依赖 :子 RDD 的每个分区 只依赖于 父 RDD 的一个分区(或少数几个固定的分区)。典型的例子是 mapfilter。这种依赖关系非常高效,因为计算可以在一个节点上以流水线 (pipeline) 的方式进行,不需要等待其他节点。

  • 宽依赖 :子 RDD 的每个分区 可能依赖于 父 RDD 的所有分区。典型的例子是 groupByKeyreduceByKeygroupByKey 需要找到所有分区中具有相同 key 的元素,并将它们聚集在一起。这个过程不可避免地需要在集群节点之间进行大规模的数据交换,这个过程被称为 洗牌 (shuffle)

你可以通过下面的示意图来理解:

窄依赖 (Narrow Dependency)
父 RDD            子 RDD
[Partition 1]  ->  [Partition A]
[Partition 2]  ->  [Partition B]
[Partition 3]  ->  [Partition C]
(map, filter, union)


宽依赖 (Wide Dependency)
父 RDD            子 RDD
[Partition 1] --\
[Partition 2] -->-- [Partition X]
[Partition 3] --/

[Partition 1] --\
[Partition 2] -->-- [Partition Y]
[Partition 3] --/
(groupByKey, join, distinct)

宽依赖是 Spark 中代价高昂的操作,因为它需要网络 I/O,并且是一个 屏障 (barrier) ,后续步骤必须等待 shuffle 完成才能开始。Spark 的调度器会根据血缘图中的宽依赖来划分 阶段 (Stage) 。在一个 Stage 内部,所有的计算都是窄依赖,可以高效地流水线执行。而 Stage 之间的边界就是 shuffle 。

一个完整的例子:用 Spark 实现 PageRank

让我们结合 PageRank 例子,看看这些概念是如何协同工作的。PageRank 是一种迭代算法,用于评估网页的重要性,非常适合用 Spark 实现。

// 1. 读取输入文件,创建初始 RDD
val lines = spark.read.textFile("in").rdd

// 2. 解析链接关系 (from, to),这是一系列窄依赖转换
val links = lines.map { s =>
  val parts = s.split("\\s+")
  (parts(0), parts(1))
}.distinct().groupByKey().cache() // distinct 和 groupByKey 是宽依赖

// 3. 初始化所有页面的 rank 为 1.0,这是一个窄依赖转换
var ranks = links.mapValues(v => 1.0)

// 4. 进行 10 次迭代
for (i <- 1 to 10) {
  // 将链接关系和排名进行 join (宽依赖)
  val contribs = links.join(ranks).values.flatMap {
    case (urls, rank) =>
      val size = urls.size
      urls.map(url => (url, rank / size))
  }
  // 按 URL 聚合贡献值,并计算新排名 (宽依赖)
  ranks = contribs.reduceByKey(_ + _).mapValues(0.15 + 0.85 * _)
}

// 5. 触发计算,并将结果收集回驱动程序
val output = ranks.collect()
output.foreach(tup => println(s"${tup._1} has rank: ${tup._2} ."))

这个例子完美地展示了 Spark 的威力:

  • 表达力 :相比 MapReduce,代码更简洁、更符合逻辑。
  • 迭代计算for 循环中的 linksranks RDD 在每次迭代中都被复用。
  • 持久化links.cache() 是一个关键优化。它告诉 Spark:“我将来会频繁使用 links RDD,请把它缓存到内存里吧!”。这样,在 10 次迭代中,links RDD 只需要从文件计算一次,后续 9 次直接从内存读取,极大地提升了性能。cache()persist(StorageLevel.MEMORY_ONLY) 的一个别名。
  • 惰性求值 :在调用 collect() 之前,整个复杂的计算图(包含 10 次迭代)只是被定义好了,并没有执行。
  • 容错 :如果在迭代的第 8 轮,某个节点挂了,导致 ranks RDD 的某个分区丢失,Spark 会根据血缘图自动从上一个 Stage 的可用数据开始重算,恢复这个丢失的分区。

对于血缘关系特别长的 RDD(比如迭代上百次的 PageRank),如果从头开始重算,代价可能会很高。为此,Spark 允许用户手动对某些关键 RDD 设置 检查点 (checkpoint) ,将它们物化到 HDFS 等可靠存储上,从而截断血缘关系,降低故障恢复的时间。

Spark vs. MapReduce:该用谁?

既然 Spark 看起来全面优于 MapReduce,那 MapReduce 是不是就该被淘汰了?

不完全是。虽然 Spark 更强大,但在某些特定场景下,MapReduce 依然有其一席之地。关键在于你的计算模式。

  • 如果你的任务是 单次遍历 一个巨大的数据集,进行简单的映射和聚合(比如统计词频),那么这个任务的主要瓶颈是 I/O。Spark 的内存缓存优势无法体现,因为它没有任何 RDD 可以被重用。在这种情况下,Spark 和 MapReduce 的性能可能不相上下,甚至 MapReduce 可能因为其成熟稳定而更受青睐。

  • 但只要你的任务涉及 迭代交互式查询 ,或者包含多个需要共享中间数据的步骤,Spark 的优势就是压倒性的。实验表明,在迭代式机器学习应用上,Spark 的性能可以比 Hadoop MapReduce 高出 20 倍。

总的来说,Spark 可以看作是 MapReduce 的一种 泛化和超集 。它不仅能完成 MapReduce 的工作,还能高效处理 MapReduce 难以胜任的复杂计算模式。

好的,这是对大数据生态圈演进之路的重写与扩充版本,希望能解答你的疑惑,并提供一个更全面的视角。

大数据生态圈的演进之路

为了更好地理解 Spark 的地位,我们有必要回顾一下大数据技术栈的演进历史。这个过程并非简单的技术迭代,而是一个不断发现问题、解决问题,从而推动整个领域向前发展的生动故事。

HDFS + MapReduce (Hadoop 1.0):奠基时代

在 Hadoop 出现之前,处理超过单机容量的数据是一项极其昂贵且复杂的任务,通常需要专用的、昂贵的硬件。Hadoop 的诞生,参考了谷歌发布的两篇革命性论文(关于 GFS 和 MapReduce),彻底改变了这一局面。

HDFS (Hadoop Distributed File System) 如何解决存储问题?

HDFS 是谷歌文件系统 (GFS) 的开源实现,其核心思想是“分而治之”和“容错于廉价硬件”。当一个大文件(如 1TB 的日志)存入 HDFS 时,它并不会被完整地存放在一台机器上。相反,它会被切分成许多固定大小的 数据块 (Block) ,通常为 128MB 或 256MB。这些数据块被分散存储在集群中成百上千台廉价的服务器(称为 DataNode )上。为了实现容错,每个数据块默认还会有 2 个副本,存放在不同的 DataNode 上。

集群中还有一个名为 NameNode 的主节点,它就像是整个文件系统的“目录”,记录着每个文件的元数据,比如文件被分成了哪些块,以及每个块和它的副本分别存储在哪台 DataNode 上。通过这种方式,HDFS 实现了用普通商用硬件存储海量数据的能力,并且当任何一台 DataNode 宕机时,数据都能从副本中恢复,保证了高可靠性。

MapReduce 如何解决计算问题及其局限性?

MapReduce 框架则负责处理存储在 HDFS 上的数据。它的主节点 JobTracker 是整个计算的大脑。当用户提交一个计算任务时,JobTracker 会做两件核心事情:

  1. 资源管理 :它持续追踪集群中所有从节点( TaskTracker )的心跳,了解每个节点上有多少可用的计算槽位(Map Slot 和 Reduce Slot)。
  2. 作业调度与监控 :它接收用户的 MapReduce 作业,将其拆分成大量的 Map 任务和 Reduce 任务,然后像一个调度中心一样,将这些任务分配给有空闲槽位的 TaskTracker 去执行。它还负责监控任务的执行进度,一旦发现某个任务失败(比如节点宕机),就会在其他节点上重新调度该任务。

这种模式虽然强大,但其局限性也十分明显。首先,JobTracker 将资源管理和 MapReduce 计算模型 紧密耦合 ,导致整个集群只能运行 MapReduce 类型的作业,无法支持像 Spark 这样的新兴计算框架。其次,JobTracker 本身是一个 单点故障 (Single Point of Failure) ,一旦它崩溃,整个集群就会瘫痪,所有正在运行的任务都会失败。最后,在超大规模集群中,JobTracker 需要管理所有任务,其自身也成为了一个巨大的性能瓶颈。

YARN (Hadoop 2.0):资源管理的革命

为了解决 Hadoop 1.0 的核心缺陷,Hadoop 2.0 引入了 YARN (Yet Another Resource Negotiator),它将 JobTracker 的功能进行了一次优雅的“权责分离”。

YARN 如何分离职能?

YARN 将 JobTracker 的两大职责拆分给了两个独立的组件:

  1. 全局的 ResourceManager (RM) :这是一个纯粹的资源调度中心,是集群的唯一主宰。它只负责管理和分配整个集群的资源(如 CPU、内存),但对应用程序的具体内容一无所知。
  2. 每个应用专属的 ApplicationMaster (AM) :当一个计算任务(无论是 MapReduce 作业还是 Spark 作业)被提交时,YARN 的 RM 首先会启动一个专属于该任务的“司令官”——ApplicationMaster。这个 AM 负责向 RM “申请”计算资源(比如“我需要 100 个容器,每个容器 4G 内存、2 个核”),在获得资源后,再由它自己负责在其获得的资源上启动、管理和监控具体的计算任务。
  • 带来了什么?

YARN 本身是 Hadoop 生态中的一个核心框架服务。用户通常不直接操作 YARN,而是通过 spark-submitmapred 等命令提交应用。这些应用框架会自动与 YARN 的 RM 通信,启动自己的 AM,从而在集群上运行。

这个解耦是革命性的。它将 Hadoop 集群从一个“只能跑 MapReduce 的专用平台”升级为了一个通用的 “数据操作系统” 。从此,任何符合 YARN 规范的计算框架(如 Spark、Flink、Storm 等)都可以作为“应用程序”运行在同一个集群之上,共享硬件资源,极大地提升了集群的利用率和灵活性。

Spark:性能的飞跃

在 YARN 提供的通用资源管理平台上,Spark 横空出世,旨在解决 MapReduce 的性能瓶颈。当一个 Spark 应用提交到 YARN 集群时,YARN 的 RM 会先为其启动 Spark 的 ApplicationMaster。随后,这个 AM 会向 RM 申请更多资源(在 YARN 中称为容器 Container)来运行 Spark 的 Executor 进程,这些 Executor 才是真正执行计算任务的工作单元。

Spark 的性能优势源于其核心抽象——RDD。通过将中间计算结果保存在内存中,并利用惰性计算和有向无环图 (DAG) 来优化整个计算流程,Spark 避免了 MapReduce 在多步骤任务中频繁的、昂贵的磁盘读写。对于需要多次迭代的机器学习算法和需要快速响应的交互式数据分析场景,Spark 提供了比 MapReduce 高出几个数量级的性能提升。

Hive:降低大数据的门槛

虽然 MapReduce 和 Spark 提供了强大的计算能力,但直接用 Java 或 Scala 编写分布式程序对许多人来说门槛太高。Hive 的出现,就是为了让更广泛的用户群体能够利用大数据的能力。

Hive 是一套完整的 数据仓库基础设施 ,它不仅仅是一种语法。其核心组件包括:

  • HiveQL :一种与标准 SQL 非常相似的查询语言,让数据分析师可以用熟悉的语法来查询海量数据。
  • 引擎 :Hive 的核心引擎负责将用户提交的 HiveQL 查询语句进行解析、优化,并最终 翻译 成底层的分布式计算作业(早期是 MapReduce,现在更多地配置为 Spark 或 Tez)。
  • Metastore :这是 Hive 的灵魂所在。它是一个独立的元数据存储服务(通常使用 MySQL 或 PostgreSQL 实现),记录了 HDFS 上非结构化数据文件的“结构化”信息。它像一个户口本,定义了“表”名、列名、数据类型,并指明了这些表对应的数据实际存放在 HDFS 的哪个目录下。正是因为有了 Metastore,Hive 才能让用户像查询传统数据库表一样查询一堆分散的文本文件。
  • 服务接口 (HiveServer2) :Hive 还可以作为一个常驻服务运行,提供 JDBC/ODBC 接口,允许各种商业智能 (BI) 工具(如 Tableau)和应用程序像连接普通数据库一样连接到 Hive,进行数据查询和分析。

HBase:赋予 Hadoop 实时读写能力

HDFS 擅长存储大文件并支持高吞吐量的顺序读取,但它天生不支持对数据的随机、实时读写。你无法高效地执行“查询用户 ID 为 123 的个人信息”这类操作。HBase 的出现正是为了弥补这一短板。

HBase 是一个构建在 HDFS 之上的 NoSQL 数据库。所谓 NoSQL(“Not Only SQL”),泛指所有非关系型的数据库。相比传统的关系型数据库(如 MySQL),NoSQL 数据库通常具备以下优势:

  • 灵活的数据模型 :它们不需要预先定义严格的表结构,可以轻松存储半结构化甚至非结构化数据。
  • 超强的水平扩展能力 :它们被设计为可以轻松地扩展到成百上千台服务器,以应对数据量和访问量的增长。
  • 高可用性 :通常内置了数据复制和自动故障转移机制。

HBase 本质上是一个巨大的、稀疏的、分布式的、多维度的、已排序的哈希表。它允许你通过一个唯一的行键 (Row Key) 在毫秒级别内从亿万行数据中定位并读写数据,完美地满足了需要对海量数据进行实时随机访问的在线应用场景,例如实时推荐系统、用户画像查询、风控系统等。

Flink 及其他:拥抱真正的流处理

随着物联网、移动互联网的发展,数据不再仅仅是离线存储的“批数据”,而是像水流一样源源不断产生的“流数据”。

Spark Streaming 的微批处理 (Micro-batching)

Spark 最早通过 Spark Streaming 模块来处理流数据。它的工作模式是“微批处理”:它将实时数据流按照一个极小的时间间隔(如 1 秒)切分成一个个微小的数据批次(mini-batch),然后用 Spark 引擎快速地处理这些小批次。这种方式巧妙地复用了 Spark 成熟的批处理引擎,可以实现很低的延迟(准实时),并且吞吐量大。但它并非真正的“逐条处理”,因为数据总要攒够一个批次的间隔才能被处理,因此存在一个固有的、最小等于批次间隔的延迟。

Flink 的真正事件驱动流处理

Apache Flink 则代表了另一条技术路线—— 真正的流处理 。它是一个 事件驱动 (Event-driven) 的框架,其核心理念是“数据流是第一公民”。在 Flink 中,每一条数据(一个事件)一旦抵达,就会被立刻处理,而无需等待凑成一个批次。这种模式能够实现最低的毫秒级甚至亚毫秒级延迟。Flink 强大的 状态管理精确一次 (exactly-once) 处理语义保证,使其非常适合构建复杂的、有状态的实时应用,如实时欺诈检测、金融交易监控和实时数据大屏等。在 Flink 的世界观里,批处理只是流处理的一个特例——一个有限的数据流。

关于 Spark 的开发语言,为什么选择 Scala

Spark 主要使用 Scala 开发。这部分是因为项目启动时 Scala 正是一门“新潮”的语言。但更重要的技术原因是,Scala 作为一门运行在 JVM 上的函数式语言,它能非常简洁、高效地定义和传递用户代码(即 闭包 (closures) ),并且可以将其序列化后发送到工作节点上执行,这是实现分布式计算的关键。

关于 RDD 概念的延伸 :虽然 RDD 的概念与 Spark 紧密相连,但其背后的核心思想——基于血缘的恢复和面向集合的 API——在许多其他系统中都有体现,如 DryadLINQ 和 FlumeJava 。值得一提的是,Spark 自身也在不断进化。如今,更推荐使用 DataFrameDataset API。它们在 RDD 的基础上,引入了更优化的列式存储和执行计划,性能通常比直接操作 RDD 更高。

最后,关于 能源效率 ,虽然它是计算机科学的一个重要议题,但在分布式系统软件设计层面,它通常不是首要的优化目标。主要的节能工作集中在数据中心设计、硬件(如 CPU 动态调频)和散热等方面。因为在软件层面进行优化的节能效果,远不如在这些物理层面进行改进来得显著。

总而言之, Spark 不仅仅是 MapReduce 的一个替代品,更是数据处理范式的一次重要飞跃。