目录
触发器(Trigger)
触发器主要是用来控制窗口什么时候触发计算。所谓的“触发计算”,本质上就是执行窗口函数,所以可以认为是计算得到结果并输出的过程。
基于 WindowedStream 调用trigger()
方法,就可以传入一个自定义的窗口触发器(Trigger)。
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<String> dataStream = env.socketTextStream("localhost", 9999);
dataStream.map(new MapFunction<String, Tuple2<String, Integer>>() {
@Override
public Tuple2<String, Integer> map(String value) {
return new Tuple2<>(value, 1);
}
})
.keyBy(value -> value.f0)
.window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
.trigger(new MyTrigger())
.sum(1)
.print();
env.execute("Flink Trigger Example");
}
public static class MyTrigger extends Trigger<Tuple2<String, Integer>, TimeWindow> {
@Override
public TriggerResult onElement(Tuple2<String, Integer> stringIntegerTuple2, long l, TimeWindow timeWindow, TriggerContext triggerContext) throws Exception {
return TriggerResult.FIRE_AND_PURGE;
}
@Override
public TriggerResult onProcessingTime(long time, TimeWindow window, TriggerContext ctx) {
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) {
return TriggerResult.CONTINUE;
}
@Override
public void clear(TimeWindow window, TriggerContext ctx) {
}
}
这段代码主要从localhost的9999端口读取数据流,每条数据映射为一个包含该数据和整数1的元组。然后按照元组的第一个元素进行分组,并在每5秒的滚动窗口中对元组的第二个元素求和。最后使用用户自定义触发器,当新元素到达时立即触发计算并清空窗口,但在处理时间或事件时间上不做任何操作。
Trigger 是窗口算子的内部属性,每个窗口分配器(WindowAssigner)都会对应一个默认的触发器。
对于 Flink 内置的窗口类型,它们的触发器都已经做了实现。例如,所有事件时间窗口,默认的触发器都是EventTimeTrigger,类似还有 ProcessingTimeTrigger 和 CountTrigger。所以一般情况下是不需要自定义触发器的,这块了解一下即可。
移除器(Evictor)
移除器(Evictor)是用于在滚动窗口或会话窗口中控制数据保留和清理的组件。它可以根据特定的策略从窗口中删除一些数据,以确保窗口中保留的数据量不超过指定的限制。
移除器通常与窗口分配器一起使用,窗口分配器负责确定数据属于哪个窗口,而移除器则负责清理窗口中的数据。
以下是一个使用移除器的代码示例,演示如何在滚动窗口中使用基于计数的移除器:
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 创建一个包含整数和时间戳的流
DataStream<Tuple2<Integer, Long>> dataStream = env.fromElements(
Tuple2.of(1, System.currentTimeMillis()),
Tuple2.of(2, System.currentTimeMillis() + 1000),
Tuple2.of(3, System.currentTimeMillis() + 2000),
Tuple2.of(4, System.currentTimeMillis() + 3000),
Tuple2.of(5, System.currentTimeMillis() + 4000),
Tuple2.of(6, System.currentTimeMillis() + 5000)
);
// 添加以下代码设置水印和事件时间戳
dataStream = dataStream.assignTimestampsAndWatermarks(WatermarkStrategy.<Tuple2<Integer, Long>>forBoundedOutOfOrderness(Duration.ofSeconds(1))
.withTimestampAssigner((event, timestamp) -> event.f1));
// 在滚动窗口中使用基于计数的移除器,保留最近3个元素
dataStream
.keyBy(value -> value.f0)
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.trigger(CountTrigger.of(3))
.evictor(CountEvictor.of(3))
.aggregate(new MyAggregateFunction(), new MyProcessWindowFunction())
.print();
env.execute("Flink Evictor Example");
}
// 自定义聚合函数
private static class MyAggregateFunction implements AggregateFunction<Tuple2<Integer, Long>, Integer, Integer> {
@Override
public Integer createAccumulator() {
return 0;
}
@Override
public Integer add(Tuple2<Integer, Long> value, Integer accumulator) {
return accumulator + 1;
}
@Override
public Integer getResult(Integer accumulator) {
return accumulator;
}
@Override
public Integer merge(Integer a, Integer b) {
return a + b;
}
}
// 自定义处理窗口函数
private static class MyProcessWindowFunction extends ProcessWindowFunction<Integer, String, Integer, TimeWindow> {
private transient ListState<Integer> countState;
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
ListStateDescriptor<Integer> descriptor = new ListStateDescriptor<>("countState", Integer.class);
countState = getRuntimeContext().getListState(descriptor);
}
@Override
public void process(Integer key, Context context, Iterable<Integer> elements, Collector<String> out) throws Exception {
int count = elements.iterator().next();
countState.add(count);
long windowStart = context.window().getStart();
long windowEnd = context.window().getEnd();
String result = "Window: " + windowStart + " to " + windowEnd + ", Count: " + countState.get().iterator().next();
out.collect(result);
}
}
这段代码主要用于对一串包含整数和时间戳的元素进行处理。首先,它创建了一个流并赋予了水印和时间戳。然后在滚动窗口中使用基于计数的触发器和驱逐器,只保留最近的三个元素。之后,通过自定义聚合和窗口函数,来处理窗口内的数据,聚合函数计算每个窗口内元素的数量,窗口函数将结果与窗口的开始和结束时间一起输出。
Flink Time 时间语义
Flink定义了三类时间
-
事件时间(Event Time):数据在数据源产生的时间,一般由事件中的时间戳描述,比如用户日志中的TimeStamp。
-
摄取时间(Ingestion Time):数据进入Flink的时间,记录被Source节点观察到的系统时间。
-
处理时间(Process Time):数据进入Flink被处理的系统时间(Operator处理数据的系统时间)。
Flink 流式计算的时候需要显示定义时间语义,根据不同的时间语义来处理数据,比如指定的时间语义是事件时间,那么我们就要切换到事件时间的世界观中,窗口的起始与终止时间都是以事件时间为依据。
在Flink中默认使用的是Process Time,如果要使用其他的时间语义,在执行环境中可以进行设置。
//设置时间语义为Ingestion Time
env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime);
//设置时间语义为Event Time 我们还需要指定一下数据中哪个字段是事件时间(下文会讲)
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
Watermark(水印)
Watermark的本质实质上是时间戳,简单而言,它是用来处理迟到数据的
在使用Flink处理数据的时候,数据通常都是按照事件产生的时间(事件时间)的顺序进入到Flink,但是在遇到特殊情况下,比如遇到网络延迟或者使用Kafka(多分区) 很难保证数据都是按照事件时间的顺序进入Flink,很有可能是乱序进入。
如果数据一旦是乱序进入,那么在使用Window处理数据的时候,就会出现延迟数据不会被计算的问题。
-
举例: 滚动窗口长度10s。
2020-04-25 10:00:01
2020-04-25 10:00:02
2020-04-25 10:00:03
2020-04-25 10:00:11 窗口触发执行
2020-04-25 10:00:05 延迟数据,不会被上个窗口所计算,导致计算结果不正确
如果有延迟数据,那么窗口需要等待全部的数据到来之后,再触发窗口执行。
需要等待多久?不可能无限期等待,我们用户可以自己来设置延迟时间,这样就可以尽可能保证延迟数据被处理。
使用Watermark就可以很好的解决延迟数据的问题。
根据用户指定的延迟时间生成水印(Watermak = 最大事件时间-指定延迟时间),当 Watermak 大于等于窗口的停止时间,这个窗口就会被触发执行。
-
举例:滚动窗口长度10s,指定延迟时间3s
2020-04-25 10:00:01 wm:2020-04-25 09:59:58
2020-04-25 10:00:02 wm:2020-04-25 09:59:59
2020-04-25 10:00:03 wm:2020-04-25 10:00:00
2020-04-25 10:00:09 wm:2020-04-25 10:00:06
2020-04-25 10:00:12 wm:2020-04-25 10:00:09
2020-04-25 10:00:08 wm:2020-04-25 10:00:05 延迟数据
2020-04-25 10:00:13 wm:2020-04-25 10:00:10
如果没有 Watermark ,那么在倒数第三条数据来的时候,就会触发执行,倒数第二条的延迟数据就不会被计算,有了水印之后就可以处理延迟3s内的数据
生成水印策略
-
周期性水印(Periodic Watermark):根据事件或者处理时间周期性的触发水印生成器(Assigner),默认100ms,每隔100毫秒自动向流里注入一个Watermark。
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime); env.getConfig().setAutoWatermarkInterval(100); DataStream<String> stream = env.socketTextStream("node01", 8888) .assignTimestampsAndWatermarks(WatermarkStrategy .<String>forBoundedOutOfOrderness(Duration.ofSeconds(3)) .withTimestampAssigner((event, timestamp) -> { return Long.parseLong(event.split(" ")[0]); }));
-
间歇性水印:间歇性水印(Punctuated Watermark)在观察到事件后,会依据用户指定的条件来决定是否发射水印。
public class PunctuatedAssigner implements AssignerWithPunctuatedWatermarks<Tuple2<String, Long>> { @Override public long extractTimestamp(Tuple2<String, Long> element, long previousElementTimestamp) { return element.f1; } @Override public Watermark checkAndGetNextWatermark(Tuple2<String, Long> lastElement, long extractedTimestamp) { return lastElement.f0.equals("watermark") ? new Watermark(extractedTimestamp) : null; } public static void main(String[] args) throws Exception { final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.addSource(new SourceFunction<Tuple2<String, Long>>() { private boolean running = true; @Overrid public void run(SourceContext<Tuple2<String, Long>> ctx) throws Exception { while (running) { long currentTimestamp = System.currentTimeMillis(); ctx.collect(new Tuple2<>("key", currentTimestamp)); if (currentTimestamp % 10 == 0) { // 每隔一段时间发出一个含有"watermark"的特殊事件 ctx.collect(new Tuple2<>("watermark", currentTimestamp)); } Thread.sleep(1000); } } @Override public void cancel() { running = false; } }).assignTimestampsAndWatermarks(new PunctuatedAssigner()) .print(); env.execute("Punctuated Watermark Example"); } }
这段代码定义了一个名为PunctuatedAssigner的时间戳和watermark分配器类,用于从接收到的元素中提取出时间戳,并根据特定条件(在本例中,元素的key是否为"watermark")生成并发送watermark。
在main方法中,创建了一个源函数,此函数每秒生成一个新的事件,并且每隔10毫秒就发出一个包含"watermark"的特殊事件。这些事件被收集,分配时间戳和watermark,然后打印出来。
允许延迟(Allowed Lateness)
Flink 还提供了另外一种方式处理迟到数据。我们可以将未收入窗口的迟到数据,放入“侧输出流”(side output)进行另外的处理。所谓的侧输出流,相当于是数据流的一个“分支”,这个流中单独放置那些错过了、本该被丢弃的数据。
此方法需要传入一个“输出标签”(OutputTag),用来标记分支的迟到数据流。因为保存的就是流中的原始数据,所以 OutputTag 的类型与流中数据类型相同:
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 定义 OutputTag 来标识侧输出流
final OutputTag<String> lateDataTag = new OutputTag<String>("late-data"){};
DataStream<String> dataStream = env.socketTextStream("localhost", 9000);
SingleOutputStreamOperator<Tuple2<String, Integer>> resultStream = dataStream
.map(new MapFunction<String, Tuple2<String, Integer>>() {
@Override
public Tuple2<String, Integer> map(String value) throws Exception {
return new Tuple2<>(value, 1);
}
})
.keyBy(value -> value.f0)
.process(new ProcessFunction<Tuple2<String, Integer>, Tuple2<String, Integer>>() {
@Override
public void processElement(Tuple2<String, Integer> value,
Context ctx,
Collector<Tuple2<String, Integer>> out) throws Exception {
if (value.f1 == 1) {
out.collect(value);
} else {
// 将迟到的数据发送到侧输出流
ctx.output(lateDataTag, "Late data detected: " + value);
}
}
});
// 获取侧输出流
DataStream<String> lateDataStream = resultStream.getSideOutput(lateDataTag);
resultStream.print();
lateDataStream.print();
env.execute("SideOutput Example");
}
这段代码首先建立一个从本地 9000 端口读取数据的流,然后将每一行数据映射为一个二元组 (value, 1)。接着按照第一个字段进行分组,并进行处理:如果二元组的第二个元素等于 1,则直接输出;否则,该条数据会被视为“迟到数据”并输出至侧输出流。最后,主流和侧输出流的结果都会打印出来。
Flink关联维度表
在Flink实际开发过程中,可能会遇到 source 进来的数据,需要连接数据库里面的字段,再做后面的处理,比如,想要通过id获取对应的地区名字,这时候需要通过id查询地区维度表,获取具体的地区名。
对于不同的应用场景,关联维度表的方式不同
-
场景1:维度表信息基本不发生改变,或者发生改变的频率很低。
实现方案:采用Flink提供的CachedFile。
Flink提供了一个分布式缓存(CachedFile),可以使用户在并行函数中很方便的读取本地文件,并把它放在TaskManager节点中,防止Task重复拉取。
此缓存的工作机制如下:程序注册一个文件或者目录(本地或者远程文件系统,例如hdfs或者s3),通过ExecutionEnvironment注册缓存文件并为它起一个名称。
当程序执行,Flink自动将文件或者目录复制到所有TaskManager节点的本地文件系统,仅会执行一次。用户可以通过这个指定的名称查找文件或者目录,然后从TaskManager节点的本地文件系统访问它。
public static void main(String[] args) throws Exception { final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.registerCachedFile("/root/id2city", "id2city"); DataStream<String> socketStream = env.socketTextStream("node01", 8888); DataStream<Integer> stream = socketStream.map(Integer::valueOf); DataStream<String> result = stream.map(new RichMapFunction<Integer, String>() { private Map<Integer, String> id2CityMap; @Override public void open(Configuration parameters) throws Exception { super.open(parameters); id2CityMap = new HashMap<>(); BufferedReader reader = new BufferedReader(new FileReader(getRuntimeContext().getDistributedCache().getFile("id2city"))); String line; while ((line = reader.readLine()) != null) { String[] splits = line.split(" "); Integer id = Integer.parseInt(splits[0]); String city = splits[1]; id2CityMap.put(id, city); } reader.close(); } @Override public String map(Integer value) throws IOException { return id2CityMap.getOrDefault(value, "not found city"); } }); result.print(); env.execute(); }
这段程序首先从"node01"主机的8888端口读取数据,然后将其转换为整数流。接着,它用一个富映射函数(RichMapFunction)将每个整数ID映射到城市名。这个映射是从在"/root/id2city"路径下注册的缓存文件中读取的。如果无法找到某个ID对应的城市,就会返回"not found city"。
在集群中查看对应TaskManager的log日志,发现注册的file会被拉取到各个TaskManager的工作目录区。
-
场景2:对于维度表更新频率比较高并且对于查询维度表的实时性要求比较高。
实现方案:使用定时器,定时加载外部配置文件或者数据库
public static void main(String[] args) throws Exception { final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); DataStream<String> stream = env.socketTextStream("node01", 8888); stream.map(new RichMapFunction<String, String>() { private HashMap<String,String> map = new HashMap<>(); @Override public void open(Configuration parameters) throws Exception { System.out.println("init data ..."); query(); Timer timer = new Timer(true); timer.schedule(new TimerTask() { @Override public void run() { try { query(); } catch (IOException e) { e.printStackTrace(); } } },1000,2000); } void query() throws IOException { Path path = Paths.get("D:\\code\\StudyFlink\\data\\id2city"); Stream<String> lines = Files.lines(path); lines.forEach(line -> { String[] parts = line.split(" "); map.put(parts[0], parts[1]); }); lines.close(); } @Override public String map(String key) throws Exception { return map.getOrDefault(key, "not found city"); } }).print(); env.execute(); }
这段代码从名为"node01"的服务器的8888端口读取数据流,然后通过映射函数将每个接收到的数据键值(假设是城市ID)转换为对应的城市名称。此映射来自一个定期更新的文件"D:\code\StudyFlink\data\id2city",如果没有找到匹配的城市ID,则返回"not found city"。
-
场景3:对于维度表更新频率高并且对于查询维度表的实时性要求较高。
实现方案:将更改的信息同步至Kafka配置Topic中,然后将kafka的配置流信息变成广播流,广播到业务流的各个线程中。
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
Properties props = new Properties();
props.setProperty("bootstrap.servers", "node01:9092,node02:9092,node03:9092");
props.setProperty("group.id", "flink-kafka-001");
props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
FlinkKafkaConsumer<String> consumer = new FlinkKafkaConsumer<>(
"configure",
new SimpleStringSchema(),
props
);
consumer.setStartFromLatest();
DataStream<String> configureStream = env.addSource(consumer);
DataStream<String> busStream = env.socketTextStream("node01", 8888);
MapStateDescriptor<String, String> descriptor = new MapStateDescriptor<>(
"dynamicConfig",
BasicTypeInfo.STRING_TYPE_INFO,
BasicTypeInfo.STRING_TYPE_INFO
);
BroadcastStream<String> broadcastStream = configureStream.broadcast(descriptor);
busStream.connect(broadcastStream).process(
new BroadcastProcessFunction<String, String, String>() {
@Override
public void processElement(String line, ReadOnlyContext ctx, Collector<String> out) throws Exception {
String city = ctx.getBroadcastState(descriptor).get(line);
if (city == null) {
out.collect("not found city");
} else {
out.collect(city);
}
}
@Override
public void processBroadcastElement(String line, Context ctx, Collector<String> out) throws Exception {
String[] elems = line.split(" ");
ctx.getBroadcastState(descriptor).put(elems[0], elems[1]);
}
}
).print();
env.execute();
}
这段代码将从Kafka中获取的数据作为广播流,然后与从socket中获取的数据处理。在处理过程中,根据socket中的数据(作为key)查找广播状态中的城市名称(作为value),如果找到,则输出城市名,否则输出"not found city"。其中,Kafka中的数据以空格分隔,第一个元素作为key,第二个元素作为value存入BroadcastState。
Table API & Flink SQL
在Spark中有DataFrame这样的关系型编程接口,因其强大且灵活的表达能力,能够让用户通过非常丰富的接口对数据进行处理,有效降低了用户的使用成本。
Flink也提供了关系型编程接口Table API以及基于Table API的SQL API,让用户能够通过使用结构化编程接口高效地构建Flink应用。同时Table API以及SQL能够统一处理批量和实时计算业务,无须切换修改任何应用代码就能够基于同一套API编写流式应用和批量应用,从而达到真正意义的流批统一。
在 Flink 1.8 架构里,如果用户需要同时流计算、批处理的场景下,用户需要维护两套业务代码,开发人员也要维护两套技术栈,非常不方便。 Flink 社区很早就设想过将批数据看作一个有界流数据,将批处理看作流计算的一个特例,从而实现流批统一。
阿里巴巴的 Blink 团队在这方面做了大量的工作,已经实现了 Table API & SQL 层的流批统一。阿里巴巴已经将 Blink 开源回馈给 Flink 社区。
开发环境构建
在 Flink 1.9 中,Table 模块迎来了核心架构的升级,引入了阿里巴巴Blink团队贡献的诸多功能,取名叫: Blink Planner。
在使用Table API和SQL开发Flink应用之前,通过添加Maven的依赖配置到项目中,在本地工程中引入相应的依赖库,库中包含了Table API和SQL接口。
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-planner_2.12</artifactId>
<version>1.13.6</version>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-api-scala-bridge_2.12</artifactId>
<version>1.13.6</version>
</dependency>
Table Environment
和DataStream API一样,Table API和SQL具有相同的基本编程模型。首先需要构建对应的 TableEnviroment 创建关系型编程环境,才能够在程序中使用Table API和SQL来编写应用程序,另外Table API和SQL接口可以在应用中同时使用,Flink SQL基于Apache Calcite框架实现了SQL标准协议,是构建在Table API之上的更高级接口。
首先需要在环境中创建 TableEnvironment 对象,TableEnvironment 中提供了注册内部表、执行Flink SQL语句、注册自定义函数等功能。根据应用类型的不同,TableEnvironment 创建方式也有所不同,但是都是通过调用create()
方法创建。
流计算环境下创建 TableEnviroment :
//创建流式计算的上下文环境
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//创建Table API的上下文环境
StreamTableEnvironment streamTableEnvironment = StreamTableEnvironment.create(env);
Table API
Table API 顾名思义,就是基于“表”(Table)的一套 API,专门为处理表而设计的
它提供了关系型编程模型,可以用来处理结构化数据,支持表和视图的概念。在此基础上,Flink 还基于 Apache Calcite 实现了对 SQL 的支持。这样一来,我们就可以在 Flink 程序中直接写 SQL 来实现需求了,非常实用。
下面是一个简单的例子,它使用Java编写了一个Flink程序,该程序使用 Table API 从CSV文件中读取数据,然后执行简单的查询并将结果写入到自定义的Sink中。
首先我们需要导入maven依赖:
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-api-java-bridge_2.12</artifactId>
<version>1.13.6</version>
</dependency>
代码示例如下:
public static void main(String[] args) throws Exception {
// 创建流处理环境
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 创建表环境
EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inBatchMode().build();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, settings);
// 从CSV文件中读取数据
DataStream<Tuple2<String, Integer>> data = env.readTextFile("input.csv")
.map(line -> {
String[] parts = line.split(",");
return new Tuple2<>(parts[0], Integer.parseInt(parts[1]));
})
.returns(Types.TUPLE(Types.STRING, Types.INT));
// 使用Table API将数据转换为表并注册为视图
String name = "people";
Schema schema = Schema.newBuilder()
.column("name", DataTypes.STRING())
.column("age", DataTypes.INT())
.build();
tableEnv.createTemporaryView(name, data, schema);
// 使用SQL查询年龄大于30的人
Table result = tableEnv.sqlQuery("SELECT name, age FROM people WHERE age > 30");
// 将结果转换为DataStream
DataStream<Row> output = tableEnv.toDataStream(result);
output.addSink(new SinkFunction<Row>() {
@Override
public void invoke(Row value, Context context) throws Exception {
// implement the sink here, e.g., write into a file, send to Kafka, etc.
}
});
env.execute();
}
这段代码是在流处理环境中实现的一个简单的ETL(提取-转换-加载)过程:它从CSV文件中读取数据,对数据进行映射和转化,然后使用SQL查询在一个临时视图上查找年龄大于30的人,最后将结果输出到某个自定义的Sink上。
Virtual Tables(虚拟表)
在环境中注册之后,我们就可以在 SQL 中直接使用这张表进行查询转换了。
Table newTable = tableEnv.sqlQuery("SELECT name, age FROM people WHERE age > 30");
得到的 newTable 是一个中间转换结果,如果之后又希望直接使用这个表执行 SQL,又该怎么做呢?由于 newTable 是一个 Table 对象,并没有在表环境中注册,所以我们还需要将这个中间结果表注册到环境中,才能在 SQL 中使用:
tableEnv.createTemporaryView("NewTable", newTable);
这里的注册其实是创建了一个“虚拟表”(Virtual Table)。这个概念与 SQL 语法中的视图(View)非常类似,所以调用的方法也叫作创建“虚拟视图” (createTemporaryView)。
表流互转
// 将表转换成数据流,并打印
tableEnv.toDataStream(result).print();
// 将数据流转换成表
// 我们还可以在 fromDataStream()方法中增加参数,用来指定提取哪些属性作为表中的字段名,并可以任意指定位置
Table table = tableEnv.fromDataStream(eventStream, $("timestamp").as("ts"),$("url"));
动态表和持续查询
在Flink中,动态表(Dynamic Tables)是一种特殊的表,它可以随时间变化。它们通常用于表示无限流数据,例如事件流或服务器日志。与静态表不同,动态表可以在运行时插入、更新和删除行。
动态表可以像静态的批处理表一样进行查询操作。由于数据在不断变化,因此基于它定义的 SQL 查询也不可能执行一次就得到最终结果。这样一来,我们对动态表的查询也就永远不会停止,一直在随着新数据的到来而继续执行。这样的查询就被称作持续查询(Continuous Query)。
下面是一个简单的例子,它使用Java编写了一个Flink程序,该程序从名为"input-topic"的Kafka主题中读取JSON格式的数据(属性包括"name"和"age"),过滤出所有年龄大于30岁的记录,并将结果输出到另一个名为"output-topic"的Kafka主题中。同时,处理的结果也会在控制台上打印出来。
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inStreamingMode().build();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, settings);
tableEnv.executeSql("CREATE TABLE input (" +
" name STRING," +
" age INT" +
") WITH (" +
" 'connector' = 'kafka'," +
" 'topic' = 'input-topic'," +
" 'properties.bootstrap.servers' = 'localhost:9092'," +
" 'format' = 'json'" +
")");
tableEnv.executeSql("CREATE TABLE output (" +
" name STRING," +
" age INT" +
") WITH (" +
" 'connector' = 'kafka'," +
" 'topic' = 'output-topic'," +
" 'properties.bootstrap.servers' = 'localhost:9092'," +
" 'format' = 'json'" +
")");
Table result = tableEnv.sqlQuery("SELECT name, age FROM input WHERE age > 30");
tableEnv.toAppendStream(result, Row.class).print();
result.executeInsert("output");
env.execute();
}
连接到外部系统
在 Table API编写的 Flink 程序中,可以在创建表的时候用 WITH 子句指定连接器(connector),这样就可以连接到外部系统进行数据交互。
其中最简单的当然就是连接到控制台打印输出:
CREATE TABLE ResultTable (
user STRING,
cnt BIGINT
WITH (
'connector' = 'print'
);
Kafka
需要导入maven依赖:
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-kafka_2.12</artifactId>
<version>1.13.6</version>
</dependency>
创建一个连接到 Kafka 的表,需要在 CREATE TABLE 的 DDL 中在 WITH 子句里指定连接器为 Kafka,并定义必要的配置参数:
CREATE TABLE KafkaTable (
`user` STRING,
`url` STRING,
`ts` TIMESTAMP(3) METADATA FROM 'timestamp'
) WITH (
'connector' = 'kafka',
'topic' = 'events',
'properties.bootstrap.servers' = 'localhost:9092',
'properties.group.id' = 'testGroup',
'scan.startup.mode' = 'earliest-offset',
'format' = 'csv'
)
MySQL
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-jdbc_2.12</artifactId>
<version>1.13.6</version>
</dependency>
创建 JDBC 表的方法与前面 Kafka 大同小异:
-- 创建一张连接到 MySQL 的 表
CREATE TABLE MyTable (
id BIGINT,
name STRING,
age INT,
status BOOLEAN,
PRIMARY KEY (id) NOT ENFORCED
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:mysql://localhost:3306/mydatabase',
'table-name' = 'users'
);
-- 将另一张表 T 的数据写入到 MyTable 表中
INSERT INTO MyTable
SELECT id, name, age, status FROM T;
Table API实战
1.创建Table
Table API中已经提供了TableSource从外部系统获取数据,例如常见的数据库、文件系统和Kafka消息队列等外部系统。
-
从文件中创建Table(静态表)
Flink允许用户从本地或者分布式文件系统中读取和写入数据,只需指定相应的参数即可。但是文件格式必须是CSV格式的。其他文件格式也支持(在Flink中还有Connector等来支持其他格式或者自定义TableSource)
public static void main(String[] args) throws Exception { // 创建流式计算的上下文环境 final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); // 创建Table API的上下文环境 StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env); // 创建CSV表源 String sourceDDL = "CREATE TABLE exampleTab (" + "`id` INT, " + "`name` STRING, " + "`score` DOUBLE" + ") WITH (" + "'connector' = 'filesystem'," + "'path' = 'D:\\code\\StudyFlink\\data\\tableexamples'," + "'format' = 'csv'" + ")"; tableEnv.executeSql(sourceDDL); // 打印表结构 ResolvedSchema schema = tableEnv.from("exampleTab").getResolvedSchema(); System.out.println(schema.toString()); }
-
从DataStream中创建 Table(动态表)
前面已经知道Table API是构建在DataStream API和DataSet API之上的一层更高级的抽象,因此用户可以灵活地使用Table API将Table转换成DataStream或DataSet数据集,也可以将DataSteam或DataSet数据集转换成Table,这和Spark中的DataFrame和RDD的关系类似。
public static void main(String[] args) throws Exception { // 先创建StreamExecutionEnvironment final StreamExecutionEnvironment bsEnv = StreamExecutionEnvironment.getExecutionEnvironment(); EnvironmentSettings bsSettings = EnvironmentSettings.newInstance().useBlinkPlanner().inStreamingMode().build(); StreamTableEnvironment bsTableEnv = StreamTableEnvironment.create(bsEnv, bsSettings); // 创建一个DataStream DataStream<Tuple2<String, Integer>> stream = bsEnv.fromElements(Tuple2.of("Alice", 3), Tuple2.of("Bob", 4)); // 将DataStream转化为Table Table table1 = bsTableEnv.fromDataStream(stream); // 再把Table转回DataStream DataStream<Row> streamAgain = bsTableEnv.toDataStream(table1); }
2.查询和过滤
在Table对象上使用select
操作符查询需要获取的指定字段,也可以使用filter
或where
方法过滤字段和检索条件,将需要的数据检索出来。
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment streamEnv = StreamExecutionEnvironment.getExecutionEnvironment();
streamEnv.setParallelism(1);
// Create the Table API execution environment.
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(streamEnv);
SingleOutputStreamOperator<Tuple5<String, String, String, Long, Long>> data = streamEnv.socketTextStream("hadoop101", 8888)
.map(new MapFunction<String, Tuple5<String, String, String, Long, Long>>() {
@Override
public Tuple5<String, String, String, Long, Long> map(String line) throws Exception {
String[] arr = line.split(",");
return new Tuple5<>(arr[0].trim(), arr[1].trim(), arr[2].trim(), Long.parseLong(arr[4].trim()), Long.parseLong(arr[5].trim()));
}
});
Table table = tableEnv.fromDataStream(data);
// Query
tableEnv.toAppendStream(table.select("f0 AS sid, f1 AS type, f3 AS callTime, f4 AS callOut"), Row.class)
.print();
// Filter Query
tableEnv.toAppendStream(table.filter("f1 === 'success'").where("f1 === 'success'"), Row.class)
.print();
tableEnv.execute("sql");
}
这段代码从一个指定的socket中读取文本数据,将每一行数据映射为一个5元组(Tuple5),然后把这个数据流转换为表,并进行查询操作。首先,它进行简单的列选择查询并打印结果;然后,它进行筛选查询,选取第二字段"成功"的记录并打印出来。整个过程在一个名为"sql"的任务中执行。
3.UDF自定义函数
用户可以在Table API中自定义函数类,常见的抽象类和接口是:
-
ScalarFunction
-
TableFunction
-
AggregateFunction
-
TableAggregateFunction
public static void main(String[] args) {
EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inBatchMode().build();
TableEnvironment tableEnv = TableEnvironment.create(settings);
// 注册UDF
tableEnv.createTemporarySystemFunction("UpperCase", UpperCaseFunction.class);
// 使用UDF
tableEnv.executeSql(
"SELECT UpperCase(myField) FROM myTable"
);
}
public static class UpperCaseFunction extends ScalarFunction {
public String eval(String str) {
return str.toUpperCase();
}
}
这段代码创建了自定义函数(UDF)并使用它。首先,它设置了 Flink 的环境,并通过 Blink Planner 以批处理模式运行。然后,它注册了一个名为 "UpperCase" 的 UDF,该函数将输入字符串转换为大写。最后,它在 SQL 查询中使用了这个 UDF,将 "myTable" 中的 "myField" 字段的值转换成大写形式。
4.Window
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 创建一个具有 Process Time 时间属性的表
tableEnv.executeSql(
"CREATE TABLE Orders (" +
"orderId INT, " +
"price DOUBLE, " +
"buyer STRING, " +
"orderTime TIMESTAMP(3)," +
"pt AS PROCTIME()" + // 使用处理时间
") WITH ('connector' = '...', ...)"
);
Table orders = tableEnv.from("Orders");
Table result1 = orders.window(Tumble.over(lit(10).minutes()).on($("pt")).as("w"))
.groupBy($("w"), $("buyer"))
.select($("buyer"), $("w").start().as("start"), $("w").end().as("end"), $("price").sum().as("totalPrice"));
// 创建一个具有 Event Time 时间属性的表,使用Watermarks
tableEnv.executeSql(
"CREATE TABLE OrdersEventTime (" +
"orderId INT, " +
"price DOUBLE, " +
"buyer STRING, " +
"orderTime TIMESTAMP(3), " +
"WATERMARK FOR orderTime AS orderTime - INTERVAL '5' SECOND" + // 使用事件时间和水印
") WITH ('connector' = '...', ...)"
);
Table ordersEventTime = tableEnv.from("OrdersEventTime");
Table result2 = ordersEventTime.window(Tumble.over(lit(10).minutes()).on($("orderTime")).as("w"))
.groupBy($("w"), $("buyer"))
.select($("buyer"), $("w").start().as("start"), $("w").end().as("end"), $("price").sum().as("totalPrice"));
// 对于 IngestionTime,Flink 1.12 中已经不推荐使用,因此在 Flink 1.13.6 版本中,你应该使用 ProcessTime 或 EventTime。
}
这段代码创建了两个表:一个使用处理时间(Process Time),另一个使用事件时间(Event Time)并设置了水印。针对这两个表,分别在买家(buyer)和10分钟的时间窗口上进行分组,并计算了每个时间窗口中的总价(totalPrice)。
多类型数据流
在 Flink 中,DataStream
,ChangelogStream
,AppendStream
和 RetractStream
用于表示不同类型的数据流。简单来说,它们之间的主要区别和联系如下:
-
DataStream:这是 Flink 的基础抽象,它表示一个无界的数据流,可以包含任何类型的元素。
-
toChangelogStream:这个方法将表转换为一个 ChangeLog 模式的 DataStream。每条记录都代表一个添加、修改或删除的事件。事件通常由可选的元数据标记(例如,'+'(添加)或'-'(撤销))、更新时间以及唯一的键和值组成。ChangelogStream 主要用于处理动态表,并且支持插入,更新和删除操作。
-
toAppendStream:这个方法将表转换为一个只包含添加操作的 DataStream。换句话说,结果表只包含插入(append)操作,不能执行更新或删除操作。如果查询的结果表支持删除或更新,则此方法会抛出异常。
-
toRetractStream:这个方法将表转换为一个包含添加和撤销消息的 DataStream。每一条添加消息表示在结果表中插入了一行,而每一条撤销消息表示在结果表中删除了一行。如果撤销消息后没有相应的添加消息,那么可能是因为输入数据发生了变化,导致之前发送的结果不再正确,需要被撤销。
Flink SQL
企业中Flink SQL比Table API用的多
Flink SQL 是 Apache Flink 提供的一种使用 SQL 查询和处理数据的方式。它允许用户通过 SQL 语句对数据流或批处理数据进行查询、转换和分析,无需编写复杂的代码。Flink SQL 提供了一种更直观、易于理解和使用的方式来处理数据,同时也可以与 Flink 的其他功能无缝集成。
Flink SQL 支持 ANSI SQL 标准,并提供了许多扩展和优化来适应流式处理和批处理场景。它能够处理无界数据流,具备事件时间和处理时间的语义,支持窗口、聚合、连接等常见的数据操作,还提供了丰富的内置函数和扩展插件机制。
下面是一个简单的 Flink SQL 代码示例,展示了如何使用 Flink SQL 对流式数据进行查询和转换。
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1); // 设置并行度为1,方便观察输出结果
// 创建 Kafka 数据源
Properties properties = new Properties();
properties.setProperty("bootstrap.servers", "localhost:9092");
properties.setProperty("group.id", "flink-consumer");
FlinkKafkaConsumer<String> kafkaConsumer = new FlinkKafkaConsumer<>("input-topic", new SimpleStringSchema(), properties);
DataStream<String> sourceStream = env.addSource(kafkaConsumer);
// 获取 StreamTableEnvironment
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 注册数据源表
tableEnv.createTemporaryView("source_table", sourceStream, "message");
// 执行 SQL 查询和转换
String query = "SELECT message, COUNT(*) AS count FROM source_table GROUP BY message";
// 执行 SQL 查询和转换
Table resultTable = tableEnv.sqlQuery(query);
DataStream<Result> resultStream = tableEnv.toDataStream(resultTable)
.map(row -> new Result(row.getField(0).toString(), (Long) row.getField(1)));
// 打印结果
resultStream.print();
env.execute("Flink SQL Example");
}
// 自定义结果类
public static class Result {
public String message;
public Long count;
public Result() {
}
public Result(String message, Long count) {
this.message = message;
this.count = count;
}
@Override
public String toString() {
return "Result{" +
"message='" + message + '\'' +
", count=" + count +
'}';
}
}
在上述示例中,我们使用 Kafka 作为数据源,并创建了一个消费者从名为 "input-topic" 的 Kafka 主题中读取数据。然后,我们将数据流注册为名为 "source_table" 的临时表。
接下来,我们使用 Flink SQL 执行 SQL 查询和转换。在这个例子中,我们查询 "source_table" 表,对 "message" 字段进行分组并计算每个消息出现的次数。查询结果会映射到自定义的 Result
类,并最终通过 print()
方法打印到标准输出。
最后,我们通过调用 env.execute()
方法来启动 Flink 作业的执行。
Flink SQL中使用窗口函数
Flink SQL中使用滚动窗口,滑动窗口和会话窗口代码示例如下:
public static void main(String[] args) throws Exception {
// 初始化流处理执行环境
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
final StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 对于实际应用程序,请替换为你的数据源
String sourceDDL =
"CREATE TABLE MySourceTable (\n" +
" user_id STRING,\n" +
" event_time TIMESTAMP(3),\n" +
" price DOUBLE\n" +
") WITH (\n" +
"'connector' = '...',\n" +
"...);\n";
tableEnv.executeSql(sourceDDL);
// 滚动窗口
String tumblingWindowQuery =
"SELECT user_id, SUM(price) as total_price\n" +
"FROM MySourceTable\n" +
"GROUP BY user_id, TUMBLE(event_time, INTERVAL '1' HOUR)";
Table tumblingWindowResult = tableEnv.sqlQuery(tumblingWindowQuery);
// 滑动窗口
String slidingWindowQuery =
"SELECT user_id, SUM(price) as total_price\n" +
"FROM MySourceTable\n" +
"GROUP BY user_id, HOP(event_time, INTERVAL '30' MINUTE, INTERVAL '1' HOUR)";
Table slidingWindowResult = tableEnv.sqlQuery(slidingWindowQuery);
// 会话窗口
String sessionWindowQuery =
"SELECT user_id, SUM(price) as total_price\n" +
"FROM MySourceTable\n" +
"GROUP BY user_id, SESSION(event_time, INTERVAL '1' HOUR)";
Table sessionWindowResult = tableEnv.sqlQuery(sessionWindowQuery);
}
程序定义了三种不同类型的窗口查询:滚动窗口(tumbling window),滑动窗口(sliding window),会话窗口(session window)。
-
滚动窗口:该查询对"MySourceTable"中的数据应用滚动窗口,窗口大小为1小时,并按user_id进行分组。每个窗口内,会计算每个用户的总价格(sum(price))。
-
滑动窗口:与滚动窗口相似, 但是窗口可以重叠. 这个查询每半小时滑动一次, 并且每次滑动都会创建一个1小时大小的窗口, 再进行与滚动窗口查询相同的计算.
-
会话窗口:会话窗口是根据数据活跃度来划分的,当一个会话内一段时间(这里设定为1小时)没有新的数据到达时,就认为会话结束。该查询按user_id和event_time的会话窗口进行分组,然后在每个窗口中计算总价格。
每个查询调用tableEnv.sqlQuery(query)
方法,并将结果存储在Table对象中。注意这些查询在调用sqlQuery时并没有立即执行,只有当你对结果做出动作(如print、collect或者写入外部系统)时,才会触发执行。
Flink内存优化
在大数据领域,大多数开源框架(Hadoop、Spark、Flink)都是基于JVM运行,但是JVM的内存管理机制往往存在着诸多类似OutOfMemoryError
的问题,主要是因为创建过多的对象实例而超过JVM的最大堆内存限制,却没有被有效回收掉。
这在很大程度上影响了系统的稳定性,尤其对于大数据应用,面对大量的数据对象产生,仅仅靠JVM所提供的各种垃圾回收机制很难解决内存溢出的问题。
在开源框架中有很多框架都实现了自己的内存管理,例如Apache Spark的Tungsten项目,在一定程度上减轻了框架对JVM垃圾回收机制的依赖,从而更好地使用JVM来处理大规模数据集。
Flink也基于JVM实现了自己的内存管理,将JVM根据内存区分为Unmanned Heap、Flink Managed Heap、Network Buffers三个区域
在Flink内部对Flink Managed Heap进行管理,在启动集群的过程中直接将堆内存初始化成Memory Pages Pool,也就是将内存全部以二进制数组的方式占用,形成虚拟内存使用空间。
新创建的对象都是以序列化成二进制数据的方式存储在内存页面池中,当完成计算后数据对象Flink就会将Page置空,而不是通过JVM进行垃圾回收,保证数据对象的创建永远不会超过JVM堆内存大小,也有效地避免了因为频繁GC导致的系统稳定性问题。
JobManager配置
JobManager在Flink系统中主要承担管理集群资源、接收任务、调度Task、收集任务状态以及管理TaskManager的功能,JobManager本身并不直接参与数据的计算过程,因此JobManager的内存配置项不是特别多,只要指定JobManager堆内存大小即可。
-
jobmanager.heap.size:设定JobManager堆内存大小,默认为1024MB。
TaskManager配置
TaskManager作为Flink集群中的工作节点,所有任务的计算逻辑均执行在TaskManager之上,因此对TaskManager内存配置显得尤为重要,可以通过以下参数配置对TaskManager进行优化和调整。
-
taskmanager.heap.size:设定TaskManager堆内存大小,默认值为1024M,如果在Yarn的集群中,TaskManager取决于Yarn分配给TaskManager Container的内存大小,且Yarn环境下一般会减掉一部分内存用于Container的容错。
-
taskmanager.jvm-exit-on-oom:设定TaskManager是否会因为JVM发生内存溢出而停止,默认为false,当TaskManager发生内存溢出时,也不会导致TaskManager停止。
-
taskmanager.memory.size:设定TaskManager内存大小,默认为0,如果不设定该值将会使用
taskmanager.memory.fraction
作为内存分配依据。 -
taskmanager.memory.fraction:设定TaskManager堆中去除Network Buffers内存后的内存分配比例。该内存主要用于TaskManager任务排序、缓存中间结果等操作。例如,如果设定为0.8,则代表TaskManager保留80%内存用于中间结果数据的缓存,剩下20%的内存用于创建用户定义函数中的数据对象存储。注意,该参数只有在
taskmanager.memory.size
不设定的情况下才生效。 -
taskmanager.memory.off-heap:设置是否开启堆外内存供Managed Memory或者Network Buffers使用。
-
taskmanager.memory.preallocate:设置是否在启动TaskManager过程中直接分配TaskManager管理内存。
-
taskmanager.numberOfTaskSlots:每个TaskManager分配的slot数量。
Flink的网络缓存优化
Flink将JVM堆内存切分为三个部分,其中一部分为Network Buffers内存。Network Buffers内存是Flink数据交互层的关键内存资源,主要目的是缓存分布式数据处理过程中的输入数据。
通常情况下,比较大的Network Buffers意味着更高的吞吐量。如果系统出现“Insufficient number of network buffers”的错误,一般是因为Network Buffers配置过低导致,因此,在这种情况下需要适当调整TaskManager上Network Buffers的内存大小,以使得系统能够达到相对较高的吞吐量。
目前Flink能够调整Network Buffer内存大小的方式有两种:一种是通过直接指定Network Buffers内存数量的方式,另外一种是通过配置内存比例的方式。
设定Network Buffer内存数量(过时)
直接设定Nework Buffer数量需要通过如下公式计算得出:
NetworkBuffersNum = total-degree-of-parallelism \* intra-node-parallelism * n
其中total-degree-of-parallelism
表示每个TaskManager的总并发数量,intra-node-parallelism
表示每个TaskManager输入数据源的并发数量,n表示在预估计算过程中Repar-titioning或Broadcasting操作并行的数量。intra-node-parallelism
通常情况下与Task-Manager的所占有的CPU数一致,且Repartitioning和Broadcating一般下不会超过4个并发。可以将计算公式转化如下:
NetworkBuffersNum = <slots-per-TM>^2 \* < TMs>* 4
其中slots-per-TM是每个TaskManager上分配的slots数量,TMs是TaskManager的总数量。对于一个含有20个TaskManager,每个TaskManager含有8个Slot的集群来说,总共需要的Network Buffer数量为8^2*204=5120个,因此集群中配置Network Buffer内存的大小约为160M较为合适。
计算完Network Buffer数量后,可以通过添加如下两个参数对Network Buffer内存进行配置。其中segment-size为每个Network Buffer的内存大小,默认为32KB,一般不需要修改,通过设定numberOfBuffers参数以达到计算出的内存大小要求。
-
taskmanager.network.numberOfBuffers:指定Network堆栈Buffer内存块的数量。
-
taskmanager.memory.segment-size:内存管理器和Network栈使用的内存Buffer大小,默认为32KB。
设定Network Buffer内存比例(推荐)
从1.3版本开始,Flink就提供了通过指定内存比例的方式设置Network Buffer内存大小。
-
taskmanager.network.memory.fraction:JVM中用于Network Buffers的内存比例。
-
taskmanager.network.memory.min:最小的Network Buffers内存大小,默认为64MB。
-
taskmanager.network.memory.max:最大的Network Buffers内存大小,默认1GB。
-
taskmanager.memory.segment-size:内存管理器和Network栈使用的Buffer大小,默认为32KB。