⚡ 02 · Spark:RDD / 惰性求值 / 血缘 / 内存计算
> Spark 是离线大数据的**事实标准**。本节不装 Spark,用 Python 模拟核心机制:
> **RDD 转换(transformation)、惰性求值、血缘(lineage)、action 触发、Shuffle 与宽窄依赖**。
📋 本节要解决的问题
| # | 问题 | 方法 |
|---|---|---|
| 1 | RDD 是什么? | 弹性分布式数据集(分区+转换) |
| 2 | 惰性求值怎么工作? | transformation 攒着,action 才执行 |
| 3 | 血缘(lineage)干什么? | 记录来源,故障重算 |
| 4 | 宽窄依赖区别? | 窄=同分区,宽=Shuffle |
%matplotlib inline
import matplotlib
import matplotlib.pyplot as plt
matplotlib.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'PingFang SC', 'Noto Sans CJK SC', 'Arial Unicode MS']
matplotlib.rcParams['axes.unicode_minus'] = False
import numpy as np
import pandas as pd
import sqlite3
import os
rng = np.random.default_rng(42)
1️⃣ Spark 核心概念(必背)
Spark 生态与"为什么快"
| 组件 | 作用 |
|---|---|
| Spark Core | RDD 抽象、任务调度、内存管理 |
| Spark SQL | DataFrame/Dataset + Catalyst 优化器(生产主力) |
| Structured Streaming | 流式处理(增量查询模型) |
| MLlib | 分布式机器学习 |
**比 MapReduce 快在哪**:MR 每个作业的中间结果必须**落盘**再由下一个作业读取(磁盘 IO 重);
Spark 把多步转换组成 **DAG**,窄依赖之间**流水线执行**(一批数据在一台机器上连续 map→filter,不落地),
只有宽依赖(Shuffle)才真正交换数据——迭代类算法(机器学习常见)可快 10~100 倍。
**RDD 的"弹性"**体现在三点:①分区可并行;②血缘可**重算**恢复;③可**缓存/释放**在内存与磁盘间切换。
print('''Spark 核心:
1) RDD:不可变、分区的数据集合(弹性=可重算、可缓存);
2) Transformation:map/filter/flatMap/groupBy... 惰性(lazy);
3) Action:collect/count/reduce/take... 立即触发;
4) DAG:转换构成有向无环图,Action 时提交执行;
5) 血缘(Lineage):每个 RDD 记录父 RDD 与转换,故障自动重算;
6) Cache/Checkpoint:缓存中间结果,切断过长血缘。
''')Spark 核心:
1) RDD:不可变、分区的数据集合(弹性=可重算、可缓存);
2) Transformation:map/filter/flatMap/groupBy... 惰性(lazy);
3) Action:collect/count/reduce/take... 立即触发;
4) DAG:转换构成有向无环图,Action 时提交执行;
5) 血缘(Lineage):每个 RDD 记录父 RDD 与转换,故障自动重算;
6) Cache/Checkpoint:缓存中间结果,切断过长血缘。
2️⃣ 手写迷你 RDD(简化版)
class MiniRDD:
def __init__(self, partitions, deps=None, op=None):
self.partitions = partitions # 分区列表(每区一个 list)
self.deps = deps or [] # 血缘
self.op = op or 'source'
def map(self, f):
# transformation:不执行,只记录闭包
new_parts = [None] * len(self.partitions)
return MiniRDD(new_parts, deps=self.deps + [f'source.map({f.__name__ if hasattr(f,"__name__") else "lambda"})'], op='map')
def filter(self, f):
return MiniRDD([None] * len(self.partitions), deps=self.deps + ['filter'], op='filter')
def materialize(self, f=None, g=None):
# 模拟执行:从源头开始真正计算
if self.op == 'source':
return [[x for x in p] for p in self.partitions]
# 简化:由闭包外部处理
return None
src = MiniRDD([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) # 3 个分区
rdd2 = src.map(lambda x: x * 2).filter(lambda x: x > 5)
print('map 后血缘:', rdd2.deps)
print('➡️ 上面只是"记录操作",没有真正执行任何计算(惰性)')map 后血缘: ['source.map(<lambda>)', 'filter']
➡️ 上面只是"记录操作",没有真正执行任何计算(惰性)
3️⃣ 惰性求值演示:action 才触发
# 完整迷你执行器:真正跑通 map/filter/count
from functools import reduce
class MiniSpark:
def __init__(self, partitions):
self._parts = partitions
self._plan = [] # (name, fn) 转换序列
self._executed = 0
def map(self, f):
self._plan.append(('map', f)); return self
def filter(self, f):
self._plan.append(('filter', f)); return self
def flatMap(self, f):
self._plan.append(('flatMap', f)); return self
def reduceByKey(self, f):
self._plan.append(('reduceByKey', f)); return self
def run(self):
parts = [list(p) for p in self._parts]
for name, fn in self._plan:
if name == 'map':
parts = [[fn(x) for x in p] for p in parts]
elif name == 'filter':
parts = [[x for x in p if fn(x)] for p in parts]
elif name == 'flatMap':
parts = [[y for x in p for y in fn(x)] for p in parts]
elif name == 'reduceByKey':
out = []
for p in parts:
d = {}
for k, v in p:
d[k] = fn(d[k], v) if k in d else v
out.append(list(d.items()))
parts = out
self._executed += 1
return parts
ms = MiniSpark([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
ms.map(lambda x: x * 2).filter(lambda x: x > 5)
print(f'构建计划后:执行次数 = {ms._executed}(尚未计算!)')
res = ms.run()
print(f'action(run) 后:执行次数 = {ms._executed},结果 = {res}')
# map 后 9 个元素,filter >5 保留 7 个
assert ms._executed == 2 and sum(len(p) for p in res) == 7
print('✅ 惰性求值:transformations 不执行,action 才执行')构建计划后:执行次数 = 0(尚未计算!)
action(run) 后:执行次数 = 2,结果 = [[6], [8, 10, 12], [14, 16, 18]]
✅ 惰性求值:transformations 不执行,action 才执行
# 可视化:惰性求值时间线(构建期 vs action 期)
fig, ax = plt.subplots(figsize=(9, 3.2))
ax.barh(['构建 map/filter 链', 'action(run)'], [1.8, 0.2], color=['#7F7F7F', '#C00000'], height=0.45)
ax.text(0.9, 0, '只记录转换,0 次数据遍历', ha='center', va='center', color='white', fontsize=11)
ax.text(0.9, 1, '0 次遍历', ha='center', va='center', color='white', fontsize=10)
ax.set_xlim(0, 2); ax.set_xlabel('时间 →', fontsize=11); ax.set_yticks([0, 1])
ax.set_title('惰性求值:transformation 阶段不碰数据,action 提交后才执行', fontsize=12)
plt.show()
# 关键证据:ms._executed 在 run() 前后分别为 0→2(map、filter 各执行 1 轮)
print('run 前 _executed 应为 0,run 后为 2 ->', '验证:构建计划时 executed =', MiniSpark([[1]])._executed)run 前 _executed 应为 0,run 后为 2 -> 验证:构建计划时 executed = 0
4️⃣ 词频(Spark 风格)
lines = ['apple banana', 'apple cherry', 'banana cherry apple']
ms2 = MiniSpark([[w for line in lines for w in line.split()]])
ms2.map(lambda w: (w, 1)).reduceByKey(lambda a, b: a + b)
out = ms2.run()
wc = dict(out[0])
print('Spark 风格词频:', wc)
assert wc['apple'] == 3 and wc['cherry'] == 2 and wc['banana'] == 2
print('✅ 词频统计与手写 MapReduce 结果一致')Spark 风格词频: {'apple': 3, 'banana': 2, 'cherry': 2}
✅ 词频统计与手写 MapReduce 结果一致
5️⃣ 宽窄依赖与 Shuffle(图示)
# 可视化:宽依赖 vs 窄依赖(分区连线)
fig, axes = plt.subplots(1, 2, figsize=(10.5, 4))
for ax, title, wide in zip(axes, ['窄依赖: map/filter(一对一)', '宽依赖: reduceByKey/join(多对多)'], [False, True]):
ax.set_xlim(0, 10); ax.set_ylim(0, 10); ax.axis('off')
ax.set_title(title, fontsize=12, color=('#C00000' if wide else '#548235'))
for i in range(3):
ax.add_patch(plt.Rectangle((1, 7.2 - i * 2.4), 2.2, 1.6, fc='#D9E2F3', ec='#2F5597'))
ax.add_patch(plt.Rectangle((6.8, 7.2 - i * 2.4), 2.2, 1.6, fc='#E2EFDA', ec='#548235'))
ax.text(2.1, 8 - i * 2.4, f'父P{i}', ha='center', va='center', fontsize=9)
ax.text(7.9, 8 - i * 2.4, f'子P{i}', ha='center', va='center', fontsize=9)
if wide:
for i in range(3):
for j in range(3):
ax.plot([3.2, 6.8], [8 - i * 2.4, 8 - j * 2.4], color='#C00000', lw=0.8, alpha=0.5)
else:
for i in range(3):
ax.plot([3.2, 6.8], [8 - i * 2.4, 8 - i * 2.4], color='#548235', lw=1.8)
plt.tight_layout(); plt.show()print('''窄依赖(Narrow):父分区只被子分区之一使用
map/filter/flatMap/union -> 无需 Shuffle,可流水线
宽依赖(Wide) :父分区被多个子分区使用
groupByKey/reduceByKey/join -> 需要 Shuffle(数据跨节点传输)
优化面试点:
1) reduceByKey 优于 groupByKey(map 端预聚合);
2) Shuffle 是性能瓶颈 -> 控制分区数、避免过多 Shuffle;
3) 血缘长 -> checkpoint 截断(故障恢复更快)。
''')
# 演示:reduceByKey 的 map 端预聚合
partitions = [[('a', 1), ('b', 1)], [('a', 1), ('a', 1)], [('b', 1), ('c', 1)]]
print('分区内预聚合(combine):')
for i, p in enumerate(partitions):
d = {}
for k, v in p:
d[k] = d.get(k, 0) + v
print(f' 分区{i}: {p} -> {list(d.items())}')窄依赖(Narrow):父分区只被子分区之一使用
map/filter/flatMap/union -> 无需 Shuffle,可流水线
宽依赖(Wide) :父分区被多个子分区使用
groupByKey/reduceByKey/join -> 需要 Shuffle(数据跨节点传输)
优化面试点:
1) reduceByKey 优于 groupByKey(map 端预聚合);
2) Shuffle 是性能瓶颈 -> 控制分区数、避免过多 Shuffle;
3) 血缘长 -> checkpoint 截断(故障恢复更快)。
分区内预聚合(combine):
分区0: [('a', 1), ('b', 1)] -> [('a', 1), ('b', 1)]
分区1: [('a', 1), ('a', 1)] -> [('a', 2)]
分区2: [('b', 1), ('c', 1)] -> [('b', 1), ('c', 1)]
6️⃣ Cache 与血缘(面试速答)
print('''1) cache(): 把中间 RDD 存内存,避免反复从源头重算;
2) checkpoint: 写到可靠存储,切断血缘(适合血缘超长);
3) 缓存 vs 检查点:缓存可能丢失(依赖血缘可重算),检查点直接切断;
4) 何时用 cache?-> 同一 RDD 被多次 action / 迭代计算;
5) cache 是 lazy 的:真正缓存发生在第一次 action 后。
''')1) cache(): 把中间 RDD 存内存,避免反复从源头重算;
2) checkpoint: 写到可靠存储,切断血缘(适合血缘超长);
3) 缓存 vs 检查点:缓存可能丢失(依赖血缘可重算),检查点直接切断;
4) 何时用 cache?-> 同一 RDD 被多次 action / 迭代计算;
5) cache 是 lazy 的:真正缓存发生在第一次 action 后。
7️⃣ DataFrame vs RDD(面试对比)
print('''| 维度 | RDD | DataFrame |
|---|---|---|
| 抽象 | 无结构数据+函数 | 有 schema 的表 |
| 优化 | 靠手写 | Catalyst 优化器自动 |
| 类型安全 | 弱(运行时) | 有(编译期 schema) |
| 易用性 | 低 | 高(SQL 语法) |
| 现状 | 底层引擎 | 生产主力(Spark SQL) |
Spark SQL 三大优势:Catalyst 优化、Tungsten 内存管理、DataSource API。
''')| 维度 | RDD | DataFrame |
|---|---|---|
| 抽象 | 无结构数据+函数 | 有 schema 的表 |
| 优化 | 靠手写 | Catalyst 优化器自动 |
| 类型安全 | 弱(运行时) | 有(编译期 schema) |
| 易用性 | 低 | 高(SQL 语法) |
| 现状 | 底层引擎 | 生产主力(Spark SQL) |
Spark SQL 三大优势:Catalyst 优化、Tungsten 内存管理、DataSource API。
执行流程与分区数设置(面试展开)
**spark-submit 之后发生了什么**:
Driver 启动 → 解析代码构建 DAG → 遇到 **Action** 触发 **Job** → DAGScheduler 按**宽依赖**切 Stage
→ 每个 Stage 生成一批 **Task**(1 分区 = 1 Task)→ TaskScheduler 把 Task 派发给 **Executor** 执行
→ 结果回传 Driver。
**分区数怎么定**(经验法则):
• 下限:≥ 总核心数(吃满并行度);
• 经验值:**总核心数的 2~3 倍**(让任务不均时忙核可接活,避免长尾);
• 太少:并行不足、单任务内存压力大;太多:调度开销、小文件(写出的文件数≈分区数)。
**Shuffle 分区数**由 `spark.sql.shuffle.partitions`(默认 200)控制,小数据调小、大数据调大。
📝 自测清单
• [ ] 能解释 RDD/Transformation/Action/DAG 四概念
• [ ] 能演示惰性求值(action 触发)
• [ ] 能讲清宽窄依赖与 Shuffle
• [ ] 能解释血缘与 cache/checkpoint 的区别
• [ ] 能对比 RDD 与 DataFrame