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

68 阅读15分钟

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

ListRDD<String> rdd = new ListRDD<>(words);
Iterator<String> iterator = rdd.compute();

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

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

rdd.map(...)
   .filter(...)
   .flatMap(...);

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

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

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

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

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

Iterator<Integer> parent = Arrays.asList(1, 2, 3).iterator();

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

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

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:

Iterator<Integer> mapped =
        new MappingIterator<>(parent, number -> number + 10);

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

mapped.next()
    -> parent.next()
    -> elementFunction.apply(父元素)
    -> 返回变换后的元素

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

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

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

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

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

RDD<Integer> plusOne = rdd.map(number -> number + 1);

这里有两个要求:

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

为此,我们引入 MapPartitionsRDD:

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:

Function<Iterator<T>, Iterator<U>>

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

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

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

return iteratorTransform.apply(parentIterator);

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

return new MappingIterator<>(parentIterator, elementFunction);

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

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

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

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

iterator -> new MappingIterator<>(iterator, elementFunction)

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

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

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,因为连最底层的原始迭代器都还没创建,数据流尚未启动。

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

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

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

/**
 * 第一个 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(),这张配方才按下面的顺序展开:

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() 返回的是"准备好以后逐个计算"的迭代器,不是已经计算完成的整批数据。

为什么叫 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(转换算子)。

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

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

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

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();

输出顺序是:

map 已构造
map(+1): 1
map(+1): 2
map(+1): 3

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

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

Transformation 只扩展计算配方;Action 才按配方创建最外层迭代器,并通过 hasNext() / next() 消费数据流。

collect() 会把结果全部放进内存

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

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

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

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,打印顺序应该是:

[+1] 1
[+1] 2
[+1] 3
[*2] 2
[*2] 3
[*2] 4

但实际输出是:

[+1] 1
[*2] 2
[+1] 2
[*2] 3
[+1] 3
[*2] 4

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

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

flowchart LR
    L[ListRDD原始Iterator] --> M1[MappingIterator+1]
    M1 --> M2[MappingIterator*2]
    M2 --> C[collect&#40&#41]

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

把这次调用展开,就是:

// 概念上的展开,不是实际源码
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() 可以直接委托给父迭代器——父迭代器有元素,结果就有元素。

但 filter 和 flatMap 没这么简单。

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

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

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

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

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

先看 filter。

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

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 用两个字段来衔接两次调用:

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

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

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

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 都能正常工作。

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

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

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

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

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

line -> Arrays.asList(line.split(" "))

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

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

完整代码见 FlatMappingIterator.java。

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

父 RDD
  -> parent.compute()
  -> 用特定 Iterator 包装
  -> 返回 MapPartitionsRDD

所以它们可以自由串联:

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,直接观察惰性和流水线顺序:

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. 流水线:元素逐个穿透两层 mapdemonstratePipeline()[+1] 与 [*2] 交替出现,证明元素逐个穿透而非逐层批处理
4. filter、flatMap 与链式组合demonstrateFilterAndFlatMap()map(*3).filter(>10) 得 [12, 15, 18];flatMap 按空格拆词得四个单词

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

[+1] 1
[*2] 2
[+1] 2
[*2] 3
[+1] 3
[*2] 4

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

mvn -pl ch03-mappartitions-pipeline test

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

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
flatMapSkipsEmptyChildIteratorsflatMap 跳过展开为空列表的父元素
flatMappingIteratorHandlesRepeatedChecksAndTrailingEmptyLists多次 hasNext() 稳定,末尾空列表不报错
transformationsStayLazyUntilCollect构造完三层算子后 map/filter/flatMap 调用计数都是 0;collect() 后才按预期次数执行

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

3.7 本章小结

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

本章建立的核心事实:

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

核心概念有五个:

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

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