像分布式的任务,我们当然希望是能一直平稳的运行,但现实有各种各样的情况会导致程序或者机器发生故障。分布式中单点故障,并不会影响其他节点的运行,单点故障中,我们需要重启服务和节点,此时也是需要能保证结果的正确性,故障恢复的速度,对处理性能的影响等。
Flink提供的数据恢复的方式,就是检查点设置,就是将某一时刻系统的状态保存下来,这样当系统故障时,可以从最近的一次检查点中恢复回来。这样其实有一个问题就是当数据在上一个算子A已经处理完,到下一个算子B的时候还没处理,这个时候保存状态的话就会有问题。因为当数据恢复的时候,我们是没有保存数据的,就要让数据重发,而这时A已经处理完该数据了,重发数据就意味着需要再处理一次。其实,这个应该是一个数据要么是所有算子都处理完保存一个状态,要么就是所有算子都还没处理时保存状态。这样就相当于把所有算子都处理完,看成是一个事务,后续数据重放的时候就不会出现错误。
其实这个就跟Flink是数据驱动的原理是一样,假设有A,B,C三个先后的算子,当下单检查点命令后,该命令会随数据一起进入到A,假设A是源算子,A会记录数据的偏移量,假设就是3,然后3就会被保存到状态中,到了B算子,B算子处理完数据之后,也会把状态保存下来,到了C算子,也是如此,处理完数据之后,再保存状态。唯有等A,B,C三个算子都处理完成,状态都正常保存之后。才会向上面反馈状态已经保存完成,期间不管哪一步出现问题,该检查点的保存都不算成功。
假设上面的检查点保存之后,发生故障,这是A算子在读取偏移量为5的数据,B算子在处理偏移量为4的数据,C刚处理完偏移量为3的数据,且已经保存好状态了。这是系统就会判断A的偏移量为3,然后重新让源数据从便宜量为4的数据重新读取并处理,这样就能确保数据处理的精确一次性了。
Flink中检查点的设置有两种算法,分别是检查点分界线(Barrier)和分布式快照算法。
检查点分界线算法就是类似于水位线的原理一样,在数据流中插入一个特殊的数据结构,之后的任务遇到它就会开始状态的保存,它的位置是限定好的,不能超过其他数据,也不能被其他数据超过。
分布式快照算法,就是一种异步分界线快照(asynchronous barrier snapshotting)算法,算法的核心就是两个原则:当上游任务向多个并行下游任务发送分界线barrier时,需要广播出去;而当多个上游任务向同一个下游任务传递分界线barrier时, 需要在下游任务执行分界线对齐(barrier alignment)操作,也就是需要等到所有并行分区的 barrier 都到齐,才可以开始状态的保存。具体的内容不做介绍。
(1)启用检查点
默认情况下,Flink是没有启用检查点的,需要手动指明。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 每隔 3 秒启动一次检查点保存,参数是毫秒
env.enableCheckpointing(3000);
(2)检查点存储
Flink主要提供了两种检查点存储方式CheckpointStorage:作业管理器的堆内存
(JobManagerCheckpointStorage)和文件系统(FileSystemCheckpointStorage)。
// 配置存储检查点到 JobManager 堆内存
env.getCheckpointConfig().setCheckpointStorage(new JobManagerCheckpointStorage());
// 配置存储检查点到文件系统
env.getCheckpointConfig().setCheckpointStorage(
new FileSystemCheckpointStorage("hdfs://namenode:40010/flink/checkpoints"));
(3)其他高级配置
检查点还有很多可以配置的选项,可以通过获取检查点配置(CheckpointConfig)来进行设置。
CheckpointConfig checkpointConfig = env.getCheckpointConfig();
检查点模式(CheckpointingMode)
设置检查点一致性的保证级别,有“精确一次”(exactly-once)和“至少一次”(at-least-once)两个选项。默认级别为exactly-once,而对于大多数低延迟的流处理程序,at-least-once 就够用了,而且处理效率会更高。
超时时间(checkpointTimeout)
用于指定检查点保存的超时时间,超时没完成就会被丢弃掉。传入一个长整型毫秒数作为参数,表示超时时间。
最小间隔时间(minPauseBetweenCheckpoints)
用于指定在上一个检查点完成之后,检查点协调器(checkpoint coordinator)最快等多久可以出发保存下一个检查点的指令。这就意味着即使已经达到了周期触发的时间点,只要距离上一个检查点完成的间隔不够,就依然不能开启下一次检查点的保存。这就为正常处理数据留下了充足的间隙。
当指定这个参数时,下面的值maxConcurrentCheckpoints 强制为 1。
最大并发检查点数量(maxConcurrentCheckpoints)
用于指定运行中的检查点最多可以有多少个。由于每个任务的处理进度不同,完全可能出现后面的任务还没完成前一个检查点的保存、前面任务已经开始保存下一个检查点了。这个参数就是限制同时进行的最大数量。
如果前面设置了 minPauseBetweenCheckpoints,则 maxConcurrentCheckpoints 这个参数就不起作用了。
开启外部持久化存储(enableExternalizedCheckpoints)
用于开启检查点的外部持久化,而且默认在作业失败的时候不会自动清理,如果想释放空间需要自己手工清理。里面传入的参数 ExternalizedCheckpointCleanup 指定了当作业取消的时候外部的检查点该如何清理。
检查点异常时是否让整个任务失败(failOnCheckpointingErrors)
用于指定在检查点发生异常的时候,是否应该让任务直接失败退出。默认为 true,如果设置为 false,则任务会丢弃掉检查点然后继续运行。
不对齐检查点(enableUnalignedCheckpoints)
不再执行检查点的分界线对齐操作,启用之后可以大大减少产生背压时的检查点保存时间。这个设置要求检查点模式(CheckpointingMode)必须为 exctly-once,并且并发的检查点个数为 1。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 启用检查点,间隔时间 1 秒
env.enableCheckpointing(1000);
CheckpointConfig checkpointConfig = env.getCheckpointConfig();
// 设置精确一次模式
checkpointConfig.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 最小间隔时间 500 毫秒
checkpointConfig.setMinPauseBetweenCheckpoints(500);
// 超时时间 1 分钟
checkpointConfig.setCheckpointTimeout(60000);
// 同时只能有一个检查点
checkpointConfig.setMaxConcurrentCheckpoints(1);
// 开启检查点的外部持久化保存,作业取消后依然保留
checkpointConfig.enableExternalizedCheckpoints( ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
// 启用不对齐的检查点保存方式
checkpointConfig.enableUnalignedCheckpoints();
// 设置检查点存储,可以直接传入一个 String,指定文件系统的路径
checkpointConfig.setCheckpointStorage("hdfs://my/checkpoint/dir")
检查点是由 Flink 自动管理的,定期创建, 发生故障之后自动读取进行恢复,而保存点(Savepoint)是由哟用户手动触发保存操作的,一般用作以下情况时使用。
bin/flink savepoint :jobId [:targetDirectory]
这里 jobId 需要填充要做镜像保存的作业 ID,目标路径 targetDirectory 可选,表示保存点存储的路径。
对于保存点的默认路径,可以通过配置文件 flink-conf.yaml 中的 state.savepoints.dir 项来设定:
state.savepoints.dir: hdfs:///flink/savepoints
当然对于单独的作业,我们也可以在程序代码中通过执行环境来设置:
env.setDefaultSavepointDir("hdfs:///flink/savepoints");
由于创建保存点一般都是希望更改环境之后重启,所以创建之后往往紧接着就是停掉作业的操作。除了对运行的作业创建保存点,我们也可以在停掉一个作业时直接创建保存点:
bin/flink stop --savepointPath [:targetDirectory] :jobId
(2)从保存点重启应用
提交启动一个 Flink 作业,使用的命令是 flink run;现在要从保存点重启一个应用,其实本质是一样的:
bin/flink run -s :savepointPath [:runArgs]
这里只要增加一个-s 参数,指定保存点的路径就可以了。
一致性其实就是结果的正确性,这里简单理解就是Flink系统中发生故障后,恢复的数据应该和没有发生故障的时候得出来的数据应该是一样的。一般说来,状态一致性有三种级别:
实际情况比上面的精确一次还要高要求,要求是端到端的精确一次性,上面的精确一次和Flink内部的策略能保证Flink内部的精确一次性,但是怎么保证输入端和输出端的精确一次性,这个就需要外部输入端和输出端也能有精确一次的保证策略才行。
(1)输入端保证
输入端最重要的就是要有数据重放功能,就是数据发送的时候,同时会发送数据的偏移量,但当数据的接收方向输入端发送偏移量的时候,需要输入端根据偏移量重新发送之前的数据。像Socket文本流就只是负责发送,并不保留数据,这样当数据消费完之后,就不能从新发送之前的数据了。而kafka就具备数据重放的功能。
(2)输出端保证
输出端这里了解事务(transactional)写入即可。相对数据输入端,输出端最大的问题就是不能撤回之前写入的数据,但利用事务写入可以做到。具体的事务写入又有两种实现的方法,具体如下:
预写日志(write-ahead-log,WAL)
当输出端系统不支持事务的处理时,可以使用预写日志,其原理比较简单,具体步骤如下:
需要注意的是,预写日志这种一批写入的方式,有可能会写入失败;所以在执行写入动作之后,必须等待发送成功的返回确认消息。在成功写入所有数据后,在内部再次确认相应的检查点,这才代表着检查点的真正完成。这里需要将确认信息也进行持久化保存,在故障恢复时,只有存在对应的确认信息,才能保证这批数据已经写入,可以恢复到对应的检查点位置。
但这种再次确认的方式,也会有一些缺陷。如果我们的检查点已经成功保存、数据也成功地一批写入到了外部系统,但是最终保存确认信息时出现了故障,Flink 最终还是会认为没有成功写入。于是发生故障时,不会使用这个检查点,而是需要回退到上一个;这样就会导致这批数据的重复写入。
阶段提交(two-phase-commit,2PC)
当输出端系统支持事务的处理时,可以使用两阶段提交的方法,它需要外部系统支持事务处理,具体步骤如下:
当中间发生故障时,当前未提交的事务就会回滚,于是所有写入外部系统的数据也就实现了撤回。两阶段提交虽然能实现端到端的精确一次性,但对外部系统的要求也比较高,要求外部系统有如下功能:
因为Kafka不仅作为输入端有数据重放的能力,还作为输出端有事务处理的能力,同时Flink也对Kafka作了很多支持,使用起来也是比较方便。
(1)kafka输入
在 Source 任务(FlinkKafkaConsumer)中将当前读取的偏移量保存为算子状态,写入到检查点中;当发生故障时,从检查点中读取恢复状态,并由连接器 FlinkKafkaConsumer 向Kafka 重新提交偏移量,就可以重新消费数据、保证结果的一致性了。
(2)Kafka输出
Flink 官方实现的Kafka 连接器中,提供了写入到Kafka 的 FlinkKafkaProducer,它就实现了 TwoPhaseCommitSinkFunction 接口:
public class FlinkKafkaProducer<IN> extends TwoPhaseCommitSinkFunction<
IN,
FlinkKafkaProducer.KafkaTransactionState,
FlinkKafkaProducer.KafkaTransactionContext> {
...
}
(3)Kafka配置
上面介绍的都是中层和底层的API,但实际应用中往往要面对大量的处理逻辑,一般针对数据的最简单的就是使用SQL语句了,Flink也做了对SQL和TableAPI的支持,这个就是最上层的API了。
首先,需要引入依赖如下:
<dependency>
<groupId>org.apache.flinkgroupId>
<artifactId>flink-table-api-java-bridge_${scala.binary.version}artifactId>
<version>${flink.version}version>
dependency>
<dependency>
<groupId>org.apache.flinkgroupId>
<artifactId>flink-table-planner-blink_${scala.binary.version}artifactId>
<version>${flink.version}version>
dependency>
<dependency>
<groupId>org.apache.flinkgroupId>
<artifactId>flink-streaming-scala_${scala.binary.version}artifactId>
<version>${flink.version}version>
dependency>
另外,如果想实现自定义的数据格式来做序列化,可以引入下面的依赖:
<dependency>
<groupId>org.apache.flinkgroupId>
<artifactId>flink-table-commonartifactId>
<version>${flink.version}version>
dependency>
使用TableAPI,首先需要创建一个表环境(TableEnvironment),然后将数据流(DataStream)转换成一个表(Table);之后就可以执行 SQL 在这个表中查询数据了。查询得到的结果依然是一个表,把它重新转换成流就可以打印输出了。
简单示例如下:
其中 Event 是自定义的类型,包含了 user、url 和 timestamp 三个字段。
// 读取数据源
SingleOutputStreamOperator<Event> eventStream = env
.fromElements(
new Event("Alice", "./home", 1000L),
new Event("Bob", "./cart", 1000L),
new Event("Alice", "./prod?id=1", 5 * 1000L), new Event("Cary", "./home", 60 * 1000L),
new Event("Bob", "./prod?id=3", 90 * 1000L), new Event("Alice", "./prod?id=7", 105 * 1000L)
);
// 获取表环境
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 将数据流转换成表
Table eventTable = tableEnv.fromDataStream(eventStream);
// 用执行SQL的方式提取数据
Table visitTable = tableEnv.sqlQuery("select url, user from " + eventTable);
// 将表转换成数据流,打印输出
tableEnv.toDataStream(visitTable).print();
其实上面用到的都是先获取流处理的环境,再将流装换成表来处理,最后再将表装换成流来输出,但其实Flink里面本身对表也有支持,也就是可以直接使用表环境,用TableAPI直接操作表的输入,装换和输出。程序基本框架大致如下:
// 直接创建表环境
TableEnvironment tableEnv = ...;
// 创建输入表,使用关键字 WITH 连接外部系统读取数据
tableEnv.executeSql("CREATE TEMPORARY TABLE inputTable ... WITH ( 'connector'= ... )");
// 注册一个表,使用关键字 WITH 连接到外部系统,用于输出
tableEnv.executeSql("CREATE TEMPORARY TABLE outputTable ... WITH ( 'connector'= ... )");
// 执行 SQL 对表进行查询转换,得到一个新的表
Table table1 = tableEnv.sqlQuery("SELECT ... FROM inputTable... ");
// 使用 Table API 对表进行查询转换,得到一个新的表
Table table2 = tableEnv.from("inputTable").select(...);
// 将得到的结果写入输出表
TableResult tableResult = table1.executeInsert("outputTable");
这种其实才是更普遍的用法,因为当使用TableAPI时,我们也并不希望跟流处理扯上关系,免得搞混。
TableAPI和SQL跟流Stream的执行环境是不一样的,而且里面的接口也不一样,所以需要为表单独创建环境,表环境主要负责的内容如下:
这里的目录Catalog主要用来管理所有数据库(database)和表(table)的元数据(metadata),默认的Catalog就叫作 default_catalog。
每个表和 SQL 的执行,都必须绑定在一个表环境(TableEnvironment)中。TableEnvironment是 Table API 中提供的基本接口类,可以通过调用静态的.create()方法来创建一个表环境实例。方法需要传入一个环境的配置参数EnvironmentSettings,它可以指定当前表环境的执行模式和计划器(planner)。
EnvironmentSettings settings = EnvironmentSettings
.newInstance()
.inStreamingMode() // 有批处理和流处理模式,这里使用流处理模式
.build();
TableEnvironment tableEnv = TableEnvironment.create(settings);
或者直接使用流处理的环境,从流处理中创建表的环境,这个也是上面的例子中用到的创建表环境的方法。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
表其实就是像我们平时理解的表,由字段和行构成的一个二维矩阵的文件。为了方便维护表,每个表在目录Catalog中都有一个唯一的ID,表的ID由目录名+数据库名+表名构成,目录名默认是default_catalog,数据库名默认是default_database,如果表名取的是Table1,那它的ID就是default_catalog.default_database.Table1。
自定义配置目录Catalog和数据库名可以有如下设置,这样后续的表名都以此作为前缀:
tableEnv.useCatalog("custom_catalog");
tableEnv.useDatabase("custom_database");
表的创建方式有两种,通过连接器connector创建或虚拟表virtual tables创建。
(1)连接器表(Connector Tables)
基本语法为:
tableEnv.executeSql("CREATE [TEMPORARY] TABLE MyTable ... WITH ( 'connector'= ... )");
调用表环境的executeSql方法传入DDL语句,其中TEMPORARY关键字可以省略,connector是使用单引号传递参数,connector的详细配置后面根据具体的外部系统再介绍。
(2)虚拟表(Virtual Tables)
基本语法为:
Table newTable = tableEnv.sqlQuery("SELECT ... FROM MyTable... ");
这里得到的并不是存在外部的实体表,而是存在内存的虚拟表,也就是说这个表是没有保存数据的,等需要使用的时候,才会去执行查询语句,将查询得到的结果作为表的内容。如果还想直接使用这个表执行SQL,还需要将表在环境中先注册,才能在SQL中使用:
tableEnv.createTemporaryView("NewTable", newTable);
这里使用的方法是创建临时视图,也就是说这个表其实跟数据库中的视图非常类似,其实就是指保存语句而已,等真正需要的时候再通过语句查询内容。
Flink提供了两种查询方式,SQL查询和TableAPI查询,SQL查询更接近于数据库的查询语句,而TableAPI更接近于Java语言的调用模式。
(1)执行 SQL 进行查询
Flink对于SQL语句的支持,是基于Apache Calcite来对SQL语句进行解析的。SQL语句的查询方式很简单,只要调用表环境的 sqlQuery()方法,传入一个字符串形式的 SQL 查询语句就可以了。执行得到的结果,是一个 Table 对象。
// 创建表环境
TableEnvironment tableEnv = ...;
// 创建表
tableEnv.executeSql("CREATE TABLE EventTable ... WITH ( 'connector' = ... )");
// 查询用户 Alice 的点击事件,并提取表中前两个字段
Table aliceVisitTable = tableEnv.sqlQuery(
"SELECT user, url " +
"FROM EventTable " +
"WHERE user = 'Alice' "
);
目前Flink支持标准SQL中的绝大部分用法,以后这块肯定也是会越来越完善。目前通过Group by,count,sum这些都是能很好的支持的。
(2)调用 Table API 进行查询
使用TableAPI时,首先需要得到表的对象,这里使用from()方法来获取表对象。
// 创建表环境
TableEnvironment tableEnv = ...;
// 创建表
tableEnv.executeSql("CREATE TABLE EventTable ... WITH ( 'connector' = ... )");
// 获取表EventTable的对象eventTable
Table eventTable = tableEnv.from("EventTable");
注意EventTable是在环境中注册的表名,代表的是连接外部的表,而eventTable 是一个Table对象,是在Java环境中的对象的概念,基于这个对象才能调用TableAPI的方法。
Table maryClickTable = eventTable
.where($("user").isEqual("Alice"))
.select($("url"), $("user"));
这里每个方法的参数都是一个表达式(Expression),用方法调用的形式直观地说明了想要表达的内容;“$”符号用来指定表中的一个字段。调用TableAPI和调用SQL表达的含义都是一样的。目前TableAPI相对SQL来说,功能还是少一点的。
(3)两种API的合并使用
因为不管上面哪种方式,其实最后得到的都是一个Table对象,所以两种API可以方便的结合在一起使用,具体看各人习惯。
输出表的注册方式跟出入表的注册方式是一样的,而输出表的数据输出则是直接调用Table 的方法 executeInsert()即可,参数就是传入一个输出的表名。
// 注册表,用于输出数据到外部系统
tableEnv.executeSql("CREATE TABLE OutputTable ... WITH ( 'connector' = ... )");
// 经过查询转换,得到结果表
Table inputTable = ...
// 将结果表写入已注册的输出表中
inputTable .executeInsert("OutputTable");
或者使用插入语句,将数据插入到外部表中:
// 将查询结果输出到 OutputTable 中
tableEnv.executeSql ( "INSERT INTO OutputTable " +
"SELECT user, url " +
"FROM EventTable " +
"WHERE user = 'Alice' "
);
一般测试的环境,我们需要将表处理的结果打印到控制台显示,在流式环境下,这个是很好解决的,但是在表环境下又该怎么处理。这其中就涉及到了需要将表和流转换的过程,Flink同样也提供了很多的方法。
(1)将表(Table)转换成流(DataStream)
调用 toDataStream()方法
最简单的方法就是直接调用表环境的toDataStream()方法就可以转换成流了。
Table aliceVisitTable = tableEnv.sqlQuery( "SELECT user, url " +
"FROM EventTable " +
"WHERE user = 'Alice' "
);
// 将表转换成数据流
tableEnv.toDataStream(aliceVisitTable).print();
调用 toChangelogStream()方法
Flink中的表跟平时的表不一样的地方在于,数据库中的表原先的数据都是固定的,而Flink中的表是数据一条一条读取进来的(因为是流式环境),这样在涉及Group by等操作的时候,表输出的数据其实是会更新的。比如第一条数据是(a,1),group by之后还是(a,1),但第二条数据(a,2)来了之后,就变成了(a,3)。但这个时候的第一次group by后的数据(a,1)已经输出了,要怎么撤回或者说是更新数据呢。直接打印到控制台或者是转换成流都是不行的。
而对于这样有更新的表,我们不要试图直接把它转换成 DataStream 打印输出,而是记录一下它的更新日志(change log)。这样一来,对于表的所有更新操作,就变成了一条更新日志的流,我们就可以转换成流打印输出了。
Table urlCountTable = tableEnv.sqlQuery( "SELECT user, COUNT(url) " +
"FROM EventTable " + "GROUP BY user "
);
// 将表转换成更新日志流
tableEnv.toChangelogStream(urlCountTable).print();
(2)将流(DataStream)转换成表(Table)
调用 fromDataStream()方法
流转换成表最简单的也是直接调用表环境的fromDataStream()方法来实现,返回的就是一个 Table 对象。期间,还可以提出属性作为表的字段名,或者把属性名改成另外的字段名。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 获取表环境
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 读取数据源
SingleOutputStreamOperator<Event> eventStream = env.addSource(...)
// 将数据流转换成表
Table eventTable = tableEnv.fromDataStream(eventStream);
// 将 timestamp 字段重命名为 ts
Table eventTable2 = tableEnv.fromDataStream(eventStream, $("timestamp").as("ts"),
$("url"));
调用createTemporaryView()方法
可以直接调用表环境的创建虚拟视图的方式来创建表,传入的两个参数,第一个依然是注册的表名,而第二个可以直接就是DataStream。之后仍旧可以传入多个参数,用来指定表中的字段。
tableEnv.createTemporaryView("EventTable", eventStream,$("timestamp").as("ts"),$("url"));
整体来看,Table中支持大部分的数据类型,这里只是将Table中支持的类型大概介绍一下,并讲一下细节。
原子类型
Flink中的基础数据类型和通用数据类型可以直接转。
DataStream<Long> stream = ...;
// 将数据流转换成动态表,动态表只有一个字段,重命名为 myLong
Table table = tableEnv.fromDataStream(stream, $("myLong"));
Tuple类型
当Tuple不做重命名时,默认的字段名就是“f0”,“f1”子类的,可以只提取部分字段,也可以重新安排顺序,还能重命名。
// 将数据流转换成只包含 f1 字段的表
Table table = tableEnv.fromDataStream(stream, $("f1"));
// 将数据流转换成包含 f0 和 f1 字段的表,在表中 f0 和 f1 位置交换
Table table = tableEnv.fromDataStream(stream, $("f1"), $("f0"));
// 将 f1 字段命名为 myInt,f0 命名为 myLong
Table table = tableEnv.fromDataStream(stream, $("f1").as("myInt"),$("f0").as("myLong"));
POJO类型
之前使用的类型都是POJO类型,对于自定义的业务更容易理解。
Table table = tableEnv.fromDataStream(stream);
Table table = tableEnv.fromDataStream(stream, $("user"));
Table table = tableEnv.fromDataStream(stream,$("user").as("myUser"),$("url").as("myUrl"));
Row类型
Flink 中还定义了一个在关系型表中更加通用的数据类型——行(Row),它是 Table 中数据的基本组织形式。Row 类型也是一种复合类型,它的长度固定,而且无法直接推断出每个字段的类型,所以在使用时必须指明具体的类型信息;我们在创建 Table 时调用的 CREATE 语句就会将所有的字段名称和类型指定,这在 Flink 中被称为表的“模式结构”(Schema)。除此之外,Row 类型还附加了一个属性 RowKind,用来表示当前行在更新操作中的类型。这样, Row 就可以用来表示更新日志流(changelog stream)中的数据,从而架起了 Flink 中流和表的转换桥梁。
所以在更新日志流中,元素的类型必须是 Row,而且需要调用 ofKind()方法来指定更新类型。下面是一个具体的例子:
DataStream<Row> dataStream = env.fromElements(
Row.ofKind(RowKind.INSERT, "Alice", 12),
Row.ofKind(RowKind.INSERT, "Bob", 5),
Row.ofKind(RowKind.UPDATE_BEFORE, "Alice", 12),
Row.ofKind(RowKind.UPDATE_AFTER, "Alice", 100));
// 将更新日志流转换为表
Table table = tableEnv.fromChangelogStream(dataStream);
其实上面也有说过,数据库里面的表或者其他批处理的表,数据都是先固定好的,每次查询都可以基于全部的数据查询得出结果数据。但Flink中的流式处理不一样,数据是一条一条来的,这意味着表的数据是动态的。由此也引出了另外的概念,就是持续查询。查询的算子是固定在程序中的,每当有数据来临,就会计算一次,因此动态表的查询都是持续查询的。
因为动态表是持续查询的,我们可以根据查询结果是否需要更新,还是只是追加,将持续查询分为更新查询和追加查询,因为这涉及到结果的更新,对这两类不同的查询方式调用的方法和原理都是不同的。
(1)更新查询
更新查询的简单理解就是持续查询会对已经输出的结果产生影响,就是会更新已经输出的结果,叫做更新查询。简单的例子如下:
// 对用户进行分组,统计点击url的次数
Table urlCountTable = tableEnv.sqlQuery(
"SELECT user, COUNT(url) as cnt
FROM EventTable GROUP BY user");
当输入数据(a,home)时,a访问home的次数为1,结果为a,1;当再次输入数据(a,home)时,a访问home的次数变成了2,结果应该为a,2。这时需要更新已经输出的数据,是为更新查询。
更新查询表urlCountTable如果想要转换成DataStream,必须调用 toChangelogStream()方法。
(2)追加查询
追加查询的简单理解就是不会对已经输出的结果产生影响,只会在原先的结果后面追加新的结果,叫做追加查询。简单的例子如下:
Table aliceVisitTable = tableEnv.sqlQuery("SELECT url, user FROM EventTable WHERE
user = 'Cary'");
当输入(Cary,home)时,会输出home,Cary;当再次输出(Cary,home)时,会在后面追加输出home,Cary。
追加查询表aliceVisitTable果想要转换成DataStream,可以调用 toDataStream(),也可以调用toChangelogStream()。
表的窗口查询也是属于追加查询,因为表的窗口查询都是在窗口关闭时将数据一次性输出,下一个窗口的数据只会追加到上一个窗口数据的后面。
(3)查询限制
实际应用的情况下,有些持续查询会因为计算代价太大,而需要对其做限制,不然会导致集群资源不足。
状态大小
如统计所有用户的点击商品数,会随着时间的推移,越来越多的用户点击,或者某个爆发的时候导致用户大量进去,这时候的状态就非常大。一是要考虑尽量避免长期跑持续查询的作业,二是要对这类作业加以限制。
更新计算
如统计用户点击次数的排名,因为每个用户点击次数的排名更新都会导致此用户后面所有的排名重新计算,当数据越来越大时,重新计算的代价非常大。避免方法跟上面一样。
动态表也是可以转换为流的,动态表跟其他的表一样,也有数据的插入、删除和更新的操作,而根据操作类型的不同,可以将动态表转换成不同编码方式的流。Flink中的TableAPI和SQL支持如下三种编码方式,分别是仅追加流,撤回流和更新插入流。
(1)仅追加(Append-only)流
动态表中只有数据的插入,没有数据的更新,这种可以直接转化成仅追加流。
(2)撤回(Retract)流
撤回流是包含两类信息的流,添加add和撤回retract信息流,当动态表中有更新和删除时,可以将动态表转换成撤回流,删除就是retract,追加就是add,更新就是先删除,后插入。
(3)更新插入(Upsert)流
更新插入,要求动态表中有主键key,Upsert其实就是update和insert的合并词,意思就是当key存在时就是更新,当key不存在时就插入,这样原本需要2个语句的更新,就成了1句,而删除还是一样。
在流中时,我们有事件时间和处理时间,同样在表中也是可以设置的,但在表中的设置跟流中的设置有不一样的地方。
(1)在创建表的DDL中定义水位线
在创建表的 DDL(CREATE TABLE 语句)中,可以增加 WATERMARK 语句来定义事件时间属性,也就是水位线。
CREATE TABLE EventTable( user STRING,
url STRING,
ts TIMESTAMP(3),
WATERMARK FOR ts AS ts - INTERVAL '5' SECOND
) WITH ( ... )
WATERMARK FOR指定ts作为时间戳,并通过INTERVAL 设置水位线的延迟时间为5秒,后面的5必须使用单引号括起来。Flink 中支持的事件时间属性数据类型必须为TIMESTAMP 或者TIMESTAMP_LTZ。如果原始的时间戳就是一个长整型的毫秒数,则需要将长整型转换成TIMESTAMP_LTZ才可以。
ts BIGINT,
ts_ltz AS TO_TIMESTAMP_LTZ(ts, 3),
WATERMARK FOR ts_ltz AS ts_ltz - INTERVAL '5' SECOND
(2)在数据流转换为表时定义时间属性
在DataStream 转换为表,调用 fromDataStream()时,调用.rowtime() 指定时间属性。
// 方法一:
// 流中数据类型为二元组 Tuple2,包含两个字段;需要自定义提取时间戳并生成水位线
DataStream<Tuple2<String, String>> stream = inputStream.assignTimestampsAndWatermarks(...);
// 声明一个额外的逻辑字段作为事件时间属性
Table table = tEnv.fromDataStream(stream, $("user"), $("url"),$("ts").rowtime());
// 方法二:
// 流中数据类型为三元组 Tuple3,最后一个字段就是事件时间戳
DataStream<Tuple3<String, String, Long>> stream = inputStream.assignTimestampsAndWatermarks(...);
// 不再声明额外字段,直接用最后一个字段作为事件时间属性
Table table = tEnv.fromDataStream(stream, $("user"), $("url"),$("ts").rowtime());
这里注意的是,时间戳的提取和水位线的生成都是在流中已经先定义好的,生成了ts的时间戳,然后再转换成表的时候能直接使用。
处理时间就比较简单了,它就是我们的系统时间,使用时不需要提取时间戳(timestamp)和生成水位线(watermark)。因此在定义处理时间属性时,必须要额外声明一个字段,专门用来保存当前的处理时间。
(1)在创建表的DDL中定义
在创建表的 DDL(CREATE TABLE 语句)中,可以增加一个额外的字段,通过调用系统内置的 PROCTIME()函数来指定当前的处理时间属性,返回的类型是TIMESTAMP_LTZ。
CREATE TABLE EventTable( user STRING,
url STRING,
ts AS PROCTIME()
) WITH ( ... );
(2)在数据流转换为表时定义
处理时间属性同样可以在将 DataStream 转换为表的时候来定义。 我们调用fromDataStream()方法创建表时,可以用.proctime()后缀来指定处理时间属性字段。由于处理时间是系统时间,原始数据中并没有这个字段,所以处理时间属性一定不能定义在一个已有字段上,只能定义在表结构所有字段的最后,作为额外的逻辑字段出现。
DataStream<Tuple2<String, String>> stream = ...;
// 声明一个额外的字段作为处理时间属性字段
Table table = tEnv.fromDataStream(stream, $("user"), $("url"),$("ts").proctime());
Flink现在用的是窗口表值函数(Windowing TVFs),窗口表值函数是 Flink 定义的多态表函数PTF,可以将表进行扩展后返回。目前提供了滚动窗口,滑动窗口,累积窗口和会话窗口等。在窗口的返回值中,还多了三个列,分别是窗口起始点(window_start)、窗口结束点(window_end)和窗口时间(window_time)。窗口起始点和窗口结束点就是窗口的开始和结束时间,窗口时间其实就是window_end-1ms,相当于窗口所能包含数据的最大时间戳。
(1)滚动窗口(TUMBLE)
滚动窗口在SQL 中的概念与 DataStream API 中的定义完全一样,是长度固定、时间对齐、无重叠的窗口,一般用于周期性的统计计算。窗口 TVF 本质上是表函数,可以对表进行扩展,所以应该把当前查询的表作为参数整体传入。具体声明如下:
TUMBLE(TABLE EventTable, DESCRIPTOR(ts), INTERVAL '1' HOUR)
以上就是对于表EventTable,再其ts字段上面开窗,开1小时长度大小的滚动窗口。
(2)滑动窗口(HOP)
滑动窗口的使用与滚动窗口类似,可以通过设置滑动步长来控制统计输出的频率。
HOP(TABLE EventTable, DESCRIPTOR(ts), INTERVAL '5' MINUTES, INTERVAL '1' HOURS));
这里我们基于时间属性 ts,在表 EventTable 上创建了大小为 1 小时的滑动窗口,每 5 分钟滑动一次。第三个参数是步长,最后一个才是窗口的大小。
(3)累积窗口(CUMULATE)
当统计的窗口时间过长时,我们又希望能隔一段时间输出统计的结果,这种将统计结果不断累加的开窗方式成为累积窗口。例如统计一天的点击量,但是按每小时输出一次数据。
CUMULATE(TABLE EventTable, DESCRIPTOR(ts), INTERVAL '1' HOURS, INTERVAL '1' DAYS))
这里我们基于时间属性 ts,在表 EventTable 上定义了一个统计周期为 1 天、累积步长为 1
小时的累积窗口。注意第三个参数为步长 step,第四个参数则是最大窗口长度。
(4)会话窗口因为Flink的支持还不完善,暂时不讲。
(1)分组聚合
其实分组聚合的内容上面都是说过的,分组聚合就是使用SUM()、MAX()、MIN()、AVG()以及 COUNT()对表进行聚合得到统计的数,其中需要注意的是动态表转换成流时需要先考虑聚合后的结果是否会更新,并在持续查询的过程中状态是否会溢出等情况。分组聚合中也是可以使用类似SQL中的distinct去重,having子句筛选分组等。
(2)窗口聚合
由于窗口函数返回的本身就是一个表,因此窗口的聚合函数直接将窗口函数当成一个表来看待即可,此时的Group by只需加上窗口聚合的时间属性即可,具体如下:
Table result = tableEnv.sqlQuery(
"SELECT " +
"user, " +
"window_end AS endT, " +
"COUNT(url) AS cnt " +
"FROM TABLE( " +
"TUMBLE( TABLE EventTable, "
+ "DESCRIPTOR(ts), "
+ + "INTERVAL '1' HOUR)) " +
"GROUP BY user, window_start, window_end "
);
注意窗口函数只输出最后的统计结果,不像统计函数那样需要更新输出结果。
(3)开窗聚合(Over)
开窗Over的含义这里不作介绍,具体去看数据库中开窗函数Over的用法。Flink的SQL中也是支持over的使用的,示例如下:
SELECT user, ts,
COUNT(url) OVER ( PARTITION BY user ORDER BY ts
RANGE BETWEEN INTERVAL '1' HOUR PRECEDING AND CURRENT ROW
) AS cnt
FROM EventTable
这里我们以 ts 作为时间属性字段,对 EventTable 中的每行数据都选取它之前 1 小时的所有数据进行聚合,统计每个用户访问 url 的总次数,并重命名为 cnt。
也可以用 WINDOW 子句来在 SELECT 外部单独定义一个OVER 窗口:
SELECT user, ts,
COUNT(url) OVER w AS cnt,
MAX(CHAR_LENGTH(url)) OVER w AS max_url FROM EventTable
WINDOW w AS ( PARTITION BY user ORDER BY ts
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
上面的w就是下面window的定义。
(4)Top N查询
简单示例:
SELECT user, url, ts, row_num FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY user
ORDER BY CHAR_LENGTH(url) desc
) AS row_num FROM EventTable)
WHERE row_num <= 2
窗口TopN示例:
// 定义子查询,进行窗口聚合,得到包含窗口信息、用户以及访问次数的结果表
String subQuery =
"SELECT window_start, window_end, user, COUNT(url) as cnt " +
"FROM TABLE ( " +
"TUMBLE( TABLE EventTable, DESCRIPTOR(ts), INTERVAL '1' HOUR )) " +
"GROUP BY window_start, window_end, user ";
// 定义Top N的外层查询
String topNQuery =
"SELECT * " +
"FROM (" +
"SELECT *, " +
"ROW_NUMBER() OVER ( " +
"PARTITION BY window_start, window_end " +
"ORDER BY cnt desc " +
") AS row_num " +
"FROM (" + subQuery + ")) " +
"WHERE row_num <= 2";
// 执行SQL得到结果表
Table result = tableEnv.sqlQuery(topNQuery);
常规的SQL中最常使用的就是多表连接,因为为了避免数据冗余,数据都是按照一定格式通过主键连接查询的,在Flink的表中也是如此。所以Flink中的联结的用法跟SQL基本是一样的,这是只展示基本用法,不再介绍。
(1)1.等值内联结(INNER Equi-JOIN)
简单示例:
SELECT *
FROM Order
INNER JOIN Product
ON Order.product_id = Product.id
(2)2.等值外联结(OUTER Equi-JOIN)
简单示例:
// 左连接
SELECT *
FROM Order
LEFT JOIN Product
ON Order.product_id = Product.id
// 右连接
SELECT *
FROM Order
RIGHT JOIN Product
ON Order.product_id = Product.id
// 全连接
SELECT *
FROM Order
FULL OUTER JOIN Product
ON Order.product_id = Product.id
简单示例:
SELECT *
FROM Order o, Shipment s WHERE o.id = s.order_id
AND o.order_time BETWEEN s.ship_time - INTERVAL '4' HOUR AND s.ship_time
虽然有了TableAPI和SQL,但是这些都是要嵌入到Java/Scala代码中进行的,而且编写完还需要打包提交作业才能运行。其实还有一个简单的方法就是Flink给我们提供的SQL客户端,它提供了一个命令行交互界面(CLI),可以直接在里面编写SQL语句,整个Flink的应用编写、提交过程就都成了写SQL了。
具体配置流程如下:
(1)首先启动本地集群
./bin/start-cluster.sh
(2)启动 Flink SQL 客户端
./bin/sql-client.sh
(3)设置运行模式
配置运行环境,有流处理和批处理两个选项,默认是流处理:
Flink SQL> SET 'execution.runtime-mode' = 'streaming';
配置执行结果模式,主要有 table、changelog、tableau 三种,默认是Table模式。table 模式就是最普通的表处理模式,结果会以逗号分隔每个字段;changelog 则是更新日志模式,会在数据前加上“+”(表示插入)或“-”(表示撤回)的前缀;而 tableau 则是经典的可视化表模式,结果会是一个虚线框的表格。
Flink SQL> SET 'sql-client.execution.result-mode' = 'table';
(4)执行 SQL 查询
CREATE TABLE EventTable (
user STRING,
url STRING,
ts BIGINT )
WITH (
'connector' = 'filesystem',
'path' = 'events.csv',
'format' = 'csv'
);
CREATE TABLE ResultTable (
user STRING,
url STRING,
cnt BIGINT )
WITH (
'connector' = 'print'
);
Flink SQL> INSERT INTO ResultTable SELECT user, COUNT(url) as cnt FROM EventTable GROUP BY user;
可以在上面的语句中看出,在使用WITH子句的时候,里面有参数可以设置,其中的connector就是连接器的意思,且不管是输入还是输入表的语法都是一样的,这个跟Stream的源算子和输出算子不一样,这是因为Flink在执行过程中,会自动解析判断是输出还是输入。
最简单的输出器就是打印到控制台了,上面也做了演示。
(1)引入依赖
当想在Flink程序中使用kafka的时候,需要导入Kafka的依赖,前面在演示的时候已经引入的就不用重复引入。
<dependency>
<groupId>org.apache.flinkgroupId>
<artifactId>flink-connector-kafka_${scala.binary.version}artifactId>
<version>${flink.version}version>
dependency>
当在Flink程序中需要使用到Kafka支持的格式时,也是需要导入对应的依赖,Kafka对CSV、JSON、Avro 格式都是支持的,例如csv的支持。
<dependency>
<groupId>org.apache.flinkgroupId>
<artifactId>flink-csvartifactId>
<version>${flink.version}version>
dependency>
如果是想在SQL客户端使用Kafka连接器,则需要下载对应的 jar 包放到 lib 目录下,而SQL客户端已经内置了csv,JSON的支持,因此无需导入,而没有内置支持的格式(比如 Avro),则需要下载对应的jar包放到lib目录下。
(2)连接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'
)
这里定义了连接器为kafka,Kafka的主题topic,也就是名字标识,Kafka的服务器,消费者组ID,消费者的起始模式,以及表格的格式。其中在KafkaTable的字段中用到了METADATA FROM,这表示的是一个元数据列,这里的 timestamp 其实就是Kafka 中数据自带的时间戳,将时间戳提取出来,转成ts字段。
(3)Upsert Kafka
正常情况下,Kafka 作为保持数据顺序的消息队列,读取和写入都应该是流式的数据,对应的就是仅追加的模式。如果我们想要有更新操作的数据,kafka就会提示异常错误。为此kafka专门新增了一个更新插入Upsert连接器。就叫upsert-kafka。这个跟之前说的是一样的,需要要求表table有主键,如果找到对应的key,就更新,找不到,就插入。当key值有值,而value为空时,认为就是将值删除处理。
简单示例:
CREATE TABLE pageviews_per_region ( user_region STRING,
pv BIGINT, uv BIGINT,
PRIMARY KEY (user_region) NOT ENFORCED
) WITH (
'connector' = 'upsert-kafka',
'topic' = 'pageviews_per_region',
'properties.bootstrap.servers' = 'localhost:9092',
'key.format' = 'avro',
'value.format' = 'avro'
);
这里需要SQL客户端支持avro,也就是需要在lib目录下面下载avro对应的jar包。
另一类常见的就是文件系统读写了,Flink本身就内置了对文件系统的支持,所以这个不需要额外的引入依赖或者导包。这里在 WITH 前使用了 PARTITIONED BY 对数据进行了分区操作。文件系统连接器支持对分区文件的访问。
简单示例如下:
CREATE TABLE MyTable (
name STRING,
age INT,
part_name1 STRING,
part_name2 STRING )
PARTITIONED BY (part_name1,part_name2) WITH (
'connector' = 'filesystem', // 连接器类型
'path' = '...', // 文件路径
'format' = '...' // 文件格式
)
关系型数据表本身就是数据库里面使用的,当然Flink也支持向数据库中读写数据。Flink 提供的 JDBC 连接器可以通过JDBC 驱动程序(driver)向任意的关系型数据库读写数据。作为 TableSink 向数据库写入数据时,运行的模式取决于创建表的 DDL 是否定义了主键primary key。如果有主键,那么 JDBC 连接器就将以更新插入(Upsert)模式运行,可以向外部数据库发送按照指定键(key)的更新(UPDATE)和删除(DELETE)操作;如果没有定义主键,那么就将在追加(Append)模式下运行,不支持更新和删除操作。
(1)引入依赖
引入JDBC连接器的依赖。
<dependency>
<groupId>org.apache.flinkgroupId>
<artifactId>flink-connector-jdbc_${scala.binary.version}artifactId>
<version>${flink.version}version>
dependency>
针对特定的数据库,还需引入数据库的驱动器相关依赖,例如MySql,特别注意是版本号需对其。
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<version>5.1.38version>
dependency>
(2)创建JDBC表
简单示例:
-- 创建一张连接到 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'
);
Apache Hive 作为一个基于 Hadoop 的数据仓库基础框架,可以说已经成为了进行海量数据分析的核心组件,因此Flink对Hive的集成做了特殊处理。Flink 提供了Hive 目录(HiveCatalog)功能,允许使用
Hive 的元存储(Metastore)来管理 Flink 的元数据。这个有以下两方面的好处:
(1)Metastore 可以作为一个持久化的目录,因此使用 HiveCatalog 可以跨会话存储 Flink 特定的元数据。这样一来,我们在 HiveCatalog 中执行执行创建Kafka 表或者ElasticSearch 表, 就可以把它们的元数据持久化存储在 Hive 的 Metastore 中;
(2)使用HiveCatalog,Flink 可以作为读写Hive 表的替代分析引擎。这样一来,在Hive 中进行批处理会更加高效;与此同时,也有了连续在 Hive 中读写数据、进行流处理的能力, 这也使得“实时数仓”(real-time data warehouse)成为了可能。
Hive与Flink连接的配置如下:
(1)引入依赖
第一步同样式引入依赖,但Hive各版本之间的变化比较大,因此需要在官网查询最新的依赖配置。
第二步是Hive是基于Hadoop的组件,因此我们首先需要提供 Hadoop 的相关支持,在环境变量中设置HADOOP_CLASSPATH:
export HADOOP_CLASSPATH=`hadoop classpath`
在 Flink 程序中可以引入以下依赖:
<dependency>
<groupId>org.apache.flinkgroupId>
<artifactId>flink-connector-hive_${scala.binary.version}artifactId>
<version>${flink.version}version>
dependency>
<dependency>
<groupId>org.apache.hivegroupId>
<artifactId>hive-execartifactId>
<version>${hive.version}version>
dependency>
打包时,建议不要将这些依赖打包到结果 jar 文件中,而是在运行时的集群环境中为不同的 Hive
版本添加不同的依赖支持。
(2)连接到Hive
在 Flink 中连接 Hive,是通过在表环境中配置 HiveCatalog 来实现的。需要说明的是,配置 HiveCatalog 本身并不需要限定使用哪个 planner,不过对 Hive 表的读写操作只有 Blink 的planner 才支持。所以一般我们需要将表环境的 planner 设置为 Blink。
下面是代码中配置Catalog 的示例
EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().build();
TableEnvironment tableEnv = TableEnvironment.create(settings);
String name = "myhive"
String defaultDatabase = "mydatabase"; String hiveConfDir = "/opt/hive-conf";
// 创建一个 HiveCatalog,并在表环境中注册
HiveCatalog hive = new HiveCatalog(name, defaultDatabase, hiveConfDir); tableEnv.registerCatalog("myhive", hive);
// 使用 HiveCatalog 作为当前会话的 catalog tableEnv.useCatalog("myhive");
我们也可以直接启动SQL 客户端,用CREATE CATALOG 语句直接创建HiveCatalog:
Flink SQL> create catalog myhive with (
'type' = 'hive',
'hive-conf-dir' = '/opt/hive-conf');
# [INFO] Execute statement succeed.
Flink SQL> use catalog myhive;
# [INFO] Execute statement succeed.
(3)设置SQL方言
(4)读写 Hive 表
CEP就是复杂事件处理(Complex Event Processing)的缩写;而 Flink CEP,就是 Flink 实现的一个用于复杂事件处理的库(library)。具体的处理过程是,把事件流中的一个个简单事件,通过一定的规则匹配组合起来,这就是“复杂事件”;然后基于这些满足规则的一组组复杂事件进行转换处理,得到想要的结果进行输出。
总结起来,复杂事件处理(CEP)的流程可以分成三个步骤:
(1)定义一个匹配规则
(2)将匹配规则应用到事件流上,检测满足规则的复杂事件
(3)对检测到的复杂事件进行处理,得到结果进行输出
模式(Pattern)
上面说的第一步就是定义一个匹配规则,也就是匹配的模式,匹配模式包含两部分内容。一是事件自己本身的特征,二就是事件之间的组合关系。事件自己本身的特征好理解,就是需要什么样类型的数据。而事件之间的组合关系其实就是事件发生的顺序,例如两个事件之间需要紧邻着,这叫严格紧邻关系;或者两个事件是要顺序即可,中间可以有其他事件,这叫宽松紧邻关系;或者反向定义,事件后面不能跟着谁。另外还有事件是否可以重复,事件与事件之间的限定时间等。
CEP一般应该在风险控制,用户画像,运维监控等,任何跟多事件关联的业务上面都可以运用。
使用CEP之前的第一步都需要引入依赖。
(1)引入依赖
要想在代码中使用CEP,需要先引入相关的依赖,如下:
<dependency>
<groupId>org.apache.flinkgroupId>
<artifactId>flink-cep_${scala.binary.version}artifactId>
<version>${flink.version}version>
dependency>
(2)个体模式
每个个体模式都以一个连接词开始定义的,比如 begin、next 等等,这是 Pattern 对象的一个方法(begin 是 Pattern 类的静态方法),返回的还是一个 Pattern。这些连接词方法有一个 String 类型参数,这就是当前个体模式唯一的名字,比如这里的“first”、“second”。在之后检测到匹配事件时,就会以这个名字来指代匹配事件。
个体模式需要一个过滤条件,用来指定具体的匹配规则。这个条件一般是通过调用.where()方法来实现的,具体的过滤逻辑则通过传入的 SimpleCondition 内的.filter()方法来定义。例如:
.<LoginEvent>begin("first") // 以第一个登录失败事件开始
.where(new SimpleCondition<LoginEvent>() {
@Override
public boolean filter(LoginEvent loginEvent) throws Exception {
return loginEvent.eventType.equals("fail");
}
})
// 或者后面连接的
.next("second") // 接着是第二个登录失败事件
.where(new SimpleCondition<LoginEvent>() {
@Override
public boolean filter(LoginEvent loginEvent) throws Exception {
return loginEvent.eventType.equals("fail");
}
})
另外个体模式,还可以接收多个事件,就是通过量词,让个体进行循环多次匹配。
(3)量词
个体模式包含单例(singleton)模式和循环(looping)模式。默认情况下,个体模式是单例模式,匹配接收一个事件;当定义了量词之后,就变成了循环模式,可以匹配接收多个事件。这里的多个事件只要保证前后顺序即可,中间可以有其他事件,所以是“宽松近邻”关系。
在 Flink CEP 中,可以使用不同的方法指定循环模式,主要有:
// 匹配事件出现 4 次
pattern.times(4);
// 匹配事件出现 4 次,或者不出现
pattern.times(4).optional();
// 匹配事件出现 2, 3 或者 4 次
pattern.times(2, 4);
// 匹配事件出现 2, 3 或者 4 次,并且尽可能多地匹配
pattern.times(2, 4).greedy();
// 匹配事件出现 2, 3, 4 次,或者不出现
pattern.times(2, 4).optional();
// 匹配事件出现 2, 3, 4 次,或者不出现;并且尽可能多地匹配
pattern.times(2, 4).optional().greedy();
// 匹配事件出现 1 次或多次
pattern.oneOrMore();
// 匹配事件出现 1 次或多次,并且尽可能多地匹配
pattern.oneOrMore().greedy();
// 匹配事件出现 1 次或多次,或者不出现
pattern.oneOrMore().optional();
// 匹配事件出现 1 次或多次,或者不出现;并且尽可能多地匹配
pattern.oneOrMore().optional().greedy();
// 匹配事件出现 2 次或多次
pattern.timesOrMore(2);
// 匹配事件出现 2 次或多次,并且尽可能多地匹配
pattern.timesOrMore(2).greedy();
// 匹配事件出现 2 次或多次,或者不出现
pattern.timesOrMore(2).optional()
// 匹配事件出现 2 次或多次,或者不出现;并且尽可能多地匹配
pattern.timesOrMore(2).optional().greedy();
(4)条件(Conditions)
对于条件的定义,主要是通过调用 Pattern 对象的.where()方法来实现的,主要可以分为简单条件、迭代条件、复合条件、终止条件几种类型。此外,也可以调用 Pattern 对象的.subtype() 方法来限定匹配事件的子类型。
pattern.subtype(SubEvent.class);
这里 SubEvent 是流中数据类型 Event 的子类型。这时,只有当事件是 SubEvent 类型时, 才可以满足当前模式pattern 的匹配条件。
middle.oneOrMore()
.where(new IterativeCondition<Event>() {
@Override
public boolean filter(Event value, Context<Event> ctx) throws Exception {
// 事件中的 user 必须以 A 开头
if (!value.user.startsWith("A")) {
return false;
}
int sum = value.amount;
// 获取当前模式之前已经匹配的事件,求所有事件 amount 之和
for (Event event : ctx.getEventsForPattern("middle")) {
sum += event.amount;
}
// 在总数量小于 100 时,当前事件满足匹配规则,可以匹配成功
return sum < 100;
}
});
pattern.subtype(SubEvent.class)
.where(new SimpleCondition<SubEvent>() {
@Override
public boolean filter(SubEvent value) {
return ... // some condition
}
});
将上面的个体模式、量词和条件等组合起来,定义一个完整的复杂事件匹配规则,就叫组合模式。一般形式如下:
Pattern<Event, ?> pattern = Pattern
.<Event>begin("start").where(...)
.next("next").where(...)
.followedBy("follow").where(...)
...
(1)初始模式
初始模式必须通过调用 Pattern 的静态方法.begin()来创建。如下所示:
Pattern<Event, ?> start = Pattern.<Event>begin("start");
begin中传入的参数就是模式的名称。
(2)近邻条件
Flink提供了三种近邻关系的条件判断,如需要判断的模式是AB,此时的数据流是ABACCBC。
(3)其他限制条件
还可以用否定的连接词来匹配模式,如.notNext()和.notFollowedBy();还能有时间限定,如.within()。
// 严格近邻条件
Pattern<Event, ?> strict = start.next("middle").where(...);
// 宽松近邻条件
Pattern<Event, ?> relaxed = start.followedBy("middle").where(...);
// 非确定性宽松近邻条件
Pattern<Event, ?> nonDetermin = start.followedByAny("middle").where(...);
// 不能严格近邻条件
Pattern<Event, ?> strictNot = start.notNext("not").where(...);
// 不能宽松近邻条件
Pattern<Event, ?> relaxedNot = start.notFollowedBy("not").where(...);
// 时间限制条件
middle.within(Time.seconds(10));
(4)循环模式中的近邻条件
上面说到的循环模式中判断的是宽松近邻条件的,那有没有办法让循环模式中的判断条件改成严格近邻条件的呢。Flink给我们准备了.consecutive()方法,为循环模式中的匹配事件增加严格的近邻条件,保证所有匹配事件是严格连续的。也就是说,一旦中间出现了不匹配的事件,当前循环检测就会终止。
// 1. 定义 Pattern,登录失败事件,循环检测 3 次
Pattern<LoginEvent, LoginEvent> pattern = Pattern
.<LoginEvent>begin("fails")
.where(new SimpleCondition<LoginEvent>() {
@Override
public boolean filter(LoginEvent loginEvent) throws Exception {
return loginEvent.eventType.equals("fail");
}
}).times(3).consecutive();
调用.allowCombinations()可以为循环模式中的事件指定非确定性宽松近邻条件,表示可以重复使用已 经 匹配的事件。
一般来说,代码中定义的模式序列,就是我们在业务逻辑中匹配复杂事件的规则。不过在有些非常复杂的场景中,可能需要划分多个“阶段”,每个“阶段”又有一连串的匹配规则。为了应对这样的需求,Flink CEP 允许我们以“嵌套”的方式来定义模式。
之前在模式序列中,我们用 begin()、next()、followedBy()、followedByAny()这样的“连接词”来组合个体模式,这些方法的参数就是一个个体模式的名称;而现在它们可以直接以一个模式序列作为参数,就将模式序列又一次连接组合起来了。这样得到的就是一个“模式组”(Groups of Patterns)。在模式组中,每一个模式序列就被当作了某一阶段的匹配条件,返回的类型是一个GroupPattern。而 GroupPattern 本身是 Pattern 的子类;所以个体模式和组合模式能调用的方法, 比如 times()、oneOrMore()、optional()之类的量词,模式组一般也是可以用的。
具体在代码中的应用如下所示:
// 以模式序列作为初始模式
Pattern<Event, ?> start = Pattern.begin(
Pattern.<Event>begin("start_start")
.where(...)
.followedBy("start_middle").where(...)
);
// 在 start 后定义严格近邻的模式序列,并重复匹配两次
Pattern<Event, ?> strict = start.next(
Pattern.<Event>begin("next_start")
.where(...)
.followedBy("next_middle").where(...)
).times(2);
// 在 start 后定义宽松近邻的模式序列,并重复匹配一次或多次
Pattern<Event, ?> relaxed = start.followedBy(
Pattern.<Event>begin("followedby_start").where(...)
.followedBy("followedby_middle").where(...)
).oneOrMore();
//在 start 后定义非确定性宽松近邻的模式序列,可以匹配一次,也可以不匹配
Pattern<Event, ?> nonDeterminRelaxed = start.followedByAny(
Pattern.<Event>begin("followedbyany_start").where(...)
.followedBy("followedbyany_middle").where(...)
).optional();
在放宽近邻的情况下,匹配的结果就很多了,有时我们并不需要那么多的结果,显得有些冗余,这时就可以通过跳过策略,只取里面的部分结果。
在 Flink CEP中,提供了模式的匹配后跳过策略(After Match Skip Strategy),专门用来精准控制循环模式的匹配结果。这个策略可以在Pattern 的初始模式定义中,作为 begin()的第二个参数传入:
Pattern.begin("start", AfterMatchSkipStrategy.noSkip())
.where(...)
...
假设代码中定义Pattern如下:
Pattern.<Event>begin("a").where(new SimpleCondition<Event>() {
@Override
public boolean filter(Event value) throws Exception {
return value.user.equals("a");
}
}).oneOrMore()
.followedBy("b").where(new SimpleCondition<Event>() {
@Override
public boolean filter(Event value) throws Exception {
return value.user.equals("b");
}
});
我们如果输入事件序列“a a a b”——这里为了区分前后不同的 a 事件,可以记作“a1 a2 a3 b”——那么应该检测到 6 个匹配结果:(a1 a2 a3 b),(a1 a2 b),(a1 b),(a2 a3 b),(a2 b),
(a3 b)。如果在初始模式的量词.oneOrMore()后加上.greedy()定义为贪心匹配,那么结果就是:
(a1 a2 a3 b),(a2 a3 b),(a3 b),每个事件作为开头只会出现一次。
接下来看跳过策略对匹配结果的影响:
定义好模式之后需要将模式用来流的匹配和提取上面,并根据此转换最终想要得到的信息。
将模式应用到事件流上的代码非常简单,只要调用 CEP 类的静态方法.pattern(),将数据流DataStream和模式Pattern作为两个参数传入就可以了。最终得到的是一个 PatternStream:
DataStream<Event> inputStream = ...
Pattern<Event, ?> pattern = ...
PatternStream<Event> patternStream = CEP.pattern(inputStream, pattern);
还能传入一个比较器,用来进行更精确的排序规则:
// 可选的事件比较器
EventComparator<Event> comparator = ...
PatternStream<Event> patternStream = CEP.pattern(input, pattern, comparator);
PatternStream 的转换操作主要可以分成两种:简单便捷的选择提取select操作,和更加通用、更加强大的处理process操作。与 DataStream 的转换类似,具体实现也是在调用API 时传入一个函数类:选择操作传入的是一个 PatternSelectFunction,处理操作传入的则是一个 PatternProcessFunction。
(1)匹配事件的选择提取
就是从PatternStream中直接把复杂事件提取出来,包装成想要的信息输出,这个操作就是选取提取(select)。
PatternStream<Event> patternStream = CEP.pattern(inputStream, pattern);
DataStream<String> result = patternStream.select(new MyPatternSelectFunction());
PatternSelectFunction会将检测到的匹配事件保存在一个 Map 里,对应的 key 就是这些事件的名称。这里的事件名称就对应着在模式中定义的每个个体模式的名称;而个体模式可以是循环模式,一个名称会对应多个事件,所以最终保存在 Map 里的value 就是一个事件的列表(List)。
MyPatternSelectFunction的示例:
class MyPatternSelectFunction implements PatternSelectFunction<Event, String>{
@Override
public String select(Map<String, List<Event>> pattern) throws Exception {
Event startEvent = pattern.get("start").get(0);
Event middleEvent = pattern.get("middle").get(0);
return startEvent.toString() + " " + middleEvent.toString();
}
}
PatternSelectFunction 里需要实现一个 select()方法,这个方法每当检测到一组匹配的复杂事件时都会调用一次。它以保存了匹配复杂事件的 Map 作为输入,经自定义转换后得到输出信息返回。这里我们假设之前定义的模式序列中,有名为“start”和“middle”的两个个体模式, 于是可以通过这个名称从 Map 中选择提取出对应的事件。注意调用 Map 的.get(key)方法后得到的是一个事件的List;如果个体模式是单例的,那么List 中只有一个元素,直接调用.get(0) 就可以把它取出。
多次登录失败报警输出:
// 1. 定义 Pattern,登录失败事件,循环检测 3 次
Pattern<LoginEvent, LoginEvent> pattern = Pattern.<LoginEvent>begin("fails")
.where(new SimpleCondition<LoginEvent>() {
@Override
public boolean filter(LoginEvent loginEvent) throws Exception {
return loginEvent.eventType.equals("fail");
}
}).times(3).consecutive();
// 2. 将 Pattern 应用到流上,检测匹配的复杂事件,得到一个
PatternStream PatternStream<LoginEvent> patternStream = CEP.pattern(stream, pattern);
// 3. 将匹配到的复杂事件选择出来,然后包装成报警信息输出
patternStream.select(new PatternSelectFunction<LoginEvent, String>() {
@Override
public String select(Map<String, List<LoginEvent>> map) throws Exception {
// 只有一个模式,匹配到了 3 个事件,放在 List 中
LoginEvent first = map.get("fails").get(0);
LoginEvent second = map.get("fails").get(1);
LoginEvent third = map.get("fails").get(2);
return first.userId + " 连续三次登录失败!登录时间:" + first.timestamp
+ ", " + second.timestamp + ", " + third.timestamp;
}
}).print("warning");
// 3. 将匹配到的复杂事件选择出来,然后包装成报警信息输出
patternStream.flatSelect(new PatternFlatSelectFunction<LoginEvent, String>() {
@Override
public void flatSelect(Map<String, List<LoginEvent>> map, Collector<String> out) throws Exception {
LoginEvent first = map.get("fails").get(0);
LoginEvent second = map.get("fails").get(1);
LoginEvent third = map.get("fails").get(2);
out.collect(first.userId + " 连续三次登录失败!登录时间:" + first.timestamp +
", " + second.timestamp + ", " + third.timestamp);
}
}).print("warning");
相对来说,PatternFlatSelectFunction比PatternSelectFunction更加的灵活,功能也能覆盖。
(2)匹配事件的通用处理
就像之前讲的,PatternProcessFunction接口一般都是最底层的接口。实际上,上面的两个接口PatternFlatSelectFunction和PatternSelectFunction底层上面都是调用了PatternProcessFunction接口,所以PatternProcessFunction函数更加的强大,功能也是更加的丰富。
简单示例如下:
// 3. 将匹配到的复杂事件选择出来,然后包装成报警信息输出
patternStream.process(new PatternProcessFunction<LoginEvent, String>() {
@Override
public void processMatch(Map<String,
List<LoginEvent>> map,
Context ctx,
Collector<String> out) throws Exception {
LoginEvent first = map.get("fails").get(0);
LoginEvent second = map.get("fails").get(1);
LoginEvent third = map.get("fails").get(2);
out.collect(first.userId + " 连续三次登录失败!登录时间:" + first.timestamp +
", " + second.timestamp + ", " + third.timestamp);
}
}).print("warning");
PatternProcessFunction 中必须实现一个 processMatch()方法;这个方法与之前的 flatSelect()类似,只是多了一个上下文 Context 参数。利用这个上下文可以获取当前的时间信息,比如事件的时间戳(timestamp)或者处理时间(processing time);还可以调用.output()方法将数据输出到侧输出流。
复杂事件检测处理的一般流程具体如下:
(1)如果当前事件符合模式匹配的条件,就接受该事件,保存到对应的 Map 中;
(2)如果在模式序列定义中,当前事件后面还应该有其他事件,就继续读取事件流进行检测;如果模式序列的定义已经全部满足,那么就成功检测到了一组匹配的复杂事件,调用PatternProcessFunction 的processMatch()方法进行处理;
(3)如果当前事件不符合模式匹配的条件,就丢弃该事件;
(4)如果当前事件破坏了模式序列中定义的限制条件,比如不满足严格近邻要求,那么当前已检测的一组部分匹配事件都被丢弃,重新开始检测。
当如果加入超时检测,以上就会有一点不同,因为超时检测不同于模式不匹配,它有可能是部分匹配,就是前面的匹配,但后面的因为在规定时间内没有符合条件的数据到来,因超时而失败。这时前面匹配的数据不应该丢弃,而是应该输出提示或者警报信息,这就要求我们有能力捕获并处理超时事件。
class MyPatternProcessFunction extends PatternProcessFunction<Event, String> implements TimedOutPartialMatchHandler<Event> {
// 正常匹配事件的处理
@Override
public void processMatch(Map<String, List<Event>> match, Context ctx, Collector<String> out) throws Exception{
...
}
// 超时部分匹配事件的处理
@Override
public void processTimedOutMatch(Map<String, List<Event>> match, Context ctx) throws Exception{
Event startEvent = match.get("start").get(0);
OutputTag<Event> outputTag = new OutputTag<Event>("time-out"){};
ctx.output(outputTag, startEvent);
}
整个Flink简单介绍就到这里,其中一些内容的明细再做深入。