Flink Kafka[输入/输出] Connector
本章重点介绍生产环境中最常用到的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依赖:
org.apache.flink flink-connector-kafka-0.11_2.12 1.10.0
分别从如何构造一个Source sinkFunction。Flink提供了现成的构造FlinkKafkaConsumer、Producer的接口,可以直接使用。这里需要注意,因为kafka有多个版本,多个版本之间的接口协议会不同。Flink针对不同版本的kafka有相应的版本的Consumer和Producer。例如:针对 08、09、10、11版本,Flink对应的consumer分别是FlinkKafkaConsumer 08、09、010、011,producer也是。
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 consumer = new FlinkKafkaConsumer011(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 producer = new FlinkKafkaProducer011(sinkTopic, new SimpleStringSchema(), properties); //------------------------------------------sink end -------------------------------------- //FlinkKafkaConsumer011 继承自 RichParallelSourceFunction env.addSource(consumer) .map(new MapFunction(){ @Override public Tuple2 map(String s) throws Exception { return new Tuple2(1L,s); } }) .filter(k -> k != null) .assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor(Time.seconds(5)) { @Override public long extractTimestamp(Tuple2 element) { return element._1; } }) .map(k ->k.toString()) .addSink(producer); //执行 env.execute("FlinkKafkaExample"); } }
如下创建代码中涉及的"sensor" Topic
[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
○ 如果构建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引擎仅保证计算状态的精准一次,要想做到端到端精准一次需要依赖一些幂等的存储系统或者事务操作。