上一章结尾,我们得到了两条原则------"分块"和"并行"。要让计算最终在数据分区上展开,代码里首先需要一个对象来代表这批数据,并说明每次该怎么访问它们。至于任务最终跑在哪台机器上,是调度层的事。
这一章先构建这个代表:一个只回答"数据怎么被访问"的对象。它的名字叫 RDD------眼下把它当成一个普通命名即可,重要的是它必须回答一个问题:数据怎么被访问。
RDD 是什么?
RDD 的全称是 Resilient Distributed Dataset(弹性分布式数据集)。这三个词分别对应它的三层能力:
- 弹性(Resilient):节点故障时能自动恢复。
- 分布式(Distributed):数据可以分散在多个分区上,第 1 章的"分块"就是在为它做准备。
- 数据集(Dataset):代表一批数据,并提供访问方式------这是本章要构建的骨架。
2.1 数据本身,还是数据的访问方式?
先看一段 Java 代码:
java
List<String> words = Arrays.asList("hello", "spark", "hello", "world");
for (String w : words) {
System.out.println(w);
}
你可能下意识地认为 words 里装着四个字符串。这个直觉没错------ArrayList 确实在内存里实实在在存着这四条数据,可以反复遍历它,每次看到的都一样。
如果习惯用 ArrayList 来承载数据,可能会下意识地认为:要表达一组数据,就应该把它们整整齐齐地装在内存里。但代表一个数据集的实体(RDD),不会持有数据的副本,也不会在定义时立即执行计算。
第一次接触这种设计时,可能会觉得有点反直觉------不存数据、不立即算,它到底代表什么?
答案隐藏在一个我们可能用过却未曾深思的 Java 接口里:Iterator。
在 Java 里,Iterator 是一个接口,它只回答两个问题:
hasNext():"还有下一个吗?"next():"把下一个给我。"
图 2-1:同一个数据源可以被看成"拥有数据"的房间,也可以被看成"访问数据"的一道门。
这张图展示了两种视角的结构差异:左侧 ArrayList 将数据封装在容器内部,右侧 Iterator 只持有指向数据源的引用和遍历位置的状态。数据并不在 Iterator 内部,所以它能表达"不存数据,只负责访问"的语义。下面用代码把这两条路径分别遍历一遍,印证图里的结构。
java
List<String> room = Arrays.asList("hello", "spark", "hello", "world");
// ArrayList 是一间"房"------数据实实在在存在里面
System.out.println("大小:" + room.size()); // 能问"有多少"
System.out.println("第2个:" + room.get(1)); // 能直接跳到任意位置
// 能反复遍历,每次看到的都一样
for (String w : room) System.out.println(w);
for (String w : room) System.out.println(w); // 再遍历一次,完全没问题
// Iterator 只是一道"门"------只能往前走
Iterator<String> door = room.iterator();
// door.size() ← 编译都过不了!没有这个方法
// door.get(1) ← 也没有。只能 next()、next()、next()...
while (door.hasNext()) {
System.out.println(door.next());
}
// 走到头了,想再遍历一次?无法重置游标,只能重新获取一个迭代器
这个对比清晰地揭示了两者的本质区别:**ArrayList 是储存数据的容器,Iterator 是访问数据的通道。容器支持随机访问------可以查询大小、跳转到任意位置、反复遍历多次。通道只支持顺序推进------两个动作:问"还有吗"、取"下一个"。一次遍历结束,通道即失效。
更深一层:通道的背后甚至不需要存在一个容器。比如:
java
// 一个永远数不到头的数字流------内存里并不真的存着"所有自然数"
Iterator<Integer> endless = new Iterator<>() {
int n = 0;
public boolean hasNext() { return true; } // 永远有下一个
public Integer next() { return n++; }
};
System.out.println(endless.next()); // 0
System.out.println(endless.next()); // 1
System.out.println(endless.next()); // 2
// 只要不停下来,它可以无限生成------而内存占用只有 int n 一个变量
ArrayList 做不到这件事:无法在内存里装下"所有自然数"。但 Iterator 可以------因为它不持有数据,元素只在 next() 被调用时按需计算。
现在只需要记住这一点:容器拥有数据,通道只访问数据。
从接口契约看
List与Iterator的区别在 Java 集合框架中,
List<T>与Iterator<T>承担着截然不同的职责,它们在类型层面定义了三种本质差异:1. 数据持有 vs 访问状态
List是一个数据容器 (Container)。它负责持有元素集合,维护数据结构(如数组、链表),并提供随机访问(get(index))和结构性查询(size())。遍历时,List本身的状态(元素集合)保持不变。Iterator是一个遍历游标 (Cursor)。它不持有数据,只持有对数据源的引用以及当前的遍历位置(指针)。其接口方法next()和hasNext()只负责推进游标和返回当前位置的元素。2. 可重复性 vs 一次性
List实现了Iterable接口,这意味着它可以被多次、独立地遍历 。每次调用list.iterator()都会返回一个全新的Iterator实例,该实例的游标被重置到起始位置。Iterator是有状态的(Stateful)。一次完整的遍历会将其游标推进到末尾,此时hasNext()返回false,且该实例不可重置、不可复用 。若要再次遍历,必须重新从List(或其他数据源)获取新的Iterator。3. 急切物化 vs 惰性生成
List要求所有元素在构造时急切求值(Eager Evaluation),即元素已全部物化在内存中。因此它无法代表无限序列。Iterator支持惰性求值 (Lazy Evaluation)。next()的实现可以封装任意计算逻辑(如逐行读取文件、实时生成序列),元素只在被请求时才产生,无需预先物化全部数据。对 RDD 的启示: 本章将要构建的
RDD,既不是持有数据的List,也不是一次性的Iterator。它的本质是一个迭代器工厂 :每次向它要数据(调用compute()),它就产出一个全新的Iterator。这样它既有List的"可重复遍历"能力,又有Iterator的"惰性计算"特性。
2.2 把"待执行的计算"装进一个对象
如果所有操作都立刻执行,会出什么问题?
一个很熟悉的例子来自 Python 的 Pandas。早期版本中,下面这样的链式操作:
python
df[df['age'] > 25].groupby('city').mean()
执行时,Pandas 会逐步求值:先过滤,生成一个中间 DataFrame;再分组,又生成一个中间 DataFrame;最后聚合。每一步都在内存中创建一份中间结果。数据量小时,这种"急切求值"方式用起来没有问题。数据变大或操作链变长时,中间副本会迅速耗尽内存,而且无法被优化------比如过滤和聚合本来可以合并成一次遍历。
Pandas 是什么?
Pandas 是 Python 里处理表格数据的核心库,其核心结构是 DataFrame------一张有行有列的表格。可以按条件筛选行、按列分组、做聚合计算。Pandas 后来引入了
eval()和query()等机制,将多个操作累积为一份执行计划,最后一次性执行,这正是延迟求值的核心思路。这说明延迟求值不是某个系统的特技,而是大规模数据处理的普遍规律。
延迟求值与此相反:不急于执行每一步,而是先累积一份"待执行操作"的清单,等到需要结果时再统一执行。这样,多个步骤可以合并成一次遍历,中间结果不必物化。
我们需要的就是这种能把"尚未执行的计算"包装起来的能力。Java 标准库里的 Supplier 正好满足这个需求:
java
@FunctionalInterface
public interface Supplier<T> {
T get();
}
Supplier 不持有结果,只持有"如何计算结果"。它是一个被封装的计算。
接口定义本身比较抽象。下面用一个叫 Deferred 的类来演示延迟执行的效果(对应代码 Deferred.java):
java
System.out.println("构造 Deferred...");
Deferred<String> lazy = new Deferred<>(() -> {
return "expensive result (" + System.currentTimeMillis() + ")";
});
System.out.println("Deferred 已构造,但计算还没发生。");
System.out.println("第一次 get(): " + lazy.get());
System.out.println("第二次 get(): " + lazy.get());
输出结果:
text
=== 1. Deferred:感受「延迟」===
构造 Deferred...
Deferred 已构造,但计算还没发生。
第一次 get(): expensive result (1734567890123)
第二次 get(): expensive result (1734567890123)
注意时间戳:构造时什么都没有发生,第一次调用 get() 才触发计算。第二次调用 get() 返回同样的时间戳------值已被缓存,不再重复计算。
注意区分两种延迟语义:
Deferred演示的是"延迟 + 缓存"------第一次计算,后续复用。而本章后面要构建的 RDD 只取"延迟"这一半:每次compute()都重新生成一个迭代器,不缓存。两者都体现了"构造 ≠ 立即执行",但复用的策略不同。下一节会展开 RDD 的做法。
2.3 RDD 的雏形:compute() 方法
有了 Iterator 和 Supplier,"访问方式"和"延迟执行"这两件事都已经讲清楚。现在可以把它们结合起来,构建 RDD 的第一个骨架。
但在看代码之前,先用一张图把第 1 章和第 2 章串起来。左侧是第 1 章代码被打包发往数据所在节点的直觉;右侧是本章要搭建的结构------代码到达节点后,会交给一个叫 RDD 的对象,由它回答"我知道怎么访问这些数据"。
图 2-2:RDD 描述一批数据及其访问方式;真正运行时,任务会在具体分区上执行计算逻辑。
不要把 RDD 想得太复杂。现在它只回答一个问题:如果有人要读数据,能不能给他一个 Iterator?
这个问题,在代码里就叫 compute()(对应代码 RDD.java):
java
public abstract class RDD<T> {
public abstract Iterator<T> compute();
}
RDD<T> 暂时只是一个约定:任何 RDD,都必须能在需要数据时返回一个 Iterator<T>。
这个定义只有一行,但它抓住了本章的关键:RDD 不保存一整份数据,也不立即算出结果;它保存的是一份"怎么访问数据"的描述。
2.4 ListRDD:第一个具体实现
有了 RDD 这个约定,就可以构建第一个具体实现了------ListRDD(对应代码 ListRDD.java)。
从这里开始的示例可以直接运行 Main.java。它会依次演示 Deferred、ListRDD 和 compute()。
java
public class ListRDD<T> extends RDD<T> {
private final List<T> data;
public ListRDD(List<T> data) {
this.data = data;
}
@Override
public Iterator<T> compute() {
return data.iterator();
}
}
构造函数只做一件事:把传入的 List 记下来。它不复制 这份 List,也不提前遍历 ------只是存一个引用。真正的迭代器,要等到 compute() 被调用时才创建。
那为什么不干脆在构造时就建好一个迭代器存着?因为 Iterator 是一次性的:走到末尾就失效了。ListRDD 必须能做到反复消费、每次都从头开始,所以它不能存一个已经创建好的迭代器,而要存"能产出迭代器的东西"。对内存 List 来说,这个东西就是 data 本身------每次 compute() 调用 data.iterator(),都得到一个全新的游标。
!NOTE 不复制副本,意味着什么?
ListRDD存的是data的引用,不是副本。如果构造之后有人改了原始List,下次compute()产出的迭代器会看到这些改动。这不是缺陷------它是"不复制一整份数据"的直接后果:数据还在原地,RDD 只保留访问它的入口。
等到真正需要数据时:
java
List<String> words = Arrays.asList("hello", "spark", "hello", "world", "spark", "is", "fun");
ListRDD<String> rdd = new ListRDD<>(words); // 构造:什么也没遍历
Iterator<String> it1 = rdd.compute(); // 拿到一个全新的迭代器
Iterator<String> it2 = rdd.compute(); // 再拿一个,全新的
System.out.println("it1 == it2 ? " + (it1 == it2) + " ← false,说明每次都是新的!");
System.out.println("第一次遍历:");
it1.forEachRemaining(w -> System.out.print(" " + w));
System.out.println();
System.out.println("第二次遍历(独立的迭代器,从头开始):");
it2.forEachRemaining(w -> System.out.print(" " + w));
System.out.println();
每次调用 compute(),都会执行一次 data.iterator(),给你一个全新的、从头开始的迭代器。运行验证一下:
text
=== 3. 每次 compute() 返回全新迭代器 ===
it1 == it2 ? false ← false,说明每次都是新的!
第一次遍历:
hello spark hello world spark is fun
第二次遍历(独立的迭代器,从头开始):
hello spark hello world spark is fun
同一个 ListRDD 可以反复消费,每次都是独立的。这里先确立一个事实:RDD 本身不是那个已经走到末尾的迭代器;RDD 是能重新生成迭代器的对象。
现在再回到第 1 章的问题:数据一旦变大,为什么这么在意"不要复制一整份数据"?
先看反面。如果把数据复制一份存进去------
java
// 错误写法:持有了数据副本
this.data = new ArrayList<>(list); // 把原始数据全部拷贝了一份
回到第 1 章的 50GB 日志场景。朴素 WordCount 的第一个难题,就是数据太大,单机内存装不下。如果每构造一个"数据的代表",又把底层数据完整复制一遍,等于在原来的问题上再加一份内存压力。ListRDD 的做法是:数据还在原地,RDD 只保存访问路径。
上面的讨论可以总结成一句话:RDD 描述的不是一份已经算好的结果,而是一条"怎么访问数据"的路径------对 ListRDD 来说,这条路径就是"从这份 List 上拿迭代器"。等真正需要时再生成数据流,而不是构造时就把结果算好存起来。
2.5 运行示例
本章没有自动化测试,验证方式是运行两个演示入口,直接观察延迟执行和迭代器的行为。
第一个入口是 IteratorExamples,演示 List 与 Iterator 的差异:
bash
mvn -q -pl ch02-lazy-iterator package
java -Dfile.encoding=UTF-8 \
-cp ch02-lazy-iterator/target/classes \
com.sparklearn.IteratorExamples
IteratorExamples.java 中有三组演示:
| 控制台部分 | 对应方法 | 验证内容 |
|---|---|---|
1. 普通 List 遍历 |
plainListForLoop() |
List 可以用 for-each 反复遍历 |
2. ArrayList 是房间,Iterator 是门 |
arrayListVsIterator() |
List 能问大小、随机取、反复遍历;Iterator 只能 next() 往前走 |
3. Iterator 可以代表无限流 |
endlessIterator() |
Iterator 不存数据、每次现算,因此能表达无限流 |
第二个入口是 Main,演示 Deferred 和 ListRDD:
bash
java -Dfile.encoding=UTF-8 \
-cp ch02-lazy-iterator/target/classes \
com.sparklearn.Main
Main.java 中有四组演示:
| 控制台部分 | 验证内容 |
|---|---|
1. Deferred:感受「延迟」 |
构造时不执行;第一次 get() 触发计算;第二次 get() 复用缓存(时间戳相同) |
2. ListRDD:不复制数据副本 |
构造 ListRDD 不遍历数据,原始 List 原样保留 |
3. 每次 compute() 返回全新迭代器 |
两次 compute() 返回不同对象;各自遍历都从头部开始 |
4. 构造 RDD 时不触发计算 |
构造期间无读取日志;调用 compute() 并消费才真正读数据 |
这两个入口都通过观察控制台输出来验证。
2.6 本章小结
回顾一下,这一章只做了一件事:把"数据的代表"构建出来。
本章建立的核心事实:
text
Iterator 是访问数据的门,不是储存数据的房
Deferred 把"待执行的计算"包装起来,构造时不执行
RDD 约定 compute() 返回 Iterator,不复制数据、不预先物化结果
具体的概念有四个:
Iterator------ 一道访问的门,不是一间储存数据的房。Deferred------ 把"待执行的计算"包装成对象,演示什么是"延迟"。RDD------ 一个约定:需要数据时,必须能通过compute()返回一个Iterator。ListRDD------ 第一个遵守这个约定的实现:不复制数据、不预先物化结果,只保存访问方式。
这四个概念回答了第 1 章结尾留下的问题:数据如何变成代码中可计算的对象------这个对象就是 RDD。它不持有数据的副本,也不急于计算结果,而是保存一条"如何生成数据流"的路径------也就是它的 compute() 方法。