Flink SQL,2024年最新GitHub已标星16k

先自我介绍一下,小编浙江大学毕业,去过华为、字节跳动等大厂,目前阿里P7

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新大数据全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友。
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上大数据知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以添加V获取:vip204888 (备注大数据)
img

正文

如果我们希望在本地的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)) " +


**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**

**需要这份系统化的资料的朋友,可以添加V获取:vip204888 (备注大数据)**
![img](https://img-blog.csdnimg.cn/img_convert/c597dd17914913b5d424d1571169a23e.png)

**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**

TS(分组集)设置多个分组情况分别统计后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)) " +

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip204888 (备注大数据)
[外链图片转存中…(img-s1TWnlL1-1713122786704)]

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值