代码
紧接着上一节,我们继续看代码:
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);
可以看到,MyTestFuntion1
的 out.collect()
调用,会直接在同一个线程里面调用到下一个 Function 的 processElement
方法,这也是 Flink 算子之间的调用方式。
在两个 Function 之间,存在着两种对象的调用:
- xxxCollector 或者 xxxOutput:Collector 类的实例化对象,中间需要经过多层处理,比如消息计数、处理 watermark 等,主要用于数据传输,当传输完以后,调用 pushToOperator 方法来调用下一个算子;
- ProcessOperator:即算子类,算子将 Function 包装以后,调用实际的 Function 来进行处理数据。
在 ProcessOperator
中调用 Function 的代码如下:
而 pushToOperator
方法就是调用了 ProcessOperator
的 processElement
方法进行的下一步数据处理。
这里有个细节需要注意,在调用 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采用基于信用的流量控制机制来处理网络传输中的背压问题。当下游处理速度跟不上上游生产速度时,下游会通知上游减少发送数据的速率,以避免数据在内存中堆积过多。