九、DataStream API之输出算子(Sink)
概述
Flink 作为数据处理框架,最终还是要把计算处理的结果写入外部存储,为外部应用提供支持,如图所示,本节将主要讲解Flink中的Sink操作。之前已经了解Flink程序如何对数据进行读取、转换等操作,最后一步当然就应该将结果数据保存或输出到外部系统了。
在Flink中,如果希望将数据写入外部系统,其实并不是一件难事。所有算子都可以通过实现函数类来自定义处理逻辑,所以只要有读写客户端,与外部系统的交互在任何一个处理算子中都可以实现。例如在MapFunction中,完全可以构建一个到Redis的连接,然后将当前处理的结果保存到Redis中。如果考虑到只需建立一次连接,也可以利用RichMapFunction,在open()生命周期中做连接操作。这样看起来很方便,却会带来很多问题。Flink作为一个快速的分布式实时流处理系统,对稳定性和容错性要求极高。一旦出现故障,应该有能力恢复之前的状态,保障处理结果的正确性。这种性质一般被称作“状态一致性”。Flink内部提供了一致性检查点(checkpoint)来保障可以回滚到正确的状态;但如果在处理过程中任意读写外部系统,发生故障后就很难回退到从前了。为了避免这样的问题,Flink的DataStreamAPI专门提供了向外部写入数据的方法:addSink。
与addSource类似,addSink方法对应着一个“Sink”算子,主要就是用来实现与外部系统连接、并将数据提交写入的;Flink程序中所有对外的输出操作,一般都是利用Sink算子完成的。
Sink一词有“下沉”的意思,有些资料会相对于“数据源”把它翻译为“数据汇”。不论怎样理解,Sink在Flink中代表了将结果数据收集起来、输出到外部的意思,所以这里统一把它直观地叫作“输出算子”。
之前一直在使用的print方法其实就是一种Sink,它表示将数据流写入标准控制台打印输出。查看源码可以发现,print方法返回的就是一个DataStreamSink。
public DataStreamSink<T> print() {
PrintSinkFunction<T> printFunction = new PrintSinkFunction<>();
return addSink(printFunction).name("Print to Std. Out");
}
与Source算子非常类似,除去一些Flink预实现的Sink,一般情况下Sink算子的创建是通过调用DataStream的.addSink()方法实现的。
stream.addSink(new SinkFunction(…));\
addSource的参数需要实现一个SourceFunction接口;类似地,addSink方法同样需要传入 一个参数,实现的是SinkFunction接口。在这个接口中只需要重写一个方法invoke(),用来将指定的值写入到外部系统中。这个方法在每条数据记录到来时都会调用:
default void invoke(IN value, Context context) throws Exception
当然,SinkFuntion多数情况下同样并不需要自己实现。Flink官方提供了一部分的框架的Sink连接器。Flink官方目前支持的第三方系统连接器:
可以看到,像Kafka之类流式系统,Flink提供了完美对接,source/sink 两端都能连接,可读可写;而对于Elasticsearch、文件系统(FileSystem)、JDBC等数据存储系统,则只提供了输出写入的sink连接器。 除Flink官方之外,Apache Bahir作为给Spark和Flink提供扩展支持的项目,也实现了一些其他第三方系统与 Flink 的连接器
除此以外,就需要自定义实现sink连接器了。下面选取一些常见的外部系统进行学习;
一、输出到文件
最简单的输出方式,当然就是写入文件了。对应读取文件作为输入数据源,Flink本来也有一些非常简单粗暴的输出到文件的预实现方法:如writeAsText()、writeAsCsv(),可以直接将输出结果保存到文本文件或Csv文件。但是,这种方式是不支持同时写入一份文件的;所以往往会将最后的Sink操作并行度设为1,这就大大拖慢了系统效率;而且对于故障恢复后的状态一致性,也没有任何保证。所以目前这些简单的方法已经要被弃用。 Flink为此专门提供了一个流式文件系统的连接器:StreamingFileSink,它继承自抽象类RichSinkFunction,而且集成了Flink的检查点(checkpoint)机制,用来保证精确一次(exactly once)的一致性语义。 StreamingFileSink为批处理和流处理提供了一个统一的Sink,它可以将分区文件写入Flink支持的文件系统。它可以保证精确一次的状态一致性,大大改进了之前流式文件 Sink 的方式。 它的主要操作是将数据写入桶(buckets),每个桶中的数据都可以分割成一个个大小有限的分区文件,这样一来就实现真正意义上的分布式文件存储。可以通过各种配置来控制“分桶” 的操作;默认的分桶方式是基于时间的,每小时写入一个新的桶。换句话说,每个桶内保存的文件,记录的都是1小时的输出数据。
StreamingFileSink支持行编码(Row-encoded)和批量编码(Bulk-encoded,比如Parquet) 格式。这两种不同的方式都有各自的构建器(builder),调用方法也非常简单,可以直接调用 StreamingFileSink 的静态方法:
- 行编码:StreamingFileSink.forRowFormat(basePath,rowEncoder)。
- 批量编码:StreamingFileSink.forBulkFormat(basePath,bulkWriterFactory)。
在创建行或批量编码Sink 时,需要传入两个参数,用来指定存储桶的基本路径(basePath)和数据的编码逻辑(rowEncoder或bulkWriterFactory)。
示例代码
package com.kunan.StreamAPI.Sink;
import com.kunan.StreamAPI.Source.Event;
import org.apache.flink.api.common.serialization.SimpleStringEncoder;
import org.apache.flink.core.fs.Path;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.sink.filesystem.StreamingFileSink;
import org.apache.flink.streaming.api.functions.sink.filesystem.rollingpolicies.DefaultRollingPolicy;
import java.util.concurrent.TimeUnit;
public class SinkToFileTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
DataStreamSource<Event> Stream = env.fromElements(
new Event("Mary", "./home", 1000L),
new Event("Bob", "./cart", 1500L),
new Event("Alice", "./prod?id=100", 1800L),
new Event("Bob", "./prod?id=1", 2000L),
new Event("Alice", "./prod?id=200", 3000L),
new Event("Bob", "./home", 2500L),
new Event("Bob", "./prod?id=120", 3600L),
new Event("Bob", "./prod?id=130", 4000L)
);
StreamingFileSink<String> stringStreamingFileSink = StreamingFileSink.<String>forRowFormat(new Path("./OutPut"),
new SimpleStringEncoder<>("UTF-8"))
.withRollingPolicy(
DefaultRollingPolicy.builder()
.withMaxPartSize(1024 * 1024 * 1024)
.withRolloverInterval(TimeUnit.MINUTES.toMillis(15))
.withInactivityInterval(TimeUnit.MINUTES.toMillis(5))
.build()
)
.build();
Stream.map(data -> data.toString())
.addSink(stringStreamingFileSink);
env.execute();
}
}
这里创建了一个简单的文件Sink,通过.withRollingPolicy()方法指定了一个“滚动策略”。
“滚动”的概念在日志文件的写入中经常遇到:因为文件会有内容持续不断地写入,所以应该给一个标准,到什么时候就开启新的文件,将之前的内容归档保存。也就是说,上面的代码设置了在以下3种情况下,就会滚动分区文件:
- 至少包含15 分钟的数据;
- 最近 5 分钟没有收到新的数据;
- 文件大小已达到1GB;
二、输出到Kafka
Kafka是一个分布式的基于发布/订阅的消息系统,本身处理的也是流式数据,所以和Flink是“天生一对”,经常会作为Flink的输入数据源和输出系统。Flink官方为Kafka提供了Source和Sink的连接器,可以用它方便地从Kafka 读写数据。
如果仅仅是支持读写,那还说明不了Kafka和Flink关系的亲密;真正让它们密不可分的是,Flink与Kafka的连接器提供了端到端的精确一次(exactly once)语义保证,这在实际项目中是最高级别的一致性保证。(后续章节做详细说明)。
现在要将数据输出到Kafka,整个数据处理的闭环已经形成,所以可以完整测试如下:
<1> 添加Kafka连接器依赖:由于之前已经测试过从Kafka数据源读取数据,连接器相关依赖已经引入。
<2> 启动 Kafka 集群(见源算子章节);
<3> 编写输出到Kafka的示例代码
示例代码
package com.kunan.StreamAPI.Sink;
import com.kunan.StreamAPI.Source.Event;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer;
import org.apache.flink.streaming.connectors.kafka.FlinkKafkaProducer;
import java.util.Properties;
public class SinkToKafka {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
//1.从Kafka读取数据
Properties properties = new Properties();
properties.setProperty("bootstrap.servers", "hadoop102:9092");
DataStreamSource<String> kafkaStream = env.addSource(new FlinkKafkaConsumer<String>("clicks", new SimpleStringSchema(), properties));
//2,用Flink进行转换处理
SingleOutputStreamOperator<String> result = kafkaStream.map(new MapFunction<String, String>() {
@Override
public String map(String value) throws Exception {
String[] fields = value.split(",");
return new Event(fields[0].trim(), fields[1].trim(), Long.valueOf(fields[2].trim())).toString();
}
});
//3,结果数据写入到Kafka
result.addSink(new FlinkKafkaProducer<String>("hadoop102:9092","Events",new SimpleStringSchema()));
// 1> 启动Zk 三个机器都要启动 都要运行 $ zkServer.sh start 查看状态: zkServer.sh status
// 2> 启动Kafka: 三个机器都要启动 都要运行 $ kafka-server-start.sh /opt/module/kafka_2.13-3.2.0/config/server.properties &
// 3> 创建生产者: $ kafka-console-producer.sh --broker-list hadoop102:9092 --topic clicks
// 4> 创建消费者: $ kafka-console-consumer.sh --bootstrap-server hadoop102:9092 --topic Events
// 5> 运行代码 在生产者端复制数据 在消费者端查看消费的数据
// zkServer.sh status
env.execute();
}
}
这里可以看到,addSink传入的参数是一个FlinkKafkaProducer。这也很好理解,因为需要向Kafka写入数据,自然应该创建一个生产者。FlinkKafkaProducer 继承了抽象类 TwoPhaseCommitSinkFunction,这是一个实现了“两阶段提交”的 RichSinkFunction。两阶段提交提供了Flink向Kafka写入数据的事务性保证,能够真正做到精确一次(exactly once)的状态一致性。
<4> 运行代码,在Linux主机启动一个消费者, 查看是否收到数据
$ kafka-console-consumer.sh --bootstrap-server hadoop102:9092 --topic Events
可以看到消费者可以正常消费数据,证明向Kafka写入数据成功。另外,也可以读取之前介绍过的任意数据源,进行更多的完整测试。
比较有趣的一个实验:可以同时将Kafka作为Flink程序的数据源和写入结果的外部系统。只要将输入和输出的数据设置为不同的topic,就可以看到整个系统运行的路径:
Flink从Kakfa的一个topic读取消费数据, 然后进行处理转换,最终将结果数据写入Kafka的另一个topic——数据从Kafka流入、经 Flink 处理后又流回到 Kafk去,这就是所谓的“数据管道”应用。
三、输出到Redis
Redis是一个开源的内存式的数据存储,提供了像字符串(string)、哈希表(hash)、列表(list)、集合(set)、排序集合(sorted set)、位图(bitmap)、地理索引和流(stream)等一系列常用的数据结构。因为它运行速度快、支持的数据类型丰富,在实际项目中已经成为了架构优化必不可少的一员,一般用作数据库、缓存,也可以作为消息代理。 Flink没有直接提供官方的Redis连接器,不过Bahir项目还是担任了合格的辅助角色,提供了Flink-Redis的连接工具。但因版本升级略显滞后,目前连接器版本为1.0,支持的Scala版本最新到2.11。由于测试不涉及到Scala的相关版本变化,所以并不影响使用。 在实际项目应用中,应该以匹配的组件版本运行。
测试步骤如下:
<1> 导入的 Redis 连接器依赖
<!--Redis连接器依赖-->
<dependency>
<groupId>org.apache.bahir</groupId>
<artifactId>flink-connector-redis_2.11</artifactId>
<version>1.0</version>
</dependency>
<2>启动Redis集群(为方便测试,只在一台机器安装Redis)
$ redis-server /opt/module/redis/redis.conf
<3>示例代码
package com.kunan.StreamAPI.Sink;
import com.kunan.StreamAPI.Source.ClickSource;
import com.kunan.StreamAPI.Source.Event;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.connectors.redis.RedisSink;
import org.apache.flink.streaming.connectors.redis.common.config.FlinkJedisPoolConfig;
import org.apache.flink.streaming.connectors.redis.common.mapper.RedisCommand;
import org.apache.flink.streaming.connectors.redis.common.mapper.RedisCommandDescription;
import org.apache.flink.streaming.connectors.redis.common.mapper.RedisMapper;
public class SinkToRedis {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
DataStreamSource<Event> stream = env.addSource(new ClickSource());
// 创建一个jedis连接配置
FlinkJedisPoolConfig config = new FlinkJedisPoolConfig.Builder()
.setHost("hadoop102")
.setPort(6379)
.setTimeout(30000)
.build();
//写入Redis
//JFlinkJedisConfigBase:Jedis 的连接配置
//RedisMapper:Redis 映射类接口,说明怎样将数据转换成可以写入 Redis 的类型
stream.addSink(new RedisSink<Event>(config,new MyRedisMapper()));
env.execute();
}
//自定义类实现RedisMapper接口
public static class MyRedisMapper implements RedisMapper<Event>{
@Override
public RedisCommandDescription getCommandDescription() {
return new RedisCommandDescription(RedisCommand.HSET,"clicks");
}
@Override
public String getKeyFromData(Event data) {
return data.user;
}
@Override
public String getValueFromData(Event data) {
return data.url;
}
}
}
<4> 运行代码,Redis查看是否收到数据
redis-cli
127.0.0.1:6379> hgetall clicks
1) "Jek"
2) "./prod?id=199"
3) "Marry"
4) "./prod?id=100"
5) "Alice"
6) "./prod?id=199"
7) "Bob"
8) "./home"
发送了多条数据, Redis中只有 8条数据. 原因是hash中的key重复了, 后面的会把前面的覆盖掉。
四、输出到ElasticSearch
ElasticSearch是一个分布式的开源搜索和分析引擎,适用于所有类型的数据。ElasticSearch有着简洁的REST风格的API,以良好的分布式特性、速度和可扩展性而闻名,在大数据领域应用非常广泛。 Flink为ElasticSearch专门提供了官方的Sink 连接器,Flink 1.13 支持当前最新版本的ElasticSearch。 写入数据的ElasticSearch的测试步骤如下。
<1> 添加Elasticsearch连接器依赖
<!--Elasticsearch连接器依赖-->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-elasticsearch7_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>
<2> 启动 Elasticsearch 集群
$ ./bin/elasticsearch
<3> 编写输出到 Elasticsearch 的示例代码
package com.kunan.StreamAPI.Sink;
import com.kunan.StreamAPI.Source.Event;
import org.apache.flink.api.common.functions.RuntimeContext;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.connectors.elasticsearch.ElasticsearchSinkFunction;
import org.apache.flink.streaming.connectors.elasticsearch.RequestIndexer;
import org.apache.flink.streaming.connectors.elasticsearch7.ElasticsearchSink;
import org.apache.http.HttpHost;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.client.Requests;
import java.util.ArrayList;
import java.util.HashMap;
public class SinkToES {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
DataStreamSource<Event> Stream = env.fromElements(
new Event("Mary", "./home", 1000L),
new Event("Bob", "./cart", 1500L),
new Event("Alice", "./prod?id=100", 1800L),
new Event("Bob", "./prod?id=1", 2000L),
new Event("Alice", "./prod?id=200", 3000L),
new Event("Bob", "./home", 2500L),
new Event("Bob", "./prod?id=120", 3600L),
new Event("Bob", "./prod?id=130", 4000L)
);
//定义hots列表
ArrayList<HttpHost> httpHosts = new ArrayList<>();
httpHosts.add(new HttpHost("hadoop102",9200));
//定义ElasticsearchFunction
ElasticsearchSinkFunction<Event> elasticsearchSinkFunction = new ElasticsearchSinkFunction<Event>() {
@Override
public void process(Event event, RuntimeContext runtimeContext, RequestIndexer requestIndexer) {
HashMap<String, String> map = new HashMap<>();
map.put(event.user, event.url);
//构建一个IndexRequest
IndexRequest request = Requests.indexRequest()
.index("clicks")
.source(map);
requestIndexer.add(request);
}
};
//写入ES
Stream.addSink(new ElasticsearchSink.Builder<>(httpHosts,elasticsearchSinkFunction).build());
env.execute();
}
}
与 RedisSink类似,连接器也实现了写入到Elasticsearch的SinkFunction——ElasticsearchSink。区别在于这个类的构造方法是私有(private)的,需要使用 ElasticsearchSink的Builder内部静态类,调用它的build()方法才能创建出真正的SinkFunction。而Builder的构造方法中又有两个参数:
- httpHosts:连接到的Elasticsearch集群主机列表;
- elasticsearchSinkFunction:这并不是SinkFunction,而是用来说明具体处理逻辑、准备数据向Elasticsearch发送请求的函数;
具体的操作需要重写中elasticsearchSinkFunction中的process方法,可以将要发送的数据放在一个HashMap中,包装成IndexRequest向外部发送HTTP请求。
<4> 运行代码,访问Elasticsearch查看是否收到数据,查询结果如下所示。
#查看当前所有索引
$ curl "hadoop102:9200/_cat/indices?v"
health status index uuid pri rep docs.count docs.deleted store.size pri.store.size
yellow open clicks osA9S5yTQpmaYIPZdShhNA 1 1 8 0 5.3kb 5.3kb
#查看具体信息
$ curl "hadoop102:9200/clicks/_search?pretty"
五、输出到MySQL(JDBC)
关系型数据库有着非常好的结构化数据设计、方便的SQL查询,是很多企业中业务数据存储的主要形式。MySQL就是其中的典型代表。尽管在大数据处理中直接与MySQL交互的场景不多,但最终处理的计算结果是要给外部应用消费使用的,而外部应用读取的数据存储往往就是MySQL。所以需要知道如何将数据输出到 MySQL这样的传统数据库。写入数据的MySQL的测试步骤如下:
<1> 添加依赖
<!--MySql连接器依赖-->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-jdbc_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<2> 在My_Flink_Test库下建表CLICKS
USE My_Flink_Test;
CREATE TABLE CLICKS(
USER VARCHAR(100) NOT NULL
,URL VARCHAR(100) NOT NULL
);
<3> 编写输出到MySQL的示例代码:
package com.kunan.StreamAPI.Sink;
import com.kunan.StreamAPI.Source.Event;
import org.apache.flink.connector.jdbc.JdbcConnectionOptions;
import org.apache.flink.connector.jdbc.JdbcSink;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
public class SinkToMySQL {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
DataStreamSource<Event> Stream = env.fromElements(
new Event("Mary", "./home", 1000L),
new Event("Bob", "./cart", 1500L),
new Event("Alice", "./prod?id=100", 1800L),
new Event("Bob", "./prod?id=1", 2000L),
new Event("Alice", "./prod?id=200", 3000L),
new Event("Bob", "./home", 2500L),
new Event("Bob", "./prod?id=120", 3600L),
new Event("Bob", "./prod?id=130", 4000L)
);
Stream.addSink(JdbcSink.sink(
"INSERT INTO CLICKS(USER,URL) VALUES (? , ?)",
((statement,event) -> {
statement.setString(1,event.user);
statement.setString(2,event.url);
}),
new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
.withUrl("jdbc:mysql://hadoop102:3306/My_Flink_Test")
.withDriverName("com.mysql.jdbc.Driver")
.withUsername("root")
.withPassword("000000")
.build()
));
env.execute();
}
}
<4>运行代码,查看是否成功写入数据。
SELECT * FROM CLICKS;