【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采用基于信用的流量控制机制来处理网络传输中的背压问题。当下游处理速度跟不上上游生产速度时,下游会通知上游减少发送数据的速率,以避免数据在内存中堆积过多。
相关推荐
华农DrLai10 小时前
Spark SQL Catalyst 优化器详解
大数据·hive·sql·flink·spark
岁岁种桃花儿11 小时前
Flink从入门到上天系列第一篇:搭建第一个Flink程序
大数据·linux·flink·数据同步
Hello.Reader19 小时前
Flink ZooKeeper HA 实战原理、必配项、Kerberos、安全与稳定性调优
安全·zookeeper·flink
Hello.Reader1 天前
Flink 使用 Amazon S3 读写、Checkpoint、插件选择与性能优化
大数据·flink
Hello.Reader1 天前
Flink 对接 Google Cloud Storage(GCS)读写、Checkpoint、插件安装与生产配置指南
大数据·flink
Hello.Reader1 天前
Flink Kubernetes HA(高可用)实战原理、前置条件、配置项与数据保留机制
贪心算法·flink·kubernetes
wending-Y1 天前
记录一次排查Flink一直重启的问题
大数据·flink
Hello.Reader1 天前
Flink 对接 Azure Blob Storage / ADLS Gen2:wasb:// 与 abfs://(读写、Checkpoint、插件与认证)
flink·flask·azure
Hello.Reader1 天前
Flink 文件系统通用配置默认文件系统与连接数限制实战
vue.js·flink·npm