从零开始手写 Spark 03|RDD 的惰性流水线

上一章,我们构建了第一个 RDD:

java 复制代码
ListRDD<String> rdd = new ListRDD<>(words);
Iterator<String> iterator = rdd.compute();

它已经能代表一批数据,也知道该如何提供一个新的 Iterator。但它还只能把数据原样交出来。实际的数据处理显然不止于此:需要把数字乘 2、筛掉无效记录、把一行文本拆成多个单词。

这一章给 RDD 加上三个最基础的变换算子

java 复制代码
rdd.map(...)
   .filter(...)
   .flatMap(...);

真正重要的不是把这三个方法写出来,而是回答一个更深的问题:

连续写下多个算子后,程序怎样做到不提前生成中间 List,而是在消费结果时让每个元素一次穿过整条计算链?

答案没有复杂的执行计划。只需要把一个 Iterator 套在另一个 Iterator 外面。

3.1 第一步:在 next() 里完成变换

先把 RDD 放到一边。假设现在只有一个整数迭代器:

java 复制代码
Iterator<Integer> parent = Arrays.asList(1, 2, 3).iterator();

现在希望从它读到的不是 1、2、3,而是 11、12、13。最直接的办法是再写一个迭代器:

为了突出本章的核心调用链,正文代码片段省略了 Objects.requireNonNull 等参数和返回值校验;可运行源码保留了这些防御性检查。

java 复制代码
public final class MappingIterator<T, U> implements Iterator<U> {
    private final Iterator<T> parent;
    private final Function<T, U> elementFunction;

    public MappingIterator(Iterator<T> parent, Function<T, U> elementFunction) {
        this.parent = parent;
        this.elementFunction = elementFunction;
    }

    @Override
    public boolean hasNext() {
        return parent.hasNext();
    }

    @Override
    public U next() {
        return elementFunction.apply(parent.next());
    }
}

完整实现见 MappingIterator.java

这个类只做两件事:

  1. hasNext() 直接询问父迭代器。
  2. next() 从父迭代器拿到一个元素,再用 elementFunction 变换它。

用它包装刚才的 parent

java 复制代码
Iterator<Integer> mapped =
        new MappingIterator<>(parent, number -> number + 10);

这行代码执行完后,number -> number + 10 一次都没有运行 。构造函数只是保存了父迭代器和函数。只有外部真正调用 mapped.next() 时,变换才发生:

text 复制代码
mapped.next()
    -> parent.next()
    -> elementFunction.apply(父元素)
    -> 返回变换后的元素

MappingIterator 没有保存结果列表,也没有提前遍历父数据。它包装在父迭代器外面,每次有人要下一个元素时,对取到的元素做一次变换。

flowchart LR C[&#34;消费者调用 next()&#34;] --> M[&#34;MappingIterator.next()&#34;] M --> P[&#34;parent.next()&#34;] P --> R[&#34;原始元素 1&#34;] R --> F[&#34;elementFunction.apply(1)&#34;] F --> O[&#34;返回 11&#34;]

这就是本章最小、也最关键的一环:变换不是先生成一批新数据,而是在读取下一个元素的过程中发生。

3.2 第二步:把迭代器变换接到 RDD 上

MappingIterator 已经能边读边变换,但它还只是一个独立的迭代器。我们希望写出的是:

java 复制代码
RDD<Integer> plusOne = rdd.map(number -> number + 1);

这里有两个要求:

  • map() 应该返回一个新的 RDD,原来的 rdd 保持不变。
  • 调用 map() 时不能立刻消费数据,只记录"以后怎么算"。

为此,我们引入 MapPartitionsRDD

java 复制代码
public final class MapPartitionsRDD<T, U> extends RDD<U> {
    private final RDD<T> parent;
    private final Function<Iterator<T>, Iterator<U>> iteratorTransform;

    public MapPartitionsRDD(
            RDD<T> parent,
            Function<Iterator<T>, Iterator<U>> iteratorTransform) {
        this.parent = parent;
        this.iteratorTransform = iteratorTransform;
    }

    @Override
    public Iterator<U> compute() {
        Iterator<T> parentIterator = parent.compute();
        return iteratorTransform.apply(parentIterator);
    }
}

完整实现见 MapPartitionsRDD.java

注意这里保存的 iteratorTransform

java 复制代码
Function<Iterator<T>, Iterator<U>>

它接收父 RDD 的迭代器对象,再返回一个新的迭代器对象。至于这个新迭代器内部做的是 mapfilter 还是 flatMapMapPartitionsRDD 并不关心。

不过这里和上一章有个关键不同。上一章的 ListRDD 是数据源头------它没有父 RDD,compute() 里直接 return data.iterator(),行为是写死的。MapPartitionsRDD 不一样:它有父 RDD,而且同一个骨架要承载 mapfilterflatMap 这些不同 的变换。行为会变,就不能只靠重写 compute(),而要把"怎么变换"存成一个字段------也就是 Function<Iterator<T>, Iterator<U>>compute() 先向父 RDD 要到迭代器,再用这个函数把它包一层。

这里最容易误会的一行是:

java 复制代码
return iteratorTransform.apply(parentIterator);

看起来像"把整个父迭代器执行一遍",但其实不是。apply 在这里只是一次普通的方法调用:把 parentIterator 这个对象交进去,拿回一个包装后的迭代器对象。以 map 为例,它等价于:

java 复制代码
return new MappingIterator<>(parentIterator, elementFunction);

这行代码只是在父迭代器外面包装一层,不会调用 parentIterator.next(),也不会遍历数据。后面 collect() 会反复调用最外层迭代器的 hasNext()next(),由这两个方法共同拉动数据。map 的元素变换发生在 next() 中;后面看到的 filterflatMap 还会在 hasNext() 中向父迭代器取数据。

本章代码里其实有两种不同层次的函数,必须把它们分清:

函数 类型 什么时候调用 做什么
iteratorTransform Iterator<T> -> Iterator<U> 每次 compute() 时调用一次 包装迭代器对象,不读取元素
elementFunction T -> U 每次 MappingIterator.next() 时调用一次 读取并变换一个元素

所以,iteratorTransform.apply(...) 中的 apply 并不等于"遍历"。Function.apply 具体做什么,完全取决于传进去的 Lambda。我们传入的是:

java 复制代码
iterator -> new MappingIterator<>(iterator, elementFunction)

这个 Lambda 的函数体只有一个 new,没有 while、没有 for、也没有 next()。因此调用它只会创建一个 MappingIterator

于是,RDD.map() 可以写成:

java 复制代码
public <U> MapPartitionsRDD<T, U> map(Function<T, U> elementFunction) {
    return new MapPartitionsRDD<>(
            this,
            iterator -> new MappingIterator<>(iterator, elementFunction));
}

把这段代码从内往外读:

  1. this 是父 RDD。
  2. 父 RDD 将来会通过 compute() 提供一个 iterator
  3. 新建 MappingIterator 包住这个父迭代器。
  4. 最终返回一个新的 MapPartitionsRDD

调用 map() 时,程序正处于构建计算图(即"写配方")的阶段,而不是执行计算(即"实际计算")的阶段。

因此parent.compute() 的方法体完全没有执行compute() 只是一段"将来如何获取原始数据"的逻辑描述,此刻仅仅是被记录下来,并没有被调用。MappingIterator 的对象还没有在内存中被创建 。此刻只是把 elementFunction(即 x -> x + 1)存进了构造函数参数里。更没有元素经过 elementFunction,因为连最底层的原始迭代器都还没创建,数据流尚未启动。

现在保存下来的,只是两层抽象的"血缘关系"描述(即"配方"):

text 复制代码
ListRDD:记下了"将来要从 data 列表里,通过 compute() 创建一个原始 Iterator"。
MapPartitionsRDD:记下了"将来要在父 RDD 提供的原始 Iterator 外面,套一个 MappingIterator 作为包装"。

这两层 RDD 对象此时仅仅以嵌套引用的形式存在于堆内存中。真正的 Iterator 链条(即实际执行计算的对象)要等到 collect() 执行、逐层调用 compute() 时,才会一层一层地创建出来。

java 复制代码
/**
 * 第一个 action:显式调用最外层迭代器的 hasNext() 和 next(),
 * 把结果逐个收集到内存。
 */
public List<T> collect() {
    List<T> result = new ArrayList<>();
    Iterator<T> iterator = compute();
    while (iterator.hasNext()) {
        T element = iterator.next();
        result.add(element);
    }
    return result;
}

直到 collect() 调用 compute(),这张配方才按下面的顺序展开:

text 复制代码
1. parent.compute()
   创建 List 的原始迭代器,但不读取元素

2. iteratorTransform.apply(parentIterator)
   创建 MappingIterator,把原始迭代器包在里面,但仍不读取元素

3. compute() 返回最外层 MappingIterator

4. collect() 进入 while 循环
   调用最外层 iterator.hasNext()
   调用最外层 iterator.next()

5. MappingIterator.next()
   调用 parent.next() 取一个原始元素
   调用 elementFunction.apply(...) 变换这一个元素

第 1 到第 3 步是在构建迭代器链 ,第 4、5 步才是在让数据流过迭代器链compute() 返回的是"准备好以后逐个计算"的迭代器,不是已经计算完成的整批数据。

!INFO 为什么叫 MapPartitionsRDD?我们明明还没有 Partition

这个类接收的变换函数是 Iterator<T> -> Iterator<U>,作用对象是整条数据流,而不是单个元素。数据流将来拆成多个分区时,这条流就对应其中一个分区,变换也就成了"对一个分区的数据流做变换"。名字里的 Partitions 提前预留的正是这层含义,所以现在虽然只有一条数据流,名字也说得通。

3.3 Transformation 只记账,Action 才执行

现在的 map() 只会构造新 RDD。要看到结果,还需要一个真正消费迭代器的collect()方法。这就是我们的第一个 action(行动算子)collect()

在当前这条只包含两层 map 的流水线里,iterator.next() 是元素变换真正发生的地方。如果 iterator 是最外层的 MappingIterator(*2),它的 next() 会继续调用父迭代器的 next();父迭代器如果又是 MappingIterator(+1),它还会继续向更里面取数据。请求就这样一层层向内传,直到最底层的 List 迭代器返回原始元素。

与它相对,map()filter()flatMap() 都是 transformation(转换算子)

两者的区别不是名字,而是会不会消费数据:

类型 本章中的方法 调用时发生什么
Transformation mapfilterflatMap 构造一个新 RDD,记录父 RDD 和变换函数
Action collect 调用 compute() 拿到最外层迭代器,再用 while 循环反复调用 hasNext()next()

可以用带打印的函数验证:

java 复制代码
RDD<Integer> plusOne = new ListRDD<>(Arrays.asList(1, 2, 3))
        .map(number -> {
            System.out.println("map(+1): " + number);
            return number + 1;
        });

System.out.println("map 已构造");
List<Integer> result = plusOne.collect();

输出顺序是:

text 复制代码
map 已构造
map(+1): 1
map(+1): 2
map(+1): 3

map 已构造 先出现,证明 map() 本身没有执行函数。直到 collect() 里的 iterator.next() 开始向最外层迭代器取元素,三个数字才依次流过 map

这就是惰性求值的具体含义:

Transformation 只扩展计算配方;Action 才按配方创建最外层迭代器,并通过 hasNext() / next() 消费数据流。
!WARNING collect() 会把结果全部放进内存

collect() 把所有元素收集进一个 List 再返回。数据量大时,这会占满 Driver 内存,所以它只适合结果量确定有限的场景。

3.4 元素逐个穿透整条流水线

单层 map 还看不出流水线的特别之处。现在连续写两层:

java 复制代码
RDD<Integer> pipeline = new ListRDD<>(Arrays.asList(1, 2, 3))
        .map(number -> {
            System.out.println("[+1] " + number);
            return number + 1;
        })
        .map(number -> {
            System.out.println("[*2] " + number);
            return number * 2;
        });

List<Integer> result = pipeline.collect();

如果每一层 map 都先生成完整的中间 List,打印顺序应该是:

text 复制代码
[+1] 1
[+1] 2
[+1] 3
[*2] 2
[*2] 3
[*2] 4

但实际输出是:

text 复制代码
[+1] 1
[*2] 2
[+1] 2
[*2] 3
[+1] 3
[*2] 4

第一个元素先完成 +1,紧接着完成 *2,变成最终结果 4;然后程序才去读取第二个原始元素。

为什么会这样?先看 collect() 调用 compute() 时构造出的嵌套关系:

flowchart RL L[&#34;ListRDD<br/>原始 Iterator&#34;] --> M1[&#34;MappingIterator<br/>+1&#34;] M1 --> M2[&#34;MappingIterator<br/>*2&#34;] M2 --> C[&#34;collect()&#34;]

collect() 消费的是最外层 MappingIterator(*2)。当它调用一次 next(),请求会由外向内传递,数据则由内向外返回:

sequenceDiagram participant C as collect participant M2 as MappingIterator(*2) participant M1 as MappingIterator(+1) participant L as List Iterator C->>M2: next() M2->>M1: next() M1->>L: next() L-->>M1: 1 Note over M1: 1 + 1 = 2 M1-->>M2: 2 Note over M2: 2 * 2 = 4 M2-->>C: 4

把这次调用展开,就是:

java 复制代码
// 概念上的展开,不是实际源码
outerFunction.apply(
    innerFunction.apply(
        listIterator.next()
    )
);

这里有两个方向,很容易混淆:

  • 请求方向collect 从外向内调用 next()
  • 数据方向 :原始元素从内向外经过每层函数,最终回到 collect

我们没有在 collect() 中反向遍历 RDD 链,也没有先生成一个"正向执行计划"。collect() 只负责消费最外层迭代器。流水线之所以形成,是因为每层迭代器都知道向自己的父迭代器取数据。

这也解释了惰性流水线的两个直接好处:

  1. 不物化中间结果。 +1 后的完整列表不会被保存。
  2. 逐元素处理。 一个元素可以连续经过多层窄变换,然后才轮到下一个元素。

注意,我们记录的是"对这条数据流统一应用什么变换",而不是为每条记录单独保存一份修改历史。这种面向整批数据、以统一算子描述变换的方式叫作粗粒度变换(coarse-grained transformation)。先记住这个词。

3.5 同一个骨架,承载 filter 和 flatMap

map 是一对一变换:父迭代器每提供一个元素,MappingIterator 就返回一个元素。它的 hasNext() 可以直接委托给父迭代器------父迭代器有元素,结果就有元素。

filterflatMap 没这么简单。

filter 是一对零或一对一的变换。父迭代器当前有元素,不代表这个元素一定能通过过滤条件。如果直接把 hasNext() 委托给父迭代器,就会在 filter 应该返回 false(没有符合条件的元素)时,错误地返回 true(因为父迭代器确实还有元素)。

也就是说:父迭代器有数据 ≠ 过滤后的结果有数据

flatMap 的问题更复杂:一个父元素可能展开成零个、一个或多个子元素。hasNext() 不能只看父迭代器有没有元素,还要看当前正在展开的子迭代器有没有消耗完。

所以,mapfilterflatMap 虽然在 RDD 层面共用同一个 MapPartitionsRDD 骨架 ,但各自需要不同的迭代器包装器来处理这些差异。

filter:为什么必须暂存下一个元素

先看 filter

既然 hasNext() 不能直接委托给父迭代器,FilteringIterator.hasNext() 必须做一件事:主动向后扫描父迭代器,直到找到一个满足条件的元素,或者确认父迭代器已耗尽。

java 复制代码
public boolean hasNext() {
    if (hasBufferedElement) {
        return true;
    }

    while (parent.hasNext()) {
        T candidate = parent.next();
        if (predicate.test(candidate)) {
            nextElement = candidate;
            hasBufferedElement = true;
            return true;
        }
    }
    return false;
}

但这个写法引出了一个新问题:hasNext() 找到匹配元素后,怎么把它交给 next()

hasNext()next() 是两次独立的调用,中间可能隔着其他操作。如果 hasNext() 找到了一个匹配元素但无法保存,等到 next() 被调用时,父迭代器已经前进了,那个元素就永远丢失了。

因此,FilteringIterator 用两个字段来衔接两次调用:

java 复制代码
private T nextElement;          // 缓存已找到、等待交付的元素
private boolean hasBufferedElement;  // 标记缓存中是否有有效元素

hasNext() 负责"预扫描并缓存" :当它找到一个满足条件的元素时,把它存进 nextElement,同时把 hasBufferedElement 设为 true。然后返回 true,表示"有下一个结果"。

next() 负责"取出缓存并清空" :它不自己扫描,而是直接从缓存里取。取到后立刻把 hasBufferedElement 设回 false,清空 nextElement,为下一次查找做准备。

java 复制代码
public T next() {
    if (!hasBufferedElement && !hasNext()) {
        throw new NoSuchElementException();
    }

    T result = nextElement;
    nextElement = null;
    hasBufferedElement = false;
    return result;
}

这里有个容易被忽略的细节:如果调用者没有先执行 hasNext()、直接调用 next()next() 内部的 !hasBufferedElement && !hasNext() 会触发一次 hasNext() 调用,由它完成扫描和缓存,然后再从缓存取出。所以无论是"先问再取"还是"直接取",filter 都能正常工作。

因此,nextElementhasBufferedElement 这两个字段不是性能优化,而是 filter 迭代器的正确性基础------没有它们,预扫描到的元素就无法跨方法传递。

接入 RDD 仍然只有一层包装:

java 复制代码
public MapPartitionsRDD<T, T> filter(Predicate<T> predicate) {
    return new MapPartitionsRDD<>(
            this,
            iterator -> new FilteringIterator<>(iterator, predicate));
}

flatMap:在父迭代器与当前子迭代器之间切换

flatMap 会把一个父元素展开成多个子元素。例如,一行文本展开成多个单词:

java 复制代码
line -> Arrays.asList(line.split(" "))

FlatMappingIterator 保存一个 current,代表当前父元素展开后的子迭代器。当 current 耗尽时,再从 parent 取下一个元素:

java 复制代码
public boolean hasNext() {
    while (!current.hasNext() && parent.hasNext()) {
        current = elementFunction.apply(parent.next()).iterator();
    }
    return current.hasNext();
}

完整代码见 FlatMappingIterator.java

三个算子虽然内部不同,接入 RDD 的骨架完全一致:

text 复制代码
父 RDD
  -> parent.compute()
  -> 用特定 Iterator 包装
  -> 返回 MapPartitionsRDD

所以它们可以自由串联:

java 复制代码
List<Integer> result = new ListRDD<>(Arrays.asList(1, 2, 3, 4, 5, 6))
        .map(number -> number * 3)
        .filter(number -> number > 10)
        .collect();

// [12, 15, 18]

3.6 动手验证:演示与测试

本章提供两种验证方式。

第一种是运行 Main,直接观察惰性和流水线顺序:

bash 复制代码
mvn -q -pl ch03-mappartitions-pipeline package
java -Dfile.encoding=UTF-8 \
  -cp ch03-mappartitions-pipeline/target/classes \
  com.sparklearn.Main

Main.java 中有四组演示:

控制台部分 对应代码 验证内容
1. MappingIterator:边读取边变换 demonstrateMappingIterator() 构造完成时函数没执行;每次 next() 才触发一次 apply
2. MapPartitionsRDD:map 不会立刻计算 demonstrateLazyMap() map() 构造期间无日志;collect() 才依次打印三个元素
3. 流水线:元素逐个穿透两层 map demonstratePipeline() [+1][*2] 交替出现,证明元素逐个穿透而非逐层批处理
4. filter、flatMap 与链式组合 demonstrateFilterAndFlatMap() map(*3).filter(>10)[12, 15, 18]flatMap 按空格拆词得四个单词

第三组演示的输出就是 3.4 节给出的交替打印顺序:

text 复制代码
[+1] 1
[*2] 2
[+1] 2
[*2] 3
[+1] 3
[*2] 4

第二种是运行自动化测试:

bash 复制代码
mvn -pl ch03-mappartitions-pipeline test

这里不要加 -q。这些测试只使用断言,不主动打印内容,测试全部通过时终端看起来会是空的。不加 -q 时,成功后可以看到类似摘要:

text 复制代码
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

IteratorContractTest.java 覆盖了这些行为:

测试 验证内容
filteringIteratorKeepsElementAcrossRepeatedHasNextCalls 连续两次 hasNext() 不丢元素,next() 取出后才变 false
filteringIteratorSupportsDirectNextAndThrowsWhenExhausted 不先 hasNext() 直接 next() 也能取到;耗尽再 next()NoSuchElementException
filteringIteratorCanReturnNull 过滤条件命中 null 时能正确返回 null
flatMapSkipsEmptyChildIterators flatMap 跳过展开为空列表的父元素
flatMappingIteratorHandlesRepeatedChecksAndTrailingEmptyLists 多次 hasNext() 稳定,末尾空列表不报错
transformationsStayLazyUntilCollect 构造完三层算子后 map/filter/flatMap 调用计数都是 0;collect() 后才按预期次数执行

Main 适合观察惰性和流水线顺序,测试则自动检查迭代器契约和惰性边界。两者验证的是同一套实现。

3.7 本章小结

这一章,我们让 RDD 从"能够提供数据"扩展成"能够描述一串数据变换"。

本章建立的核心事实:

text 复制代码
transformation 用 Iterator 套 Iterator 描述变换,不立即消费数据
action 才从最外层迭代器拉动数据
元素逐个穿透整条流水线,中间不物化

核心概念有五个:

  1. MappingIterator :在 next() 中应用函数,实现边读边变换。
  2. MapPartitionsRDD :保存父 RDD 和 Iterator -> Iterator 的分区级变换。
  3. FilteringIteratorFlatMappingIterator:分别实现过滤和一对多展开。
  4. mapfilterflatMap:构造新 RDD 的 transformations,不立即消费数据。
  5. collect():第一个 action,从最外层迭代器持续拉取结果。

到这里,惰性求值已经不再是一个抽象名词。它就是一条清晰的调用链:外层取数据,内层逐级提供;原始元素返回时,依次经过每一层变换。

相关推荐
cc复CC1 小时前
从零开始手写 Spark 02|数据流与延迟迭代
大数据·分布式
七夜zippoe3 小时前
基于 DolphinDB 构建分布式集群:从节点配置到高可用部署的完整实践
分布式·集群·dolphindb·节点配置·高可用部署
Acrellea3 小时前
并网验收难破局:分布式光伏如何攻克孤岛、逆流两大技术关卡
分布式
杨运交13 小时前
[060][调度模块]Redisson vs Redis 原生锁:两种分布式锁实现深度对比
数据库·redis·分布式
heimeiyingwang18 小时前
【架构实战】分布式事务:从CAP定理到Seata实战,一文讲透跨服务数据一致性
分布式·架构
lakernote1 天前
图解 Kafka Consumer 常用 API:poll、seek、pause、wakeup 到底在控制什么?
分布式·kafka·linq
数据库小学妹1 天前
集群与分布式啥区别?从主从集群到分布式实战
分布式·分布式数据库·数据库架构·集群·分库分表·主从集群·集群与分布式
江畔柳前堤1 天前
AgentScope 设计与原理全解:从消息原语到分布式智能体工程底座
大数据·人工智能·分布式·目标检测·机器学习·语言模型·架构
萧瑟余晖1 天前
Java深入解析篇三十四之分布式事务
java·开发语言·分布式