本章重点介绍生产环境中最常用到的Flink kafka connector
。使用Flink
的同学,一定会很熟悉kafka
,它是一个分布式的、分区的、多副本的、 支持高吞吐的、发布订阅消息系统。生产环境环境中也经常会跟kafka
进行一些数据的交换,比如利用kafka consumer
读取数据,然后进行一系列的处理之后,再将结果写出到kafka
中。这里会主要分两个部分进行介绍,一是Flink kafka Consumer
,一个是Flink kafka Producer
Flink 输入输出至 Kafka案例
首先看一个例子来串联下Flink kafka connector
。代码逻辑里主要是从 kafka里读数据,然后做简单的处理,再写回到kafka
中。首先需要引入 flink-kafka
相关的pom.xml
依赖:
xml
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-kafka-0.11_2.12</artifactId>
<version>1.10.0</version>
</dependency>
分别从如何构造一个Source sinkFunction
。Flink
提供了现成的构造FlinkKafkaConsumer
、Producer
的接口,可以直接使用。这里需要注意,因为kafka
有多个版本,多个版本之间的接口协议会不同。Flink
针对不同版本的kafka
有相应的版本的Consumer
和Producer
。例如:针对 08
、09
、10
、11
版本,Flink
对应的consumer
分别是FlinkKafkaConsumer 08
、09
、010
、011
,producer
也是。
java
package com.zzx.flink;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.api.java.utils.ParameterTool;
import org.apache.flink.streaming.api.CheckpointingMode;
import org.apache.flink.streaming.api.TimeCharacteristic;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer011;
import org.apache.flink.streaming.connectors.kafka.FlinkKafkaProducer011;
import scala.Tuple2;
import scala.tools.nsc.transform.patmat.Logic;
import java.util.Properties;
/**
* @description: Flink 从kafka 中读取数据并写入kafka
* @author: zzx
* @createDate: 2020/7/22
* @version: 1.0
*/
public class FlinkKafkaExample {
public static void main(String[] args) throws Exception{
//ParameterTool 从参数中读取数据
final ParameterTool params = ParameterTool.fromArgs(args);
//设置执行环境
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//使参数在web界面中可用
env.getConfig().setGlobalJobParameters(params);
/** TimeCharacteristic 中包含三种时间类型
* @PublicEvolving
* public enum TimeCharacteristic {
* //以operator处理的时间为准,它使用的是机器的系统时间来作为data stream的时间
* ProcessingTime,
* //以数据进入flink streaming data flow的时间为准
* IngestionTime,
* //以数据自带的时间戳字段为准,应用程序需要指定如何从record中抽取时间戳字段
* EventTime
* }
*/
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
/**
* CheckpointingMode: EXACTLY_ONCE(执行一次) AT_LEAST_ONCE(至少一次)
*/
env.enableCheckpointing(60*1000, CheckpointingMode.EXACTLY_ONCE);
//------------------------------------------source start -----------------------------------
String sourceTopic = "sensor";
String bootstrapServers = "hadoop1:9092";
// kafkaConsumer 需要的配置参数
Properties props = new Properties();
// 定义kakfa 服务的地址,不需要将所有broker指定上
props.put("bootstrap.servers", bootstrapServers);
// 制定consumer group
props.put("group.id", "test");
// 是否自动确认offset
props.put("enable.auto.commit", "true");
// 自动确认offset的时间间隔
props.put("auto.commit.interval.ms", "1000");
// key的序列化类
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
// value的序列化类
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
//从kafka读取数据,需要实现 SourceFunction 他给我们提供了一个
FlinkKafkaConsumer011<String> consumer = new FlinkKafkaConsumer011<String>(sourceTopic, new SimpleStringSchema(), props);
//------------------------------------------source end -----------------------------------------
//------------------------------------------sink start -----------------------------------
String sinkTopic = "topic";
Properties properties = new Properties();
properties.put("bootstrap.servers", bootstrapServers);
properties.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
FlinkKafkaProducer011<String> producer = new FlinkKafkaProducer011<String>(sinkTopic, new SimpleStringSchema(), properties);
//------------------------------------------sink end --------------------------------------
//FlinkKafkaConsumer011 继承自 RichParallelSourceFunction
env.addSource(consumer)
.map(new MapFunction<String, Tuple2<Long,String>>(){
@Override
public Tuple2<Long, String> map(String s) throws Exception {
return new Tuple2<>(1L,s);
}
})
.filter(k -> k != null)
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<Tuple2<Long, String>>(Time.seconds(5)) {
@Override
public long extractTimestamp(Tuple2<Long, String> element) {
return element._1;
}
})
.map(k ->k.toString())
.addSink(producer);
//执行
env.execute("FlinkKafkaExample");
}
}
如下创建代码中涉及的"sensor" Topic
c
[root@hadoop1 kafka_2.11-2.2.2]# bin/kafka-topics.sh --create --zookeeper hadoop1:2181 --topic sensor --replication-factor 2 --partitions 4
Flink kafka Consumer
反序列化数据: 因为kafka
中数据都是以二进制byte
形式存储的。读到Flink
系统中之后,需要将二进制数据转化为具体的java
、scala
对象。具体需要实现一个schema
类定义如何序列化和反序列数据。反序列化时需要实现DeserializationSchema
接
口,并重写deserialize(byte[] message)
函数,如果是反序列化kafka
中kv
的数据时,需要实现KeyedDeserializationSchema
接口,并重写 deserialize(byte[] messageKey, byte[] message, String topic, int partition, long offset)
函数。
另外Flink
中也提供了一些常用的序列化反序列化的schema
类。例如,SimpleStringSchema
,按字符串方式进行序列化、反序列化。TypeInformationSerializationSchema
,它可根据Flink
的TypeInformation
信息来推断出需要选择的schema
。JsonDeserializationSchema
使用 jackson
反序列化 json
格式消息,并返回ObjectNode
,可以使用get("property")
方法来访问相应字段。
消费起始位置设置
如何设置作业消费kafka
起始位置的数据,这一部分Flink
也提供了非常好的封装。在构造好的FlinkKafkaConsumer
类后面调用如下相应函数,设置合适的起始位置。
【1】setStartFromGroupOffsets
,也是默认的策略,从group offset
位置读取数据,group offset
指的是kafka broker
端记录的某个group
的最后一次的消费位置。但是kafka broker
端没有该group
信息,会根据kafka
的参数auto.offset.reset
的设置来决定从哪个位置开始消费。
○ setStartFromEarliest
,从kafka
最早的位置开始读取。
○ setStartFromLatest
,从kafka
最新的位置开始读取。
○ setStartFromTimestamp(long)
,从时间戳大于或等于指定时间戳的位置开始读取。Kafka
时间戳,是指kafka
为每条消息增加另一个时戳。该时戳可以表示消息在proudcer
端生成时的时间、或进入到kafka broker
时的时间。
○ setStartFromSpecificOffsets
,从指定分区的offset
位置开始读取,如指定的offsets
中不存某个分区,该分区从group offset
位置开始读取。此时需要用户给定一个具体的分区、offset
的集合。
一些具体的使用方法可以参考下图。需要注意的是,因为Flink
框架有容错机制,如果作业故障,如果作业开启checkpoint
,会从上一次 checkpoint
状态开始恢复。或者在停止作业的时候主动做savepoint
,启动作业时从savepoint
开始恢复。这两种情况下恢复作业时,作业消费起始位置是从之前保存的状态中恢复,与上面提到跟kafka
这些单独的配置无关。
topic 和 partition 动态发现
实际的生产环境中可能有这样一些需求:
场景一 ,有一个Flink
作业需要将五份数据聚合到一起,五份数据对应五个kafka topic
,随着业务增长,新增一类数据,同时新增了一个 kafka topic
,如何在不重启作业的情况下作业自动感知新的topic
。
场景二 ,作业从一个固定的kafka topic
读数据,开始该topic
有10
个partition
,但随着业务的增长数据量变大,需要对kafka partition
个数进行扩容,由10
个扩容到20
。该情况下如何在不重启作业情况下动态感知新扩容的partition
?
针对上面的两种场景,首先需要在构建FlinkKafkaConsumer
时的properties
中设置flink.partition-discovery.interval-millis
参数为非负值,表示开启动态发现的开关,以及设置的时间间隔。此时FlinkKafkaConsumer
内部会启动一个单独的线程定期去kafka
获取最新的meta
信息。针对场景一,还需在构建FlinkKafkaConsumer
时,topic
的描述可以传一个正则表达式(如下图所示)描述的pattern
。每次获取最新kafka meta
时获取正则匹配的最新topic
列表。针对场景二,设置前面的动态发现参数,在定期获取kafka
最新meta
信息时会匹配新的partition
。为了保证数据的正确性,新发现的partition
从最早的位置开始读取。
commit offset 方式
Flink kafka consumer commit offset
方式需要区分是否开启了checkpoint
。如果checkpoint
关闭,commit offset
要依赖于kafka
客户端的auto commit
。 需设置enable.auto.commit
,auto.commit.interval.ms
参数到consumer properties
,就会按固定的时间间隔定期auto commit offset
到 kafka
。如果开启checkpoint
,这个时候作业消费的offset
,Flink
会在state
中自己管理和容错 。此时提交offset
到kafka
,一般都是作为外部进度的监控,想实时知道作业消费的位置和lag
情况。此时需要setCommitOffsetsOnCheckpoints
为true
来设置当checkpoint
成功时提交offset
到kafka
。此时commit offset
的间隔就取决于checkpoint
的间隔,所以此时从kafka
一侧看到的lag
可能并非完全实时,如果checkpoint
间隔比较长lag
曲线可能会是一个锯齿状。
Timestamp Extraction/Watermark 生成
我们知道当Flink
作业内使用EventTime
属性时,需要指定从消息中提取时间戳和生成水位的函数。FlinkKakfaConsumer
构造的source
后直接调用assignTimestampsAndWatermarks
函数设置水位生成器的好处是此时是每个partition
一个watermark assigner
,如下图。source
生成的时戳为多个partition
时戳对齐后的最小时戳。此时在一个source
读取多个partition
,并且partition
之间数据时戳有一定差距的情况下,因为在 source
端watermark
在partition
级别有对齐,不会导致数据读取较慢partition
数据丢失。
Flink kafka Producer
【1】Producer
分区: 使用FlinkKafkaProducer
往kafka
中写数据时,如果不单独设置partition
策略,会默认使用FlinkFixedPartitioner
,该 partitioner
分区的方式是task
所在的并发id
对topic
总partition
数取余:parallelInstanceId % partitions.length
。
○ 此时如果sink
为4
,paritition
为1
,则4
个task
往同一个partition
中写数据。但当sink task < partition
个数时会有部分partition
没有数据写入,例如sink task
为2
,partition
总数为4
,则后面两个partition
将没有数据写入。
○ 如果构建FlinkKafkaProducer
时,partition
设置为null
,此时会使用kafka producer
默认分区方式,非key
写入的情况下,使用round-robin
的方式进行分区,每个task
都会轮循的写下游的所有partition
。该方式下游的partition
数据会比较均衡,但是缺点是partition
个数过多的情况下需要维持过多的网络连接,即每个task
都会维持跟所有partition
所在broker
的连接。
容错
Flink kafka 09
、010
版本下,通过setLogFailuresOnly
为false
,setFlushOnCheckpoint
为true
, 能达到at-least-once
语义。setLogFailuresOnly
默认为false
,是控制写kafka
失败时,是否只打印失败的log
不抛异常让作业停止。setFlushOnCheckpoint
,默认为true
,是控制是否在 checkpoint
时fluse
数据到kafka
,保证数据已经写到kafka
。否则数据有可能还缓存在kafka
客户端的buffer
中,并没有真正写出到kafka
,此时作业挂掉数据即丢失,不能做到至少一次的语义。
Flink kafka 011
版本下,通过两阶段提交的sink
结合kafka
事务的功能,可以保证端到端精准一次。
疑问与解答
【问题一】: 在Flink consumer
的并行度的设置:是对应topic
的partitions
个数吗?要是有多个主题数据源,并行度是设置成总体的 partitions
数吗?
【解答】: 这个并不是绝对的,跟topic
的数据量也有关,如果数据量不大,也可以设置小于partitions
个数的并发数。但不要设置并发数大于partitions
总数,因为这种情况下某些并发因为分配不到partition
导致没有数据处理。
【问题二】: 如果partitioner
传null
的时候是round-robin
发到每一个partition
?如果有key
的时候行为是kafka
那种按照key
分布到具体分区的行为吗?
【解答】: 如果在构造FlinkKafkaProducer
时,如果没有设置单独的partitioner
,则默认使用FlinkFixedPartitioner
,此时无论是带key
的数据,还是不带key
。如果主动设置partitioner
为null
时,不带key
的数据会round-robin
轮询的方式写出到partition
,带key
的数据会根据key
,相同key
数据分区的相同的partition
。
【问题三】: 如果checkpoint
时间过长,offset
未提交到kafka
,此时节点宕机了,重启之后的重复消费如何保证呢?
【解答】: 首先开启checkpoint
时offset
是Flink
通过状态state
管理和恢复的,并不是从kafka
的offset
位置恢复。在checkpoint
机制下,作业从最近一次checkpoint
恢复,本身是会回放部分历史数据,导致部分数据重复消费,Flink
引擎仅保证计算状态的精准一次,要想做到端到端精准一次需要依赖一些幂等的存储系统或者事务操作。