Flink SQL

文章目录

Table API 和 SQL

在Flink提供的多层级API中,核心是DataStreamAPI,这是我们开发流处理应用的基本途径。底层是处理函数(Process Function),可以访问事件的时间信息、注册定时器、自定义状态,进行有状态的流处理。DataStream API和处理函数比较通用,有了这些API,理论上我们就可以实现所有场景的需求了。

不过在企业的实际应用中,为了提高开发效率,一般大数据的框架都会向上提供SQL语法。不管是Hive、Spark、还是Flink,都提供了SQL语法支持。
Flink的多层级API
Flink提供的Table API这一层中,处理对象从DataStream上升到了Table。此时的数据处理就从DataStream之间的转换变成了Table之间的转换。

需要说明的是,Table API和SQL最初并不完善。在Flink 1.9版本合并阿里内部版本Blink后才发生了比较大的改变,之后也一直处在快速开发和完善的过程中,直到Flink 1.12版本才基本上做到了功能上的完善。但是即使在目前最新的1.13版本中,Table API和SQL也依然不算稳定,接口用法还在不断调整和更新。

1.1 快速上手

事实上,Table API和Flink SQL的使用非常简单,我们首先得到一个"表"(Table),然后对他调用Table API,或者直接写SQL就可以了。

1.1.1 需要引入的依赖

如果我们想要在代码中使用Table API,需要先引入相关的依赖:

<dependency>
	<groupId>org.apache.flink</groupId>
	<artifactId>flink-table-api-java-bridge_${scala.binary.version}</artifactId>
	<version>${flink.version}</version>
</dependency>

这里的依赖是一个Java的"桥接器"(bridge),主要负责Table api和下层DataStream API的连接支持,按照不同的语言分为Java版和Scala版。

如果我们希望在本地的IDE中运行Table API和SQL,还需要引入以下依赖:

<dependency>
	<groupId>org.apache.flink</groupId>
	<artifactId>flink-table-planner-blink_${scala.binary.version}</artifactId>
 	<version>${flink.version}</version>
</dependency>
<dependency>
	<groupId>org.apache.flink</groupId>
 	<artifactId>flink-streaming-scala_${scala.binary.version}</artifactId>
 	<version>${flink.version}</version>
</dependency>

这里主要添加的依赖是blink的"计划器"(planner),它是Table API的核心组件,负责提供运行时环境,并生成程序的执行计划。这里我们用到的是新版的blink planner。由于Flink安装包的lib目录下自带了planner,所以在生产集群环境中提交的作业不需要打包这个依赖。
并且在Table API的内部实现上,部分相关的代码使用Scala实现的,所以还需要额外添加一个Scala版流处理的相关依赖。

另外,如果想实现自定义的数据格式来做序列化,可以引入下面的依赖:

<dependency>
	<groupId>org.apache.flink</groupId>
	<artifactId>flink-table-common</artifactId>
	<version>${flink.version}</version>
</dependency>

1.1.2 一个简单示例

引入依赖后,我们可以尝试在Flink代码中使用Table API和SQL了。比如,我们 自定义一些Event类(包含user、url和timestamp三个字段)作为输入的数据源,然后从中提取url地址和用户名user两个字段作为输出。

这个需求如果用sql实现的话就是:

select url,user from EventTable;

在代码中的具体实现为:

public class TableExample {
	public static void main(String[] args) throws Exception {
 	// 获取流执行环境
 	StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
 	env.setParallelism(1);
 	
 	// 读取数据源
 	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();
 	// 执行程序
 	env.execute();
 	}
}

//+I代表每条数据都是Insert到表中的新增数据
//+I[./home, Alice]
//+I[./cart, Bob]
//+I[./prod?id=1, Alice]
//+I[./home, Cary]
//+I[./prod?id=3, Bob]
//+I[./prod?id=7, Alice]

这里我们的编程模型为:
1.创建流环境(StreamExecutionEnvironment)
2.根据流环境得到输入流(inputStream)
3.根据流环境得到表环境(StreamTableEnvironment)
4.根据表环境 把 输入流转换为表(Table)
5.对Table进行查询操作
6.根据表环境 将返回的结果Table再转换成流打印出去

Table是Table API中的核心接口类,地位等同于DataStream在DataStream API,是我们操作的主要对象。基于Table,我们也可以调用一系列查询方法直接进行转换,这就是所谓的Table API的处理方式:

// 用 Table API 方式提取数据
Table clickTable2 = eventTable.select($("url"), $("user"));

这里的$符号是Table API中定义的"表达式"类 Expressions中的一个方法(scala),对这个方法传入一个字段名称,就可以指代数据表中的对应字段。

1.2 基本API

通过上节中的简单示例,我们对Table API和SQL用法有了一个大致的了解。本节接着上节继续展开,对API的相关用法作一个更详细的说明。

1.2.1 程序架构(编程模型)

在Flink的Table API中,输入数据可以定义成一张表,然后对这张表进行查询(即表之间的转换)。Table经由查询转换成一个新的Table。类似流操作时DataStream的转换操作。 最后还可以定义一张用于输出的表,负责将处理结果写入到外部系统。

我们可以注意到,使用Table API编程的流程和使用DataStream API时非常相似,也可以分为1.创建环境(Env) 2.获得输入数据源(Source) 3.转换(Transform) 4.输出数据(Sink) 4部分。

程序基本架构如下:

// 1.创建表环境
TableEnvironment tableEnv = ...;

// 2.创建输入表,连接外部系统读取数据
tableEnv.executeSql("CREATE TEMPORARY TABLE inputTable ... WITH ( 'connector' = ... )");

// 3.注册一个表,连接到外部系统,用于输出
tableEnv.executeSql("CREATE TEMPORARY TABLE outputTable ... WITH ( 'connector' = ... )");

// 4.1 执行 SQL 对表进行查询转换,得到一个新的表
Table table1 = tableEnv.sqlQuery("SELECT ... FROM inputTable... ");

// 4.2 使用 Table API 对表进行查询转换,得到一个新的表
Table table2 = tableEnv.from("inputTable").select(...);

// 5.将得到的结果写入输出表
TableResult tableResult = table1.executeInsert("outputTable");

与上一节举的例子有一点不同的是,这里获得Table和输出数据时,不是从一个DataStream转换成Table,或是把结果Table转换成DataStream输出。而是直接通过执行DDL来创建一个表。其中执行的CREATE语句中用WITH指定了外部系统的连接器,通过这个连接器到外部系统中读取数据,这样我们就可以完全抛开DataStream API,直接用SQL语句实现全部的流处理过程。相比上一节的DataStream -> Table 1 -> … -> Table n -> DataStream要简洁很多。

我们还可以注意到,我们只是通过和外部系统的连接创建了两张表,至于哪张是输入表,哪种是输出表,完全由sql逻辑决定。比如,当我们从table 1查数据,将数据写入到table 2时,table 1就是输入表,table 2 就是输出表;反过来,如果我们从table 2中查数据,将数据写入到table 1中时,那么table 2就是输入表,table 1就是输出表。

ps:在早期的版本中,有专门的用于输入输出的TableSource和TableSink,现在已被弃用,对于Table来说不再区分Source和Sink。(DataStream还是区分的 eg: env.addSource(SourceFunction); env.addSink(SinkFunction);)

1.2.2 创建表环境

对于Flink这样的流处理框架来说,数据流和表在结构上还是有区别的。所以使用Table API和SQL时需要一个有别于DataStream的运行时环境,这就是所谓的"表环境"(TableEnvironment)。

TableEnvironment主要负责:

  • 注册Catalog和表
  • 执行SQL查询
  • 注册用户自定义函数(UDF)
  • DataStream和表之间的转换

这里的Catalog是"目录",和标准SQL中的概念是一致的,主要用来管理所有数据库(database)和表(table)的元数据(metadata)。可以认为我们所定义的表都是"挂靠"在某个Catalog下的。在表环境中用户可以自定义Catalog,并在其中注册表和自定义函数(UDF)。默认的Catalog叫做default_catalog。

每个表和SQL的执行,都必须依赖于表环境(TableEnvironment)。TableEnvironment是Table API中提供的基本接口类,可以通过调用静态方法TableEnvironment.create()来创建一个表环境实例。方法中需要传入一个环境的配置参数EnvironmentSettings,它可以指定当前表环境的执行模式和计划器(planner)。执行模式有批处理和流处理两种选择,默认是流处理模式。计划器默认使用的是blink planner。

EnvironmentSettings settings = EnvironmentSettings
 .newInstance()
 .inStreamingMode() // 使用流处理模式
 .build();
TableEnvironment tableEnv = TableEnvironment.create(settings);

对于流处理场景,其实默认配置就完全够用了。所以我们可以用另一种更简单的方式来创建表环境。(通过StreamExecutionEnvironment来创建StreamTableEnvironment)

import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

这里的"流式表环境"(StreamTableEnvironment)是继承自TableEnvironment的子接口。调用它的静态方法StreamTableEnvironment.create(),只需要直接将当前的流执行环境(StreamExecutionEnvironment对象)作为参数传入,就可以创建出对应的流式表环境了。这也正是我们之间的例子中使用的方式。

1.2.3 创建表

表(Table)是我们非常熟悉的一个概念,它是关系型数据库中数据存储的基本形式,也是SQL执行的基本对象。
Flink中的表概念也并不特殊,是由多个"行"数据构成的,每个行(Row)又可以有定义好的许多列(Column)字段。整体来看,表就是固定类型的数据组成的二维矩阵。

为了方便的查询表,表环境(TableEnvironment)中会维护一个目录(Catalog)和表的对应关系。所有表都是通过Catalog来进行注册的。表在环境中有一个唯一的ID,由三部分组成:1.目录(Catalog) 2.数据库(Database) 3.表名。 在默认情况下,目录名叫做default_catalog,数据库名叫做default_database。
所以如果我们直接创建一个叫做MyTable的表,事实上我们创建的这个表是:

default_catalog.default_database.MyTable

具体创建表的方式,有通过连接器(connector)创建表和虚拟表(virtual tables)两种。

1.连接器表(Connector Tables)

最直观的创建表的方式,就是通过连接器(connector)连接到外部系统,然后定义出对应的表结构。例如,我们可以连接到Kafka或者外部文件系统,将存储在这些外部系统中的数据以"表"的形式定义出来,这样对表的读写就可以通过连接器转换成对外部系统的读写了。当我们在表环境中读取这种表,连接器就会从外部系统中读取数据并进行转换;而当我们向这种表中写入数据时,连接器就会将数据输出(Sink)到外部系统中。

在代码中,我们可以调用表环境TableEnvironment的executeSql()方法,传入一个DDL作为参数执行SQL操作。这里我们传入一个CREATE语句进行表的创建,并通过WITH关键字指定连接到外部系统的连接器:

tableEnv.executeSql("CREATE [TEMPORARY] TABLE MyTable ... WITH ( 'connector' = ... )");

这里的TEMPORARY关键字可省略。关于连接器,后续我们会再详细进行介绍。

上述例子中我们没有定义Catalog和Database,所以使用的都是默认的Catalog和默认的Database。这个表的完整ID就是default_catalog.default_database.myTable。如果希望使用自定义的目录名和库名,可以使用如下方式进行设置:

tableEnv.useCatalog("custom_catalog");
tableEnv.useDatabase("custom_database");

这样我们创建的表完整ID就变成了custom_catalog.custom_database.MyTable。之后在表环境中创建的所有表,ID也都会以custom_catalog.custom_database作为前缀。

2.虚拟表(Virtual Tables)

当我们在表环境中注册了一个表后(上述已经通过连接器表的方式注册了myTable),我们就可以直接在SQL语句中对这张表进行查询转换了:

Table newTable = tableEnv.sqlQuery("SELECT ... FROM myTable ...");

这里调用了表环境的sqlQuery()方法,直接传入一条SQL语句作为参数执行查询,返回一个Table对象。Table是Flink的Table API中提供的核心接口类,代表一个表实例。

得到的这个newTable对象同样也是一个表实例,如果我们希望继续对newTable进行查询,应该怎么做呢?
由于newTable还并没有在表环境中进行注册,因此我们首先需要在表环境中注册newTable,才能在SQL中直接查询:

tableEnv.createTemporaryView("NewTable",newTable);

这里创建的NewTable可以理解为SQL中的视图,即NewTable是虚拟的,并不会真正的存储数据。当我们对NewTable进行查询的时候,只是把NewTable对应的SQL片段嵌入到我们的查询SQL中去而已。

将中间表注册为虚拟表后,我们就可以用SQL对虚拟表NewTable进行查询转换了。不难看到,通过虚拟表可以非常方便的让SQL分步骤执行得到中间结果,为代码编写提供了很大的便利。

我们也可以通过调用Table对象的现成API的方式来完成查询转换,这与对注册好的表进行SQL查询的效果是一样的。见1.2.4节。

1.2.4 表的查询

创建好了表后,接下来就是要对表进行查询转换了。对一个表的查询(Query)操作,事实上就对应着流数据的转换(Transform)操作。
Flink为我们提供了两种查询方式:SQL和Table API。

1.执行SQL进行查询

基于表执行SQL语句,是我们最为熟悉的查询方式。Flink基于Apache Calcite来提供对SQL的支持,Calcite是一个为不同的计算平台提供标准SQL查询的底层工具,很多大数据框架比如Apache Hive、Apache Kylin中的SQL支持都是通过依赖于Calcite实现的。
在代码中,我们只要调用表环境的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中的绝大部分用法,并提供了丰富的计算函数。这样我们可以直接在Flink中通过写SQL来满足数据需求,大大降低了Flink上手的难度。

例如:我们可以通过GROUP BY关键字定义分组聚合,调用COUNT()、SUM()这样的函数进行统计计算:

Table urlCountTable = tableEnv.sqlQuery(
 "SELECT user, COUNT(url) " +
 "FROM EventTable " +
 "GROUP BY user "
 );

上面的查询返回的是一个新的Table对象urlCountTable,我们可以再次将它注册为虚拟表继续在SQL中调用。
另外,我们也可以直接将查询的结果写入到已经注册的表中,通常用这种方式将查询转换得到的结果数据写入到外部系统,这需要调用表环境TableEnv的executeSql()方法来执行ddl,传入的是一个INSERT语句:

// 注册表
tableEnv.executeSql("CREATE TABLE EventTable ... WITH ( 'connector' = ... )");
tableEnv.executeSql("CREATE TABLE OutputTable ... WITH ( 'connector' = ... )");

// 将查询结果输出到 OutputTable 中
tableEnv.executeSql (
"INSERT INTO OutputTable " +
 "SELECT user, url " +
 "FROM EventTable " +
 "WHERE user = 'Alice' "
 );

2.调用Table API进行查询

另外一种查询方式就是调用Table API。这是嵌入在Java和Scala语言内的查询API,通过一步步链式调用Table的方法,就可以定义出所有的查询转换操作。每一步方法调用的返回结果,都是一个Table。

由于Table API是基于Table的Java实例进行调用的,因此我们首先要得到表的Java对象。先前环境中已经注册过了EventTable,我们可以通过from()方法先得到该表对应的Table对象

tableEnv.executeSql("CREATE TABLE EventTable ... WITH ( 'connector' = ... )");
Table eventTable = tableEnv.from("EventTable");

其中传入的参数就是注册好的表名。注意这里eventTable是一个Table对象,而EventTable是在环境中注册的表名。得到Table对象eventTable之后,我们就可以调用各种Table API进行转换操作了,每一个转换操作得到的都是一个新的Table对象。

Table maryClickTable = eventTable
 .where($("user").isEqual("Alice"))
 .select($("url"), $("user"));

这里"$"符号用来表示指定表中的一个字段,它实际是scala中定义的"表达式"方法。上面的代码和直接执行SQL是等效的。

Table API相比直接写SQL更麻烦一些,并且目前Table API支持的功能相对更少。可以预见未来Flink社区也会以扩展SQL为主。所以我们接下来也会以介绍SQL为主,简略地提及Table API。

3.两种API的结合使用

可以发现,无论是调用Table API还是使用sqlQuery(“sql”)语句执行SQL,返回的都是一个Table对象。所以这两种API的查询可以很方便的结合在一起。

  • 无论是哪种方式得到的Table对象,都可以继续调用Table API进行查询转换
  • 如果想要对一个表执行SQL操作(在SQL语句中用FROM关键字引用),必须现在环境中将该Table对象注册为虚拟表:
tableEnv.createTemporaryView("MyTable",myTable);

注意:这里的第一个参数"MyTable"是注册的表名,即视图名;而第二个参数myTable是Java中的Table对象。

ps:在1.1.2的简单示例中,我们没有将Table对象注册为虚拟表就直接在SQL中使用了:

Table clickTable = tableEnvironment.sqlQuery("select url,user from " + eventTable);

这其实是一种简略的写法,我们将Table对象名eventTable直接以字符串拼接的方式添加到SQL语句中。在解析时会自动注册一个同名的虚拟表到环境中,这样就省略了创建虚拟视图的步骤。

两种API殊途同归,但是从易用性的角度考虑,一般企业中会选择SQL实现的方式。

1.2.5 输出表

表的创建和查询,对应着流处理中的读取数据源(Source)和转换(Transform)。而流处理中最后一个步骤Sink,也就是将结果数据输出到外部系统,就对应着表的输出操作。

在代码中,输出一张表最直接的方法,就是调用Table对象的executeInsert()方法将该table写入到一个已经注册过的表中,方法传入的参数就是注册的表名,通常这个表是与外部系统连接的连接表。

// 注册表,用于输出数据到外部系统
tableEnv.executeSql("CREATE TABLE OutputTable ... WITH ( 'connector' = ... )");
// 经过查询转换,得到结果表
Table result = ...
// 将结果表写入已注册的输出表中
result.executeInsert("OutputTable");

这里将表中数据写入到外部存储系统,类似于DataStream API中调用addSink()方法时传入的SinkFunction,有不同的连接器对它进行实现。关于不同外部系统的连接器,我们在1.8节进行介绍。

1.2.6 表和流的转换

从创建表环境开始,经历了表的创建、查询转换和输出,我们已经可以使用Table API和SQL进行完整的流处理了。不过在应用的开发过程中,我们的测试业务逻辑一般不会直接将Table结果直接写入到外部系统,而是现在本地控制台打印输出。对于DataStream这非常容易,直接调用ds.print()方法就可以看到结果数据流的内容了。但是对Table对象比较悲剧的是-----它并没有提供print()方法,我们需要将Table对象先转换成DataStream流,然后进行打印输出。
这就涉及到了表和流的转换。

1.将表(Table)转换成流(DataStream)

1.调用toDataStream()方法

将一个Table对象转换成DataStream对象非常简单,只要调用表环境tableEnv的toDataStream()方法就可以了。例如:

Table aliceVisitTable = tableEnv.sqlQuery(
 "SELECT user, url " +
 "FROM EventTable " +
 "WHERE user = 'Alice' "
 );
// 将表转换成数据流
tableEnv.toDataStream(aliceVisitTable).print();

这里toDataStream(xx)的入参是Table对象。

2.调用toChangelogStream()方法

上述的aliceVisitTable转换成流打印输出是很简单的。但是,如果我们希望将"用户点击次数统计表"urlCountTable进行打印输出,就会抛出一个TableException异常:

Exception in thread "main" org.apache.flink.table.api.TableException: Table sink 
'default_catalog.default_database.Unregistered_DataStream_Sink_1' doesn't 
support consuming update changes ...

这表示当前的TableSink并不支持表的更新(update)操作,这是什么意思呢?

我们可以把print操作也看成是一个sink操作,只是这个sink的目的地是控制台,这个异常的意思是打印输出的sink操作不支持对数据进行更新。具体来说,urlCountTable这个表中进行了分组聚合统计,所以表中的输出结果是会随着新数据的到来而更新的。比如,Alice的第一个点击事件到来,表中会有一行 (Alice,1);第二个点击事件到来,结果就要被更新为 (Alice,2)。但是之前的 (Alice,1)已经被打印输出了,“覆水难收”,我们又没法对它进行更改,所以就会抛出异常。

解决方法是:对于这样可能会更新结果的表,我们不要试图用toDataStream方法将Table转换成流后打印输出,而是记录它的"更新日志"(change log)。这样一来,对于表的所有更新操作,就变成了一条更新日志的流,有新增,有撤回,我们就可以将Table转换成日志流打印输出了。

代码中需要调用的是表环境tableEnv的toChangelogStream()方法:

Table urlCountTable = tableEnv.sqlQuery(
 "SELECT user, COUNT(url) " +
 "FROM EventTable " +
 "GROUP BY user "
 );
// 将表转换成更新日志流
tableEnv.toChangelogStream(urlCountTable).print();

与"更新日志流"(Changelog Streams)对应的,是那些只会做简单转换、没有进行聚合统计的表,比如前面的aliceVisitTable。这些表的特点是只会插入数据,不会更新,所以也被叫做"仅插入流"(Insert-Only Streams)。

2.将流(DataStream)转换成表(Table)

1.调用fromDataStream()方法

想要将一个DataStream转换成表也很简单,可以通过调用表环境tableEnv的fromDataStream()方法来实现,入参是一个DataStream对象,返回的是一个Table对象。例如,我们可以直接将时间流eventStream转换成一个表:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 获取表环境
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 读取数据源
SingleOutputStreamOperator<Event> eventStream = env.addSource(...)
// 将数据流转换成表
Table eventTable = tableEnv.fromDataStream(eventStream);

由于流中的数据本身就是定义好的POJO类Event,所以我们将流转换成表后,每一行数据就对应着一个Event对象。而表中的列名就对应着Event中的属性名。

另外,我们还可以在fromDataStream()方法中增加参数,用来指定提取哪些属性作为表中的字段名,并可以随意指定属性/字段顺序:

// 提取 Event 中的 timestamp 和 url 作为表中的列
Table eventTable2 = tableEnv.fromDataStream(eventStream, $("timestamp"), $("url"));

需要注意的是,timestamp本身是SQL中的关键字,所以我们在定义表名、列名是要尽量避免。我们可以通过使用表达式的as()方法对字段进行重命名:

// 将 timestamp 字段重命名为 ts
Table eventTable2 = tableEnv.fromDataStream(eventStream, $("timestamp").as("ts"), $("url"));
2.调用createTemporaryView()方法

使用fromDataStream()方法简单直观,可以直接实现DataStream到Table的转换。不过如果我们希望直接在SQL中引用这张表,就还需要调用表环境tableEnv的createTemporaryView()方法来创建虚拟视图了。

之前我们也用过tableEnv.createTemporaryView(“tableName”,Table)的方式来创建虚拟视图。createTemporaryView方法由于重载了,所以我们有两种使用方式。
第一个就是入参是注册的表名和表对象。

tableEnv.createTemporaryView("NewTable",newTable);

第二个就是入参是注册的表名和DataStream对象,之后可以传入任意多个参数,用来指定表中的字段。当然也可以用as()方法为这些字段取别名。

tableEnv.createTemporaryView("EventTable", eventStream, $("timestamp").as("ts"),$("url"));

使用了tableEnv.createTemporaryView方法后,我们就可以在SQL中直接引用注册后的表了(将表名直接包裹在sql语句中)

3.支持的数据类型

前面示例中的DataStream转Table,流中的数据类型都是定义好的POJO类,流转换成表后POJO类的属性名对应的是表中的字段名。如果DataStream中的类型是简单的基本类型,还可以直接转换成表嘛?这就涉及到了Table中支持的数据类型。

虽然实际应用中DataStream中基本都是POJO类,但是这个我们也来了解一下。

先说结论:DataStream中支持的数据类型,Table中也都是支持的,只不过在进行转换时需要注意一些细节。

1.原子类型

在Flink中,基础数据类型(Integer、Double、String)和通用数据类型(也就是不可再拆分的数据类型)统一称为"原子类型"。原子类型的DataStream,转换之后就成了只有一列的Table,列字段(field)的数据类型可以由原子类型推断出。另外,还可以在fromDataStream()方法中增加参数,用来重新命名列字段。

StreamTableEnvironment tableEnv = ...;
DataStream<Long> stream = ...;
// 将数据流转换成动态表,动态表只有一个字段,重命名为 myLong
Table table = tableEnv.fromDataStream(stream, $("myLong"));
2.Tuple类型

当原子类型不做重命名时,默认的字段名是:f0。容易想到,这其实就是将原子类型看做了一元组Tuple1d的处理结果。
Table支持Flink中定义的元组类型Tuple,对应在表中的字段名默认是元组中元素的属性名f0、f1、f2…。所有字段都可以被重新排序,也可以只提取其中的一部分字段。字段也可以通过调用表达式的as()方法来进行重命名。

StreamTableEnvironment tableEnv = ...;
DataStream<Tuple2<Long, Integer>> stream = ...;
// 将数据流转换成只包含 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"));
3.POJO类型(常用)

Flink也支持多种数据类型组合而成的简单Java对象(POJO类型)。由于POJO类中通常已经定义好了可读性强的字段名,这种类型的数据流转换成Table读起来就显得无比顺畅了。

将POJO类型的DataStream转换成Table,如果不手动指定字段名称,就会直接使用原始POJO类型中的属性名称作为表的字段名称。POJO中的字段同样可以被重新排序、任意提取和重命名。这在之前的例子中也有过体现:

StreamTableEnvironment tableEnv = ...;
DataStream<Event> stream = ...;
Table table = tableEnv.fromDataStream(stream);
Table table = tableEnv.fromDataStream(stream, $("user"));
Table table = tableEnv.fromDataStream(stream, $("user").as("myUser"), $("url").as("myUrl"));
4.Row类型

Flink中还定义了一个在关系型表中更加通用的数据类型-----行(Row),它是Table中数据的基本组织形式。Row也是一种复合类型,它的长度固定,并且无法直接推断出每个字段的类型,所以在使用时必须指明具体的类型信息。我们在创建Table时调用的CREATE语句会将所有的字段名称和类型指定,这在Flink中被称为表的"Schema"。除此以外,Row类型还附加了一个属性RowKind,用来表示当前行在更新操作中的类型。这样,Row就可以用来表示更新日志流(changelog stream)中的数据,从而架起了Flink中流和表的转换桥梁。

所以在更新日志流中,元素的类型必须是Row,而且需要调用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);

4.综合应用示例

接下来,我们把介绍过的这些API整合起来,写出一段完整的代码。
同样还是用户的一组点击事件,我们可以查询某个用户(例如Alice)点击的url列表,也可以统计出每个用户累计的点击次数,这可以用两句SQL来分别实现。具体代码如下:

public class TableToStreamExample {
	public static void main(String[] args) throws Exception {
	// 获取流环境
 	StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
 	env.setParallelism(1);
 	
 	// 读取数据源
 	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);
 	
 	// 将数据流转换成表
	tableEnv.createTemporaryView("EventTable", eventStream);
 
 	// 查询 Alice 的访问 url 列表
 	Table aliceVisitTable = tableEnv.sqlQuery("SELECT url, user FROM EventTable WHERE user = 'Alice'");
 
	// 统计每个用户的点击次数
 	Table urlCountTable = tableEnv.sqlQuery("SELECT user, COUNT(url) FROM EventTable GROUP BY user");
 
 	// 将表转换成数据流,在控制台打印输出
 	tableEnv.toDataStream(aliceVisitTable).print("alice visit");
 	tableEnv.toChangelogStream(urlCountTable).print("count");
 
 	// 执行程序
 	env.execute();
	}
}

其中用户Alice的点击url列表只需要一个简单的条件查询就可以得到,对应的表中只有插入操作,所以我们可以直接调用toDataStream()方法将它转换成DataStream打印输出。控制台输出的结果如下:

alice visit > +I[./home, Alice]
alice visit > +I[./prod?id=1, Alice]
alice visit > +I[./prod?id=7, Alice]

这里每条数据前缀的+I就是RowKind.INSERT。

而由于统计点击次数时用到了分组聚合,造成结果表中数据会有更新操作,所以在打印输出时需要将表urlCountTable转换成更新日志流(changelog stream)。控制台输出的结果如下:

count> +I[Alice, 1]
count> +I[Bob, 1]
count> -U[Alice, 1]
count> +U[Alice, 2]
count> +I[Cary, 1]
count> -U[Bob, 1]
count> +U[Bob, 2]
count> -U[Alice, 2]
count> +U[Alice, 3]

这里数据的前缀出现了+I、-U和+U三种RowKind,分别表示INSERT(插入)、UPDATE_BEFORE(更新前)和UPDATE_AFTER(更新后)。当收到每个用户的第一次点击事件时,表里就会插入一条数据,例如+I [Alice,1]、+I [Bob,1]。而之后每当用户增加一次点击事件,就会带来一次更新操作。更新日志流(changelog stream)中对应会出现两条数据,分别表示之前数据的实效和新数据的生效。例如:当Alice的第二条点击数据到来时,会出现一个-U [Alice,1] 和一个 +U [Alice,2],表示Alice的点击个数从1变成了2。

这种表示更新日志的方式,有点像是声明"撤回了"之前的一条数据、再插入一条更新后的数据,类似binlog,这种流也叫做"回撤流"(Retract Stream)。关于表到流转换过程中的编码方式,我们会在下节进行更深入的讨论。

1.3 流处理中的表

上一节介绍了Table API和SQL的基本使用方法。我们可以发现,在Flink中使用表和SQL基本上和其他场景是一样的。不过表和流的转换,却稍微有些复杂。当我们将一个Table转换成DataStream时,有"仅插入流"(Insert-Only Stream)和 “更新日志流”(Changelog Stream)两种不同的方式,具体使用哪种方式取决于表中是否存在更新(update)操作。

更新日志流的这种撤回+更新的麻烦是不可避免的,原因就是Flink面对的是源源不断的无界流,而不是像Mysql、Hive等面对的是有界数据集。我们无法等所有数据都到齐后再做查询,没来一条数据就要更新一次结果。

关系型表/SQL流处理
处理的数据对象字段元组的有限集合字段元组的无限序列
查询(Query)可以访问到完整的数据输入无法访问到所有数据,必须"持续"处理流式输入
查询种植条件生成固定大小的结果集后终止永不停止,根据持续收到的数据不断更新查询结果

接下来我们深入探讨一下流处理中表的概念。

1.3.1 动态表和持续查询

流处理中面对的数据是连续不断的,这导致了流处理中的"表"和我们熟悉的关系型数据库中的表完全不同。所以基于表执行的查询操作,也就有了新的含义。
如果我们希望把流数据转换成表的形式,那么这个表中的数据就会不断增长。如果进一步基于表执行SQL查询,那么得到的结果就不会是一成不变的,而是会随着新数据的到来持续更新查询结果。

1. 动态表(Dynamic Tables)

当流中有新数据到来,初始的表中会插入一行。而基于这个表定义的SQL查询,应该不断的在之前查询结果的基础上更新结果,这样得到的表就会不断的动态变化,被称为"动态表"(Dynamic Tables)。

动态表是Flink在Table API和SQL中的核心概念,它为流处理提供了表和SQL支持。我们所数据的表一般用来做批处理,面向的是固定的数据集,可以认为是"静态表"。而动态表则完全不同,它里面的数据会随时间变化而变化。

2.持续查询(Continuous Query)

动态表可以像静态的批处理表一样进行查询操作。由于数据在不断变化,因此基于它定义的SQL查询也不可能执行一次就得到最终结果。这样一来,我们对动态表的查询也永远不能停止,会一直随着新数据的到来而继续执行。这样的查询被称作"持续查询"(Continuous Query)。对动态表定义的查询操作,都是持续查询;而持续查询的结果也是一个动态表。

由于每次数据到来都会触发查询操作,因此可以认为一次查询面对的数据集,就是当前输入动态表中的所有数据。类似于对当前时刻的动态表做了一个快照(snapshot),把它这一时刻的快照数据当作有限数据集进行处理。流式数据的到来会触发连续不断的快照查询,类似动画一样一帧帧串联起来,就构成了持续查询。注意,持续查询的结果也是动态表。
持续查询
持续查询的步骤如下:

  1. 流(stream)被转换为动态表(dynamic table)
  2. 对动态表进行持续查询(continuous query),结果返回新的动态表
  3. 查询结果的动态表被转换为流

1.3.2 将流转换成动态表

为了能够使用SQL来做流处理,我们必须把流(stream)先转换成动态表(Table)。之前在讲解基本API时,已经讲解过了代码中的DataStream和Table如何转换;现在我们则要抛开具体的数据类型,从原理上理解流和动态表的转换过程。

如果把流看作一张表,那么流中每条数据的到来,都可以被看作是对表的一次插入(Insert)操作,会在表的末尾添加一行数据。因为流是连续不断的,并且之前向下游的输出结果无法改变、只能在后面追加。所以我们其实是通过一个只有插入操作(insert-only)的更新日志流(changelog stream),来构建一个表。

为了更好的理解流转换成动态表的过程,我们用1.2节中举的例子来做分析说明:

// 获取流环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
// 读取数据源
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);
	
	// 将数据流转换成表
	tableEnv.createTemporaryView("EventTable", eventStream, $("user"), $("url"), 
$("timestamp").as("ts"));
 
	// 统计每个用户的点击次数
	Table urlCountTable = tableEnv.sqlQuery("SELECT user, COUNT(url) as cnt FROM EventTable GROUP BY user");
	// 将表转换成数据流,在控制台打印输出
	tableEnv.toChangelogStream(urlCountTable).print("count");
 
	// 执行程序
	env.execute()

这里的输入数据,就是用户在网站上的点击访问行为 Event(name,url,timestamp),数据类型被包装成POJO类型。我们将这个输入流注册为了一个动态表,叫做EventTable。表中的字段定义如下:

[
	user:VARCHAR,	// 用户名
	url:VARCHAR,	// 用户访问的URL
	ts:BIGINT	//时间戳
]

如下图所示,每当一个用户点击事件到来,就对应着动态表中的一次插入(Insert)操作,每个事件就是动态表中的一行数据。随着更多的点击事件到来,动态表将不断增长。
动态表

1.3.3 用SQL持续查询

1. 更新(Update)查询

我们在代码中定义了一个SQL查询:

// 统计每个用户的点击次数
Table urlCountTable = tableEnv.sqlQuery("SELECT user, COUNT(url) as cnt FROM EventTable GROUP BY user");

这个查询很简单,就是分组聚合后统计每个用户的点击次数。这里我们已经提前把原始的动态表注册为EventTable,经过查询后得到结果表urlCountTable。这个结果动态表中包含两个字段:

[
	user:VARCHAR,	//用户名
	cnt:BIGINT	//用户访问url的次数
]

如下图所示,当原始动态表EventTable不停的插入新的数据时,查询得到的urlCountTable会持续的进行结果更新。由于cnt结果会被更新,所以这里的urlCountTable如果想要转换成DataStream,必须调用toChangelogStream()方法。
查询结果表的插入与更新
具体步骤解释如下:

  1. 当查询启动时,原始动态表EventTable为空
  2. 当第一行Alice的点击数据插入EventTable时,动态查询也因为来了一个新的事件被触发,结果表urlCountTable中插入一行数据 [Alice,1]。
  3. 当第二行Bob点击数据插入EventTable表时,查询将更新结果表并插入新行 [Bob,1]。
  4. 第三行Alice的点击事件又来了,此时不会插入新行,而是会更新原行 [Alice,1],更新后的行为 [Alice,2]
  5. 当第四行Cary的点击数据插入到EventTable时,查询将第三行 [Cary,1] 数据插入到结果表中

2.追加(Append)查询

上面的例子中,结果表中会出现更新操作。如果我们只执行一个简单的条件查询,结果表中就会像原始表EventTable中一样,只有插入(Insert)操作了:

Table aliceVisitTable = tableEnv.sqlQuery("SELECT url,user from EventTable where user = 'Cary'");

上述这样的持续查询,被称为追加查询(Append Query),它定义的结果表中只有INSERT操作。这种追加查询得到的结果表,既可以用toDataStream()方法转换成流,也可以像更新查询一样调用toChangelogStream()方法转换成流。

到这里为止,我们似乎可以总结出一个规律:只要用到了聚合,在之前的结果上有叠加,就会产生更新操作,就是一个更新查询。但是事实上,更新查询的判断标准是结果表中的数据是否会有UPDATE操作,如果有聚合操作,但是聚合的结果不再改变,那么也不是更新查询。

比较典型的例子是窗口聚合。

我们可以开一个滚动窗口,统计每一个小时内所有用户的点击次数,并在结果表中增加一个endT字段,表示当前统计窗口的结束时间。这时结果表的字段定义如下:

[
	user:VARCHAR,	//用户名
	cnt:BIGINT,		//用户访问url的次数
	endT:TIMESTAMP	//窗口结束时间
]

例如,当水位线到达13:00时窗口闭合,此时对窗口中的有限数据集进行计算得到结果输出到下游。下一个小时的窗口闭合后再次进行计算并输出到下游,而不会更新之前的数据。
窗口计算
我们可以发现,对窗口的计算其实就是对有界数据集的计算,结果表的更新日志流中只会包含INSERT操作,而没有更新UPDATE操作。所以这里的持续查询,仍然是一个追加(Append)查询。结果表如果希望转换成DataStream,可以直接调用toDataStream()方法。当然,调用toChangelogStream()也是可以的。

需要注意的是,由于涉及事件时间的时间窗口,我们还需要提取时间戳和生成水位线。完整代码如下:

import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
import static org.apache.flink.table.api.Expressions.$;

public class AppendQueryExample {
	public static void main(String[] args) throws Exception {
		StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
 		env.setParallelism(1);

		// 读取数据源,并分配时间戳、生成水位线
 		SingleOutputStreamOperator<Event> eventStream = env.fromElements(
 			new Event("Alice", "./home", 1000L),
 			new Event("Bob", "./cart", 1000L),
 			new Event("Alice", "./prod?id=1", 25 * 60 * 1000L),
 			new Event("Alice", "./prod?id=4", 55 * 60 * 1000L),
 			new Event("Bob", "./prod?id=5", 3600 * 1000L + 60 * 1000L),
 			new Event("Cary", "./home", 3600 * 1000L + 30 * 60 * 1000L),
 			new Event("Cary", "./prod?id=7", 3600 * 1000L + 59 * 60 * 1000L) 
 		)
 		.assignTimestampsAndWatermarks(
 				WatermarkStrategy.<Event>forMonotonousTimestamps()
 				.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
 					@Override
					 public long extractTimestamp(Event element, long recordTimestamp) {
 						return element.timestamp;
 					}
 				})
 		);
 		
		// 创建表环境
 		StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
 		
		// 将数据流转换成表,并指定时间属性
		// 将 timestamp 指定为事件时间,并命名为 ts
 		Table eventTable = tableEnv.fromDataStream(eventStream,$("user"),$("url"),$("timestamp").rowtime().as("ts"));

		// 为方便在 SQL 中引用,在环境中注册表 EventTable
 		tableEnv.createTemporaryView("EventTable", eventTable);
		// 设置 1 小时滚动窗口,执行 SQL 统计查询
 		Table result = tableEnv.sqlQuery(
 						"SELECT " +
 						"user, " +
 						"window_end AS endT, " + // 窗口结束时间
 						"COUNT(url) AS cnt " + // 统计 url 访问次数
 						"FROM TABLE( " +
 							"TUMBLE( TABLE EventTable, " + // 1 小时滚动窗口
 							"DESCRIPTOR(ts), " +
 							"INTERVAL '1' HOUR)) " +
 						"GROUP BY user, window_start, window_end ");
		tableEnv.toDataStream(result).print();
 		env.execute();
 	}
}

运行结果如下:

+I[Alice, 1970-01-01T01:00, 3]
+I[Bob, 1970-01-01T01:00, 1]
+I[Cary, 1970-01-01T02:00, 2]
+I[Bob, 1970-01-01T02:00, 1]

可以看到,所有的输出结果都以+I 为前缀,表示所有数据都是以INSERT操作追加到查询结果表中的。这是一个追加查询,所以我们可以直接用toDataStream()把结果Table转换成流是没有问题的。

3.查询限制

在实际应用中,有些持续查询会因为计算代价太高而收到限制。所谓的"代价太高",可能是需要维护的状态持续增长 越来越大,也可能是由于更新数据的计算太复杂。

  • 状态大小:
    当我们用持续查询做流处理时,往往会运行至少几周到几个月。所以持续查询处理的数据总量会越来越大。以我们之前举的统计每个用户访问url的次数的例子,如果随着时间的推移用户数越来越大,那么需要维护的状态也会越来越大。
    上面这个例子还好,只需要维护<user,cnt>即可。当涉及到计算中位数,平均数这些,我们通常需要把所有数据作为状态变量缓存下来,才能不断计算。最终可能会耗尽存储空间导致查询失败。
select user,count(url)
from clicks
group by user
  • 更新计算
    对于有些查询来说,更新计算的复杂度很高。每来一条新的数据,更新结果的时候需要全部重新计算,并且对很多已经输出的行进行更新。一个典型的例子就是RANK()函数,它会基于一组数据计算当前值的排名。例如下面的SQL查询,会根据用户最后一次点击的时间(lastAction)为每个用户计算一个排名。
    当我们收到一个新的数据,用户的最后一次点击时间(lastAction)就会更新,进而必须重新对所有既存用户数据进行重新计算得到一个新的排名。当一个用户的排名发生改变时,它之后的所有用户的排名都会发生改变(+1)。对这些用户我们也必须更新其结果。这样的更新操作无疑代价巨大,而且还会随着用户的增多越来越严重。
select user,rank() over(order by lastAction)
from (
	select user,max(ts) as lastAction from EventTable group by user
);

这样的查询操作,不太适合作为连续查询在整个流上生效。而是要配合窗口来为其指定一个生效范围,避免状态变量的无限扩张导致OOM。我们会在1.5节中展开介绍。

1.3.4 将动态表转换为流

与关系型数据库中的表一样,动态表也可以通过插入(Insert)、更新(Update)和删除(Delete)操作,进行持续的更改。将动态表转换为流或将其写入外部系统时,就需要对这些更改操作进行编码,通过发送编码消息的方式告诉外部系统要执行的操作。在Flink中,Table API和SQL支持3种编码方式:

  • 仅追加流(Append-only)
    仅通过插入(Insert)操作来修改的动态表,可以直接转换为"仅追加流"。这个流中发出的每条数据,其实就是动态表中新增的每一行。

  • 撤回流(Retract)
    撤回流是包含两类消息的流,添加(add)消息和撤回(retract)消息。
    具体的编码规则是:INSERT插入操作为add消息;DELETE删除操作编码为retract消息;而UPDATE更新操作编码为 更新前行的retract+更新后行的add消息。这样,我们可以通过add和retract的组合来指明所有的增删改操作,一个动态表就可以转换为撤回流了。
    可以看到,更新操作对于撤回流来说,对应着两个消息:之前消息的撤回(删除)和新数据的插入。
    撤回流
    这里我们用+代表add消息(对应插入INSERT操作),用-代表retract消息(对应DELETE操作)。当Alice的第一个点击事件到来时,结果表新增一条数据 [Alice,1];而当Alice的第二个点击事件到来时,结果表会将 [Alice,1] 更新为 [Alice,2],对应的编码是删除 [Alice,1] + 插入 [Alice,2]。这样当一个外部系统收到这样的两条消息时,就知道是要对Alice的点击统计次数进行更新了。

  • 更新插入流(Upsert)
    更新插入流中只包含两种类型的消息:更新插入(upsert)消息和删除(delete)消息。
    对于更新插入流来说,INSERT插入操作和UPDATE更新操作,统一被编码为upsert消息。而DELETE删除操作责备编码为delete消息。
    既然upsert又指代insert又指代update,那么问题就来了,什么时候应该insert数据? 什么时候又应该update数据呢?
    这就需要动态表中必须有唯一的键(key)。通过这个key进行查询,如果存在对应的数据就做更新(update),如果不存在就直接插入(insert)。指定动态表的key是一个动态表可以被转换为更新插入流的必要条件。 当然,收到这条流中数据的外部系统,也需要知道这唯一的键(key),这样才能正确的处理消息。
    upsert流
    可以看到,更新插入流跟撤回流的主要区别在于,更新(update)操作由于有key的存在,只用单条消息就可以完成update操作,因此效率更高。

    需要注意的是,在代码里将动态表转换为DataStream时,只支持仅追加(append-only)流和撤回(retract)流。我们调用toChangelogStream()得到的其实就是撤回流。这也很好理解,DataSteam中并没有key的定义,所以只能通过两条消息一减一增来表示更新操作。而连接到外部系统时,则可以支持不同的编码方法,这取决于外部系统的特性。

1.3.5 关于动态表转换为流的一些问答

在 Flink Table API 中,根据不同的应用场景,可以产生三种不同类型的数据流:Append-only 流、Retract 流和 Upsert 流。以下是使用 Java 代码实现这三种流的示例,以及它们的输出说明。

  • Append-only 流:
    Append-only 流是指只有插入操作的数据流,没有更新和删除操作。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

// 创建一个简单的Append-only流
DataStream<Tuple2<Long, String>> dataStream = env.fromElements(
        new Tuple2<>(1L, "Alice"),
        new Tuple2<>(2L, "Bob"),
        new Tuple2<>(3L, "Cindy")
);

// 将DataStream转换为Table
Table table = tableEnv.fromDataStream(dataStream, $("userId"), $("userName"));

// 将Table转换为DataStream
DataStream<Tuple2<Boolean, Tuple2<Long, String>>> appendStream = tableEnv.toAppendStream(table, Types.TUPLE(Types.BOOLEAN, Types.TUPLE(Types.LONG, Types.STRING)));

// 打印输出
appendStream.print();

输出:

(true,(1,Alice))
(true,(2,Bob))
(true,(3,Cindy))
  • Retract 流:
    Retract 流用于处理有更新和删除操作的动态表,其中每条数据都带有一个布尔标记。第一个字段true表示插入(ADD),false表示撤回(RETRACT)上一次的插入。
// 假设我们有一个带有撤回消息的Table
Table retractTable = ...;

// 将Table转换为Retract流
DataStream<Tuple2<Boolean, Row>> retractStream = tableEnv.toRetractStream(retractTable, Row.class);

// 打印输出
retractStream.print();

输出:

(true, Row(1, Alice))
(true, Row(2, Bob))
(false, Row(2, Bob))  // 撤回之前的Bob
(true, Row(2, Bobby)) // 插入更新后的Bob
  • Upsert 流:
    Upsert 流用于有主键的动态表,只有主键的变化会导致更新或删除操作。Upsert 流与 Retract 流类似,但是对于每个主键只保留最新的状态。
// 假设我们有一个带有主键的动态Table,例如,userId为主键
Table upsertTable = ...;

// 将Table转换为Upsert流
DataStream<Tuple2<Boolean, Row>> upsertStream = tableEnv.toRetractStream(upsertTable, Row.class);

// 打印输出
upsertStream.print();

输出:

(true, Row(1, Alice))
(true, Row(2, Bob))
(true, Row(3, Cindy))
(true, Row(2, Bobby)) // 更新Bob的记录,由于是upsert,之前的Bob记录不需要撤回

toChangelogStream()
q1
q2
q3
a3
q4
q5
q6
a6
q7
q8
q10
q11

1.4 时间属性和窗口

基于时间的操作(比如时间窗口计算),需要定义相关的时间语义和时间字段的信息。在Table API和SQL中,会给表上单独提供一个逻辑上的时间字段,专门用来在表处理程序中指示时间。

所谓的时间属性(time attributes),其实就是每个表模式结构(schema)的一部分。它可以在创建表的DDL中被直接定位为一个字段,也可以在DataStream转换成表时定义。一旦定义了时间属性,它就可以被当作一个普通字段引用,并且可以在基于时间的操作中使用。

时间属性的数据类型为TIMESTAMP,它的行为类似于常规时间戳,可以直接访问并且进行计算。

按照时间语义的不同,我们可以把时间分为事件时间(event time)处理时间(process time)

1.4.1 事件时间

在实际应用中,最常用的就是事件时间。在事件时间语义下,允许表处理程序根据每个数据中包含的时间戳(也就是事件发生的时间)来生成结果。

事件时间语义最大的用途就是处理乱序事件或者延迟事件的场景。我们通过设置水位线(watermark)来表示事件时间的进展,**水位线的计算公式为:当前观察到的最大事件时间 - 最大延迟时间 - 1ms。 ** 当水位线没过窗口的闭合时间时,窗口闭合开始计算。因为有最大延迟时间的存在,所以可以保证在一定乱序程度内,对数据的处理也可以获得正确的结果。

为了获得水位线,Flink需要从事件数据中提取时间戳,并生成水位线。

事件时间属性可以在创建表的DDL中定义,也可以在数据流和表的转换中定义。

1.在创建表的DDL中定义

在创建表的DDL(CREATE TABLE)语句中,可以增加一个字段,通过WATERMARK语句来定义事件时间属性。WATERMARK语句主要用来定义水位线(watermark)的生成表达式,这个表达式会将带有事件时间戳的字段标记为事件时间属性,并给出水位线的最大延迟时间:

CREATE TABLE EventTable(
	user STRING,
	url STRING,
	ts TIMESTAMP(3),
	WATERMARK FOR ts AS ts - INTERAVL '5' SECOND
) WITH (
	'connector' = '',
	...
)

这里我们把ts字段定义为事件时间属性,并且基于ts设置了5s的水位线延迟。这里的5s是以时间间隔的形式定义的,格式是 INTERVAL <数值> <时间单位>。

其中这里的数值必须用单引号引起来,而单位用SECOND还是SECONDS都是等效的。

定义事件时间例子

example1

import org.apache.flink.table.api.*;
import static org.apache.flink.table.api.Expressions.*;

// 创建 TableEnvironment
TableEnvironment tableEnv = TableEnvironment.create(EnvironmentSettings.newInstance().build());

// 创建一个 Table Schema,其中包含一个时间戳字段
Schema schema = Schema.newBuilder()
    .column("id", DataTypes.STRING())
    .column("timestamp_field", DataTypes.TIMESTAMP(3))
    .columnByExpression("proc_time", "PROCTIME()")
    .watermark("timestamp_field", "timestamp_field - INTERVAL '5' SECOND") // 定义水印策略,事件时间字段减去 5 秒延迟
    .build();

// 创建 Table
tableEnv.createTable("my_table", TableDescriptor.forConnector("kafka")
    .schema(schema)
    .option("topic", "input-topic")
    .option("properties.bootstrap.servers", "localhost:9092")
    .format("json")
    .build()
);

// 现在可以查询这个表,并且使用 `timestamp_field` 作为事件时间属性
Table resultTable = tableEnv.from("my_table")
    .window(Tumble.over(lit(10).minutes()).on($("timestamp_field")).as("w"))
    .groupBy($("id"), $("w"))
    .select($("id"), $("w").start().as("window_start"), $("w").end().as("window_end"), $("id").count().as("cnt"));

example2
example3

TIMESTAMP和TIMESTAMP_LTZ例子

Flink中支持的事件时间属性类型必须为TIMESTAMP类型或者TIMESTAMP_LTZ。这里TIMESTAMP_LTZ是指带有本地时区信息的时间戳(TIMESTAMP WITH LOCAL TIME ZONE)。
example1
example2
example3
example4

`TIMESTAMP` 和 `TIMESTAMP_LTZ` 是 Flink SQL 中的两种时间数据类型,它们在时区处理上有本质的区别。

### `TIMESTAMP`

`TIMESTAMP` 类型表示一个没有时区信息的时间戳。它通常用于表示本地时间,而不考虑时区。当你使用 `TIMESTAMP` 类型时,你需要确保所有的时间值都是在同一个时区下处理的,或者已经被转换到了某个统一的时区。如果你的数据源中包含了不同时区的时间,使用 `TIMESTAMP` 类型可能会导致时区混淆和错误的时间计算。

例如,如果你在北京(东八区)使用 `TIMESTAMP` 记录一个事件,那么这个时间戳就代表北京时间。如果你把这个时间戳发送到在伦敦(零时区)的服务器上,没有时区信息的话,伦敦的服务器可能会错误地将其解释为伦敦时间。

### `TIMESTAMP_LTZ`

`TIMESTAMP_LTZ` 类型表示一个带有时区信息的时间戳,`LTZ` 是 `Local Time Zone` 的缩写。这个类型的时间戳在内部总是以 UTC(协调世界时)存储,但在与客户端交互时会根据客户端的本地时区进行转换。这意味着,无论事件发生在世界上的哪个地方,`TIMESTAMP_LTZ` 类型都能确保时间的一致性和准确性。

使用 `TIMESTAMP_LTZ` 类型时,Flink 会自动处理时区转换,这对于分布在不同地理位置的系统之间的时间同步非常有用。

### 举例说明

假设我们有两个事件,一个发生在北京时间(UTC+8)的2023-04-01 12:00:00,另一个发生在纽约时间(UTC-4,考虑夏令时)的2023-04-01 12:00:00。如果我们使用 `TIMESTAMP` 和 `TIMESTAMP_LTZ` 分别记录这两个事件,我们会得到以下结果:

#### 使用 `TIMESTAMP`

- 北京事件记录为 `2023-04-01 12:00:00`(无时区信息)
- 纽约事件记录为 `2023-04-01 12:00:00`(无时区信息)

在这种情况下,我们失去了时区信息,如果将这两个时间戳进行比较,它们看起来是同时发生的,尽管实际上它们相差了 12 个小时。

#### 使用 `TIMESTAMP_LTZ`

- 北京事件记录为 `2023-04-01 12:00:00`(内部以 UTC 存储,即 `2023-04-01 04:00:00`)
- 纽约事件记录为 `2023-04-01 12:00:00`(内部以 UTC 存储,即 `2023-04-01 16:00:00`)

在这种情况下,即使我们在不同的时区看到了相同的本地时间,内部存储的 UTC 时间是不同的。如果将这两个时间戳进行比较,我们可以正确地得出北京事件比纽约事件早发生了 12 个小时。

总结来说,`TIMESTAMP` 适合在时区固定或不重要的场景中使用,而 `TIMESTAMP_LTZ` 更适合处理分布在不同时区的全球数据。
UTC 是 "Coordinated Universal Time" 的缩写,中文意为 "协调世界时"。它是目前国际上最主要的世界时间标准,用于民用时间的统一和协调。UTC 与格林威治标准时间(GMT,Greenwich Mean Time)在日常用途上几乎是等价的,但在技术上有所区别。

UTC 是通过原子时钟来维持的,这些原子时钟的精确度非常高,可以保证时间的统一性和准确性。世界各地的时间通常是相对于 UTC 来定义的,比如北京时间是 UTC+8,意味着北京时间比 UTC 快 8 个小时。

UTC 不受任何地区的夏令时(Daylight Saving Time)影响,它全年保持不变。各个时区在定义自己的本地时间时,会指明相对于 UTC 的偏移量。例如,如果一个时区在冬季是 UTC+1,在夏令时期间可能会调整为 UTC+2,以利用更多的日照时间。

在国际交流、计算机网络、航空航天和其他需要精确时间协调的领域,UTC 扮演着极其重要的角色。
CREATE TABLE events(
	user STRING,
	url STRING,
	ts BIGINT,
	ts_ltz AS TO_TIMESTAMP_LTZ(ts,3),
	WATERMARK FOR ts_ltz AS ts_ltz - INTERVAL '5' SECONDS
) WITH (
	...
)

这里我们另外定义了一个字段ts_ltz,这个字段是把长整形的ts转换为TIMESTAMP_LTZ得到的。然后我们使用WATERMARK语句把ts_ltz字段设为事件时间属性,并设置了5s的水位线延迟。

2.在数据流转换为表时定义

除了在DDL中定义事件时间字段,我们也可以在DataStream转换为Table时来定义事件时间字段。当我们使用fromDataStream()方法由流获得表时,可以追加参数来定义表中的字段结构。这时可以给某个字段加上.rowtime()后缀,就表示将当前字段指定为事件时间属性。 这个字段可以是数据中本不存在、额外追加上去的"逻辑字段"。也可以是数据中本身就有的字段,此时这个字段就会被事件时间属性所覆盖,类型也会被转换为TIMESTAMP。无论使用哪种方式,时间属性字段中保存的都是事件的时间戳(TIMESTAMP类型)。

需要注意的是,我们只能在fromDataStream()中用.rowtime()指定时间属性,而事件时间戳的提取和水位线的生成应该在之前的DataStream上就定义好了。由于DataStream中没有时区概念,因此Flink会将事件时间属性解析成不带时区的TIMESTAMP类型,所有的时间值都会被当作UTC标准时间。

// 方法一:
// 流中数据类型为二元组 Tuple2,包含两个字段;需要自定义提取时间戳并生成水位线
DataStream<Tuple2<String, String>> stream = inputStream.assignTimestampsAndWatermarks(...);
// 声明一个额外的逻辑字段作为事件时间属性
Table table = tEnv.fromDataStream(stream, $("user"), $("url"), $("ts").rowtime());

// 方法二:
// 流中数据类型为三元组 Tuple3,最后一个字段就是事件时间戳,返回最后一个字段ts作为事件时间戳
DataStream<Tuple3<String, String, Long>> stream = inputStream.assignTimestampsAndWatermarks(...);
// 不再声明额外字段,直接用最后一个字段作为事件时间属性
Table table = tEnv.fromDataStream(stream, $("user"), $("url"), $("ts").rowtime());

1.4.2 处理时间

相比之下处理时间就比较简单了,他就是我们的系统时间,使用时不需要提取时间戳(timestamp)和生成水位线(watermark)。在定义处理时间属性时,必须要额外声明一个字段,专门用来保存当前的处理时间。

类似的,处理时间的定义也有两种方式:1.DDL中定义。 2.在数据流转换成表时定义。

1.在创建表的DDL中定义

在创建表的DDL(CREATE TABLE语句)中,可以增加一个额外的字段,**通过调用系统内置的PROCTIME()函数来指定当前的处理时间属性,PROCTIME()返回的类型是TIMESTAMP_LTZ。 **

CREATE TABLE EventTable(
 user STRING,
 url STRING,
 ts AS PROCTIME()
) WITH (
 ...
);

这里的时间属性,其实是以"计算列"(computed column)的形式定义出来的。所谓的计算列是Flink SQL中引入的特殊概念,可以用一个AS语句来在表中产生不存在的列,并且可以利用原有的列、各种运算符以及内置函数。在前面事件时间属性的定义中,将ts字段转换成TIMESTAMP_LTZ类型的ts_ltz,也是计算列的定义方式。

额外:FOR SYSTEM_TIME AS OF xxx.time AS xxx

additional

2.在数据流转换为表时定义

处理时间属性同样可以在将DataStream转换成表的时候定义。当我们调用fromDataStream()方法创建表时,可以用.proctime()后缀来指定处理时间属性字段。由于处理时间是系统时间,原始数据中并没有这个字段,所以处理时间属性一定不能定义在一个已有字段上,只能定义在表结构所有字段的最后,作为额外的逻辑字段出现。

DataStream<Tuple2<String, String>> stream = ...;

// 声明一个额外的字段作为处理时间属性字段
Table table = tEnv.fromDataStream(stream, $("user"), $("url"), $("ts").proctime());

1.4.3 窗口(Window)

讲完了时间属性,接下来就可以定义窗口进行计算了。我们知道,窗口可以将无界流切割成大小有限的"桶"(bucket)来进行计算,通过截取有限数据集来处理无限的数据流。在DataStream API中提供了对不同类型的窗口进行定义和处理的接口,而在Table API和SQL中,类似的功能也都可以实现。

1.分组窗口(Group Window,老版本)

在Flink 1.12之前的版本中,Table API和SQL提供了一组"分组窗口"(Group Window)函数,常用的时间窗口如滚动窗口、滑动窗口、会话窗口都有对应的实现。具体在SQL中就是调用TUMBLE()、HOP()、SESSION(),传入时间属性字段、窗口大小等参数就可以了。

以滚动窗口为例:

TUMBLE(ts,INTERVAL '1' HOUR)

这里的ts是定义好的时间属性字段,窗口大小用"时间间隔"INTERVAL来定义。

在进行窗口计算时,分组窗口是将窗口本身当作一个字段对数据进行分组的,可以对组内的数据进行聚合。 基本使用方式如下:

Table result = tableEnv.sqlQuery(
			"SELECT " +
			"user, " +
			"TUMBLE_END(ts, INTERVAL '1' HOUR) as endT, " +
			"COUNT(url) AS cnt " +
			"FROM EventTable " +
 			"GROUP BY " + // 使用窗口和用户名进行分组
			"user, " +
			"TUMBLE(ts, INTERVAL '1' HOUR)" // 定义 1 小时滚动窗口
		);

这里定义了 1 小时的滚动串口,将窗口和用户user一起作为分组的字段。用聚合函数COUNT()对窗口中的数据个数进行了聚合统计,并将结果字段重命名为cnt。用TUMPLE_END()函数获取滚动窗口的结束时间,重命名为endT提取出来。
example1
example2
example3
example4
如果select后的TUMBLE_START()中的interval的时间和group by后的TUMBLE()中的interval的时间不一样 会报错吗?
example5
分组窗口的功能比较有限,只支持窗口聚合,所以目前已经处于弃用的状态(deprecated)。

2.窗口表值函数(Windowing TVFs,新版本)

从Flink 1.13版本开始,Flink开始使用窗口表值函数(Windowing table-valued functions,Windowing TVFs)来定义窗口。窗口表值函数是Flink定义的多态表函数(PTF),可以将表作为操作对象,进行扩展后返回。表函数(table function)可以看做是一个返回表的函数,关于这部分内容,我们会在1.6节中进行介绍。

目前Flink提供了以下几个窗口TVF:

  • 滚动窗口(Tumbling Windows)
  • 滑动窗口(Hop Windows)
  • 累积窗口(Cumulate Windows)
  • 会话窗口(Session Windows 目前尚未完全支持)

窗口表值函数可以完全替代传统的分组窗口函数。窗口TVF更符合SQL标准,性能得到了优化,拥有更强大的功能。
可以支持基于窗口的复杂计算,例如窗口Top-N、窗口联结(window join)等等。

但是,目前窗口TVF的功能还不完善,会话窗口和很多高级功能还不支持,不过也正在快速的更新完善中。可以预见在未来的版本中,窗口TVF将越来越强大,将会是窗口处理的唯一入口。

在窗口TVF的返回值中,除去原始表中的所有列,还增加了用来描述窗口的额外3个列:“窗口起始点”(window_start)、“窗口结束点”(window_end)、“窗口时间”(window_time)。起始点和结束点比较好理解,这里的"窗口时间"指的是窗口中的时间属性,它的值等于window_end - 1ms,相当于是窗口中能够包含的数据的最大时间戳值。

表值函数在SQL中的声明方式,和之前的分组窗口是类似的,直接调用TUMBLE()、HOP()、CUMULATE()就可以实现滚动、滑动和累积窗口,不过传入的参数会有所不同。下面我们就分别对这几种窗口TVF进行介绍:

  • 滚动窗口(TUMBLE)
    滚动窗口在SQL中的概念与DataStream API中与在DataStream API中的定义完全一样,都是长度对齐、时间对齐、无重叠的窗口,一般用于周期性的统计计算。
    在SQL中通过调用TUMBLE()就可以声明一个滚动窗口。在SQL中不考虑计数窗口,所以滚动窗口就指滚动时间窗口,参数为:当前要查询的表,当前的时间属性字段,窗口大小。

    TUMBLE(TABLE EventTable,DESCRIPTOR(ts),INTERVAL '1' HOUR)
    

    这里基于时间字段ts,对表EventTable中的数据开了大小为 1 小时的滚动窗口。窗口会将表中的每一行数据,按照它们ts的值将其分配到对应的窗口中去。

  • 滑动窗口(HOP)
    滑动窗口的使用与滚动窗口类似,可以通过设置滑动步长来控制统计输出的频率。在SQL中通过调用HOP()来声明滑动窗口。参数为:当前要查询的表,当前的时间属性字段,滑动步长,窗口大小。

    HOP(Table EventTable,DESCRIPTOR(ts),INTERVAL '5' MINUTES, INTERVAL '1' HOURS)
    

    这里我们基于时间属性ts,在表EventTable上创建了大小为 1 小时的滑动窗口,每 5 分钟滑动一次。**需要注意的是,紧跟在时间属性字段后面第三个参数是步长(slide),第四个参数才是窗口大小(size)。 **

  • 累积窗口(CUMULATE)
    滚动窗口和滑动窗口,可以用来计算大多数周期性的统计指标。不过在实际应用中还会遇到这一类需求:当我们的统计周期比较长,希望中间每隔一段时间就输出一次当前的统计值。与滑动窗口不同的是,在一个统计周期内,我们会多次输出统计值,这些窗口是不断叠加累积的。

    比如,我们按天来统计网站的PV(Page View),如果用 1 天的滚动窗口计算,需要到每天24点才会计算一次,输出频率太低;如果用滑动窗口,计算频率可以提高,但是统计的就是"过去24小时的PV",而不是某天的PV了。
    而我们真正希望的是,按照自然日统计每天的PV,不过需要每隔一小时就输出一次当天到目前为止的PV值。这种特殊的窗口就叫做"累积窗口"(Cumulate Window)。
    累积窗口
    累积窗口是窗口TVF中新增的功能,它会在一定的统计周期内进行累积计算。累积窗口中的参数为:1.要查询的表 2.时间字段 3.累积步长(step)4.最大窗口长度(max window size)。 所谓的最大窗口长度其实就是我们所说的"统计周期",最终目的就是统计这段时间内的数据。
    如上图所示,开始时,创建的第一个窗口大小为步长step大,之后的每个窗口都会在之前的基础上再扩展step的长度,直到达到设置的最大窗口长度。
    在SQL中可以用CUMULATE()函数来定义,具体如下:

    CUMULATE(Table EventTable,DESCRIPTOR(ts),INTERVAL '1' HOURS,INTERVAL '1' DAYS)
    

    这里我们基于时间属性ts,在表EventTable 上定义了一个最大窗口大小为 1 天、累积步长 1 小时的累积窗口。注意第三个参数为步长step,第四个参数则是最大窗口长度。
    上面所有的语句只是定义了窗口,类似于DataStream API中的窗口分配器 eg:window(TumblingEventTimeWindows.of(Time.seconds(5)))。在SQL中窗口的完整调用,还需要配合聚合操作和其他操作。我们会在下一节详细讲解窗口的聚合。

1.5 聚合(Aggregation)查询

在SQL中,一个很常见的功能就是对某一列的多条数据做一个合并统计,得到一个结果值。比如:求和、最大最小值、平均值等等,这种操作叫做聚合查询(Aggregation)。
Flink中的SQL是流处理和标准SQL结合的产物,所以聚合查询也可以分成两种:流处理中特有的聚合(主要指窗口聚合:滑动/滚动/计数窗口),以及SQL原生的聚合查询方式。

1.5.1 分组聚合

SQL中一般说到的聚合我们都熟悉,主要是通过内置的一些聚合函数来实现的,比如SUM()、MAX()、MIN()、AVG()以及COUNT()。它们的特点是对多条输入数据进行计算,得到一个唯一值,属于"多对一"的转换。例如:

Table eventCountTable = tableEnv.sqlQuery("select COUNT(*) from EventTable");

更普遍的聚合函数的使用方式是配合GROUP BY子句指定分组的键(key),从而对数据按照某个字段做一个分组统计。例如我们之前举的例子,可以按照用户名进行分组统计,计算每个用户点击url的次数。

SELECT user,count(url) as cnt
from
EventTable
group by user

这种聚合方式,就叫做"分组聚合"(group aggregation)。从概念上讲,SQL中的分组聚合对应着DataStream API中keyBy之后得到的KeyedStream后做的聚合转换。它们都是按照某个key对数据进行分流,各自维护状态来进行聚合统计的。在流处理中,分组聚合同样是一个持续查询,而且是一个更新查询,得到的是一个动态表。每当流中有一个新的数据到来时,都会导致结果表的更新操作。 因此,想要将结果表转换成流或输出到外部系统,必须采用撤回流(retract stream)或更新插入流(upsert stream)的编码方式。 在代码中用toChangelogStream()将表转换成流输出即可。

另外,在持续查询的过程中,由于用于分组的key可能会不断增加,因此计算结果所需要维护的状态也会持续增长。这里状态的增多主要是可能key的种类会变多,但由于我们是求最大值,所以每个key只维护一个值,类似增量聚合窗口,所以其实还好。但是如果是类似于求中位数或求平均数的计算,那我们就需要把各种key 的所有数据都作为状态保存下来,类似全窗口聚合函数,消耗的内存就比较大了。此时为了防止OOM,用RocksDB就比较好了(超过内存限制的状态将被溢写到磁盘。)

为了防止状态无限增长耗尽资源,Flink Table API 和 SQL可以在表环境中配置状态的生存时间(TTL)。

TableEnvironment tableEnv = ...;

//获取表环境的配置
TableConfig tableConfig = tableEnv.getConfig();
//配置状态的保留时间
tableConfig.setIdleStateRetention(Duration.ofMinutes(60));

或者可以直接设置配置项table.exec.state.ttl

TableEnvironment tableEnv = ...;
Configuration configuration = tableEnv.getConfig.getConfiguration();
configuration.setString("table.exec.state.ttl","60 min");

这两种方式是等效的。但是需要注意的是,配置TTL可能导致统计结果不准确,因为状态变量到期后会清除。这其实是以牺牲正确性为代价换取了资源的释放。

此外,在Flink SQL的分组聚合中同样可以使用DISTINCT进行去重的聚合处理;可以使用HAVING对聚合结果进行条件筛选;还可以使用GROUPING SETS(分组集)设置多个分组情况分别统计后UNION ALL到一起。这些语法跟标准SQL中的用法一致,这里不再详细展开。

可以看到的是,分组聚合既是SQL原生的聚合查询,也是流处理中的聚合操作,这是实际应用中最常见的聚合方式。当然,使用的聚合函数一般都是系统内置的,如果希望实现特殊操作的话可以使用自定义函数UDF。关于自定义函数(UDF),我们会在1.7节中详细介绍。

1.5.2 窗口聚合

在流处理中,往往需要将无限数据流划分成有限数据集进行统计计算,这就是所谓的"窗口"。在1.4.3节中已经介绍了窗口的声明方式,这相当于DataStream API中的窗口分配器(window assigner),只是明确了窗口的划分形式以及数据如何分配。而具体的窗口计算处理操作,在DataStream API中还需要窗口函数(window function)来进行定义(在DataStream API中是WindowProcessFunction)。

在Flink的Table API和SQL中,窗口的计算是通过"窗口聚合函数"(window aggregation)来实现的。与分组聚合类似,窗口聚合也需要调用SUM()、MAX()、MIN()、COUNT()等聚合函数,通过GROUP BY子句指定分组的字段。只不过窗口聚合时,需要将窗口信息作为分组key的一部分定义出来。 在Flink 1.12版本之前,是直接把窗口自身作为分组key放在GROUP BY之后的,所以也叫"分组窗口聚合"(eg: group by user,TUMBLE(ts,INTERVAL ‘1’ hour) 见1.4.3节)。而Flink 1.13版本开始使用了"窗口表值函数"(Windowing TVF)。窗口函数的入参是表信息和窗口信息,返回的也是一个扩展了窗口额外信息字段的表,所以窗口会出现在FROM后面,GROUP BY后面的则是窗口新增的字段window_start和window_end。

比如,我们将1.4.3中分组窗口的聚合,用窗口TVF对比实现以下:

Table result = tableEnv.sqlQuery(
			"SELECT " +
			"user, " +
			"TUMBLE_END(ts, INTERVAL '1' HOUR) as endT, " +
			"COUNT(url) AS cnt " +
			"FROM EventTable " +
 			"GROUP BY " + // 使用窗口和用户名进行分组
			"user, " +
			"TUMBLE(ts, INTERVAL '1' HOUR)" // 定义 1 小时滚动窗口
		);
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 "
 );

这里我们以ts作为事件属性字段,基于EventTable定义了1小时的滚动窗口,希望统计出每小时内每个用户点击url的次数。 用来分组的字段是用户名user,以及表示窗口起始位置的window_start和窗口结束为止的window_end。而这里的TUMBLE()是表值函数,所以返回的是一个表(Table),我们后续的聚合查询就是基于这个Table进行的。

Flink SQL中目前提供了滚动窗口TUMBLE()、滑动窗口HOP()、累积窗口CUMULATE() 三种表值函数(TVF)。 在具体应用中,我们还需要提前定义好时间属性。 下面是一段窗口聚合的完整代码,以累积窗口为例:

import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
import static org.apache.flink.table.api.Expressions.$;


public class CumulateWindowExample {
	public static void main(String[] args) throws Exception {
		StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
 		env.setParallelism(1);
		
		// 读取数据源,并分配时间戳、生成水位线
 		SingleOutputStreamOperator<Event> eventStream = env.fromElements(
 				new Event("Alice", "./home", 1000L),
 				new Event("Bob", "./cart", 1000L),
 				new Event("Alice", "./prod?id=1", 25 * 60 * 1000L),
 				new Event("Alice", "./prod?id=4", 55 * 60 * 1000L),
 				new Event("Bob", "./prod?id=5", 3600 * 1000L + 60 * 1000L),
 				new Event("Cary", "./home", 3600 * 1000L + 30 * 60 * 1000L),
 				new Event("Cary", "./prod?id=7", 3600 * 1000L + 59 * 60 * 1000L)
 				).assignTimestampsAndWatermarks(
 					WatermarkStrategy.<Event>forMonotonousTimestamps()
 					.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
 						@Override
 						public long extractTimestamp(Event element, long recordTimestamp) {
 							return element.timestamp;
 						}
 					})
 				);
 				
		// 创建表环境
 		StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
		// 将数据流转换成表,并指定时间属性
 		Table eventTable = tableEnv.fromDataStream(eventStream,$("user"),$("url"),$("timestamp").rowtime().as("ts"));
		// 为方便在 SQL 中引用,在环境中注册表 EventTable
 		tableEnv.createTemporaryView("EventTable", eventTable);
		// 设置累积窗口,执行 SQL 统计查询
 		Table result = tableEnv.sqlQuery(
					 	"SELECT " +
 					  	"user, " +
 						"window_end AS endT, " +
 						"COUNT(url) AS cnt " +
 						"FROM TABLE( " +
 							"CUMULATE( TABLE EventTable, " + // 定义累积窗口
 									   "DESCRIPTOR(ts), " +
 									   "INTERVAL '30' MINUTE, " +
 									   "INTERVAL '1' HOUR)) " +
 						"GROUP BY user, window_start, window_end "
 					);
 		tableEnv.toDataStream(result).print();
		env.execute();
 	}
}

这里我们使用了统计周期为1小时,累积间隔为30分钟的累积窗口。代码执行结果如下:

+I[Alice, 1970-01-01T00:30, 2]
+I[Bob, 1970-01-01T00:30, 1]
+I[Alice, 1970-01-01T01:00, 3]
+I[Bob, 1970-01-01T01:00, 1]
+I[Bob, 1970-01-01T01:30, 1]
+I[Cary, 1970-01-01T02:00, 2]
+I[Bob, 1970-01-01T02:00, 1]

与分组聚合不同,窗口聚合不会将中间聚合的状态输出,只会在窗口闭合时最后输出一个结果。所以所有的数据都是以INSERT操作追加到结果动态表中国呢的,因此每行数据前都有+I的前缀。所以窗口聚合查询都属于追加操作,没有更新操作,代码中可以直接用toDataStream()将结果表转换成流。

具体来看,上面代码输入的前三条数据属于第一个半小时的累积窗口,其中Alice的访问数据有两条,Bob的访问数据有1条,所以输出了两条结果:[Alice, 1970-01-01T00:30, 2] 和 [Bob, 1970-01-01T00:30, 1]。
之后又到来的一条Alice的数据属于第二个半小时的范围,同时也没有超过最大窗口大小:1小时,所以会在之前Alice的窗口上进行叠加,输出 [Alice, 1970-01-01T01:00, 3]。
而Bob在第二个半小时窗口内没有新增数据,所以当第二个窗口闭合后仍然输出[Bob, 1970-01-01T01:00, 1]。从第二个小时开始,因为已经超过了第一个最大窗口,新增的数据属于新的统计周期,就要全部从零开始重新计数了。

相比之前的分组聚合函数,Flink 1.13版本的窗口表值函数(Windowing TVF)聚合有更强大的功能。除了应用简单的聚合函数、提取窗口开始时间(window_start)和结束时间(window_end)之外,窗口TVF还提供了一个扩展字段:window_time,用于表示窗口中的时间属性。这样就可以方便的进行窗口的级联(cascading window)和计算了。
另外,窗口TVF还支持GROUPING SETS,极大的扩展了窗口的应用范围。

居于窗口的聚合,是流处理中聚合统计的一个特色,也是与标准SQL最大的不同之处。在实际项目中,很多统计指标都是基于时间窗口来进行计算的,所以窗口聚合是Flink SQL中非常重要的功能。基于窗口TVF的聚合未来也会有更多功能的支持,比如窗口Top N、会话窗口、窗口联结等。

1.5.3 开窗(Over)聚合

在标准SQL中还有另外一类比较特殊的聚合方式,可以针对每一行数据开窗计算一个聚合值。
比如说,我们可以以每行数据为基准,对它开窗,计算它之前1小时内的所有数据的平均值;也可以计算它之前10个数的平均值。就好像是在每一行数据上打开了一扇窗户、收集数据进行统计一样,这就是所谓的"开窗函数"。开窗函数的聚合与之前两种聚合(分组group by聚合、窗口TVF+group by聚合)有本质上的不同:前两种聚合都是"多对一"的关系,将数据分组之后进行聚合计算,只会输出一个聚合结果。而开窗函数是对每行数据都做一次开窗聚合,因此聚合之后表的行数不会有任何减少, 是一个"多对多的关系"。

与标准SQL中一致,Flink SQL中的开窗函数也是通过OVER子句来实现的, 所以有时开窗聚合也叫做"OVER聚合"(Over Aggregation)。基本语法如下:

SELECT
<聚合函数> OVER (
			  [PARTITION BY <字段 1>[, <字段 2>, ...]]
 			  ORDER BY <时间属性字段>
 			  <开窗范围>),
...
FROM ...

主要上述OVER中的语句顺序必须严格遵守。例如:应该是 partition by dt order by price desc rows between unbounded preceding and current row 而不能是 rows between unbounded preceding and current row partition by dt order by price desc

这里OVER关键字前面是一个聚合函数,它会应用在后面OVER定义的窗口上。在OVER子句中主要有以下几个部分:

  • PARTITION BY(可选) 用来指定分区的key。
  • ORDER BY(可选)用于指定窗口内排序的key,后面只能跟时间属性字段 并且只能升序
  • 开窗范围(可选)用于指定窗口内统计计算的范围,默认大小是窗口的第一行到当前行

对于开窗函数而言,还有一个需要指定的就是开窗的范围。这个范围是由 BETWEEN <下界> AND <上界> 来定义的,也就是 "下界到上界"的范围。目前支持的下界只能是CURRENT ROW,一般的形式为:

... BETWEEN ... PRECEDING AND CURRENT ROW

前面我们提到,开窗选择的范围可以基于时间,也可以基于数据的数量。所以开窗的模式大概分为两种:范围间隔(RANGE intervals 时间)和行间隔(ROW intervals 行数)

  • 范围间隔
    范围间隔以RANGE为前缀,就是基于ORDER BY 指定的时间字段去选取一个范围,一般就是当行时间戳之前的一段时间。例如开窗范围是当前行之前1小时的数据:
    RANGE BETWEEN INTERVAL '1' HOUR PRECEDING AND CURRENT ROW
    
  • 行间隔
    行间隔以ROWS为前缀,就是直接确定要选多少行,由当前行出发向前选取就n行可以了。例如开窗范围选择当前行之前的5行数据(最终聚合会包含当前行,参与计算的有6条数据):
ROWS BETWEEN 5 PRECEDING AND CURRENT ROW

下面是一个具体示例:

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小时的所有数据进行聚合,统计出每个用户在近1小时内访问url的总次数,并重命名为cnt。最终将表中每行user、ts以及扩展字段cnt提取出来。

可以看到,整个开窗聚合的结果是对每一行数据都计算一个对应的聚合值,就像是为表扩展了一个新的字段一样。Flink中聚合范围上界最多只能到当前行,这点和离线的窗口计算有所不同。 所以当前行后新到的数据不会影响之前数据的聚合结果,所以结果表只需要不断插入(INSERT)就可以了。也就是说执行上面SQL得到的动态表,可以用toDataStream()直接转换成流打印输出。

开窗聚合与窗口表值函数(窗口TVF聚合)本质上不同 ,但是还是有一些相似之处的:它们都是在无界的数据流中划定了一个范围,截取出有限数据集进行聚合统计。这其实就是窗口的含义。

1.5.4 应用实例 — Top N

1.普通Top N

在Flink SQL中,是通过OVER聚合和一个条件筛选来实现Top N的。具体来说,是通过将一个特殊的聚合函数ROW_NUMBER()应用到OVER窗口上,统计出每一行排序后的行号,最后用WHERE子句筛选行号小于等于N的那些行返回。

SELECT ...
	FROM (
 		SELECT ...,
 		ROW_NUMBER() OVER ([PARTITION BY <字段 1>[, <字段 1>...]]
							ORDER BY <排序字段 1> [asc|desc][, <排序字段 2> [asc|desc]...]) AS row_num
 		FROM ...)
WHERE row_num <= N [AND <其它条件>]

注意讲OVER窗口时说过,目前ORDER BY后面只能跟时间字段,并且只支持升序。那为什么这里ROW_NUMBER()后的OVER里又可以指定任意字段进行排序了呢?

Flink SQL over开窗中order by后只能跟时间属性字段
这是因为目前Flink中OVER窗口并不完善,不过针对Top N这样一个经典应用场景,Flink SQL专门用OVER聚合做了优化实现。只有在Top N的应用场景中,OVER窗口 ORDER BY后才可以跟其他排序字段。 而想要实现Top N,就必须按照上面的格式进行定义,否则Flink SQL的优化器将无法正常解析。并且,目前Table API中并不支持ROW_NUMBER()函数,所以只有SQL这一种通用的Top N实现方式。

下面是一个具体的示例,统计每个用户的访问事件中,按照字符长度排序的前两个url:

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

这里我们以用户作为分区字段,以访问url的字符长度作为排序的字段,降序排列后计算出每一行的行号,这样就相当于在EventTable上扩展出一列row_num。最后筛选row_num<=2的所有数据,就得到了每个用户访问的长度最长的两个url。

需要特别说明的是,这里的Top N计算是一个更新查询(实际上是全量状态数据做top N排序)。新数据到来后,可能会改变之前数据的排名,所以会有更新(UPDATE)操作。因此如果执行上面的SQL得到动态表,需要调用toChangelogStream()才能转换成流打印输出。

2.窗口Top N

除了对全量数据直接进行Top N的计算,我们也可以计算每一个窗口内的Top N。

例如电商行业,实际应用中往往有这样的需求:统计一段时间内的热门商品。这就需要先开一个时间窗口,在窗口中统计每个商品的点击量。然后将统计数据收集起来,按窗口边界/窗口再进行分组,并按照时间窗口内的点击量大小降序排序,选取前N个作为结果返回。

如果想要基于窗口TVF实现这样一个通用的窗口Top N聚合函数是比较麻烦的,目前Flink SQL尚不支持。不过我们可以借鉴之前的思路,使用OVER窗口统计行号来实现。

那么怎么划分时间窗口呢?我们可以先做一个窗口TVF聚合,将窗口信息window_start、window_end连同每个商品的点击量一并返回,这样就得到了带有窗口信息扩展字段的结果表。接下来像一般的Top N计算即可,不过这里的分区条件要把窗口边界也加进去。最后筛选前N行得到结果。所以窗口Top N的实现就是窗口聚合与OVER聚合的结合使用。

下面是一个具体案例的代码实现。由于用户访问事件Event中没有商品相关信息,因此我们统计的是每小时内有最多访问行为的用户,取前两名,每小时输出一次,相当于是每个小时活跃用户的查询:

import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
import static org.apache.flink.table.api.Expressions.$;

public class WindowTopNExample {
	public static void main(String[] args) throws Exception {
		StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
		env.setParallelism(1);

		// 读取数据源,并分配时间戳、生成水位线
		SingleOutputStreamOperator<Event> eventStream = env.fromElements(
			new Event("Alice", "./home", 1000L),
			new Event("Bob", "./cart", 1000L),
 			new Event("Alice", "./prod?id=1", 25 * 60 * 1000L),
 			new Event("Alice", "./prod?id=4", 55 * 60 * 1000L),
 			new Event("Bob", "./prod?id=5", 3600 * 1000L + 60 * 1000L),
 			new Event("Cary", "./home", 3600 * 1000L + 30 * 60 * 1000L),
 			new Event("Cary", "./prod?id=7", 3600 * 1000L + 59 * 60 * 1000L)
			).assignTimestampsAndWatermarks(
 				WatermarkStrategy.<Event>forMonotonousTimestamps()
 								.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
 									@Override
 									public long extractTimestamp(Event element, longrecordTimestamp){
 										return element.timestamp;
 									}
 								})
 				);
 
 	// 创建表环境
 	StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
 	// 将数据流转换成表,并指定时间属性
 	// 将 timestamp 指定为事件时间,并命名为 ts
 	Table eventTable = tableEnv.fromDataStream(eventStream,$("user"),$("url"),$("timestamp").rowtime().as("ts"));
 	// 为方便在 SQL 中引用,在环境中注册表 EventTable
 	tableEnv.createTemporaryView("EventTable", eventTable);
 	// 定义子查询,进行窗口聚合,得到包含窗口信息、用户以及访问次数的结果表
 	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);
 	tableEnv.toDataStream(result).print();
	env.execute();
	}
}

这里注意一点,每当一小时的TUMBLE_WINDOW闭合时,就会像下游算子发送数据,这个数据包含window_start、window_end、cnt。下游算子拿到这个数据后,进行row_number的计算。这里为什么我们还需要over中指定partition by window_start, window_end呢? 这是因为首先我们的状态数据(下游算子收到的窗口闭合计算发送的数据)保存的时间都比较久,如果不指定分区条件,则会全量进行row_number排序。就算我们指定ttl为1小时,状态保存的生命周期是从状态被创建到生存满1小时,这也就是说很可能会保存跨小时的状态数据,此时row_number全量状态数据排序也不是我们期望的按小时窗口进行排序。
总结就是,按TUMBLE(Table,ts,INTERVAL ‘1’ HOUR) + row_number over(partiiton by window_start,window_end order by xxx desc)的方式是最稳妥的。

上述代码中为了更好的可读性,我们将SQL拆分成了用来做窗口聚合的内部子查询,和套用Top N模版的外层查询。

  1. 首先基于ts事件时间字段定义了1小时的滚动窗口,统计EventTable中1小时时间窗口内每个用户的访问次数 重命名为cnt。为了方便后面排序,我们把window_start和window_end也提取出来,与user和cnt一起作为聚合结果表中的字段。
  2. 然后套用Top N模版,对窗口聚合的结果表中的每一行数据进行OVER聚合统计行号,注意这里是以窗口信息进行分组,按访问次数cnt进行排序,并筛选行号小于等于2的数据,最后就可以得到每个时间窗口内访问次数最多的前两个用户了。

运行结果如下:

+I[1970-01-01T00:00, 1970-01-01T01:00, Alice, 3, 1]
+I[1970-01-01T00:00, 1970-01-01T01:00, Bob, 1, 2]
+I[1970-01-01T01:00, 1970-01-01T02:00, Cary, 2, 1]
+I[1970-01-01T01:00, 1970-01-01T02:00, Bob, 1, 2]

可以看到,第一个小时窗口中,Alice有3次访问排名第一,Bob有1次访问排名第二。而第二个小时内,Cary以2次访问排名第一,Bob仍以1次访问排名第二。由于窗口的统计结果只会输出一次(每个时间窗口闭合时输出一次),所以排名不会随着后续数据到来而倍更新。这里的结果表中只有插入(INSERT)操作。 也就是说,窗口Top N计算是一个追加查询,可以直接用toDataStream()将结果表转换成流打印输出。

1.6 联结(Join)查询

按照数据库理论,关系型表的设计往往至少需要满足三范式(3NF),表中的列都直接依赖于主键(没有传递依赖和部分依赖)。这样就可以避免数据冗余和更新异常。例如商品的订单信息,我们会将其保存在一个"订单表"中,而这个表中只有商品ID,详情则需要到"商品表"中按照ID去查询。这样的好处是当我们需要商品信息时,比如通过订单表中记录的商品id到商品表中去查询,而不能在订单表中获取。这就使得当某个商品信息发生变化时,只要更新商品表即可,而不需要在订单表中对所有这个商品订单进行更改。不过这样的坏处就是我们无法从一个单独的表中获得所有想要的数据,获得完整的数据依赖大量的表连接。
三范式

在标准SQL中,可以将多个表连接合并起来,从而查询出想要的信息。这种操作就是表的联结(Join)。
在Flink SQL中,同样支持各种灵活的联结(Join)查询,操作的对象是动态表。

在流处理中,动态表的Join对应着两条数据流的Join操作。与上一节的聚合查询类似,Flink SQL中的联结查询大体上也可以分为两类:SQL原生的联结查询方式,和流处理中特有的联结查询。
connect算子
keyby问题

1.6.1 常规联结查询

常规联结(Regular Join)是SQL中原生定义的JOIN方式,是最通用的一类联结操作。它的具体语法与标准SQL的联结完全相同,通过关键字JOIN来联结两个表,后面用ON来指明联结条件。按照习惯,我们一般以"左侧"和"右侧"来区分联结操作的两个表。

在两个动态表的联结中,任何一侧表的插入(INSERT)或更改(UPDATE)操作都会让联结的结果表发生改变。 例如左表 left join 右表 on 非时间字段(Regular Join),左表数据到达,右表数据未到达时。左表数据会先补足null后下发,等右表中数据到达后,会再触发一次联结并更新数据下发。所以,常规联结查询一般是更新(Update)查询。

与标准SQL一致,Flink SQL的常规联结也可以分为内连接(INNER JOIN)和外连接(OUTER JOIN)。注意在表连接时,等值连接可以通过hash分区充分利用并行度,而不等值连接则会是global分区,把数据全塞到一个分区里,下游的并行只有1,效率很低。
regular join
regular join2
regular join3
regular join4
regular join5
regular join6
Flink SQL的Regular Join和Interval Join

1.6.3 Join方式总结

Flink SQL join方式总结
Flink SQL join方式总结 2

1.Regular Join

Regular Join

1.使用方式

当A流 join B流时,直接通过非时间属性字段关联。eg:a.id = b.id 即为regular join
Regular Join 会在设置的状态ttl内,把左流中的数据和右流中的数据都存下来,为了与未来到来的数据进行联结,每次新数据到来后,到另一条流中寻找相同key的数据,进行insert/update的下发操作。
regular join

Regular Join得到的是一条Retract/Upsert流(具体得到Retract流还是Upsert流取决于表中有没有定义主键)。
tochangelogstream

1.关联方式(以L作为左流中的数据标识,R作为右流中的数据标识)
Inner Join

流任务中,只有左流及右流中都有相同key的数据,才输出 [+I, (L,R)]

Left Join

流任务中,如果左流数据先到达,无论此时右流中有没有能join到的数据,左流都会尝试join并下发(Join到输出[+I,(L,R)],没Join到输出[+I,(L,null)])。如果右流数据后续到达了,发现左流中之前输出过没有没有join到的数据现在能join到了,则会发起回撤动作,先输出[-U,(L,null)] 再输出 [+U,(L,R)]

Web UI算子:
web ui算子

Right Join

同Left jon。

Full Join

Left Join + Right Join。流程基本一致,只是任何一条流中的新到来数据都会尝试关联另一条流中的对应数据,并可能发起回撤动作。

2.案例一:Inner Join
-- 曝光日志数据
CREATE TABLE show_log_table (
    log_id BIGINT,
    show_params STRING
) WITH (
  'connector' = 'datagen',
  'rows-per-second' = '2',
  'fields.show_params.length' = '1',
  'fields.log_id.min' = '1',
  'fields.log_id.max' = '100'
);

-- 点击日志数据
CREATE TABLE click_log_table (
  log_id BIGINT,
  click_params     STRING
)
WITH (
  'connector' = 'datagen',
  'rows-per-second' = '2',
  'fields.click_params.length' = '1',
  'fields.log_id.min' = '1',
  'fields.log_id.max' = '10'
);

CREATE TABLE sink_table (
    s_id BIGINT,
    s_params STRING,
    c_id BIGINT,
    c_params STRING
) WITH (
  'connector' = 'print'
);

-- 流的 INNER JOIN,条件为 log_id
INSERT INTO sink_table
SELECT
    show_log_table.log_id as s_id,
    show_log_table.show_params as s_params,
    click_log_table.log_id as c_id,
    click_log_table.click_params as c_params
FROM show_log_table
INNER JOIN click_log_table ON show_log_table.log_id = click_log_table.log_id;

-- 输出结果为:
+I[5, d, 5, f]
+I[5, d, 5, 8]
+I[5, d, 5, 2]
+I[3, 4, 3, 0]
+I[3, 4, 3, 3]
...
3.案例二:Left Join
CREATE TABLE show_log_table (
    log_id BIGINT,
    show_params STRING
) WITH (
  'connector' = 'datagen',
  'rows-per-second' = '1',
  'fields.show_params.length' = '3',
  'fields.log_id.min' = '1',
  'fields.log_id.max' = '10'
);

CREATE TABLE click_log_table (
  log_id BIGINT,
  click_params     STRING
)
WITH (
  'connector' = 'datagen',
  'rows-per-second' = '1',
  'fields.click_params.length' = '3',
  'fields.log_id.min' = '1',
  'fields.log_id.max' = '10'
);

CREATE TABLE sink_table (
    s_id BIGINT,
    s_params STRING,
    c_id BIGINT,
    c_params STRING
) WITH (
  'connector' = 'print'
);

INSERT INTO sink_table
SELECT
    show_log_table.log_id as s_id,
    show_log_table.show_params as s_params,
    click_log_table.log_id as c_id,
    click_log_table.click_params as c_params
FROM show_log_table
LEFT JOIN click_log_table ON show_log_table.log_id = click_log_table.log_id;

-- 输出结果为:
+I[5, f3c, 5, c05]
+I[5, 6e2, 5, 1f6]
+I[5, 86b, 5, 1f6]
+I[5, f3c, 5, 1f6]
-U[3, 4ab, null, null]
+U[3, 4ab, 3, 765]
-U[3, 6f2, null, null]
+U[3, 6f2, 3, 765]
+I[2, 3c4, null, null]
+I[3, 4ab, 3, a8b]
+I[3, 6f2, 3, a8b]
+I[2, c03, null, null]
...
4.关于Regular Join的注意事项
  • Regular Join的联结条件可以不是等值的。等值Join和非等值Join的区别在于:等值Join 数据shuffle策略是Hash,会按照on中的等值条件将关联键发往下游对应的分区中。非等值join的数据shuffle策略是Global,所有数据往一个分区里发,再按照非等值条件进行关联。
    global join
  • 流的上游是无限的数据,而Regular Join因为不确定已有的数据什么时候能关联到未来的数据,因此会将两条流的所有数据都存储在State中,所以Flink任务的State会无限增大,因此我们需要为State设置一个合适的ttl,防止State无限增大。

2.Interval Join

Interval Join

Interval Join 2

1.使用方式

在Regular Join的基础上,在联结条件内多指定一个关于时间字段的联结条件。这个关于时间字段的联结条件可以是 = 、< 、> 、<= 、>= 、between…

FROM a join b 
ON a.log_id = b.log_id
AND a.row_time BETWEEN b.row_time - INTERVAL '4' HOUR AND b.row_time;
2.应用场景

Interval Join的概念只在流处理里有,是让一条流去join另一条流中前后一段时间内的数据。

为什么有了Regular Join还需要Interval Join呢?或者说Interval Join相比Regular Join的优势在哪?

首先Regular Join会产生回撤流,但是在实时数仓中一般流写入的sink都是Kafka这样的消息队列,最后接clickhouse/doris之类的存储引擎。但是这类存储系统都不具备回撤数据的能力,也就是每次的数据下发都会被记录下来。而Interval Join是为了消灭回撤流而出现的。Flink对参与Interval Join的两流的要求是:1.两流中都必须定义了proctime或者rowtime字段 2.两流都必须是append-only流。得益于这两个规定,Interval Join的输出流也只会是append-only流。 这是因为 例如A流inner joinB流,A流中的数据会在状态中等待我们定义的时间,如果到期后能关联上,数据就下发,如果关联不上,就不下发。所以就能产生一条append-only流。

如果参与Interval Join的两流不是append-only流,则有可能报错:

UnsupportedOperationException: Interval Join on a non-append-only table is not supported.

或者

ValidationException: Interval Join requires append-only input streams.
3.关联方式
Inner Interval Join

流任务中,只有两条流能join到才下发数据,否则不下发。(满足Join on中的条件:两条流中的数据在时间区间内并且满足其他等值条件),输出 [+I,(L,R)]

Left Interval Join

流任务中,左流数据到达之后,如果没有join到右流中的数据,就会等待(放在State)中等。如果在规定的时间间隔内右流数据到达了,发现能和保存在状态中的左流数据join到,就会输出 [+I,(L,R)]。规定时间一到,就说明可能关联不上了,就输出[+I,(L,null)],并把刚刚左流保存在状态中的那条数据删除。

web ui:
web ui

Right Interval Join

同Left Interval Join,只是左右顺序不同。

Full Interval Join

Left Interval Join + Right Interval Join。两条流中的任意一条数据到来后,都会在状态中保留一定时间,在此期间,能关联上的数据尽可能下发,如果时间到期后一条时间都没关联上,则补全null下发 并将其从状态中删除。

4.案例一:Left Interval Join(注意得到的是一条append-only流)
CREATE TABLE show_log (
    log_id BIGINT,
    show_params STRING,
    row_time AS cast(CURRENT_TIMESTAMP as timestamp(3)),
    WATERMARK FOR row_time AS row_time
) WITH (
  'connector' = 'datagen',
  'rows-per-second' = '1',
  'fields.show_params.length' = '1',
  'fields.log_id.min' = '1',
  'fields.log_id.max' = '10'
);

CREATE TABLE click_log (
    log_id BIGINT,
    click_params STRING,
    row_time AS cast(CURRENT_TIMESTAMP as timestamp(3)),
    WATERMARK FOR row_time AS row_time
)
WITH (
  'connector' = 'datagen',
  'rows-per-second' = '1',
  'fields.click_params.length' = '1',
  'fields.log_id.min' = '1',
  'fields.log_id.max' = '10'
);

CREATE TABLE sink_table (
    s_id BIGINT,
    s_params STRING,
    c_id BIGINT,
    c_params STRING
) WITH (
  'connector' = 'print'
);

INSERT INTO sink_table
SELECT
    show_log.log_id as s_id,
    show_log.show_params as s_params,
    click_log.log_id as c_id,
    click_log.click_params as c_params
FROM show_log 
LEFT JOIN click_log 
ON show_log.log_id = click_log.log_id
AND show_log.row_time BETWEEN click_log.row_time - INTERVAL '5' SECOND AND click_log.row_time + INTERVAL '5' SECOND;

-- 输出结果
+I[6, e, 6, 7]
+I[11, d, null, null]
+I[7, b, null, null]
+I[8, 0, 8, 3]
+I[13, 6, null, null]
5.注意事项
  • 相比于Regular Join的即时下发,Interval Join具有延迟下发的特点
    interval join
  • 实时 Interval Join 可以不是 等值 join。等值 join 和 非等值 join 区别在于,等值 join 数据 shuffle 策略是 Hash,会按照 Join on 中的等值条件作为 id 发往对应的下游;非等值 join 数据 shuffle 策略是 Global,所有数据发往一个并发,然后将满足条件的数据进行关联输出
  • interval join 的时间区间取决于日志的真实情况:设置大了容易造成任务的 state 太大,并且时效性也会变差。设置小了,join 不到,下发的数据在后续使用时,数据质量会存在问题。所以建议使用时先使用离线数据做一遍两条流的时间戳 diff 比较,来确定真实情况下的时间戳 diff 的分布是怎样的。举例:你通过离线数据 join 并做时间戳 diff 后发现 99% 的数据都能在时间戳相差 5min 以内 join 到,那么你就有依据去设置 interval 时间差为 5min。
  • interval join 中的时间区间条件既支持事件时间,也支持处理时间。事件时间由 watermark 推进。

3.Tmeporal Join(快照Join)

Flink的几种join总结
Temporal Join
Temporal Join 2

Flink SQL的Temporal Join简而言之就是事实表和"版本维度表"的连接。所谓的版本表,就是记录了数据随着时间推移而产生变化的表,类似离线计算中的拉链表,可以预见的是这个版本表将会是一个changelog流(一个主键的维值将会随时间变化而变化)。当我们连接某个版本表时,并不是把当前的数据根据关联键连接起来就行了,而是希望能根据主表中数据发生的时间,到右表(版本表)中找到对应版本的数据。

1.使用方式

FOR SYSTEM_TIME AS OF table1.{ proctime | rowtime } [AS alias2]

SELECT [column_list]
FROM table1 [AS <alias1>]
[LEFT] JOIN table2 FOR SYSTEM_TIME AS OF table1.{ proctime | rowtime } [AS <alias2>]
ON table1.column-name1 = table2.column-name1
Temporal Join的使用条件
  • 左右表都需要定义时间字段,事件时间和处理时间均可(一般都是事件时间,在temporal join中使用处理时间意义不大。)
  • 左表不用非得定义primary key,但是右表(维表)必须定义,因为flink 需要知道维表中的主键,才能推测出以什么为准来规定时间范围。
  • 右表(维表)中的时间区间是由时间字段隐式推断出来的。eg:具有相同主键的时间字段就会形成一个时间区间。
    时间区间
  • 左表关联右表时,关联条件中必须有右表的primary key,此外也可以补充其他关联条件。
  • 在 Flink SQL 中,一个表是否被视为时态表取决于它是否在查询中与时态表函数(如 FOR SYSTEM_TIME AS OF)一起使用,不需要显示的指定维表为create temporal table…
2.应用场景

在离线计算中,有一种表叫拉链快照表,拉链快照表的主要作用是根据时间有效范围(start_time,end_time)来处理具有缓慢变化维的数据。在Flink中,使用一个明细表去join这个拉链快照表的join方式就叫做Temporal Join。其中维表(是一张拉链快照表,也是一个Flink 流,可能是Flink CDC得到的 )叫做versioned table。Flink会在temporal join时根据左表定义的时间字段到右表对应的时间区间内去匹配维值。

一个经典的例子是汇率数据(实时的根据汇率)。例如人民币对美元的汇率在12:00之前(事件时间)是7:1,在12:00之后是6:1。那么对于12:00之前产生的数据,我们就应该用7:1的汇率进行计算,在12:00之后产生的数据,我们应该用6:1的汇率进行计算。

3.为什么需要temporal join而不是用where来模拟temporal join呢?

temporal join1
temporal join2
temporal join3

4.Primary Key定义方式

temporal join的条件之一是维表必须定义primary key,通常我们看到的ddl会这么写:

PRIMARY KEY(currency) NOT ENFORCED

这里的NOT ENFORCED是什么意思呢?
not enforced 1
not enforced 2
这里需要注意的是:如果使用了NOT ENFORCED的话,Flink会认为你已经确认了这条流中不会有主键重复的数据,因此不会强制检查数据的唯一性。但是如果数据流中真出现了主键重复的数据,Flink也不会报错(因为不会检查),这样可能使输出的结果不正确(因为主键重复的数据多次出现)。

案例一:Left Join
-- 1. 定义一个输入订单表
CREATE TABLE orders (
    order_id    STRING,
    price       DECIMAL(32,2),
    currency    STRING,
    order_time  TIMESTAMP(3),
    WATERMARK FOR order_time AS order_time
) WITH (/* ... */);

-- 2. 定义一个汇率 versioned 表,其中 versioned 表的概念下文会介绍到
CREATE TABLE currency_rates (
    currency STRING,
    conversion_rate DECIMAL(32, 2),
    update_time TIMESTAMP(3) METADATA FROM `values.source.timestamp` VIRTUAL,
    WATERMARK FOR update_time AS update_time,
    PRIMARY KEY(currency) NOT ENFORCED
) WITH (
   'connector' = 'kafka',
   'value.format' = 'debezium-json',
   /* ... */
);

SELECT 
     order_id,
     price,
     currency,
     conversion_rate,
     order_time,
FROM orders
-- 3. Temporal Join 逻辑
-- SQL 语法为:FOR SYSTEM_TIME AS OF
LEFT JOIN currency_rates FOR SYSTEM_TIME AS OF orders.order_time
ON orders.currency = currency_rates.currency;

-- 输出
order_id  price  货币       汇率             order_time
========  =====  ========  ===============  =========
o_001     11.11  EUR       1.14             12:00:00
o_002     12.51  EUR       1.10             12:06:00

4.Lookup Join(维表join)

lookup join
lookup join 2
lookup join的底层实现是一条数据流,一个存储系统。注意这里和temporal join有点区别,temporal join中是两条流join,而这里是一个存储系统,一条主流。也就是说,如果使用lookup join的话,维表值会以最新值为准,如果希望维值能以主表的事件时间为准的话,就要用flink cdc把存储系统中的数据变成流,然后用temporal join。

Lookup join的底层时间实际就是flatmap算子,来一条数据就触发一次到外部存储系统中的维表查询。

1.使用方式
FROM show_log AS s
LEFT JOIN user_profile FOR SYSTEM_TIME AS OF s.proctime AS u
ON s.user_id = u.user_id
  • 使用lookup join的条件是主表必须也只能定义处理时间字段
2.案例一 (使用曝光用户日志流(show_log) 关联用户画像维表((user_profile))

曝光用户日志流(show_log)数据(数据存储在 kafka 中):

log_id timestamp         		user_id
1       2021-11-01 00:01:03 	a
2       2021-11-01 00:03:00 	b
3       2021-11-01 00:05:00 	c
4       2021-11-01 00:06:00 	b
5       2021-11-01 00:07:00	 	c

用户画像维表(user_profile)数据(数据存储在 redis 中):

user_id(主键) 	age     sex
a               12-18   男
b               18-24   女
c               18-24   男
CREATE TABLE show_log (
    log_id BIGINT,
    `timestamp` as cast(CURRENT_TIMESTAMP as timestamp(3)),
    user_id STRING,
    proctime AS PROCTIME()
)
WITH (
  'connector' = 'datagen',
  'rows-per-second' = '10',
  'fields.user_id.length' = '1',
  'fields.log_id.min' = '1',
  'fields.log_id.max' = '10'
);

CREATE TABLE user_profile (
    user_id STRING,
    age STRING,
    sex STRING
    ) WITH (
  'connector' = 'redis',
  'hostname' = '127.0.0.1',
  'port' = '6379',
  'format' = 'json',
  'lookup.cache.max-rows' = '500',
  'lookup.cache.ttl' = '3600',
  'lookup.max-retries' = '1'
);

CREATE TABLE sink_table (
    log_id BIGINT,
    `timestamp` TIMESTAMP(3),
    user_id STRING,
    proctime TIMESTAMP(3),
    age STRING,
    sex STRING
) WITH (
  'connector' = 'print'
);

-- lookup join 的 query 逻辑
INSERT INTO sink_table
SELECT 
    s.log_id as log_id
    , s.`timestamp` as `timestamp`
    , s.user_id as user_id
    , s.proctime as proctime
    , u.sex as sex
    , u.age as age
FROM show_log AS s
LEFT JOIN user_profile FOR SYSTEM_TIME AS OF s.proctime AS u
ON s.user_id = u.user_id

-- 输出
log_id  timestamp           user_id 	age     sex
1       2021-11-01 00:01:03 	a       12-182       2021-11-01 00:03:00 	b       18-243       2021-11-01 00:05:00	 	c       18-244       2021-11-01 00:06:00 	b       18-245       2021-11-01 00:07:00 	c       18-24

web ui算子

3.注意事项
  • lookup 维表关联必须在主表中定义处理时间字段
  • lookup 默认是无缓存,即主流中来一条数据就到维表中查一次,这样的好处是时刻能获得最新维值,坏处是查询效率较低。如果要开启缓存的话,需要改配置项。(打开缓存的话,可能导致查询不到最新的维值)
  • 如果开启了缓存,需要设置缓存中能保存的最大维值行数和缓存的ttl。

5.窗口Join

window join通常的使用范式是:1.使用窗口TVF得到一个有窗口扩展信息的表。2.在关联条件中添加 a.window_start=b.window_start and a.window_end=b.window_end
window join

6.Array Expansion

7.Table Expansion

1.7 函数

  • 20
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值