【6】示例逐行解析 Flink 的运行过程(二)

代码

紧接着上一节,我们继续看代码:

java 复制代码
package com.wanli;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.streaming.api.functions.source.SourceFunction;
import org.apache.flink.util.Collector;

import java.util.Random;

@Slf4j
public class SimpleTestMain {
  public static void main(String[] args) throws Exception {
    StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
    env.setParallelism(1); // 设置全局并行度为1,这样确保只有一个线程在运行
    // env.disableOperatorChaining(); // 暂时先不禁用算子链

    DataStreamSource<TestEvent> dataStreamSource = env.addSource(new TestSource());
    dataStreamSource
        .process(new MyTestFuntion1())
        .process(new MyTestFuntion2())
        .print();

    System.out.println("ThreadId=" + Thread.currentThread().getId());
    env.execute("Test Job");
  }

  // 自定义第一个 Function
  public static class MyTestFuntion1 extends ProcessFunction<TestEvent, TestEvent> {
    public MyTestFuntion1() {
      System.out.println("ObjectHash=" + this.hashCode());
    }

    @Override
    public void processElement(TestEvent value, ProcessFunction<TestEvent, TestEvent>.Context ctx,
        Collector<TestEvent> out) throws Exception {
      value.setName(value.getName() + "-T" + Thread.currentThread().getId() + "-O" + this.hashCode());
      out.collect(value);
    }
  }


  // 自定义第二个 Function
  public static class MyTestFuntion2 extends ProcessFunction<TestEvent, TestEvent> {
    public MyTestFuntion2() {
      System.out.println("ObjectHash=" + this.hashCode());
    }

    @Override
    public void processElement(TestEvent value, ProcessFunction<TestEvent, TestEvent>.Context ctx,
        Collector<TestEvent> out) throws Exception {
      value.setName(value.getName() + "-T" + Thread.currentThread().getId() + "-O" + this.hashCode());
      out.collect(value);
    }
  }


  // 定义事件
  @Data
  public static final class TestEvent {
    private final long time = System.currentTimeMillis();
    private String name;
    private int value;

    @Override
    public String toString() {
      return "name=" + name + ", value=" + value + ", time=" + time;
    }
  }


  // 自定义数据源
  public static final class TestSource implements SourceFunction<TestEvent> {
    private static final String[] NAMES = {"Zhangsan", "Lisi", "Wangwu", "Liuliu"};
    private static final Random RANDOM = new Random(System.currentTimeMillis());
    private boolean closed = false;

    @Override
    public void run(SourceContext<TestEvent> ctx) throws Exception {
      while (!closed) {
        Thread.sleep(1000L);
        TestEvent event = new TestEvent();
        event.setName(NAMES[RANDOM.nextInt(NAMES.length)]);
        event.setValue(RANDOM.nextInt(1000));
        ctx.collect(event);
      }
      log.info("source closed.");
    }

    @Override
    public void cancel() {
      closed = true;
    }
  }
}

调试 & 讲解

单个线程中不同的 Function 是怎么串起来的?

上述代码将并行度设置为 1 ,并且启用了算子合并,这样 Function1 和 Function2 的运行就可以在同一个线程中了。

将断点打在 MyTestFunction2 第 54 行,并启动 debug:

看红色圈出来的部分,第 54 行代码为:

less 复制代码
value.setName(value.getName() + "-T" + Thread.currentThread().getId() + "-O" + this.hashCode());

对应的下面第 40 行代码为:

csharp 复制代码
out.collect(value);

可以看到,MyTestFuntion1out.collect() 调用,会直接在同一个线程里面调用到下一个 Function 的 processElement 方法,这也是 Flink 算子之间的调用方式。

在两个 Function 之间,存在着两种对象的调用:

  • xxxCollector 或者 xxxOutput:Collector 类的实例化对象,中间需要经过多层处理,比如消息计数、处理 watermark 等,主要用于数据传输,当传输完以后,调用 pushToOperator 方法来调用下一个算子;
  • ProcessOperator:即算子类,算子将 Function 包装以后,调用实际的 Function 来进行处理数据。

ProcessOperator 中调用 Function 的代码如下:

pushToOperator 方法就是调用了 ProcessOperatorprocessElement 方法进行的下一步数据处理。

这里有个细节需要注意,在调用 pushToOperator 的时候,将 Record 复制了一遍,所以在 Function1 中 collect 的 value 和 Function2 中接收到的不是同一个对象。

不同线程中不同的 Function 是怎么串起来的?

将代码第 18 行的注释去掉,禁用算子链,这样不同的算子就是在不同线程里面运行的,将断点打在第 54 行,开启调试。

可以看到,与上面算子合并的相比,这里的调用入口不再是前面一个 Function 的 collect 调用,而是一个新的线程,其中的 doRun 等方法在前面的文章中已经讲到过,就是运行 Task 的方法。

除了中间的 mailBox 相关的以外,其他的在前面都比较熟悉了,mailBox 涉及到数据是从哪里以什么方式传输过来的。

我们看 org.apache.flink.streaming.runtime.io.AbstractStreamTaskNetworkInput#emitNext 方法:

方法中 emitNext 即为处理下一步数据的方法,在方法中,首先读取一个标识符,即为 result,result 是用来标识当前读取的对象是什么类型,比如对象的部分数据、缓冲区结尾等,一旦该记录是一个全量类型的记录(result.isFullRecord()),则表示对象读取完成,可以进行下一步的处理。

调用 processElement() 方法来进行下一步的处理,后面的处理过程与前面所述的就一致了。

那怎么读取记录的呢?我们看这一行:

java 复制代码
result = currentRecordDeserializer.getNextRecord(deserializationDelegate);

顺着代码调用下钻到 org.apache.flink.runtime.io.network.api.serialization.SpillingAdaptiveSpanningRecordDeserializer#readNextRecord 中,然后调用到 readNonSpanningRecord 方法:

这里可以看到,从直接内存 buffer 中读取字节,并且读取到 target 里面,读取和反序列化代码参考:org.apache.flink.runtime.plugable.ReusingDeserializationDelegate#read

注意:Java.nio.DirectByteBuffer 是堆外内存,从堆外读取数据然后直接反序列化,避免了 Java 的 GC 带来的额外开销等,提升了效率。

而在 runMailboxLoop 中,主要就是接收数据并处理。

那数据是谁写到内存的呢?

将断点打在第 40 行,即 Function1 的 out.collect(value) 这一行。执行下钻,最终会下钻到 org.apache.flink.streaming.runtime.io.RecordWriterOutput#pushToRecordWriter 方法,并在该方法内通过 NIO 的形式写到内存 buffer。

总结一下:out.collect(value) 将数据写到内存 buffer,在另外一个线程里面运行的算子,从堆外内存直接读取并反序列化数据,然后再做处理。

分布式情况下数据是怎么传输的?

这里无法直接调试看到,简单介绍下(实际上就是加了网络传输步骤)。

1. 数据生产(collect方法)

  • 数据生成 :在Flink中,上游的Task(可能是一个算子或算子链)在处理完数据后,会通过Collector接口的collect方法将结果数据收集起来。
  • 序列化:在将数据发送到网络之前,通常需要对其进行序列化,以减少网络传输的数据量并提高传输效率。

2. 数据写入ResultPartition

  • ResultPartition:序列化后的数据会被写入到ResultPartition中。ResultPartition是Flink中用于存储和传输数据分区的组件。
  • ResultSubPartition:ResultPartition可能包含多个ResultSubPartition,每个ResultSubPartition对应下游Task的一个并行实例。

3. 通知JobManager

  • 就绪通知:当ResultPartition中的数据准备好可供消费时,它会通知JobManager。JobManager是Flink集群中的master节点,负责任务调度、异常恢复和任务协调等。

4. 数据请求与传输

  • 任务调度:JobManager会检查哪些下游Task需要这些数据,并触发这些Task的调度(如果它们尚未被调度)。

  • 数据请求:下游Task(可能位于不同的TaskManager上)会向ResultPartition发起数据请求。

  • 网络传输

    • Netty框架:Flink使用Netty作为网络通信框架,通过Netty来建立TaskManager之间的TCP连接。
    • 数据传输:数据通过TCP连接从上游TaskManager的ResultPartition传输到下游TaskManager的InputGate。
    • 缓冲与批处理:为了提高传输效率,数据在传输过程中可能会经过缓冲和批处理。

5. 数据接收与处理

  • InputGate:下游TaskManager的InputGate负责接收来自上游的数据。
  • InputChannel:InputGate可能包含多个InputChannel,每个InputChannel对应上游的一个ResultSubPartition。
  • 反序列化:接收到的数据会经过反序列化,恢复成原始的数据格式。
  • processElement :反序列化后的数据会被传递给下游Task的processElement方法进行处理。

6. 流量控制与反压

  • Credit-based Flow Control:Flink采用基于信用的流量控制机制来处理网络传输中的背压问题。当下游处理速度跟不上上游生产速度时,下游会通知上游减少发送数据的速率,以避免数据在内存中堆积过多。
相关推荐
JermeryBesian1 天前
Flink系列知识之:Checkpoint原理
大数据·flink
全栈弟弟1 天前
高级大数据开发协会
大数据·数据仓库·hadoop·flink·spark
武子康1 天前
大数据-134 - ClickHouse 集群三节点 安装配置启动
java·大数据·分布式·clickhouse·架构·flink
wumingxiaoyao3 天前
Big Data 流处理框架 Flink
大数据·flink·big data·流处理框架·实时数据处理
武子康3 天前
大数据-132 - Flink SQL 基本介绍 与 HelloWorld案例
java·大数据·数据库·sql·flink·spark·scala
Lansonli4 天前
大数据Flink(一百一十七):Flink SQL的窗口操作
大数据·flink
馍 馍4 天前
【Flink& Flick CDC】学习笔记
笔记·学习·flink
Qyt-Coding4 天前
Flink学习2
大数据·flink
颹蕭蕭4 天前
pyflink 安装和测试
flink·pyflink
Parallel23334 天前
Flink+Spark相关记录
大数据·flink·spark