从零开始手写 Spark 02|数据流与延迟迭代

上一章结尾,我们得到了两条原则------"分块"和"并行"。要让计算最终在数据分区上展开,代码里首先需要一个对象来代表这批数据,并说明每次该怎么访问它们。至于任务最终跑在哪台机器上,是调度层的事。

这一章先构建这个代表:一个只回答"数据怎么被访问"的对象。它的名字叫 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() 被调用时按需计算。

现在只需要记住这一点:容器拥有数据,通道只访问数据。

从接口契约看 ListIterator 的区别

在 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() 方法

有了 IteratorSupplier,"访问方式"和"延迟执行"这两件事都已经讲清楚。现在可以把它们结合起来,构建 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。它会依次演示 DeferredListRDDcompute()

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,演示 ListIterator 的差异:

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,演示 DeferredListRDD

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,不复制数据、不预先物化结果

具体的概念有四个:

  1. Iterator ------ 一道访问的门,不是一间储存数据的房。
  2. Deferred ------ 把"待执行的计算"包装成对象,演示什么是"延迟"。
  3. RDD ------ 一个约定:需要数据时,必须能通过 compute() 返回一个 Iterator
  4. ListRDD ------ 第一个遵守这个约定的实现:不复制数据、不预先物化结果,只保存访问方式。

这四个概念回答了第 1 章结尾留下的问题:数据如何变成代码中可计算的对象------这个对象就是 RDD。它不持有数据的副本,也不急于计算结果,而是保存一条"如何生成数据流"的路径------也就是它的 compute() 方法。

相关推荐
数据智研1 小时前
【数据分享】全国农产品成本收益资料汇编(1953-2025)
大数据·人工智能·信息可视化·数据分析
yinshuzhineng2 小时前
问题:如何实现数字化转型,增强竞争优势?
大数据·人工智能·制造
七夜zippoe2 小时前
基于 DolphinDB 构建分布式集群:从节点配置到高可用部署的完整实践
分布式·集群·dolphindb·节点配置·高可用部署
Zaimmm2 小时前
临床文献智能检索哪家强?2026年主流AI循证平台深度测评与推荐
大数据·人工智能·microsoft
QYR_Jodie2 小时前
2026-2032全球玻璃纤维网市场分析:2032年将达到5.55亿美元
大数据·人工智能
八角Z2 小时前
AI Agent作为经济参与者的兴起:机器经济与传统金融体系的并存与交织
大数据·人工智能·服务发现
资深电气设计3 小时前
数据中心800V直流断路器选型分析:ABB电气产品矩阵的技术适配性与评估要点
大数据·线性代数·矩阵
hzp6663 小时前
hudi学习0:目录
大数据·hudi
Acrellea3 小时前
并网验收难破局:分布式光伏如何攻克孤岛、逆流两大技术关卡
分布式