码到成功

spark rdd快速上手


这篇文章主要用 Python 代码演示 PySpark 中常见 RDD 的用法,包括 RDD 创建、常用转换算子、行动算子、键值 RDD、缓存、分区和一些实战注意点。

## RDD 可以理解成什么

可以把 RDD 理解成一个分布式集合。

普通 Python 列表只存在一台机器的内存里,而 RDD 的数据会被切成多个分区,分布在不同 Executor 上并行计算。

例如:

```text
RDD
├── partition-0
├── partition-1
├── partition-2
└── partition-3
```

RDD 有几个很重要的特点:

- 数据分布在多个分区中
- 转换操作是惰性执行的
- 行动操作才会真正触发计算
- 可以缓存重复使用的数据
- 可以通过血缘关系进行容错恢复

所谓惰性执行,就是你写了 `map`、`filter`、`flatMap` 这类操作,Spark 不会马上运行。只有遇到 `collect`、`count`、`take`、`saveAsTextFile` 这类行动算子时,任务才会真正提交。

## 准备一个 SparkContext

在 PySpark 中,一般可以通过 `SparkSession` 拿到 `SparkContext`:

```python
from pyspark.sql import SparkSession

spark = SparkSession.builder \
    .appName("rdd-demo") \
    .getOrCreate()

sc = spark.sparkContext
```

后面的 RDD 示例都基于这个 `sc`。

## 创建 RDD

最简单的方式是从 Python 集合创建:

```python
nums = sc.parallelize([1, 2, 3, 4, 5])
```

也可以指定分区数:

```python
nums = sc.parallelize([1, 2, 3, 4, 5], 2)

print(nums.getNumPartitions())
```

读取文本文件:

```python
lines = sc.textFile("hdfs:///data/access.log")
```

如果是本地测试,也可以读取本地文件:

```python
lines = sc.textFile("file:///tmp/access.log")
```

`textFile` 读取后得到的是一个 `RDD[str]`,每个元素是一行文本。

## map:一条进,一条出

`map` 会对 RDD 中的每个元素执行一个函数,并返回新的 RDD。

```python
nums = sc.parallelize([1, 2, 3, 4])

result = nums.map(lambda x: x * 10)

print(result.collect())
```

输出:

```text
[10, 20, 30, 40]
```

`map` 很适合做字段转换、类型转换、简单计算。

比如从日志中取 IP:

```python
lines = sc.parallelize([
    "10.0.0.1 /home 200",
    "10.0.0.2 /detail 404",
    "10.0.0.3 /pay 500",
])

ips = lines.map(lambda line: line.split(" ")[0])

print(ips.collect())
```

## flatMap:一条进,多条出

`flatMap` 和 `map` 很像,但它可以把一个元素拆成多个元素。

单词拆分是最经典的例子:

```python
lines = sc.parallelize([
    "hello spark",
    "hello rdd",
])

words = lines.flatMap(lambda line: line.split(" "))

print(words.collect())
```

输出:

```text
['hello', 'spark', 'hello', 'rdd']
```

如果用 `map`:

```python
result = lines.map(lambda line: line.split(" "))

print(result.collect())
```

输出会是:

```text
[['hello', 'spark'], ['hello', 'rdd']]
```

`map` 保留嵌套结构,`flatMap` 会把嵌套结构打平。

## filter:过滤数据

`filter` 用来保留满足条件的数据。

```python
nums = sc.parallelize([1, 2, 3, 4, 5, 6])

even = nums.filter(lambda x: x % 2 == 0)

print(even.collect())
```

输出:

```text
[2, 4, 6]
```

过滤日志也很常见:

```python
error_logs = lines.filter(lambda line: "ERROR" in line)
```

`filter` 不改变元素结构,只改变数据量。

## distinct:去重

`distinct` 用于去重:

```python
nums = sc.parallelize([1, 1, 2, 2, 3, 3])

unique = nums.distinct()

print(unique.collect())
```

输出:

```text
[1, 2, 3]
```

需要注意,`distinct` 通常会触发 Shuffle。数据量很大时,不要随手乱用。

## union、intersection、subtract

RDD 支持一些集合类操作。

```python
rdd1 = sc.parallelize([1, 2, 3])
rdd2 = sc.parallelize([3, 4, 5])
```

合并:

```python
print(rdd1.union(rdd2).collect())
```

输出:

```text
[1, 2, 3, 3, 4, 5]
```

`union` 不会自动去重。

取交集:

```python
print(rdd1.intersection(rdd2).collect())
```

取差集:

```python
print(rdd1.subtract(rdd2).collect())
```

这些操作看起来简单,但大多涉及 Shuffle,数据量大时要关注性能。

## sample:抽样

`sample` 可以从 RDD 中抽取一部分数据。

```python
nums = sc.parallelize(range(100))

sampled = nums.sample(
    withReplacement=False,
    fraction=0.1
)

print(sampled.take(10))
```

参数说明:

- `withReplacement`:是否放回抽样
- `fraction`:抽样比例

抽样很适合做数据预览、调试、估算数据分布。

## groupBy:按规则分组

`groupBy` 可以根据函数返回值分组。

```python
nums = sc.parallelize([1, 2, 3, 4, 5, 6])

grouped = nums.groupBy(lambda x: x % 2)

result = grouped.mapValues(list).collect()

print(result)
```

输出类似:

```text
[(0, [2, 4, 6]), (1, [1, 3, 5])]
```

`groupBy` 用起来方便,但它会把相同分组的数据拉到一起。如果某个分组特别大,容易造成内存压力。

如果只是做求和、计数,通常更推荐使用 `reduceByKey`。

## 行动算子:真正触发计算

常见行动算子有:

```python
rdd.collect()
rdd.count()
rdd.first()
rdd.take(10)
rdd.reduce(lambda a, b: a + b)
rdd.foreach(lambda x: print(x))
rdd.saveAsTextFile("hdfs:///output/path")
```

`collect` 会把所有数据拉回 Driver:

```python
data = rdd.collect()
```

数据量小时它很好用。数据量大时,它可能把 Driver 内存打爆。

调试时更推荐:

```python
print(rdd.take(10))
```

或者:

```python
print(rdd.sample(False, 0.01).take(20))
```

## reduce:聚合元素

`reduce` 可以把 RDD 中的元素合并成一个结果。

```python
nums = sc.parallelize([1, 2, 3, 4])

total = nums.reduce(lambda a, b: a + b)

print(total)
```

输出:

```text
10
```

`reduce` 的函数最好满足结合律,否则分布式执行时结果可能不稳定。

适合的场景包括:

- 求和
- 求最大值
- 求最小值
- 合并结构一致的数据

## foreach:不要误会它运行在哪里

`foreach` 会在 Executor 端执行函数。

```python
rdd.foreach(lambda x: print(x))
```

初学时很容易写出这种代码:

```python
count = 0

def add_count(x):
    global count
    count += 1

rdd.foreach(add_count)

print(count)
```

这通常不会得到你想要的结果。因为 Executor 里执行的是 Driver 变量的副本,不会直接修改 Driver 端的 `count`。

要统计数量,用:

```python
print(rdd.count())
```

要做辅助统计,可以使用累加器。

## Pair RDD:键值对 RDD

在 PySpark 里,键值对通常用 tuple 表示。

```python
pairs = sc.parallelize([
    ("spark", 1),
    ("hadoop", 1),
    ("spark", 1),
])
```

这种结构可以使用很多按 key 处理的算子,比如 `reduceByKey`、`groupByKey`、`join`、`sortByKey`。

## reduceByKey:按 key 聚合

单词统计是 `reduceByKey` 的经典例子:

```python
lines = sc.parallelize([
    "hello spark",
    "hello rdd",
    "spark rdd",
])

word_count = (
    lines
    .flatMap(lambda line: line.split(" "))
    .map(lambda word: (word, 1))
    .reduceByKey(lambda a, b: a + b)
)

print(word_count.collect())
```

输出类似:

```text
[('hello', 2), ('spark', 2), ('rdd', 2)]
```

`reduceByKey` 会先在分区内做本地聚合,再进行 Shuffle,通常比 `groupByKey` 更适合聚合统计。

## groupByKey:能用,但要谨慎

`groupByKey` 会把相同 key 的所有 value 放到一起。

```python
pairs = sc.parallelize([
    ("spark", 1),
    ("spark", 1),
    ("hadoop", 1),
])

grouped = pairs.groupByKey()

print(grouped.mapValues(list).collect())
```

输出:

```text
[('spark', [1, 1]), ('hadoop', [1])]
```

如果只是为了求和,不推荐这样写:

```python
result = pairs.groupByKey().mapValues(sum)
```

更推荐:

```python
result = pairs.reduceByKey(lambda a, b: a + b)
```

`groupByKey` 会把所有 value 都拉到同一个 key 下,大 key 很容易造成内存压力。

## mapValues:只处理 value

`mapValues` 会保留 key,只转换 value。

```python
scores = sc.parallelize([
    ("Tom", 80),
    ("Jerry", 90),
])

result = scores.mapValues(lambda score: score + 10)

print(result.collect())
```

输出:

```text
[('Tom', 90), ('Jerry', 100)]
```

这种写法比手动拆 tuple 更清晰。

## flatMapValues:一个 value 拆成多个 value

```python
data = sc.parallelize([
    ("user1", "redis,spark,hadoop"),
    ("user2", "spark,kafka"),
])

result = data.flatMapValues(lambda tags: tags.split(","))

print(result.collect())
```

输出:

```text
[('user1', 'redis'), ('user1', 'spark'), ('user1', 'hadoop'), ('user2', 'spark'), ('user2', 'kafka')]
```

它适合标签展开、明细展开这类场景。

## sortByKey 和 sortBy

按 key 排序:

```python
data = sc.parallelize([
    (3, "c"),
    (1, "a"),
    (2, "b"),
])

sorted_data = data.sortByKey()

print(sorted_data.collect())
```

降序:

```python
sorted_desc = data.sortByKey(ascending=False)
```

如果不是键值 RDD,可以用 `sortBy`:

```python
users = sc.parallelize([
    ("Tom", 20),
    ("Jerry", 18),
    ("Alice", 25),
])

sorted_users = users.sortBy(lambda x: x[1])

print(sorted_users.collect())
```

排序通常会触发 Shuffle,大数据量排序要谨慎。

## join:连接两个 Pair RDD

RDD 可以按 key 做连接。

```python
users = sc.parallelize([
    (1, "Tom"),
    (2, "Jerry"),
])

orders = sc.parallelize([
    (1, "order-a"),
    (1, "order-b"),
    (2, "order-c"),
])

joined = users.join(orders)

print(joined.collect())
```

输出类似:

```text
[(1, ('Tom', 'order-a')), (1, ('Tom', 'order-b')), (2, ('Jerry', 'order-c'))]
```

常见连接还有:

```python
users.leftOuterJoin(orders)
users.rightOuterJoin(orders)
users.fullOuterJoin(orders)
```

两个大 RDD 做 join 时,通常会产生 Shuffle。要特别注意 key 倾斜和分区数量。

如果其中一个数据集很小,可以考虑广播变量。

## cogroup:按 key 聚合多个 RDD

`cogroup` 可以把多个 RDD 中相同 key 的 value 聚到一起。

```python
rdd1 = sc.parallelize([
    ("a", 1),
    ("b", 2),
])

rdd2 = sc.parallelize([
    ("a", 10),
    ("a", 20),
    ("c", 30),
])

result = rdd1.cogroup(rdd2)

print(result.mapValues(lambda x: (list(x[0]), list(x[1]))).collect())
```

输出类似:

```text
[('a', ([1], [10, 20])), ('b', ([2], [])), ('c', ([], [30]))]
```

它适合多个来源按同一个 key 汇总处理的场景。

## aggregateByKey:更灵活的按 key 聚合

如果输入 value 类型和输出 value 类型不同,可以使用 `aggregateByKey`。

例如计算每个班级的平均分:

```python
scores = sc.parallelize([
    ("class-a", 80),
    ("class-a", 90),
    ("class-b", 70),
])

sum_count = scores.aggregateByKey(
    (0, 0),
    lambda acc, score: (acc[0] + score, acc[1] + 1),
    lambda acc1, acc2: (acc1[0] + acc2[0], acc1[1] + acc2[1])
)

avg = sum_count.mapValues(lambda x: x[0] / x[1])

print(avg.collect())
```

`aggregateByKey` 的三个核心部分:

- 初始值
- 分区内聚合函数
- 分区间合并函数

它比 `reduceByKey` 灵活,但也更容易写错。

## combineByKey:更底层的聚合

`combineByKey` 可以更细粒度地控制按 key 聚合过程。

还是计算平均分:

```python
scores = sc.parallelize([
    ("Tom", 80),
    ("Tom", 90),
    ("Jerry", 70),
])

combined = scores.combineByKey(
    lambda score: (score, 1),
    lambda acc, score: (acc[0] + score, acc[1] + 1),
    lambda acc1, acc2: (acc1[0] + acc2[0], acc1[1] + acc2[1])
)

avg = combined.mapValues(lambda x: x[0] / x[1])

print(avg.collect())
```

三个函数分别表示:

- 第一次遇到 key 时创建初始聚合值
- 分区内继续合并 value
- 分区间合并聚合结果

如果 `reduceByKey` 或 `aggregateByKey` 已经能表达清楚,就不用强行上 `combineByKey`。

## mapPartitions:按分区处理

`map` 是每条数据调用一次函数。

`mapPartitions` 是每个分区调用一次函数。

```python
nums = sc.parallelize([1, 2, 3, 4], 2)

result = nums.mapPartitions(lambda iterator: (x * 2 for x in iterator))

print(result.collect())
```

它适合按分区初始化资源,比如每个分区创建一次外部连接:

```python
def process_partition(iterator):
    conn = create_connection()
    try:
        for record in iterator:
            yield query_by_connection(conn, record)
    finally:
        conn.close()

result = rdd.mapPartitions(process_partition)
```

注意不要随便把整个 `iterator` 转成 list。分区数据过大时,内存会很难受。

## foreachPartition:按分区写外部系统

写数据库、搜索引擎、消息系统时,`foreachPartition` 往往比 `foreach` 更合适。

不推荐每条数据都创建一次连接:

```python
def write_one(record):
    conn = create_connection()
    try:
        write_record(conn, record)
    finally:
        conn.close()

rdd.foreach(write_one)
```

更推荐每个分区创建一次连接:

```python
def write_partition(iterator):
    conn = create_connection()
    try:
        for record in iterator:
            write_record(conn, record)
    finally:
        conn.close()

rdd.foreachPartition(write_partition)
```

这样可以明显减少连接创建开销。

## coalesce 和 repartition

查看分区数:

```python
print(rdd.getNumPartitions())
```

减少分区:

```python
small = rdd.coalesce(2)
```

重新分区:

```python
more = rdd.repartition(10)
```

`repartition` 会触发 Shuffle。`coalesce` 默认不触发 Shuffle,更适合在过滤后数据量变少时减少分区。

例如减少输出小文件:

```python
filtered = big_rdd.filter(lambda x: x is not None)
filtered.coalesce(4).saveAsTextFile("hdfs:///output/result")
```

## cache 和 persist

RDD 默认不会自动保存中间结果。如果一个 RDD 被多个行动算子重复使用,最好缓存。

```python
parsed = lines.map(parse_line).filter(lambda x: x is not None)

parsed.cache()

print(parsed.count())
print(parsed.take(10))
```

`cache` 等价于使用默认内存级别。

也可以使用 `persist` 指定存储级别:

```python
from pyspark import StorageLevel

parsed.persist(StorageLevel.MEMORY_AND_DISK)
```

不用了可以释放:

```python
parsed.unpersist()
```

缓存适合复用数据,不适合见到 RDD 就缓存。缓存太多会占用 Executor 内存。

## checkpoint:截断血缘关系

如果一个 RDD 的转换链很长,可以使用 checkpoint 截断血缘关系。

先设置 checkpoint 目录:

```python
sc.setCheckpointDir("hdfs:///spark/checkpoint")
```

使用:

```python
result = rdd.map(func1).filter(func2).reduceByKey(lambda a, b: a + b)

result.cache()
result.checkpoint()
result.count()
```

`checkpoint` 需要行动算子触发。和 `cache` 一起使用,通常可以减少重复计算。

## 广播变量

如果每个任务都要使用一份小数据,可以使用广播变量。

例如一个小字典:

```python
country_dict = {
    "cn": "China",
    "us": "United States",
}

bc_country = sc.broadcast(country_dict)

codes = sc.parallelize(["cn", "us", "unknown"])

result = codes.map(lambda code: bc_country.value.get(code, "Unknown"))

print(result.collect())
```

广播变量适合:

- 小表关联
- 规则字典
- 配置映射
- 黑白名单

不要广播太大的对象,否则 Driver 和 Executor 都会有压力。

## 累加器

累加器适合做辅助计数。

```python
invalid_count = sc.accumulator(0)

def parse_line(line):
    parts = line.split(" ")
    if len(parts) < 3:
        invalid_count.add(1)
        return None
    return parts

parsed = lines.map(parse_line).filter(lambda x: x is not None)

parsed.count()

print(invalid_count.value)
```

累加器适合做监控和统计,不适合参与关键业务逻辑。

原因是任务失败重试时,累加器可能出现重复累加,需要谨慎理解它的值。

## 一个完整例子:访问日志统计

假设日志格式是:

```text
ip url status
```

例如:

```text
10.0.0.1 /home 200
10.0.0.2 /detail 200
10.0.0.1 /home 500
```

统计每个 URL 的访问次数:

```python
lines = sc.textFile("hdfs:///data/access.log")

url_count = (
    lines
    .map(lambda line: line.split(" "))
    .filter(lambda arr: len(arr) >= 3)
    .map(lambda arr: (arr[1], 1))
    .reduceByKey(lambda a, b: a + b)
)

url_count.saveAsTextFile("hdfs:///output/url-count")
```

统计状态码数量:

```python
status_count = (
    lines
    .map(lambda line: line.split(" "))
    .filter(lambda arr: len(arr) >= 3)
    .map(lambda arr: (arr[2], 1))
    .reduceByKey(lambda a, b: a + b)
)

print(status_count.collect())
```

取访问量最高的几个 URL:

```python
top_urls = (
    url_count
    .map(lambda x: (x[1], x[0]))
    .sortByKey(ascending=False)
    .take(10)
)

print(top_urls)
```

如果只是在 Driver 端取少量结果,`take` 是可以接受的。不要用 `collect` 拉全量结果。

## 一个完整例子:用户标签展开

假设数据格式是:

```text
user_id tag1,tag2,tag3
```

示例数据:

```python
data = sc.parallelize([
    "u1 redis,spark,hadoop",
    "u2 spark,kafka",
    "u3 redis",
])
```

展开成 `(user_id, tag)`:

```python
user_tags = (
    data
    .map(lambda line: line.split(" "))
    .filter(lambda arr: len(arr) == 2)
    .map(lambda arr: (arr[0], arr[1]))
    .flatMapValues(lambda tags: tags.split(","))
)

print(user_tags.collect())
```

统计每个标签出现次数:

```python
tag_count = (
    user_tags
    .map(lambda x: (x[1], 1))
    .reduceByKey(lambda a, b: a + b)
)

print(tag_count.collect())
```


## RDD 和 DataFrame 怎么选

RDD 更灵活,适合处理结构不固定的数据,也适合写一些底层控制更多的逻辑。

DataFrame 更适合结构化数据处理。它有优化器,很多查询和聚合可以自动优化,代码也更接近 SQL。

如果是常规 ETL、报表、聚合分析,DataFrame 往往更省心。

如果是学习 Spark 原理、处理非结构化数据、或者需要非常自由的函数式处理方式,RDD 仍然很有价值。


真正写好 RDD 任务,关键不只是会调用 API,还要知道哪些操作会触发 Shuffle,哪些操作会把数据拉回 Driver,哪些场景会造成分区不均或内存压力。

理解这些之后,RDD 就不只是一个 API 集合,而是一套清晰的分布式计算思路。