Flink Table API与SQL(一)

在第六章中,我们已经掌握了Apache Flink多级API的知识。前面的编码范式都建立在DataStream核心API之上,尽管这种方法功能强大,但编程过程相对复杂。除了这种基于DataStream/DataSet API的编程模式,Flink还提供了Table API和SQL编程模式。Table API将数据抽象为"表",并以类似SQL的语法进行处理,避免了复杂的函数定义。而SQL编程则是Flink中最高层次的抽象,支持使用标准SQL对数据进行处理。值得注意的是,SQL查询可以在Table API定义的表上执行,这两种方式在实际工作中常常相互结合。

通过采用Flink的Table API和SQL编程方式,我们极大地降低了编程难度。在实际企业开发中,强烈建议采用这种方式来进行数据分析处理。在本章中,我们将详细深入介绍这一部分内容。

TablAPI 和SQL集成在同一套API中,这套API的核心概念是Table,用作查询的输入和输出,所有批处理和流处理的Table API和SQL程序都遵循相同的模型,在Flink1.15版本后,Flink Java和Scala相关依赖进行了部分合并,在编写 Table API和SQL查询代码时,Java项目和Scala项目都需要如下共同依赖:

复制代码
<!-- Flink Table 运行环境所需依赖包 -->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-runtime</artifactId>
  <version>${flink.version}</version>
</dependency>
​
<!-- Flink Table Planner 依赖包 -->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-planner-loader</artifactId>
  <version>${flink.version}</version>
</dependency>

在Flink未来版本中,Scala API将会被弃用,截止到Flink1.17版本,Flink还支持Scala API 。Flink Table和SQL 编程中Java 代码与Scala代码还有些包依赖不同,如Table API编程需要依赖包(flink-table-api-java/flink-table-api-scala*{scala.binary.versioin})、Table API和DataStream互操作需要依赖包(flink-table-api-java-bridge/flink-table-api-scala-bridge*{scala.binary.version})。所以除了以上两个公共的依赖包之外,Java API还需要导入如下依赖:

复制代码
<!-- Table API 依赖包-->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-api-java</artifactId>
  <version>${flink.version}</version>
</dependency>
​
<!-- Table API + DataStream 所需依赖包-->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-api-java-bridge</artifactId>
  <version>${flink.version}</version>
</dependency>

编写Scala API还需要导入如下依赖:

复制代码
<!-- Table API With Scala 依赖包-->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-api-scala_${scala.binary.version}</artifactId>
  <version>${flink.version}</version>
</dependency>

<!-- Table API + DataStream 所需依赖包-->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-api-scala-bridge_${scala.binary.version}</artifactId>
  <version>${flink.version}</version>
</dependency>

此外,在第六章节RedisSink中我们导入了flink-connector-redis包,该包中包含flink-table-api-java-bridge、flink-table-api-java依赖与现在导入的flink-table-runtime依赖包中的两个依赖冲突,所以这里我们将对应的冲突包从flink-connector-redis包中去除。

复制代码
<dependency>
  <groupId>org.apache.bahir</groupId>
  <artifactId>flink-connector-redis_2.12</artifactId>
  <version>${flink-connector-redis.version}</version>
  <!-- 该依赖与Flink Table 依赖冲突,排除该依赖 -->
  <exclusions>
    <exclusion>
      <groupId>org.apache.flink</groupId>
      <artifactId>flink-table-api-java-bridge_2.12</artifactId>
    </exclusion>
    <exclusion>
      <groupId>org.apache.flink</groupId>
      <artifactId>flink-table-api-java</artifactId>
    </exclusion>
  </exclusions>
</dependency>

下面通过一个简单的案例来体验下Flink Table API和SQL编程,该案例中读取"datagen"(该Connector可以自定生成数据)数据,并进行Table API和SQL操作。

  • Java代码:
复制代码
//1.准备环境配置
EnvironmentSettings settings = EnvironmentSettings
        .newInstance()
        .inStreamingMode()//使用流模式
//      .inBatchMode() //使用批模式
        .build();

//2.创建TableEnvironment
TableEnvironment tableEnv = TableEnvironment.create(settings);

//设置并行度为1
        tableEnv.getConfig().getConfiguration().setString("parallelism.default", "1");

//3.通过Table API 创建Source表
tableEnv.createTemporaryTable("SourceTable", TableDescriptor.forConnector("datagen")
       // 定义表结构
       .schema(Schema.newBuilder()
           .column("f0", DataTypes.STRING())
.build())
       // 每秒钟生成1条数据
       .option(DataGenConnectorOptions.ROWS_PER_SECOND, 10L)
.build());

//4.通过SQL DDL创建Sink Table
tableEnv.executeSql("CREATE TABLE SinkTable (" +
                "  f0 STRING" +
                ") WITH (" +
                "  'connector' = 'print'" +
                ")");

//5.通过Table API查询数据
Table table1 = tableEnv.from("SourceTable");

//6.通过SQL查询数据
Table table2 = tableEnv.sqlQuery("select * from SourceTable");

//7.通过Table API将查询结果写入SinkTable
table1.insertInto("SinkTable").execute();

//8.通过SQL将查询结果写入SinkTable
tableEnv.executeSql("insert into SinkTable select * from SourceTable");
  • Scala代码
复制代码
//1.准备环境配置
val settings: EnvironmentSettings = EnvironmentSettings.newInstance()
      .inStreamingMode()
      .build()

//2.创建TableEnvironment
val tableEnv: TableEnvironment = TableEnvironment.create(settings)

//设置并行度为1
    tableEnv.getConfig().getConfiguration().setString("parallelism.default", "1")

//3.通过Table API 创建Source表
val unit: Unit = tableEnv.createTemporaryTable("SourceTable", TableDescriptor.forConnector("datagen")
      .schema(Schema.newBuilder()
        .column("f0", DataTypes.STRING())
        .build())
      .option[java.lang.Long](DataGenConnectorOptions.ROWS_PER_SECOND, 10L)
      .build())

//4.通过SQL DDL创建Sink Table
tableEnv.executeSql("CREATE TABLE SinkTable (" +
      "  f0 STRING" +
      ") WITH (" +
      "  'connector' = 'print'" +
      ")")

//5.通过Table API查询数据
val table1: Table = tableEnv.from("SourceTable")

//6.通过SQL查询数据
val table2: Table = tableEnv.sqlQuery("select * from SourceTable")

//7.通过Table API将查询结果写入SinkTable
//    table1.executeInsert("SinkTable")
table1.insertInto("SinkTable").execute()

//8.通过SQL将查询结果写入SinkTable
tableEnv.executeSql("insert into SinkTable select * from SourceTable")

使用Flink Table API 和SQL编程时,与Flink DataStream编程一样,需要有对应的执行环境、数据源Source及数据Sink。以上代码首先创建TableEnvironment环境,然后通过Table API 或者SQL DDL 方式都可以创建Flink Source Table或者Sink Table ,创建好Source表后,可以通过Table API或者SQL 语句方式查询Flink表中数据进行转换处理,每一步转换都会得到Table对象,然后将Table结果通过Table API或者SQL 语句方式写出到Sink表中。可见在Flink Table和SQL编程中使用Flink Table API或者SQL 方式都能完成整个数据处理流程。

TableEnvironment

TableEnvironment是Table API和SQL查询的核心概念,主要负责注册表、注册外部Catalog、执行SQL查询、注册自定义函数以及DataStream和Table之间的转换。Table API 和SQL查询的核心Table对象需要与对应的TableEnvironment绑定,不能在同一条查询中使用不同的TableEnvironment中的表,创建TableEnvironment有两种方式,下面分别进行介绍。

1) 通过TableEnvionment.create()创建

复制代码
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.TableEnvironment;

EnvironmentSettings settings = EnvironmentSettings  
.newInstance()  
.inStreamingMode()    //.inBatchMode()  
.build();
TableEnvironment tEnv = TableEnvironment.create(settings);

以上代码中inStreamingMode指的是Flink流式处理,inBatchMode()指的是Flink批处理。

2) 通过现有的StreamExecutionEnvironment创建

用户也可以从现有的StreamExecutionEnvironment对象中创建一个StreamTableEnvironment对象,StreamTableEnvironment实现了TableEnvironment接口,可以直接对Flink 流使用Table Api或者SQL查询处理,同时方便与DataStream API互相操作。关于Table API与 DataStream API互相操作详细可见后续小节。

复制代码
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 tEnv = StreamTableEnvironment.create(env);

创建表

表标识符(identifier)

Flink中创建的表由三部分组成:Catalog名称、数据库名称以及表名称。Catalog是元数据管理中心(元数据包含数据库、表、表结构等信息),如果catalog或者数据库没有指明,就会使用默认值,Flink中默认catalog为default_catalog,默认database为default_database,例如在Flink中创建一张表tbl,该表全部名称为:default_catalog.default_database.tbl。用户可以在Flink程序中执行一个catalog和数据库作为"当前catalog"和"当前数据库",这样创建表时只需要指定表名即可。如下:

复制代码
// 创建TableEnvironment
val tEnv: TableEnvironment = ...

// 指定使用的catalog和数据库
tEnv.useCatalog("custom_catalog")
tEnv.useDatabase("custom_database")

// 创建 Table对象
val table: Table = ...

// 注册名为 'exampleView' 的视图,该视图全称为'custom_catalog'.'custom_database'.'exampleView' 
tableEnv.createTemporaryView("exampleView", table)

就算在Flink程序中指定了默认的Catalog和数据库,也可以创建属于其他Catalog或者数据库的表,如下:

复制代码
// 接以上代码,在Flink中注册名称为'exampleView'的视图,该视图所属数据库为'other_database',所属catalog为'custom_catalog'
tableEnv.createTemporaryView("other_database.exampleView", table)

// 接以上代码,注册名称为'example.View'的视图,该视图所属catalog为'custom_catalog',所属数据库为'custom_database'
tableEnv.createTemporaryView("`example.View`", table)
// 接以上代码,注册名称为'exampleView'的视图,该视图所属catalog为'other_catalog',所属数据库为'other_database'
tableEnv.createTemporaryView("other_catalog.other_database.exampleView", table)

注意:Flink表名遵循SQL标准,如果Flink中注册的表有"."、"-"等特殊符号时,需要使用反引号(`)进行转义。

表类型

在Flink中创建的表分为永久表(Permanent Table)和临时表(Temporary Table)两种。

永久表需要catalog维护表和元数据(例如Hive Metastore),一旦永久表被创建,它将对任何连接到Catalog的Flink会话可见且持续存在,直至该表被明确删除。

临时表通常保存于内存中并且仅在创建它们的Flink会话(Session)持续期间存在,这些表对于其他Flink会话不可见。在Flink中可以创建与永久表相同表名的临时表,该临时表会屏蔽永久表,所有查询该表的语句都会作用于临时表,除非将与永久表名称相同的临时表删除后,才能查询对应名称的永久表。

创建表方式

在Flink中创建表是可以是虚拟表(视图Views)也可以是常规表(表Tables)。视图View可以从已经存在的Table对象中创建,Table对象一般是Table API或者SQL的查询结果,表Tables可以读取外部数据创建,例如:文件、数据库或者消息队列。下面分别介绍虚拟表和常规表的创建方式。

  • 虚拟表

基于已有的Table对象创建虚拟表的方式如下:

复制代码
// 创建TableEnvironment对象
TableEnvironment tableEnv = ...; 

// 通过操作创建Table对象 
Table projTable = tableEnv.from("X").select(...);

// 将projTable Table对象注册为名为"projectedTable"的表
tableEnv.createTemporaryView("projectedTable", projTable);

以上基于已有Table对象创建的表类似于数据库中的View视图,该表并不会存储对应Table对象的结果,当后续Flink操作引用该注册的表时,对应的Table对象会内嵌到对应查询中被执行,当多次引用该注册的表示,那么该Table对象会被内嵌到每个查询中并被执行多次。

  • 常规表

常规表时直接读取外部数据注册为表。在Flink中创建常规表可以通过connector声明,Connector描述了存储表数据的外部系统,例如常见的Kafka、文件系统都可以通过这种方式来创建Flink表。Connector声明创建表时又可以通过Table API方式声明创建或者通过Flink SQL DDL语句来创建,建议使用Flink SQL DDL语句来创建。

1) Table API方式创建Flink表

复制代码
// 创建TableDescriptor对象
TableDescriptor sourceDescriptor = TableDescriptor.forConnector("datagen")
.schema(Schema.newBuilder()
.column("f0", DataTypes.STRING())
.build())
.option(DataGenConnectorOptions.ROWS_PER_SECOND, 100L)
.build();

// 通过Table API方式创建表
tableEnv.createTable("SourceTableA", sourceDescriptor);
tableEnv.createTemporaryTable("SourceTableB", sourceDescriptor);

注意以上代码中"datagen"是一种Table Connector,该Connector可以随机产生数据,以上配置是每秒生成100条随机数据。关于更多TableConnector的使用参考Table Connector小节。

2) SQL DDL方式创建Flink表

复制代码
// 通过SQL DDL方式创建表
tableEnv.executeSql("CREATE [TEMPORARY] TABLE MyTable (...) WITH (...)");

由于通过SQL DDL方式创建Flink表相比于Table API方式创建表要简单的多,所以在实际工作中建议直接使用SQL DDL方式创建Flink表。

查询表

查询表可以通过Table API、SQL 语句或是两者结合方式来完成,下面通过一个案例来学习Table API、SQL查询以及两者混用方式对Flink表进行查询。

该案例读取Kafka基站日志数据,要求过滤通话成功并且通话时长大于10的日志数据并统计每个基站通话总时长。由于从Kafka中读取基站日志数据,这里需要启动Kafka并创建Kafka中存储基站日志数据的topic:

复制代码
#创建Kafka stationlog-topic
kafka-topics.sh --bootstrap-server node1:9092,node2:9092,node3:9092 --create --topic stationlog-topic  --partitions 3 --replication-factor 3

#向Kafka stationlog-topic中生产数据
kafka-console-producer.sh  --bootstrap-server node1:9092,node2:9092,node3:9092 --topic stationlog-topic

Flink Table API或者SQL查询Kafka中数据时,需要指定读取Kafka中数据的格式,这里指定数据格式为csv格式,所以还需要在Java或者Scala项目中导入csv格式需要的依赖包,如下:

复制代码
<!-- Flink Connector连接Kafka csv数据格式依赖包 -->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-csv</artifactId>
  <version>${flink.version}</version>
</dependency>
Table API查询表

Table API是关于Java和Scala集成语言式查询API,其查询语句不是由字符串指定,而是在宿主语言中逐步构建,Table API 是基于 Table 类的,该类表示一个表(可以是流处理或批处理),并提供使用关系操作的方法,这些方法返回一个新的 Table 对象,该对象表示对输入 Table 进行关系操作的结果。关系操作可以由多个方法调用组成,例如 table.groupBy(...).select(),其中 groupBy(...) 用于指定对 table 进行分组,而 select(...) 用于在 table 分组的基础上进行投影操作。

  • Java代码
复制代码
//创建TableEnvironment
 EnvironmentSettings settings = EnvironmentSettings.newInstance()
         .inStreamingMode()
         .build();
 TableEnvironment tableEnv = TableEnvironment.create(settings);

 //读取Kafka基站日志数据,通过 TableDescriptor 定义表结构
 tableEnv.createTemporaryTable("stationlog_tbl", TableDescriptor.forConnector("kafka")
         .schema(Schema.newBuilder()
                 .column("sid", DataTypes.STRING())
                 .column("call_out", DataTypes.STRING())
                 .column("call_in", DataTypes.STRING())
                 .column("call_type", DataTypes.STRING())
                 .column("call_time", DataTypes.BIGINT())
                 .column("duration", DataTypes.BIGINT())
                 .build())
         .option("topic", "stationlog-topic")
         .option("properties.bootstrap.servers", "node1:9092,node2:9092,node3:9092")
         .option("properties.group.id", "testGroup")
         .option("scan.startup.mode", "latest-offset")
         .option("format", "csv")
         .build());


 //通过Table API 获取Table对象
 Table stationLogTbl = tableEnv.from("stationlog_tbl");

//过滤通话成功并且通话时长大于10的数据信息
 Table resultTbl = stationLogTbl
         .filter($("call_type").isEqual("success").and($("duration").isGreater(10)))
         .groupBy($("sid"))
         .select($("sid"),$("duration").sum().as("total_duration"));

 //打印输出
 resultTbl.execute().print();
  • Scala代码
复制代码
//创建TableEnvironment
val settings: EnvironmentSettings = EnvironmentSettings.newInstance()
  .inStreamingMode()
  .build()

val tableEnv: TableEnvironment = TableEnvironment.create(settings)

//读取Kafka基站日志数据,通过 TableDescriptor 定义表结构
tableEnv.createTemporaryTable("station_tbl",TableDescriptor.forConnector("kafka")
  .schema(Schema.newBuilder()
    .column("sid", DataTypes.STRING())
    .column("call_out",DataTypes.STRING())
    .column("call_in",DataTypes.STRING())
    .column("call_type",DataTypes.STRING())
    .column("call_time",DataTypes.BIGINT())
    .column("duration",DataTypes.BIGINT())
    .build())
  .option("topic","stationlog-topic")
  .option("properties.bootstrap.servers","node1:9092,node2:9092,node3:9092")
  .option("properties.group.id","test-group")
  .option("scan.startup.mode","latest-offset")
  .option("format","csv")
  .build())

//通过Table API 获取Table 对象
val stationLogTbl: Table = tableEnv.from("station_tbl")

//过滤通话成功并且通话时长大于10的数据信息
val resultTbl: Table = stationLogTbl.filter($"call_type" === "success" && $"duration" > 10)
  .groupBy($"sid")
  .select($"sid", $"duration".sum().as("total_duration"))

//打印输出
val execute: TableResult = resultTbl.execute
execute.print()

以上Java和Scala代码编写过程中需要注意如下几点:

  1. Table API中引用列Java和Scala代码书写方式不同。Java代码中通过导入import static org.apache.flink.table.api.Expressions.,然后通过("列名")来引入列;Scala代码中直接通过$"列名"即可获取列。

  2. Java API和Scala API 书写Table API略有不同。例如,Java中通过stationLogTbl.filter(("call_type").isEqual("success").and(("duration").isGreater(10)))来实现过滤数据,Scala中写法为stationLogTbl.filter("call_type" === "success" \&\& "duration" > 10)。

  3. 编写Scala代码时导入的包和类比较多,可以直接通过导入import org.apache.flink.table.api._来避免导入过多的包和类。

以上代码编写完成启动后,向Kafka中依次输入如下数据:

复制代码
#kafka-console-producer.sh  --bootstrap-server node1:9092,node2:9092,node3:9092 --topic stationlog-topic
001,181,182,success,1000,40
#该数据不会输出结果
002,182,183,fail,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,50
#该数据不会输出结果
003,181,183,fail,5000,50
#该数据不会输出结果
001,183,184,success,3000,10
002,184,185,success,4000,40
001,181,183,success,7000,50

在控制台中可以看到输出对应的流式结果,如下:

复制代码
+----+--------------------------------+----------------------+
| op |                            sid |       total_duration |
+----+--------------------------------+----------------------+
| +I |                            001 |                   40 |
| -U |                            001 |                   40 |
| +U |                            001 |                   70 |
| +I |                            002 |                   50 |
| -U |                            002 |                   50 |
| +U |                            002 |                   90 |
| -U |                            001 |                   70 |
| +U |                            001 |                  120 |

以上结果每行op列下+I、-U、+U分别表示INSERT(插入)、UPDATE_BEFORE(更新前)以及UPDATE_AFTER(更新后),当某个sid数据第一次输入后输出+I标记的行,当该sid继续输入数据后,可以看到-U和+U数据输出,表示之前数据失效和新数据生效,这里-U相当于"撤回"(Retract)了一条数据,+U相当于新增一条数据 。实际上这里Table输出的是Changelog Stream(变更日志流),这种流记录了数据发生变更的历史,关于变更日志流在Table API和DataStream集成小节还会介绍。

SQL查询表

Flink SQL是建立在Apache Calcite的基础上,实现了SQL标准。通过Flink SQL,我们可以使用常规字符串针对Flink批数据或者流数据来指定SQL查询。

  • Java代码
复制代码
//创建TableEnvironment
EnvironmentSettings settings = EnvironmentSettings.newInstance()
        .inStreamingMode()
        .build();
TableEnvironment tableEnv = TableEnvironment.create(settings);

//读取Kafka基站日志数据,通过SQL DDL方式定义表结构
tableEnv.executeSql("" +
        "create table stationlog_tbl (" +
        "   sid string," +
        "   call_out string," +
        "   call_in string," +
        "   call_type string," +
        "   call_time bigint," +
        "   duration bigint" +
        ") with (" +
        "   'connector' = 'kafka'," +
        "   'topic' = 'stationlog-topic'," +
        "   'properties.bootstrap.servers' = 'node1:9092,node2:9092,node3:9092'," +
        "   'properties.group.id' = 'testGroup'," +
        "   'scan.startup.mode' = 'latest-offset'," +
        "   'format' = 'csv'" +
        ")");

//通过SQL统计过滤通话成功并且通话时长大于10的数据信息
Table resultTbl = tableEnv.sqlQuery("" +
        "select sid,sum(duration) as total_duration " +
        "from stationlog_tbl " +
        "where call_type='success' and duration>10 " +
        "group by sid");

//打印结果
resultTbl.execute().print();
  • Scala代码
复制代码
//创建TableEnvironment
val settings: EnvironmentSettings = EnvironmentSettings.newInstance()
  .inStreamingMode()
  .build()

val tableEnv: TableEnvironment = TableEnvironment.create(settings)

//读取Kafka基站日志数据,通过SQL DDL方式定义表结构
tableEnv.executeSql(
  """
    |create table station_tbl(
    |   sid string,
    |   call_out string,
    |   call_in string,
    |   call_type string,
    |   call_time bigint,
    |   duration bigint
    |) with(
    |   'connector' = 'kafka',
    |   'topic' = 'stationlog-topic',
    |   'properties.bootstrap.servers' = 'node1:9092,node2:9092,node3:9092',
    |   'properties.group.id' = 'test-group',
    |   'scan.startup.mode' = 'latest-offset',
    |   'format' = 'csv'
    |)
    |""".stripMargin)

//通过SQL统计过滤通话成功并且通话时长大于10的数据信息
val resultTbl: Table = tableEnv.sqlQuery(
  """
    |select sid,sum(duration) as total_duration
    |from station_tbl
    |where call_type = 'success' and duration > 10
    |group by sid
    |""".stripMargin)

//打印输出
resultTbl.execute().print()

以上代码编写完成启动后,向Kafka中依次输入如下数据:

复制代码
#kafka-console-producer.sh  --bootstrap-server node1:9092,node2:9092,node3:9092 --topic stationlog-topic
001,181,182,success,1000,40
#该数据不会输出结果
002,182,183,fail,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,50
#该数据不会输出结果
003,181,183,fail,5000,50
#该数据不会输出结果
001,183,184,success,3000,10
002,184,185,success,4000,40
001,181,183,success,7000,50

在控制台中可以看到输出对应的流式结果,如下:

复制代码
+----+--------------------------------+----------------------+
| op |                            sid |       total_duration |
+----+--------------------------------+----------------------+
| +I |                            001 |                   40 |
| -U |                            001 |                   40 |
| +U |                            001 |                   70 |
| +I |                            002 |                   50 |
| -U |                            002 |                   50 |
| +U |                            002 |                   90 |
| -U |                            001 |                   70 |
| +U |                            001 |                  120 |

可见通过SQL方式查询表结果与Table API方式查询结果一样,但SQL查询编码方式更为简便,在实际工作中建议使用SQL方式进行Flink表操作。

Table API与SQL查询混用查询表

由于Table API和SQL查询都返回Table对象,两者可以在编写Flink代码时进行混用,如:可以在SQL查询返回的Table对象上定义Table API查询,或者通过TableEnvironment中注册的表可以在SQL查询的FROM子句中引用。

  • Java代码
复制代码
//创建TableEnvironment
EnvironmentSettings settings = EnvironmentSettings.newInstance()
        .inStreamingMode()
        .build();
TableEnvironment tableEnv = TableEnvironment.create(settings);

//读取Kafka基站日志数据,通过 TableDescriptor 定义表结构
tableEnv.createTemporaryTable("stationlog_tbl", TableDescriptor.forConnector("kafka")
        .schema(Schema.newBuilder()
                .column("sid", DataTypes.STRING())
                .column("call_out", DataTypes.STRING())
                .column("call_in", DataTypes.STRING())
                .column("call_type", DataTypes.STRING())
                .column("call_time", DataTypes.BIGINT())
                .column("duration", DataTypes.BIGINT())
                .build())
        .option("topic", "stationlog-topic")
        .option("properties.bootstrap.servers", "node1:9092,node2:9092,node3:9092")
        .option("properties.group.id", "testGroup")
        .option("scan.startup.mode", "latest-offset")
        .option("format", "csv")
        .build());

//通过Table API 获取Table对象,并过滤数据
Table filter = tableEnv.from("stationlog_tbl")
        .filter($("call_type").isEqual("success").and($("duration").isGreater(10)));


//通过SQL统计通话数据信息
Table resultTbl = tableEnv.sqlQuery("select sid,sum(duration) as total_duration from " + filter + " group by sid");

//打印结果
resultTbl.execute().print();
  • Scala代码
复制代码
//创建TableEnvironment
val settings = EnvironmentSettings.newInstance()
  .inStreamingMode()
  .build()

val tableEnv = TableEnvironment.create(settings)

//读取Kafka基站日志数据,通过 TableDescriptor 定义表结构
tableEnv.createTemporaryTable("station_tbl", TableDescriptor.forConnector("kafka")
  .schema(Schema.newBuilder()
    .column("sid", DataTypes.STRING())
    .column("call_out", DataTypes.STRING())
    .column("call_in", DataTypes.STRING())
    .column("call_type", DataTypes.STRING())
    .column("call_time", DataTypes.BIGINT())
    .column("duration", DataTypes.BIGINT())
    .build())
  .option("topic", "stationlog-topic")
  .option("properties.bootstrap.servers", "node1:9092,node2:9092,node3:9092")
  .option("properties.group.id", "test-group")
  .option("scan.startup.mode", "latest-offset")
  .option("format", "csv")
  .build())

//通过Table API 获取Table 对象,并过滤数据
val filter: Table = tableEnv.from("station_tbl").filter($"call_type" === "success" && $"duration" > 10)

//通过SQL统计通话数据信息
val resultTbl: Table = tableEnv.sqlQuery("select sid,sum(duration) as total_duration from " + filter + " group by sid")

//打印输出
resultTbl.execute().print()

以上代码编写完成启动后,向Kafka中依次输入如下数据:

复制代码
#kafka-console-producer.sh  --bootstrap-server node1:9092,node2:9092,node3:9092 --topic stationlog-topic
001,181,182,success,1000,40
#该数据不会输出结果
002,182,183,fail,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,50
#该数据不会输出结果
003,181,183,fail,5000,50
#该数据不会输出结果
001,183,184,success,3000,10
002,184,185,success,4000,40
001,181,183,success,7000,50

在控制台中可以看到输出对应的流式结果,如下:

复制代码
+----+--------------------------------+----------------------+
| op |                            sid |       total_duration |
+----+--------------------------------+----------------------+
| +I |                            001 |                   40 |
| -U |                            001 |                   40 |
| +U |                            001 |                   70 |
| +I |                            002 |                   50 |
| -U |                            002 |                   50 |
| +U |                            002 |                   90 |
| -U |                            001 |                   70 |
| +U |                            001 |                  120 |

输出表

在Flink中分析的Table结果可以通过TableSink写出到外部系统中。TableSink是一个通用的接口,用于支持多种文件格式(如 CSV、Apache Parquet、Apache Avro)、存储系统(如 JDBC、Apache HBase、Apache Cassandra、Elasticsearch)或消息队列系统(如 Apache Kafka、RabbitMQ)。

Flink 输出表创建及Table数据输出也支持Table API和SQL两种方式。下面通过案例来演示Table API和SQL两种方式将Table的结果写出到文件系统,包括创建输出表以及写出数据结果的过程。

Table API输出表

以下案例是读取Kafka中基站日志数据,以csv格式写出到文件系统中。在该案例中我们通过Table API方式创建Kafka Source表和FileSystem Sink表,然后读取Kafka中基站日志数据形成Table对象,最后将Table对象通过Table API方式写出到Sink表对应的文件系统中。

  • Java代码
复制代码
//创建TableEnvironment
EnvironmentSettings settings = EnvironmentSettings.newInstance()
        .inStreamingMode()
        .build();
TableEnvironment tableEnv = TableEnvironment.create(settings);

//将Table写出文件中必须设置checkpoint,Flink SQL 中设置checkpoint的间隔
tableEnv.getConfig().getConfiguration().setLong("execution.checkpointing.interval", 5000L);

//读取Kafka基站日志数据,通过 TableDescriptor 定义表结构
tableEnv.createTemporaryTable("stationlog_tbl", TableDescriptor.forConnector("kafka")
        .schema(Schema.newBuilder()
                .column("sid", DataTypes.STRING())
                .column("call_out", DataTypes.STRING())
                .column("call_in", DataTypes.STRING())
                .column("call_type", DataTypes.STRING())
                .column("call_time", DataTypes.BIGINT())
                .column("duration", DataTypes.BIGINT())
                .build())
        .option("topic", "stationlog-topic")
        .option("properties.bootstrap.servers", "node1:9092,node2:9092,node3:9092")
        .option("properties.group.id", "testGroup")
        .option("scan.startup.mode", "latest-offset")
        .option("format", "csv")
        .build());

//通过Table API 方式查询结果数据
Table tableResult = tableEnv.from("stationlog_tbl");

//通过 TableDescriptor 定义输出表结构
tableEnv.createTemporaryTable("CsvSinkTable", TableDescriptor.forConnector("filesystem")
        .schema(Schema.newBuilder()
                .column("sid", DataTypes.STRING())
                .column("call_out", DataTypes.STRING())
                .column("call_in", DataTypes.STRING())
                .column("call_type", DataTypes.STRING())
                .column("call_time", DataTypes.BIGINT())
                .column("duration", DataTypes.BIGINT())
                .build())
        .option("path", "file:///D:\\data\\flink\\output")
        //设置检查生成文件的频率,每2秒检查一次,默认1分钟
        .option("sink.rolling-policy.check-interval", "2s")
        //设置文件滚动策略,每10秒生成一个文件,默认30分钟
        .option("sink.rolling-policy.rollover-interval", "10s")
        .format(FormatDescriptor.forFormat("csv")
                .option("field-delimiter", "|")
                .build())
        .build());

//将结果数据写入到文件系统
//tableResult.executeInsert("CsvSinkTable");
tableResult.insertInto("CsvSinkTable").execute();
  • Scala代码
复制代码
//创建TableEnvironment
val settings: EnvironmentSettings = EnvironmentSettings.newInstance()
  .inStreamingMode()
  .build()
val tableEnv: TableEnvironment = TableEnvironment.create(settings)

//将Table写出文件中必须设置checkpoint,Flink SQL 中设置checkpoint的间隔
tableEnv.getConfig.getConfiguration.setLong("execution.checkpointing.interval", 5000L)

//读取Kafka基站日志数据,通过 TableDescriptor 定义表结构
tableEnv.createTemporaryTable("station_tbl", TableDescriptor.forConnector("kafka")
  .schema(Schema.newBuilder()
    .column("sid", DataTypes.STRING())
    .column("call_out", DataTypes.STRING())
    .column("call_in", DataTypes.STRING())
    .column("call_type", DataTypes.STRING())
    .column("call_time", DataTypes.BIGINT())
    .column("duration", DataTypes.BIGINT())
    .build())
  .option("topic", "stationlog-topic")
  .option("properties.bootstrap.servers", "node1:9092,node2:9092,node3:9092")
  .option("properties.group.id", "test-group")
  .option("scan.startup.mode", "latest-offset")
  .option("format", "csv")
  .build())

//通过Table API 获取Table 对象
val resultTbl: Table = tableEnv.from("station_tbl")

//通过 TableDescriptor 定义输出表结构
tableEnv.createTemporaryTable("CsvSinkTable", TableDescriptor.forConnector("filesystem")
  .schema(Schema.newBuilder()
    .column("sid", DataTypes.STRING())
    .column("call_out", DataTypes.STRING())
    .column("call_in", DataTypes.STRING())
    .column("call_type", DataTypes.STRING())
    .column("call_time", DataTypes.BIGINT())
    .column("duration", DataTypes.BIGINT())
    .build())
  .option("path", "file:///D:\\data\\flink\\output")
  //设置检查生成文件的频率,每2秒检查一次,默认1分钟
  //设置检查生成文件的频率,每2秒检查一次,默认1分钟
  .option("sink.rolling-policy.check-interval", "2s")
  //设置文件滚动策略,每10秒生成一个文件,默认30分钟
  .option("sink.rolling-policy.rollover-interval", "10s")
  .format(FormatDescriptor.forFormat("csv")
        .option("field-delimiter", "|")
        .build())
  .build())

//将结果数据写入到文件系统
resultTbl.executeInsert("CsvSinkTable")

以上编写Java和Scala代码需要注意以下几点:

  1. 将Table数据写出到文件系统中需要开启checkpoint,当有数据写出时会生成文件。

  2. Table API中写出Table对象到输出表时需要执行execute触发执行。要么通resultTbl.insertInto("CsvSinkTable").execute(),要么通过 resultTbl.executeInsert("CsvSinkTable"),二选其一即可。

  3. FileSystem Sink只支持Append流写出,不支持更新流写出。

代码编写完成后执行,向Kafka中输入如下数据:

复制代码
#kafka-console-producer.sh  --bootstrap-server node1:9092,node2:9092,node3:9092 --topic stationlog-topic
001,181,182,success,1000,40
002,182,183,fail,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,50
003,181,183,fail,5000,50

可以在对应的磁盘目录中找到生成的数据文件,内容如下:

复制代码
"001"|"181"|"182"|"success"|1000|40
"002"|"182"|"183"|"fail"|3000|20
"001"|"183"|"184"|"success"|2000|30
"002"|"184"|"185"|"success"|6000|50
"003"|"181"|"183"|"fail"|5000|50
SQL输出表

以下案例读取Kafka中基站日志数据,以csv格式写出到文件系统中。在该案例中我们通过SQL方式创建Kafka Source表和FileSystem Sink表,然后通过SQL语句方式读取Kafka中基站日志数据,将数据插入到Sink表对应的文件系统中。

  • Java代码
复制代码
//创建TableEnvironment
EnvironmentSettings settings = EnvironmentSettings.newInstance()
        .inStreamingMode()
        .build();
TableEnvironment tableEnv = TableEnvironment.create(settings);

//将Table写出文件中必须设置checkpoint,Flink SQL 中设置checkpoint的间隔
tableEnv.getConfig().getConfiguration().setLong("execution.checkpointing.interval", 5000L);

//读取Kafka基站日志数据,通过SQL DDL方式定义表结构
tableEnv.executeSql("" +
        "create table stationlog_tbl (" +
        "   sid string," +
        "   call_out string," +
        "   call_in string," +
        "   call_type string," +
        "   call_time bigint," +
        "   duration bigint" +
        ") with (" +
        "   'connector' = 'kafka'," +
        "   'topic' = 'stationlog-topic'," +
        "   'properties.bootstrap.servers' = 'node1:9092,node2:9092,node3:9092'," +
        "   'properties.group.id' = 'testGroup'," +
        "   'scan.startup.mode' = 'latest-offset'," +
        "   'format' = 'csv'" +
        ")");

//通过SQL DDL方式定义文件系统表
tableEnv.executeSql("" +
        "create table CsvSinkTable (" +
        "   sid string," +
        "   call_out string," +
        "   call_in string," +
        "   call_type string," +
        "   call_time bigint," +
        "   duration bigint" +
        ") with (" +
        "   'connector' = 'filesystem'," +
        "   'path' = 'file:///D:/data/flinkdata/result'," +
        "   'sink.rolling-policy.check-interval' = '2s'," +
        "   'sink.rolling-policy.rollover-interval' = '10s'," +
        "   'format' = 'csv'," +
        "   'csv.field-delimiter' = '|'" +
        ")");

//SQL方式将数据写入文件系统
tableEnv.executeSql("" +
        "insert into CsvSinkTable " +
        "select sid,call_out,call_in,call_type,call_time,duration " +
        "from stationlog_tbl ");
  • Scala代码
复制代码
//创建TableEnvironment
val settings: EnvironmentSettings = EnvironmentSettings.newInstance()
  .inStreamingMode()
  .build()
val tableEnv: TableEnvironment = TableEnvironment.create(settings)

//将Table写出文件中必须设置checkpoint,Flink SQL 中设置checkpoint的间隔
tableEnv.getConfig.getConfiguration.setLong("execution.checkpointing.interval", 5000L)

//读取Kafka基站日志数据,通过SQL DDL方式定义表结构
tableEnv.executeSql("" +
  "create table stationlog_tbl (" +
  "   sid string," +
  "   call_out string," +
  "   call_in string," +
  "   call_type string," +
  "   call_time bigint," +
  "   duration bigint" +
  ") with (" +
  "   'connector' = 'kafka'," +
  "   'topic' = 'stationlog-topic'," +
  "   'properties.bootstrap.servers' = 'node1:9092,node2:9092,node3:9092'," +
  "   'properties.group.id' = 'testGroup'," +
  "   'scan.startup.mode' = 'latest-offset'," +
  "   'format' = 'csv'" +
  ")")

//通过SQL DDL方式定义文件系统表
tableEnv.executeSql("" +
  "create table CsvSinkTable (" +
  "   sid string," +
  "   call_out string," +
  "   call_in string," +
  "   call_type string," +
  "   call_time bigint," +
  "   duration bigint" +
  ") with (" +
  "   'connector' = 'filesystem'," +
  "   'path' = 'file:///D:/data/flinkdata/result'," +
  "   'sink.rolling-policy.check-interval' = '2s'," +
  "   'sink.rolling-policy.rollover-interval' = '10s'," +
  "   'format' = 'csv'," +
  "   'csv.field-delimiter' = '|'" +
  ")")

//SQL方式将数据写入文件系统
tableEnv.executeSql("" +
  "insert into CsvSinkTable " +
  "select sid,call_out,call_in,call_type,call_time,duration " +
  "from stationlog_tbl ")

通过SQL语句方式将数据结果输出到表与Table API方式一样,需要开启checkpoint数据才能被写出到外部系统文件,并且insert into 语句方式底层自动会有exeucte触发执行。以上SQL语句Java和Scala 两种实现代码几乎一样,并且相比于Table API将数据写出到目标表清晰简单,所以在实际工作中我们建议使用SQL语句方式将分析结果数据写出到目标表中。

以上代码编写完成后执行,向Kafka中输入如下数据:

复制代码
#kafka-console-producer.sh  --bootstrap-server node1:9092,node2:9092,node3:9092 --topic stationlog-topic
001,181,182,success,1000,40
002,182,183,fail,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,50
003,181,183,fail,5000,50

可以在对应的磁盘目录中找到生成的数据文件,内容如下:

复制代码
"001"|"181"|"182"|"success"|1000|40
"002"|"182"|"183"|"fail"|3000|20
"001"|"183"|"184"|"success"|2000|30
"002"|"184"|"185"|"success"|6000|50
"003"|"181"|"183"|"fail"|5000|50

Table API与Stream API集成

在Flink处理数据场景中,Table API和DataStream API同等重要,DataStream API 以相对低级的编程API提供数据处理的操作(如:时间、状态等),而Tabel API抽象了许多内部细节,提供了结构化和声明式的API。两个API都可以处理有界流和无界流,并且两者互不附属依赖,但在实际工作中两种API混合使用很重要。

在以下情况中可以在Flink中混用两种API,方便我们处理分析数据。

  • 除了数据主要业务处理外,读取和写出数据时可以使用Table API很方便的连接访问外部系统。

  • 在实现Flink主要业务前,可以通过SQL函数方式对数据进行无状态清洗处理,然后再使用DataStream API处理主要业务。

  • 当Table API中不能完成底层操作(自定义定时器、状态管理)时,可以切换到DataStream API来完成。

需要注意,在DataStream和Table API之间切换会增加一些转换开销。例如,Table运行时内部数据结构是RowData,在API切换时需要将该对象转换为对用户更加友好的数据结构Row,通常情况下,这种开销可以忽略不计,但为了完整起见在这里提及。

在Flink中批处理可以看成流处理的特殊情况,所以下面我们默认以Flink流处理来讲解Flink DataStream API与Table API之间的转换。

集成注意项

在集成Flink Table API和DataStream API时有一些关键点需要了解,包括两者集成依赖导入、配置项配置及触发Flink程序方式。下面分别进行介绍。

1. 关于依赖和导入

Flink Table API和Stream API两者集成混合使用时,需要在项目中导入"flink-table-api-java-bridge"依赖,该依赖支持Table API和DataStream API连接,在Java和Scala中导入该依赖不同,在前面小节中我们已经导入,这里不再重复导入该依赖。

Java项目中flink-table-api-java-bridge依赖如下:

复制代码
<!-- Table API + DataStream 所需依赖包-->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-api-java-bridge</artifactId>
  <version>${flink.version}</version>
</dependency>

Scala项目中flink-table-api-java-bridge依赖如下:

复制代码
<!-- Table API + DataStream 所需依赖包-->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-api-scala-bridge_${scala.binary.version}</artifactId>
  <version>${flink.version}</version>
</dependency>

此外,Table API 与DataStream API 集成时需要依赖非常多的类和包,为了简便在编程时可以直接在Java或者Scala编程按照如下方式导入必要的包和依赖。

Java 编程中引入类和包方式:

复制代码
// Java DataStream API 导入必要类和包
import org.apache.flink.streaming.api.*;
import org.apache.flink.streaming.api.environment.*;

// Table API 与 Java DataStream API 整合导入必要类和包
import org.apache.flink.table.api.*;
import org.apache.flink.table.api.bridge.java.*;

Scala 编程引入类和包方式:

复制代码
// Scala DataStream API 导入必要类和包
import org.apache.flink.api.scala._
import org.apache.flink.streaming.api.scala._

//Table API 与 Scala DataStream API 整合导入必要类和包
import org.apache.flink.table.api._
import org.apache.flink.table.api.bridge.scala._

2. 关于配置

在集成Flink Table API和DataStream API时,关于Flink运行配置参数可以通过StreamExecutionEnvironment和StreamTableEnvironment进行配置。两种方式都可以选择,因为基于StreamExecutionEnvironment设置的配置项会自动传递给TableEnvironment。

无论选择哪种方式进行配置参数设置,建议在切换到Table API处理数据之前进行配置参数设置,因为这些配置参数是在Flink作业计划执行阶段传递给Table API,在Table API编程之前尽早设置配置项可以避免一些配置项由于Flink作业已经计划而无法生效的问题。这样可以确保Table API和DataStream API之间的配置一致性,避免潜在的配置问题,保证作业的正确执行。通过StreamExecutionEnvironment和StreamTableEnvironment进行配置项设置如下:

复制代码
import java.time.ZoneId;
import org.apache.flink.streaming.api.CheckpointingMode;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
// 创建 Java DataStream API
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 使用DataStream 尽早设置配置项
env.setParallelism(10);
env.getConfig().addDefaultKryoSerializer(MyCustomType.class, CustomKryoSerializer.class);
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);

// 创建 TableEnvironment
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 使用TableEnvironment尽早设置配置项
tableEnv.getConfig().setLocalTimeZone(ZoneId.of("Europe/Berlin"));
// 开始设置Flink数据数据逻辑
... ...

3. 关于execute触发

在DataStream API编程中,为了对DataStream Sink输出进行触发,我们需要最后调用StreamExecutionEnvironment.execute()方法来执行整个Flink程序。在Flink Table API中与DataStream API类似,也需要调用execute方法来触发整个Flink Table流程。

在Table API中触发整个Flink Table流程的操作如下:

复制代码
#Table API execute触发执行
tableEnv.from("InputTable").insertInto("OutputTable").execute();

#SQL 查询中 execute触发执行,在Flink SQL查询中executeSQL方法自带了execute方法
tableEnv.executeSql("INSERT INTO OutputTable SELECT * FROM InputTable");

DataStream转换成Table

DataStream 转换成Table操作有三种方式,可以通过TableEnvironment对象调用对应方法来实现:

  1. TableEnvironment.fromDataStream(...)

  2. TableEnvironment.createTemporaryView(...)

  3. TableEnvironment.fromChangelogStream(...)

下面分别进行介绍。

tblEnv.fromDataStream(...)

在Flink中可以通过TableEnvironment对象调用fromDataStream()方法将DataStream转换成Table对象。fromDataStream()有如下两种重载方法:

  • fromDataStream(DataStream):将仅包含插入操作的数据流转换成Table。默认情况不传播EventTime和Watermark。

  • fromDataStream(DataStream, Schema):将仅包含插入操作的数据流转换成Table。传入的Schema对象可以用来丰富列的数据类型、添加ProcessTime/EventTime、指定Watermark策略等。

下面结合案例分别介绍以上两种重载方法使用。

1. fromDataStream(DataStream)使用演示

以下代码是通过读取Socket中基站日志数据形成StationLog对象类型的DataStream,将该DataStream通过tblEnv.fromDataStream(DataStream)方式转换成Table对象。这种方式转换成的Table表对应的每条数据对应DataStream中的每条数据,表的列名和类型自动从数据流的TypeInformation派生,Table中默认的列就是当前StationLog对象中的属性,并且区分大小写。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });

//将DataStream 转换成 Table
Table table = tableEnv.fromDataStream(stationLogDS);

//打印表结构
table.printSchema();

//打印表数据
TableResult tableResult = table.execute();
tableResult.print();
  • Scala代码
复制代码
//创建流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.api.scala._

//创建TableEnv
val tableEnv: StreamTableEnvironment = StreamTableEnvironment.create(env)

//读取socket中基站日志数据并转换为StationgLog类型DataStream
val stationLogDS: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr: Array[String] = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//将DataStream转换为Table
val table = tableEnv.fromDataStream(stationLogDS)

//打印表结构
table.printSchema()

//打印表数据
table.execute().print()

以上代码执行后,打印Scheam如下:

复制代码
(
  `sid` STRING,
  `callOut` STRING,
  `callIn` STRING,
  `callType` STRING,
  `callTime` BIGINT,
  `duration` BIGINT
)

当向socket中输入如下数据后,输出结果如下:

复制代码
#向socket-9999中输入如下数据
001,181,182,success,1000,40
002,182,183,fail,3000,20
001,183,184,success,2000,30

#控制台输出数据
+----+---------+---------+---------+---------+----------+---------+
| op |     sid | callOut |  callIn |callType | callTime |duration |
+----+---------+---------+---------+---------+----------+---------+
| +I |     001 |     181 |     182 | success |     1000 |      40 |
| +I |     002 |     182 |     183 | success |     3000 |      20 |
| +I |     001 |     183 |     184 | success |     2000 |      30 |

2. fromDataStream(DataStream, Schema)使用演示

以下代码是通过读取Socket中基站日志数据形成StationLog对象类型的DataStream,将该DataStream通过fromDataStream(DataStrea,Schema)方式转换成Table对象。同样这种方式默认转换成的Table对象列与StationLog对象中的属性一样,可以通过Schema对象指定Table列类型、Watermark等。

使用tblEnv.fromDataStream(DataStrea,Schema)方式转换成的Table对象时注意以下几点:

  1. 转换成的Table对象默认列的类型由对象中属性推断而来,默认Table对象包含所有属性对应的列。

  2. 可通过Schem.newBuilder().column方法指定列类型,指定后,生成的Table对象只包含指定的列,如果需要所有列需要全部指定。

  3. 通过Schem.newBuilder().columnByExpression指定新列,该新列可以通过SQL表达式获取。

  4. 通过Schem.newBuilder().watermark设置watermark水位线。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });

Table table = tableEnv.fromDataStream(stationLogDS,
        Schema.newBuilder()
                //指定字段类型,第一个参数是指定名,第二个参数是指定类型
                .column("sid", DataTypes.STRING())
                .column("callOut", DataTypes.STRING())
                .column("callIn", DataTypes.STRING())
                .column("callType", DataTypes.STRING())
                .column("callTime", DataTypes.BIGINT())
                .column("duration", DataTypes.BIGINT())
                //添加新列,第一个参数是新列名,第二个参数是表达式,这里是指定为当前处理时间
                .columnByExpression("proc_time", "PROCTIME()")
                //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
                //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
                .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
                .build());

//打印表结构
table.printSchema();

//打印数据结果
TableResult tableResult = table.execute();
tableResult.print();
  • Scala代码
复制代码
//创建流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.api.scala._

//创建TableEnv
val tableEnv: StreamTableEnvironment = StreamTableEnvironment.create(env)

//读取socket中基站日志数据并转换为StationgLog类型DataStream
val stationLogDS: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr: Array[String] = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//将DataStream转换为Table
val table: Table = tableEnv.fromDataStream(stationLogDS,
  Schema.newBuilder()
    //指定字段名和字段类型
    .column("sid", DataTypes.STRING())
    .column("callOut", DataTypes.STRING())
    .column("callIn", DataTypes.STRING())
    .column("callType", DataTypes.STRING())
    .column("callTime", DataTypes.BIGINT())
    .column("duration", DataTypes.BIGINT())
    //增加列,指定列名和表达式
    .columnByExpression("proc_time", "PROCTIME()")
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
    //指定Watermark
    .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
    .build()
)

//打印表结构
table.printSchema()

//打印表数据
table.execute().print()

以上代码中TIMESTAMP_LTZ(3)类型代表Stream流中事件时间类型,关于该类型,参照后续小节。代码运行后,输出Table对应的Schema如下:

复制代码
(
  `sid` STRING,
  `callOut` STRING,
  `callIn` STRING,
  `callType` STRING,
  `callTime` BIGINT,
  `duration` BIGINT,
  `proc_time` TIMESTAMP_LTZ(3) NOT NULL *PROCTIME* AS PROCTIME(),
  `rowtime` TIMESTAMP_LTZ(3) *ROWTIME* AS TO_TIMESTAMP_LTZ(callTime,3),
  WATERMARK FOR `rowtime`: TIMESTAMP_LTZ(3) AS rowtime - INTERVAL '2' SECOND
)

当向socket中输入如下数据后,输出结果如下:

复制代码
#向socket-9999中输入如下数据
001,181,182,success,1000,40
002,182,183,fail,3000,20
001,183,184,success,2000,30

#控制台输出数据
+----+-----+--------+--------+--------+---------+----------+-------------------------+-------------------------+
| op | sid |callOut | callIn |callType|callTime |  duration|               proc_time |                 rowtime |
+----+-----+--------+--------+--------+---------+----------+-------------------------+-------------------------+
| +I | 001 |    181 |    182 |success |    1000 |       40 | 2023-07-27 19:43:50.404 | 1970-01-01 08:00:01.000 |
| +I | 002 |    182 |    183 |   fail |    3000 |       20 | 2023-07-27 19:43:50.499 | 1970-01-01 08:00:03.000 |
| +I | 001 |    183 |    184 |success |    2000 |       30 | 2023-07-27 19:43:51.101 | 1970-01-01 08:00:02.000 |

此外,如果在DataStream转换成Table对象前已经对数据流设置过Watermark,可以直接通过如下方式传递watermark给Table对象。

复制代码
Table table = tableEnv.fromDataStream(
        dataStream,
        Schema.newBuilder()
            .columnByMetadata("rowtime","TIMESTAMP_LTZ(3)")
            .watermark("rowtime","SOURCE_WATERMARK()")
            .build())
table.printSchema()
tblEnv.createTemporaryView(...)

在Flink中可以通过TableEnvironment对象调用createTemporaryView()方法将DataStream转换成Table对象。与fromDataStream(...)方法类似,createTemporaryView(...)方法也有两种重载方法:

  • createTemporaryView(String,DataStream):将数据流注册为一个临时视图,以便在 SQL 中访问,第一个参数为注册的表名,第二个参数是对应的DataStream。

  • createTemporaryView(String,DataStream, Schema):将数据流注册为一个临时视图,以便在 SQL 中访问,第一个参数为注册的表名;第二个参数是对应的DataStream;第三个参数传入的Schema对象可以用来丰富列的数据类型、添加ProcessTime/EventTime、指定Watermark策略等。

以上第二个重载方法中Schema使用与fromDataStrea(DataStream,Schema)方法中使用一样,这里不再单独演示。此外需要特别注意从DataStream创建的视图只能被注册为临时视图,不能注册在永久目录中。

以下代码是通过读取Socket中基站日志数据形成StationLog对象类型的DataStream,将DataStream通过createTemporaryView(String,DataStream)方式转换成Table对象。这种方式转换成的Table表对应的每条数据对应DataStream中的每条数据,Table中默认的列就是当前StationLog对象中的属性,并且区分大小写。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });

//将DataStream 转换成 Table
tableEnv.createTemporaryView("stationlog_tbl", stationLogDS);

//打印表结构
Table table = tableEnv.from("stationlog_tbl");
table.printSchema();

//打印数据结果
table.execute().print();
  • Scala代码
复制代码
//创建流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.api.scala._

//创建TableEnv
val tableEnv: StreamTableEnvironment = StreamTableEnvironment.create(env)

//读取socket中基站日志数据并转换为StationgLog类型DataStream
val stationLogDS: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr: Array[String] = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//将DataStream转换为Table
tableEnv.createTemporaryView("stationlog_tbl", stationLogDS)

//打印表结构
val table: Table = tableEnv.from("stationlog_tbl")
table.printSchema()

//打印表数据
table.execute().print()

以上代码执行后,打印Scheam如下:

复制代码
(
  `sid` STRING,
  `callOut` STRING,
  `callIn` STRING,
  `callType` STRING,
  `callTime` BIGINT,
  `duration` BIGINT
)

当向socket中输入如下数据后,输出结果如下:

复制代码
#向socket-9999中输入如下数据
001,181,182,success,1000,40
002,182,183,fail,3000,20
001,183,184,success,2000,30

#控制台输出数据
+----+---------+---------+---------+---------+----------+---------+
| op |     sid | callOut |  callIn |callType | callTime |duration |
+----+---------+---------+---------+---------+----------+---------+
| +I |     001 |     181 |     182 | success |     1000 |      40 |
| +I |     002 |     182 |     183 | success |     3000 |      20 |
| +I |     001 |     183 |     184 | success |     2000 |      30 |

此外,tblEnv.createTemporaryView这种方式还可以通过传入Table对象方式进行列的重命名,使用方式如下:

复制代码
//给Table重命名列,id、name为重命名列
tableEnv.createTemporaryView(
"MyView",
    tableEnv.fromDataStream(dataStream).as("id", "name"))
tblEnv.fromChangelogStream(...)

在Flink中,通过读取外部数据得到的DataStream流,默认情况下是一直增加的,这意味着DataStream流的特点是只有插入操作,而没有更新操作,这类流通常被称为"append-only"(仅追加)或"insert-only"(仅插入)流。简言之,新数据会不断追加到流中,而原有数据则不会被修改。

除了"append-only"流,Flink还支持另一种流称为Changelog Stream(变更日志流),这种流可以描述数据的增删改操作,允许记录数据发生变更的历史。每条流都代表一次数据的变更操作,即一条变更日志(change log)。如:通过Table API对Table数据流进行聚合统计并输出的结果就是变更日志流。

上文中tblEnv.fromDataStream和tblEnv.createTemporaryView两种方法读取的DataStream数据流都是"insert-only"流,Flink还提供了tblEnv.fromChangelogStream(DataStream)方法,用于将Changelog Stream(变更日志流)转换成Table对象。这种方法有一些要求:DataStream的类型必须是Row类型,并且通过Row类型指定每条数据的更新类型(RowKind),RowKind表示每个数据记录的操作类型,例如插入、删除或更新。

fromChangelogStream(...)方法有三种重载方法:

  • fromChangelogStream(DataStream):将变更日志流转换成Table对象,不传播EventTime和Watermark,会根据数据中RowKind来判断数据操作类型。

  • fromChangelogStream(DataStream, Schema):将变更日志流转换成Table对象。传入的Schema对象可以用来丰富列的数据类型、添加ProcessTime/EventTime、指定Watermark策略等。

  • fromChangelogStream(DataStream, Schema, ChangelogMode):将变更日志流转换成Table对象;schema同上;ChangelogMode指定要处理数据流中的RowKind类型。

需要注意的是,在Flink中直接读取Changelog Stream变更日志流的情况相对较少。通常情况下,我们会先通过Flink表(Table)的API对数据流进行处理,然后再得到Changelog Stream变更日志流。这样处理过的Changelog Stream包含了更多有用的信息,如数据的增删改操作,更适合在Flink中进行进一步的处理和分析。

下面我们通过一个简单案例来说明tblEnv.fromChangelogStream的使用。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//为了看出数据顺序效果,设置并行度为1
env.setParallelism(1);

//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

DataStream<Row> dataStream =
        env.fromElements(
                Row.ofKind(RowKind.INSERT, "zs", 18),
                Row.ofKind(RowKind.INSERT, "ls", 19),
                Row.ofKind(RowKind.UPDATE_BEFORE, "zs", 18),
                Row.ofKind(RowKind.UPDATE_AFTER, "zs", 20),
                Row.ofKind(RowKind.DELETE, "ls", 19)
                );

//将DataStream 转换成 Table
Table result = tableEnv.fromChangelogStream(dataStream,
        //通过Schema指定主键
        Schema.newBuilder().primaryKey("f0").build(),
        //指定ChangelogMode,这里使用all(),表示所有类型的数据都会被处理
        ChangelogMode.all()
);

//打印表结构
result.execute().print();
  • Scala代码
复制代码
//创建流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.streaming.api.scala._

//创建TableEnv
val tableEnv: StreamTableEnvironment = StreamTableEnvironment.create(env)


val dataStream:
  DataStream[Row] = env.fromElements(
  Row.ofKind(RowKind.INSERT, "zs", Int.box(18)),
  Row.ofKind(RowKind.INSERT, "ls", Int.box(19)),
  Row.ofKind(RowKind.UPDATE_BEFORE, "zs", Int.box(18)),
  Row.ofKind(RowKind.UPDATE_AFTER, "zs", Int.box(20)),
  Row.ofKind(RowKind.DELETE, "ls", Int.box(19))
)(Types.ROW(Types.STRING, Types.INT))

//将DataStream 转换成 Table
val result: Table = tableEnv.fromChangelogStream(
  dataStream,
  //通过Schema指定主键
  Schema.newBuilder.primaryKey("f0").build,
  //指定ChangelogMode,这里使用all(),表示所有类型的数据都会被处理
  ChangelogMode.all()
)

//打印表结构
result.printSchema()

//打印表数据
result.execute.print()

以上Java代码或Scala代码中为了保证数据读取顺序设置并行度为1,以方便分析结果,输出结果如下:

复制代码
+----+--------------------------------+-------------+
| op |                             f0 |          f1 |
+----+--------------------------------+-------------+
| +I |                             zs |          18 |
| +I |                             ls |          19 |
| -U |                             zs |          18 |
| +U |                             zs |          20 |
| -D |                             ls |          19 |
+----+--------------------------------+-------------+

输出结果中,+I代表增加一条数据;-U代表更新前数据;+U代表更新后数据;-D代表删除数据。

以上Java和Scala代码中,通过Row.ofKind()来包装每条数据,其中可以指定RowKind表示每条数据的操作类型,由于Row.ofKind()方法中需要传入Object引用类型数据对象,在Scala中需要对基本数据类型进行Int.box(18)进行装箱操作成为引用数据类型,Java中基本数据类型会自动装箱操作。

tblEnv.fromChangelogStream(...)方法中传入ChangelogMode表示有哪些操作数据类型处理,需要根据传入的数据类型来指定对应模式,有三种指定方式如下:

  • ChangelogMode.insertOnly():只包含RowKind.INSERT操作类型。

  • ChangelogMode.upsert():包含RowKind.INSERT、RowKind.UPDATE_AFTER、RowKind.DELETE操作类型。

  • ChangelogMode.all():包含RowKind.INSERT、RowKind.UPDATE_BEFORE、RowKind.UPDATE_AFTER、RowKind.DELETE操作类型。

Table转换成DataStream

Table转换成DataStream操作有两种方式,可以通过TableEnvironment对象调用对应方法来实现:

  1. TableEnvironment.toDataStream(...)

  2. TableEnvironment.toChangelogStream(...)

tblEnv.toDataStream(...)只支持对insert-only(插入流)对应的Table进行转换,tblEnv.toChangelogStream(...)可以对changelog stream(更新日志流)对应的Table进行转换,下面分别进行介绍。

tblEnv.toDataStream()

在Flink中可以通过TableEnvironment对象调用toDataStream()方法将Table转换成DataStream对象,Table中的数据只能是insert-only类型。toDataStream()有如下三种重载方法:

  • toDataStream(Table):将Table对象转换成DataStream流,转换后的DataStream为Row类型DataStream,会自动传递Watermark。

  • toDataStream(Table, AbstractDataType):将Table对象转换成DataStream流,通过AbstractDataType显式指定转换成DataStream的对象类型及字段信息。

  • toDataStream(Table, Class):将Table对象转换成DataStream流,直接指定Class类通过反射方式指定转换成DataStream的对象类型,建议使用这种方式。

下面结合案例分别介绍以上三种重载方法使用。

1. toDataStream(Table)使用演示

以下代码是通过读取Socket中基站日志数据形成StationLog对象类型的DataStream,将该DataStream转换成Table对象后,再通过tableEnv.toDataStream(Table)转换成DataStream。tableEnv.toDataStream(Table)默认返回Row类型的DataStream。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });

//将DataStream 转换成 Table
tableEnv.createTemporaryView("stationlog_tbl", stationLogDS);
Table table = tableEnv.from("stationlog_tbl");

//将Table转换为DataStream
DataStream<Row> rowDataStream = tableEnv.toDataStream(table);
rowDataStream.print();

//执行任务
env.execute();
  • Scala代码
复制代码
//创建流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.api.scala._

//创建TableEnv
val tableEnv: StreamTableEnvironment = StreamTableEnvironment.create(env)

//读取socket中基站日志数据并转换为StationgLog类型DataStream
val stationLogDS: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr: Array[String] = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//将DataStream转换为Table
tableEnv.createTemporaryView("stationlog_tbl", stationLogDS)
val table: Table = tableEnv.from("stationlog_tbl")

//将Table转换为DataStream
val rowDataStream: DataStream[Row] = tableEnv.toDataStream(table)
rowDataStream.print()

//执行任务
env.execute()

以上Java和Scala代码运行之后,向socket-9999中输入如下数据,输出结果如下:

复制代码
#socket-9999中输入如下数据
001,181,182,success,1000,40
002,182,183,success,3000,20
001,183,184,success,2000,30

#控制台输出结果如下
3> +I[002, 182, 183, success, 3000, 20]
4> +I[001, 183, 184, success, 2000, 30]
2> +I[001, 181, 182, success, 1000, 40]

2. toDataStream(Table,AbstractDataType)使用演示

以下代码是通过读取Socket中基站日志数据形成StationLog对象类型的DataStream,将该DataStream转换成Table对象后,再通过tableEnv.toDataStream(Table,AbstractDataType)转换成DataStream,通过AbstractDataType参数可以显式指定Table数据映射的对象及字段。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });

//将DataStream 转换成 Table
tableEnv.createTemporaryView("stationlog_tbl", stationLogDS);
Table table = tableEnv.from("stationlog_tbl");

//将Table转换为DataStream
DataStream<StationLog> resultDS = tableEnv.toDataStream(
        table,
        //显式指定输出数据类型
        DataTypes.STRUCTURED(
                StationLog.class,
                DataTypes.FIELD("sid", DataTypes.STRING()),
                DataTypes.FIELD("callOut", DataTypes.STRING()),
                DataTypes.FIELD("callIn", DataTypes.STRING()),
                DataTypes.FIELD("callType", DataTypes.STRING()),
                DataTypes.FIELD("callTime", DataTypes.BIGINT()),
                DataTypes.FIELD("duration", DataTypes.BIGINT())
        )
);

//输出流
resultDS.print();

env.execute();
  • Scala代码
复制代码
//创建流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.api.scala._

//创建TableEnv
val tableEnv: StreamTableEnvironment = StreamTableEnvironment.create(env)

//读取socket中基站日志数据并转换为StationgLog类型DataStream
val stationLogDS: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr: Array[String] = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//将DataStream转换为Table
tableEnv.createTemporaryView("stationlog_tbl", stationLogDS)
val table: Table = tableEnv.from("stationlog_tbl")

//将Table转换为DataStream
val resultDS: DataStream[StationLog] = tableEnv.toDataStream[StationLog](
  table,
  DataTypes.STRUCTURED(
    classOf[StationLog],
    DataTypes.FIELD("sid", DataTypes.STRING()),
    DataTypes.FIELD("callOut", DataTypes.STRING()),
    DataTypes.FIELD("callIn", DataTypes.STRING()),
    DataTypes.FIELD("callType", DataTypes.STRING()),
    DataTypes.FIELD("callTime", DataTypes.BIGINT()),
    DataTypes.FIELD("duration", DataTypes.BIGINT())
  )
)

//打印结果
resultDS.print()

env.execute()

以上Java和Scala代码运行之后,向socket-9999中输入如下数据,输出结果如下:

复制代码
#socket-9999中输入如下数据
001,181,182,success,1000,40
002,182,183,success,3000,20
001,183,184,success,2000,30

#控制台输出结果如下
8> StationLog(001,181,182,success,1000,40)
1> StationLog(002,182,183,success,3000,20)
2> StationLog(001,183,184,success,2000,30)

3. toDataStream(Table, Class)使用演示

以下代码是通过读取Socket中基站日志数据形成StationLog对象类型的DataStream,将该DataStream转换成Table对象后,再通过tableEnv.toDataStream(Table,Class)转换成DataStream。这种方式是通过Class反射获取DataStream类型,比tableEnv.toDataStream(Table,AbstractDataType)方式书写简单。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });

//将DataStream 转换成 Table
tableEnv.createTemporaryView("stationlog_tbl", stationLogDS);
Table table = tableEnv.from("stationlog_tbl");

//将Table转换为DataStream
DataStream<StationLog> resultDS = tableEnv.toDataStream(table, StationLog.class);

//打印结果
resultDS.print();

env.execute();
  • Scala代码
复制代码
//创建流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.api.scala._

//创建TableEnv
val tableEnv: StreamTableEnvironment = StreamTableEnvironment.create(env)

//读取socket中基站日志数据并转换为StationgLog类型DataStream
val stationLogDS: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr: Array[String] = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//将DataStream转换为Table
tableEnv.createTemporaryView("stationlog_tbl", stationLogDS)
val table: Table = tableEnv.from("stationlog_tbl")

//将Table转换为DataStream
val resultDS: DataStream[StationLog] = tableEnv.toDataStream(table, classOf[StationLog])

//打印结果
resultDS.print()

//执行任务
env.execute()

以上Java和Scala代码运行之后,向socket-9999中输入如下数据,输出结果如下:

复制代码
#socket-9999中输入如下数据
001,181,182,success,1000,40
002,182,183,success,3000,20
001,183,184,success,2000,30

#控制台输出结果如下
1> StationLog(001,181,182,success,1000,40)
2> StationLog(002,182,183,success,3000,20)
3> StationLog(001,183,184,success,2000,30)
tblEnv.toChangelogStream()

在Flink中可以通过TableEnvironment对象调用toChangelogStream()方法将Table转换成DataStream对象,Table中的数据可以是insert-only类型也可以是Changelog Stream(变更日志流)。toChangelogStream()有如下三种重载方法:

  • toChangelogStream(Table):将Table对象转换成DataStream流。该方法是fromChangelogStream(DataStream)的反向操作。转换后的DataStream为Row类型DataStream并在运行时为每条记录设置RowKind标志,会自动传递Watermark。

  • toChangelogStream(Table, Schema):将Table对象转换成DataStream流。该方法是fromChangelogStream(DataStream, Schema)的反向操作。通过Schema可以增强所生成的列数据类型,会自动传递watermark。

  • toChangelogStream(Table, Schema,ChangelogMode):完全控制将Table对象如何转换成DataStream流。可以通过ChangelogMode指定处理数据流中哪些RowKind类型。

在使用toChangelogStream()方法将Table对象转换成DataStream时,如果Table中是"insert-only"插入流时用法与toDataStream(...)方法类似,但如果涉及到数据聚合得到Table是Changelog Stream变更日志流,在将Table转换成DataStream时还是使用toDataStream()方法就会报如下错误:

复制代码
Table sink 'Unregistered_DataStream_Sink_1' doesn't support consuming update changes [...].

这时只能通过toChangelogStream方法将Changelog Stream变更日志流对应Table对象转换成DataStream对象。下面结合案例分别介绍以上三种重载方法使用。

1. toChangelogStream(Table)使用演示

下面通过案例来演示toChangelogStream(Table)方法将Table转换成DataStream并传递watermark。案例通过读取Socket中基站日志数据形成StationLog对象类型的DataStream,然后将该DataStream转换成Table对象,使用Table API按照基站分组统计每个基站的通话时长。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

//为了能看出watermark传递效果,这里设置并行度为1
env.setParallelism(1);

//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });

//设置watermark
SingleOutputStreamOperator<StationLog> dsWithWatermark = stationLogDS.assignTimestampsAndWatermarks(
        WatermarkStrategy.<StationLog>forBoundedOutOfOrderness(Duration.ofSeconds(2))
                .withTimestampAssigner(new SerializableTimestampAssigner<StationLog>() {
                    @Override
                    public long extractTimestamp(StationLog element, long recordTimestamp) {
                        return element.getCallTime();
                    }
                })
);

//将DataStream 转换成 Table
Table table = tableEnv.fromDataStream(
        dsWithWatermark,
        Schema.newBuilder()
                .columnByExpression("rowtime","TO_TIMESTAMP_LTZ(callTime,3)")
                .watermark("rowtime", "SOURCE_WATERMARK()")
                .build()
);

//使用Table API 对 Table进行查询
Table resultTbl = table
        .groupBy($("sid"))
        .select($("sid"), $("duration").sum().as("totalDuration"));

//将Table转换为DataStream
DataStream<Row> rowDataStream = tableEnv.toChangelogStream(resultTbl);

//打印watermark
rowDataStream.process(new ProcessFunction<Row, String>() {
    @Override
    public void processElement(Row row, ProcessFunction<Row, String>.Context context, Collector<String> collector) throws Exception {
        collector.collect("数据:"+row+",当前watermark为:" + context.timerService().currentWatermark());
    }
}).print();

//执行任务
env.execute();
  • Scala代码
复制代码
//创建流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment

//为了可以看到传递watermark效果,这里设置并行度为1
env.setParallelism(1)

//导入隐式转换
import org.apache.flink.api.scala._

//创建TableEnv
val tableEnv: StreamTableEnvironment = StreamTableEnvironment.create(env)

//读取socket中基站日志数据并转换为StationgLog类型DataStream
val stationLogDS: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr: Array[String] = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//设置watermark
val dsWithWatermark: DataStream[StationLog] = stationLogDS.assignTimestampsAndWatermarks(
  WatermarkStrategy.forBoundedOutOfOrderness[StationLog](Duration.ofSeconds(2))
    .withTimestampAssigner(new SerializableTimestampAssigner[StationLog] {
      override def extractTimestamp(element: StationLog, recordTimestamp: Long): Long = element.callTime
    })
)

//将DataStream转换为Table
val table: Table = tableEnv.fromDataStream(
  dsWithWatermark,
  Schema.newBuilder()
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
    .watermark("rowtime", "SOURCE_WATERMARK()")
    .build()
)

//使用Table API 对 Table进行查询
val resultTable: Table = table
  .groupBy($"sid")
  .select($"sid", $"duration".sum().as("totalDuration"))

//将Table转换为DataStream
val rowDataStream: DataStream[Row] = tableEnv.toChangelogStream(resultTable)

rowDataStream.process(new ProcessFunction[Row,String] {
  override def processElement(row: Row, context: ProcessFunction[Row, String]#Context, collector: Collector[String]): Unit = {
    collector.collect(s"数据:$row,当前watermark为:${context.timerService().currentWatermark()}" )
  }
}).print()

env.execute()

在以上代码中,对读取Socket中数据得到DataStream设置watermark后,通过TableEnvironment.fromDataStream(DataStream,Schema)方式将流转换成了Table对象,因为这种方式可以向下游传递watermark,转换成的Table可以通过Schema.newBuilder().watermark("rowtime", "SOURCE_WATERMARK()")代码获取DataStream中watermark,但是由于Table中要求的Watermark数据类型与DataStrearm中不同,还需要通过"TO_TIMESTAMP_LTZ"方法对原始数据流中"callTime"列进行类型转换,转换成符合Table中watermark的时间类型。进而对Table对象进行一系列数据分析,将最终得到的Table结果通过最后通过TableEnv.toChangelogStream(Table)方法转换成DataStream,通过调用DataStream.process()方法获取了数据结果及watermark值。

为了能直观看出watermark的传递,代码中我们设置并行度为1,以上代码编写完成后,向socket中输入以下数据,获取的结果如下:

复制代码
#socket-9999中输入数据
001,181,182,success,1000,40
002,182,183,success,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,50
003,181,183,success,5000,50

#控制台输出结果如下(注意:每条数据展示的wm值是截止上条数据输入后的wm值)
数据:+I[001, 40],当前watermark为:-9223372036854775808
数据:+I[002, 20],当前watermark为:-1001
数据:-U[001, 40],当前watermark为:999
数据:+U[001, 70],当前watermark为:999
数据:-U[002, 20],当前watermark为:999
数据:+U[002, 70],当前watermark为:999
数据:+I[003, 50],当前watermark为:3999

当然,在将Table对象转换成DataStream时,Table对象中也可以没有时间概念,也可以直接使用TableEnvironment.toChanglogStream(...)将Table对象转换成DataStream对象。

2. toChangelogStream(Table, Schema,ChangelogMode)使用演示

下面通过案例来演示toChangelogStream(...)方法将Table转换成DataStream,TableEnvironment.toChangelogStream(Table, Schema)与TableEnvironment.toChangelogStream(Table, Schema,ChangelogMode)两个方法使用类似,指定的Scheam需要与Table中的列个数、列顺序、列类型保持一致,ChangelogMode可以指定处理Table中哪种类型的数据流,可以指定三种方式:ChangelogMode.insertOnly()、ChangelogMode.upsert()、ChangelogMode.all()。这里通过一个案例进行介绍。

案例同样通过读取Socket中基站日志数据形成StationLog对象类型的DataStream,然后将该DataStream转换成Table对象,使用Table API按照基站分组统计每个基站的通话时长。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });

//设置watermark
SingleOutputStreamOperator<StationLog> dsWithWatermark = stationLogDS.assignTimestampsAndWatermarks(
        WatermarkStrategy.<StationLog>forBoundedOutOfOrderness(Duration.ofSeconds(2))
                .withTimestampAssigner(new SerializableTimestampAssigner<StationLog>() {
                    @Override
                    public long extractTimestamp(StationLog element, long recordTimestamp) {
                        return element.getCallTime();
                    }
                })
);

//将DataStream 转换成 Table
Table table = tableEnv.fromDataStream(
        dsWithWatermark,
        Schema.newBuilder()
                .columnByExpression("rowtime","TO_TIMESTAMP_LTZ(callTime,3)")
                .watermark("rowtime", "SOURCE_WATERMARK()")
                .build()
);

//使用Table API 对 Table进行查询
Table resultTbl = table
        .groupBy($("sid"))
        .select($("sid"), $("duration").sum().as("totalDuration"));

//将Table转换为DataStream
DataStream<Row> rowDataStream = tableEnv.toChangelogStream(
        resultTbl,
        //执行查询的列及类型
        Schema.newBuilder()
                .column("sid", DataTypes.STRING())
                .column("totalDuration", DataTypes.BIGINT())
                .build(),
        ChangelogMode.upsert()
);

rowDataStream.print();

//执行任务
env.execute();
  • Scala代码
复制代码
//创建流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.api.scala._

//创建TableEnv
val tableEnv: StreamTableEnvironment = StreamTableEnvironment.create(env)

//读取socket中基站日志数据并转换为StationgLog类型DataStream
val stationLogDS: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr: Array[String] = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//将DataStream转换为Table
val table: Table = tableEnv.fromDataStream(
  stationLogDS,
  Schema.newBuilder()
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
    .watermark("rowtime", "SOURCE_WATERMARK()")
    .build()
)

//使用Table API 对 Table进行查询
val resultTable: Table = table
  .groupBy($"sid")
  .select($"sid", $"duration".sum().as("totalDuration"))

//将Table转换为DataStream
val rowDataStream: DataStream[Row] = tableEnv.toChangelogStream(
  resultTable,
  Schema.newBuilder()
    .column("sid", DataTypes.STRING())
    .column("totalDuration", DataTypes.BIGINT())
    .build(),
  ChangelogMode.upsert()
)

rowDataStream.print()

env.execute()

以上代码中,在将Table转换成DataStream时指定ChangelogMode为upsert,代表只会将数据流中RowKind.INSERT、RowKind.UPDATE_AFTER、RowKind.DELETE操作类型数据写出,需要注意的是由于Table是ChanelogStream(变更日志流),不仅仅包含插入数据,还有变更数据,所以这里不能指定ChangelogMode.insertOnly()模式,否则报错。编写完成后,向socket中输入以下数据,获取的结果如下:

复制代码
#socket-9999中输入数据
001,181,182,success,1000,40
002,182,183,success,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,50
003,181,183,success,5000,50

#控制台输出结果如下
6> +I[001, 40]
1> +I[002, 20]
6> +U[001, 70]
1> +U[002, 70]
1> +I[003, 50]

Table及SQL流式概念

本小节主要介绍Flink Table API及SQL编程中的一些概念,包括状态、动态表、时间属性、时态表内容,以方便后续对Flink Table API和SQL编程有深刻认识。

Table API中的状态

在Flink Table和SQL编程中也有状态数据的维护,例如通过Table API统计每个基站通话次数时,Flink就需要维护状态保存通话次数。所以在Flink Table API和SQL编程中也可以像DataStream API编程中一样设置状态后端、checkpoint以及Savepoint,当程序发生故障时基于保存的状态数据进行程序恢复。

状态使用

Flink Table API和SQL 程序是声明式的,在其计算过程中涉及到的状态并不会像DataStream API中容易获取,Flink Planner优化器会自动决定是否需要状态来计算正确结果。如,普通的select ... from....where这种查询是没有状态的,对于表的Join、聚合、去重操作需要保存中间结果,这种情况就需要状态。此外,我们还可以通过TableEnvironment来设置table.exec.state.ttl参数,决定状态保存的时长。

下面通过代码方式来演示Flink Table API和SQL编程中状态的使用,该案例读Socket中基站日志数据,按照基站进行分组统计基站的通话时长。

  • Java代码
复制代码
//获取DataStream的运行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

//获取Table API的运行环境
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//设置状态保存时间,默认为0,表示不清理状态,这里设置5s
tableEnv.getConfig().set("table.exec.state.ttl","5000");

SingleOutputStreamOperator<StationLog> ds = env.socketTextStream("node5", 9999)
        .map(new MapFunction<String, StationLog>() {
            @Override
            public StationLog map(String s) throws Exception {
                String[] split = s.split(",");
                return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
            }
        });

//将DataStream转换成Table
tableEnv.createTemporaryView("station_tbl",ds);

//通过SQL统计通话时长信息
Table result = tableEnv.sqlQuery("select sid,sum(duration) as total_duration from station_tbl group by sid");

//打印输出
result.execute().print();
  • Scala代码
复制代码
//获取DataStream的运行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.streaming.api.scala._

//获取Taebl API的运行环境
val tableEnv = StreamTableEnvironment.create(env)

//设置状态保存时间,默认为0,表示不清理状态,这里设置5s
tableEnv.getConfig().set("table.exec.state.ttl", "5000")

val ds: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//将DataStream转换成Table
tableEnv.createTemporaryView("station_log", ds)

//通过SQL统计通话时长信息
val result = tableEnv.sqlQuery("select sid, sum(duration) as duration from station_log group by sid")

//打印输出
result.execute().print()

以上代码运行后,向socket-9999端口中输入基站日志数据,对于连续5秒内输入的相同基站日志数据,Flink 会对基站统计的通话时长数据进行状态保存,当相同基站日志数据隔5秒后再次输入,可以看到该基站统计数据会重新统计。

复制代码
#socket-9999中输入数据
001,181,182,success,1000,40
002,182,183,result,3000,20
001,183,184,success,2000,30

#隔5s后输入,可以看到基站001的统计重新开始
001,184,185,success,6000,50
状态注意点

在Flink Table API和SQL编程中使用状态需要注意如下几点:

  1. 关于Table参数的设置需要通过TableEnvironment来设置,例如:状态保存时长,关于Checkpoint的设置可以通过StreamEnvironment来设置。

  2. 目前Flink版本进行Savepoint恢复程序时需要保证Flink版本以及代码中查询语句不能改变,否则无法从保存的状态中恢复程序。

动态表(Dynamic Table)

Flink中的Table API和SQL本质上是基于关系型表的操作方式,然而关系型表本身是有界的,更适合批处理的场景,虽然Flink也可以用于批处理,但更多实际场景中Flink要处理的是无界数据流。关系型表查询与Flink中流处理对比如下:

对比项 关系型表查询 Flink Table/SQL查询
查询特点 有界数据查询 无界数据查询
数据访问 可以访问完整表数据 无法访问流式完整数据,必须等待数据流输入
查询是否终止 批处理查询完后终止 流查询不会结束

通过以上对比可以看到关系型表中进行查询可以访问完整的有界数据集,并且查询返回所有表数据后查询终止,而流查询无法访问所有数据,必须持续等待流式输入。由于Flink并不向关系型表查询一样获取全部数据,那么当实时流中数据发生变化(更新、删除)时,Flink流式处理实现与关系型查询相同语义就成为一个问题。

为了在流处理中实现与关系型查询相同的语义,Flink引入了动态表 (Dynamic tables)的概念,动态表通过将流式数据转换为"插入流"(Insert-Only Streams)或"更新日志流"(Changelog Streams)的形式来实现数据的持续更新。当数据流到来时,动态表会连续查询(Continuous Query),持续处理流式的INSERT、UPDATE和DELETE操作,并根据这些操作来实时更新查询结果。

通过将动态表与流处理相结合,Flink能够优雅地处理无界数据流,同时提供灵活且强大的实时数据处理能力。

动态表及连续查询

动态表(Dynamic tables)是Flink的Table API和SQL支持流数据的核心概念,它与表示批处理数据的静态表不同。动态表是随时间变化的,在动态表上执行的查询被称为连续查询(Continuous Query),它永远不会终止,而是生成另一个动态表作为结果,连续查询不断更新其结果表以反映输入表的动态变化。

动态表在查询执行期间不一定将所有数据进行物化,动态表连续查询输出的结果在语义上与关系型表批查询的结果是等价的。通过动态表上的连续查询,Flink能够优雅地处理无界数据流,将实时的数据处理和查询能力与传统的关系型表操作相结合,为流式数据处理提供了强大而灵活的方式。

假设现在Flink中有一张Insert-only的changelog变更日志表(也是动态表),该表记录了用户访问URL的情况,表名为clicks,包含user(用户名)、CTime(访问URL时间)、url(用户访问的URL),Flink中每条实时插入的数据都对应表中的一条数据,如下:

下面结合该变更日志表对Flink动态表更新、追加及动态表到流转换进行解释。

动态表更新与追加

Flink中基于动态表进行连续查询会生成一个新的动态表,连续查询不会终止,下面基于动态表的连续查询来说明向动态表中更新与追加数据的整个流程。

如下图所示,该查询是一个简单的group by COUNT查询,查询SQL基于user字段对clicks表进行分组聚合统计访问的URL总数量。

当查询开始,clicks表(左侧)是空的,当实时数据被插入到表中后,开始统计得到结果表(右侧)。当第一行数据"Mary,./home"插入到clicks表后,结果表结果为"Mary,1";当第2行"Bob,./cart"插入到clicks表后,查询会更新结果表,插入一行新数据"Bob,1";当第3行"Mary,./prod?id=1"插入到clicks表后,结果表中已存在的"Mary,1"这条数据将会被更新为"Mary,2";当第4行"Liz,./home"插入到clicks表后,结果表中新增"Liz,1"... ... 以上整个流程中,Flink通过连续查询实时向动态结果表中插入和追加数据,实现与关系型表查询一致的语义。

查询限制

在Flink中执行连续查询时,有一些查询会产生很高的计算代价,需要引起注意,其中代价产生的原因主要有两个方面:状态大小和计算更新,下面分别介绍。

  • 状态大小

由于连续查询在无界流上计算,可能需要运行数周或数月,因此处理的数据总量可能非常大,一些聚合查询需要维护先前输出的结果。例如之前用户访问URL案例中,如果为每个用户计算URL的点击次数,就需要维护每个用户的点击次数信息。随着用户的增加,维护的计数状态可能会随时间增长,导致存储空间耗尽,最终导致查询失败。

复制代码
SELECT
 user,
 COUNT(url)
FROM clicks
GROUP BY user;
  • 计算更新

某些查询在添加或更新一条输入记录时,需要重新计算和更新大量已输出的结果行。这种情况下,计算更新的代价非常高,不适合作为连续查询执行。例如以下SQL使用到RANK函数,该函数用来对数据进行排序,该SQL中为输入的每个用户基于最后点击时间计算排名,当有新数据输入时,就需要更新该用户的最后点击时间并重新计算排名,同时还需要更新所有较低排名的行。这样的更新操作的代价极大,随着数据量的增多,情况会越来越严重。

复制代码
SELECT user, RANK() OVER (ORDER BY lastAction)
FROM (  
SELECT user, MAX(cTime) AS lastAction FROM clicks GROUP BY user
);

在执行连续查询时,可以通过调整查询配置参数来权衡维持状态的大小,以此保证Flink能够灵活地处理各种流数据查询。

动态表到流的转换

在Flink的Table API和SQL编程中,动态表可以像普通数据库表一样不断被INSERT、UPDATE和DELETE修改,将动态表转换为流或写入外部系统时,需要对这些变化进行编码。

在前面"Table转换成DataStream"小节讲解中,将Table对象转换成DataStream流时可以使用TableEnvironment.toChangelogStream(Table, Schema,ChangelogMode)方法,通过指定ChangelogMode来决定将Table中哪些数据转出到DataStream中,Flink动态表到流转换所谓的编码实际上就是通过指定ChangelogMode来进行的,决定可以将什么类型操作数据写出到DataStream中,ChangelogMode可以指定三种方式:ChangelogMode.insertOnly()、ChangelogMode.upsert()、ChangelogMode.all(),这三种编码方式对应如下三种数据流。

  • Append-only流

对应ChangelogMode.insertOnly(),表示将仅通过INSERT操作修改的动态表转换为DataStream流时,只需要输出插入(Insert)的数据行。

  • Retract流

Retract流也叫撤回流,对应ChangelogMode.all(),表示将插入、更新前、更新后、删除所有操作数据转换到DataStream中。Retract流包含两种类型的消息:add messages和retract messages。将动态表转换为Retract流时,INSERT操作编码为add message,DELETE操作编码为retract message,UPDATE操作编码为retract message(表示更新前的行)和add message(表示更新后的行)。下图显示了将动态表转换为Retract流的过程。

  • Upsert流:

对应ChangelogMode.upsert(),Upsert流包含两种类型的消息:upsert messages和delete messages。这种方式将带有唯一键(key)的动态表转换为DataStream流时,INSERT和UPDATE操作编码为upsert message,DELETE操作编码为delete message。与Retract流相比,Upsert流的主要区别在于UPDATE操作基于key直接更新,只需单个消息编码,因此更高效。下图显示了将动态表转换为Upsert流的过程。

我们可以通过调用TableEnvironment.toChangelogStream(Table, Schema,ChangelogMode)方法来决定将Table转换成何种类型的流,默认得到的就是Retract流,在将动态流写出到外部系统时,不同的外部系统支持不同的编码方式,后续再做讲解。

时间属性

在Flink Table API和SQL编程中,有两种不同的时间概念:处理时间(ProcessTime)和事件时间(EventTime),与DataStream API中的定义相同,ProcessTime是指Flink处理数据的机器的系统时间,EventTime是指数据本身携带的时间戳。

Table API和SQL中可以为表单独指定一个逻辑上的时间字段,用来表示处理时间或事件时间,这个时间属性字段是表结构的一部分,可以像普通字段一样使用,并且可以应用于事件相关的操作,例如设置窗口函数来对数据进行分组和聚合。下面我们对Table API和SQL编程中时间属性以及如何设置processTime和EventTime进行讲解。

时区及时间类型

Flink提供了丰富的日期和时间数据类型,并且支持在Session会话级别设置时区。Flink 支持在 TIMESTAMP 列和 TIMESTAMP_LTZ 列上定义时间属性。TIMESTAMP用于表示时间戳,没有具体时区信息,而TIMESTAMP_LTZ用于描述时间线上的绝对时间点,并带有本地时区信息,适用于跨时区计算。

  • 时区设置

Flink Table API和SQL编程中默认的时区为用户本地时区,例如我们此刻在北京,使用的时区就是'Asia/Shanghai'时区,也可以在SQL编程中或者代码编程中设置时区信息。

复制代码
#在SQL Client中设置时区信息
-- 设置为 UTC 时区
Flink SQL> SET 'table.local-time-zone' = 'UTC';
-- 设置为上海时区
Flink SQL> SET 'table.local-time-zone' = 'Asia/Shanghai';


#在代码中设置时区信(以Java代码为例)
EnvironmentSettings envSetting = EnvironmentSettings.inStreamingMode();
TableEnvironment tEnv = TableEnvironment.create(envSetting);
// 设置为 UTC 时区,即
tEnv.getConfig().setLocalTimeZone(ZoneId.of("UTC"));
// 设置为上海时区
tEnv.getConfig().setLocalTimeZone(ZoneId.of("Asia/Shanghai"));
  • TIMESTAMP类型

TIMESTAMP时间类型用于描述"年-月-日 时:分:秒.小数秒"对应的时间戳,可以通过一个字符串来指定,TIMESTAMP是 TIMESTAMP(p) WITHOUT TIME ZONE 的简写,精度 p 支持的范围是0-9,默认是6。

  • TIMESTAMP_LTZ类型

TIMESTAMP_LTZ(p)时间类型是 TIMESTAMP(p) WITH LOCAL TIME ZONE 的简写,即本地时区时间戳,根据Flink使用session会话中配置的时区决定时间是多少, 所以我们常说TIMESTAMP_LTZ用于描述时间线上的绝对时间点。

TIMESTAMP_LTZ使用long类型存储自标准Java纪元(1970-01-01T00:00:00Z)以来的毫秒数,精度 p 支持的范围是0-9,默认是6。

下面通过Java代码来演示时区以及TIMESTAMP、TIMESTAMP_LTZ时间类型之间的关系。

复制代码
//准备环境配置
EnvironmentSettings settings = EnvironmentSettings
        .newInstance()
        .inStreamingMode()
        .build();

//创建TableEnvironment
TableEnvironment tableEnv = TableEnvironment.create(settings);

//设置时区
tableEnv.getConfig().setLocalTimeZone(ZoneId.of("Asia/Shanghai"));
//tableEnv.getConfig().setLocalTimeZone(ZoneId.of("UTC"));

Table table1 = tableEnv.sqlQuery("" +
        "SELECT " +
        "TIMESTAMP '1970-01-01 00:00:01.001' AS ntz," +
        "TO_TIMESTAMP_LTZ(4001, 3) AS ltz"
);
table1.printSchema();
table1.execute().print();

//转换数据
Table table2 = tableEnv.sqlQuery("" +
        "SELECT " +
        "CAST (ltz AS TIMESTAMP_LTZ(3)) AS cast_ltz_3," +
        "CAST (ltz AS TIMESTAMP_LTZ(6)) AS cast_ltz_6," +
        "CAST (ntz AS TIMESTAMP(3)) AS cast_ntz_3," +
        "CAST (ntz AS TIMESTAMP(6)) AS cast_ntz_6 " +
        "from "+table1
);
table2.printSchema();
table2.execute().print();

以上代码设置了时区并对TIMESTAMP和TO_TIMESTAMP_LTZ时间类型打印Schema并进行转换测试,通过以上代码测试我们可以很直观的观察到两种类型的区别。

如果我们将时区设置为"Asia/Shanghai",那么对应输出结果如下:

复制代码
#table1 Schema
(
  `ntz` TIMESTAMP(3) NOT NULL,
  `ltz` TIMESTAMP_LTZ(3)
)

#table1结果
+----+-------------------------+-------------------------+
| op |                     ntz |                     ltz |
+----+-------------------------+-------------------------+
| +I | 1970-01-01 00:00:01.001 | 1970-01-01 08:00:04.001 |
+----+-------------------------+-------------------------+

#table2 Schema
(
  `cast_ltz_3` TIMESTAMP_LTZ(3),
  `cast_ltz_6` TIMESTAMP_LTZ(6),
  `cast_ntz_3` TIMESTAMP(3) NOT NULL,
  `cast_ntz_6` TIMESTAMP(6) NOT NULL
)

#table2 结果
+----+-------------------------+----------------------------+-------------------------+----------------------------+
| op |              cast_ltz_3 |                 cast_ltz_6 |              cast_ntz_3 |                 cast_ntz_6 |
+----+-------------------------+----------------------------+-------------------------+----------------------------+
| +I | 1970-01-01 08:00:04.001 | 1970-01-01 08:00:04.001000 | 1970-01-01 00:00:01.001 | 1970-01-01 00:00:01.001000 |
+----+-------------------------+----------------------------+-------------------------+----------------------------+

如果我们将时区设置为"UTC",那么对应输出结果如下:

复制代码
#table1 Schema
(
  `ntz` TIMESTAMP(3) NOT NULL,
  `ltz` TIMESTAMP_LTZ(3)
)

#table1结果
+----+-------------------------+-------------------------+
| op |                     ntz |                     ltz |
+----+-------------------------+-------------------------+
| +I | 1970-01-01 00:00:01.001 | 1970-01-01 00:00:04.001 |
+----+-------------------------+-------------------------+


#table2 Schema
(
  `cast_ltz_3` TIMESTAMP_LTZ(3),
  `cast_ltz_6` TIMESTAMP_LTZ(6),
  `cast_ntz_3` TIMESTAMP(3) NOT NULL,
  `cast_ntz_6` TIMESTAMP(6) NOT NULL
)

#table2 结果
+----+-------------------------+----------------------------+-------------------------+----------------------------+
| op |              cast_ltz_3 |                 cast_ltz_6 |              cast_ntz_3 |                 cast_ntz_6 |
+----+-------------------------+----------------------------+-------------------------+----------------------------+
| +I | 1970-01-01 00:00:04.001 | 1970-01-01 00:00:04.001000 | 1970-01-01 00:00:01.001 | 1970-01-01 00:00:01.001000 |
+----+-------------------------+----------------------------+-------------------------+----------------------------+
定义ProcessTime(处理时间)

ProcessTime相对来说比较简单,不需要从事件中获取时间,也不需要生成Watermark,Flink Table API和SQL编程中指定ProcessTime有两种方式:在DDL语句中指定和在DataStream到Table转换时指定。

1. 在创建表的DDL中定义

ProcessTime可以在创建表的DDL中对列通过PROCTIME()函数来指定,PROCTIME()函数的返回类型是TIMESTAMP_LTZ(在Flink 1.13版本之前,PROCTIME() 函数返回的类型是 TIMESTAMP)。创建表DDL中指定ProcessTime方式如下:

复制代码
CREATE TABLE user_actions (
  user_name STRING,
  data STRING,
  user_action_time AS PROCTIME() -- 声明一个额外的列作为处理时间属性
) WITH (
  ...
);
SELECT TUMBLE_START(user_action_time, INTERVAL '10' MINUTE), COUNT(DISTINCT user_name)
FROM user_actions
GROUP BY TUMBLE(user_action_time, INTERVAL '10' MINUTE);

案例:读取Kafka中基站日志数据,每隔5秒进行数据条数统计。

  • Java代码
复制代码
//创建TableEnvironment
EnvironmentSettings settings = EnvironmentSettings.newInstance()
        .inStreamingMode()
        .build();
TableEnvironment tableEnv = TableEnvironment.create(settings);

//读取Kafka基站日志数据,通过SQL DDL方式定义表结构
tableEnv.executeSql("" +
        "create table stationlog_tbl (" +
        "   sid string," +
        "   call_out string," +
        "   call_in string," +
        "   call_type string," +
        "   duration bigint," +
        "   call_time AS PROCTIME()" +
        ") with (" +
        "   'connector' = 'kafka'," +
        "   'topic' = 'stationlog-topic'," +
        "   'properties.bootstrap.servers' = 'node1:9092,node2:9092,node3:9092'," +
        "   'properties.group.id' = 'testGroup'," +
        "   'scan.startup.mode' = 'latest-offset'," +
        "   'format' = 'csv'" +
        ")");

//通过SQL每隔5秒统计一次数据条数
Table resultTbl = tableEnv.sqlQuery("" +
        "select " +
        "TUMBLE_START(call_time,INTERVAL '5' SECOND) as window_start," +
        "TUMBLE_END(call_time,INTERVAL '5' SECOND) as window_end," +
        "count(sid) as cnt " +
        "from stationlog_tbl " +
        "group by TUMBLE(call_time,INTERVAL '5' SECOND)");

//打印结果
resultTbl.execute().print();
  • Scala代码
复制代码
//创建TableEnvironment
val settings: EnvironmentSettings = EnvironmentSettings.newInstance()
  .inStreamingMode()
  .build()

val tableEnv: TableEnvironment = TableEnvironment.create(settings)

//读取Kafka基站日志数据,通过SQL DDL方式定义表结构
tableEnv.executeSql("" +
  "create table stationlog_tbl (" +
  "   sid string," +
  "   call_out string," +
  "   call_in string," +
  "   call_type string," +
  "   duration bigint," +
  "   call_time AS PROCTIME()" +
  ") with (" +
  "   'connector' = 'kafka'," +
  "   'topic' = 'stationlog-topic'," +
  "   'properties.bootstrap.servers' = 'node1:9092,node2:9092,node3:9092'," +
  "   'properties.group.id' = 'testGroup'," +
  "   'scan.startup.mode' = 'latest-offset'," +
  "   'format' = 'csv'" +
  ")")

//通过SQL每隔5秒统计一次数据条数
val result: Table = tableEnv.sqlQuery("" +
  "select " +
  "TUMBLE_START(call_time,INTERVAL '5' SECOND) as window_start," +
  "TUMBLE_END(call_time,INTERVAL '5' SECOND) as window_end," +
  "count(sid) as cnt " +
  "from stationlog_tbl " +
  "group by TUMBLE(call_time,INTERVAL '5' SECOND)")

//打印结果
result.execute().print()

以上Java代码和Scala代码 SQL DDL方式编程方式类似,指定了call_time为ProcessTime,SQL中"group by TUMBLE(call_time,INTERVAL '5' SECOND)"写法是设置每隔5秒设置一个基于ProcessTime的窗口,并且在查询中通过"TUMBLE_START"、"TUMBLE_END"获取了窗口的起始时间,关于Flink Table API中的窗口后续小节还会介绍。

代码运行后可以向Kafka stationlog-topic中输入如下数据,可以看到每隔5s后会有对应窗口结果输出。

复制代码
#socket-9999中输入如下数据
001,181,182,success,40
002,182,183,success,20
001,183,184,success,30
002,184,185,success,50
003,181,183,success,50

2. 在DataStream到Table转换时定义

在DataStream转换到Table对象时,可以通过TableEnvironment.fromDataStream(DataStream, Schema)方法中的Schema指定ProcessTime列,该列名不能与现已存在Table对象中的列名重复。通过这种方式指定ProcessTime的使用形式如下:

复制代码
DataStream<Tuple2<String, String>> stream = ...;
// 声明一个额外的字段作为时间属性字段
Table table = tEnv.fromDataStream(
stream,
Schema.newBuilder()
.columnByExpression("proc_time","PROCTIME()")
.build()
);

WindowedTable windowedTable = table.window(
        Tumble.over(lit(10).minutes())
            .on($("user_action_time"))
            .as("userActionWindow"));

案例:读取Socket中基站日志数据,每隔5秒进行数据条数统计。

  • Java代码
复制代码
//创建StreamExecutionEnvironment
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

//创建表执行环境
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

SingleOutputStreamOperator<StationLog> ds = env.socketTextStream("node5", 9999)
        .map(new MapFunction<String, StationLog>() {
            @Override
            public StationLog map(String s) throws Exception {
                String[] split = s.split(",");
                return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
            }
        });

//将DataStream转换成Table
Table table = tableEnv.fromDataStream(
        ds,
        Schema.newBuilder()
                .columnByExpression("proc_time", "PROCTIME()")
                .build()
);

//通过SQL每隔5秒统计一次数据条数
Table resultTbl = tableEnv.sqlQuery("" +
        "select " +
        "TUMBLE_START(proc_time,INTERVAL '5' SECOND) as window_start," +
        "TUMBLE_END(proc_time,INTERVAL '5' SECOND) as window_end," +
        "count(sid) as cnt " +
        "from " +table+
        " group by TUMBLE(proc_time,INTERVAL '5' SECOND)");

//打印结果
resultTbl.execute().print();
  • Scala代码
复制代码
//获取DataStream的运行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.streaming.api.scala._

//获取Taebl API的运行环境
val tableEnv = StreamTableEnvironment.create(env)

val ds: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

//将DataStream转换成Table
val table: Table = tableEnv.fromDataStream(
  ds,
  Schema.newBuilder()
    .columnByExpression("proc_time", "PROCTIME()")
    .build()
)

//通过SQL每隔5秒统计一次数据条数
val resultTbl: Table = tableEnv.sqlQuery("" +
  "select " +
  "TUMBLE_START(proc_time,INTERVAL '5' SECOND) as window_start," +
  "TUMBLE_END(proc_time,INTERVAL '5' SECOND) as window_end," +
  "count(sid) as cnt " +
  "from " + table +
  " group by TUMBLE(proc_time,INTERVAL '5' SECOND)")

//打印输出
resultTbl.execute().print()

以上Java代码和Scala代码中通过读取socket中的数据形成StationLog类型的DataStream,虽然StationLog中有callTime,这里我们并不使用该列作为事件时间,为了演示ProcessTime,这里我们指定了新列"proc_time"为ProcessTime,通过以上代码可以看出,DataStream转换成Table指定ProcessTime的方式Java和Scala编码方式类似。代码运行后可以向socket中输入如下数据,可以看到每隔5s后会有对应窗口结果输出。

复制代码
#socket-9999中输入如下数据
001,181,182,success,1000,40
002,182,183,success,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,50
003,181,183,success,5000,50
定义EventTime(事件时间)

在Flink编程中,我们可以从数据流中获取事件的时间列,用以指定事件时间(EventTime)并生成watermark。基于事件时间处理数据可以帮助我们在处理乱序事件时,区分正常到达的事件和晚到的事件,从而确保结果的一致性和可复现性。

类似于指定处理时间(ProcessTime),在Flink的Table API和SQL编程中,我们也有两种方式来指定事件时间:一种是在DDL语句中直接指定,另一种是在将DataStream转换为Table的过程中指定。

1. 在创建表的DDL中定义

在Flink中,事件时间来源于事件中的某列,我们可以在CREATE TABLE DDL中基于该时间列使用"WATERMARK"语句来定义watermark。"WATERMARK"语句用于在已有时间字段上定义一个watermark生成表达式,并标记该字段为事件时间属性,Flink支持在TIMESTAMP列和TIMESTAMP_LTZ列上定义EventTime(事件时间)。

如果源数据中的时间列的时间戳以"年-月-日-时-分-秒"的形式表示(通常为不带时区信息的字符串),建议将事件时间属性定义在TIMESTAMP列上。如下使用示例中,在CREATE TABLE DDL中,我们可以声明TIMESTAMP字段为事件时间属性,并使用延迟5秒的策略来生成watermark使用了TUMBLE函数对事件时间属性进行窗口划分,然后进行聚合操作。

复制代码
CREATE TABLE user_actions (
  user_name STRING,
  data STRING,
  user_action_time TIMESTAMP(3),
  -- 声明 user_action_time 是事件时间属性,并且用 延迟 5 秒的策略来生成 watermark
  WATERMARK FOR user_action_time AS user_action_time - INTERVAL '5' SECOND
) WITH (
  ...
);

SELECT TUMBLE_START(user_action_time, INTERVAL '10' MINUTE), COUNT(DISTINCT user_name)
FROM user_actions
GROUP BY TUMBLE(user_action_time, INTERVAL '10' MINUTE);

如果源数据中的时间戳表示为一个标准Java纪元时间(通常为long值),建议将事件时间属性定义在TIMESTAMP_LTZ列上。如下使用实例中,在CREATE TABLE DDL中,我们可以使用TO_TIMESTAMP_LTZ函数将BIGINT类型转换为TIMESTAMP_LTZ类型,并声明TIMESTAMP_LTZ字段为事件时间属性,并使用5秒延迟的策略来生成watermark,然后使用了TUMBLE函数对事件时间属性进行窗口划分进行聚合操作。

复制代码
CREATE TABLE user_actions (
 user_name STRING,
 data STRING,
 ts BIGINT,
 time_ltz AS TO_TIMESTAMP_LTZ(ts, 3),
 -- 声明time_ltz 是事件时间属性,并且用 延迟 5 秒的策略来生成 watermark
 WATERMARK FOR time_ltz AS time_ltz - INTERVAL '5' SECOND
) WITH (
 ...
);

SELECT TUMBLE_START(time_ltz, INTERVAL '10' MINUTE), COUNT(DISTINCT user_name)
FROM user_actions
GROUP BY TUMBLE(time_ltz, INTERVAL '10' MINUTE);

案例:读取Kafka中基站日志数据,每隔5秒进行数据条数统计。

  • Java代码
复制代码
//创建TableEnvironment
EnvironmentSettings settings = EnvironmentSettings.newInstance()
        .inStreamingMode()
        .build();
TableEnvironment tableEnv = TableEnvironment.create(settings);

//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000");


//读取Kafka基站日志数据,通过SQL DDL方式定义表结构
tableEnv.executeSql("" +
        "create table stationlog_tbl (" +
        "   sid string," +
        "   call_out string," +
        "   call_in string," +
        "   call_type string," +
        "   call_time bigint," +
        "   duration bigint," +
        "   time_ltz AS TO_TIMESTAMP_LTZ(call_time,3)," +
        "   WATERMARK FOR time_ltz AS time_ltz - INTERVAL '2' SECOND" +
        ") with (" +
        "   'connector' = 'kafka'," +
        "   'topic' = 'stationlog-topic'," +
        "   'properties.bootstrap.servers' = 'node1:9092,node2:9092,node3:9092'," +
        "   'properties.group.id' = 'testGroup'," +
        "   'scan.startup.mode' = 'latest-offset'," +
        "   'format' = 'csv'" +
        ")");

//通过SQL每隔5秒统计一次数据条数
Table resultTbl = tableEnv.sqlQuery("" +
        "select " +
        "TUMBLE_START(time_ltz,INTERVAL '5' SECOND) as window_start," +
        "TUMBLE_END(time_ltz,INTERVAL '5' SECOND) as window_end," +
        "count(sid) as cnt " +
        "from stationlog_tbl " +
        "group by TUMBLE(time_ltz,INTERVAL '5' SECOND)");

//打印结果
resultTbl.execute().print();
  • Scala代码
复制代码
//创建TableEnvironment
val settings: EnvironmentSettings = EnvironmentSettings.newInstance()
  .inStreamingMode()
  .build()

val tableEnv: TableEnvironment = TableEnvironment.create(settings)

//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig.set("table.exec.source.idle-timeout", "5000")

//读取Kafka基站日志数据,通过SQL DDL方式定义表结构
tableEnv.executeSql("" +
  "create table stationlog_tbl (" +
  "   sid string," +
  "   call_out string," +
  "   call_in string," +
  "   call_type string," +
  "   call_time bigint," +
  "   duration bigint," +
  "   time_ltz AS TO_TIMESTAMP_LTZ(call_time,3)," +
  "   WATERMARK FOR time_ltz AS time_ltz - INTERVAL '2' SECOND" +
  ") with (" +
  "   'connector' = 'kafka'," +
  "   'topic' = 'stationlog-topic'," +
  "   'properties.bootstrap.servers' = 'node1:9092,node2:9092,node3:9092'," +
  "   'properties.group.id' = 'testGroup'," +
  "   'scan.startup.mode' = 'latest-offset'," +
  "   'format' = 'csv'" +
  ")")

//通过SQL每隔5秒统计一次数据条数
val result: Table = tableEnv.sqlQuery("" +
  "select " +
  "TUMBLE_START(time_ltz,INTERVAL '5' SECOND) as window_start," +
  "TUMBLE_END(time_ltz,INTERVAL '5' SECOND) as window_end," +
  "count(sid) as cnt " +
  "from stationlog_tbl " +
  "group by TUMBLE(time_ltz,INTERVAL '5' SECOND)")

//打印结果
result.execute().print()

以上Java和Scala代码中在SQL DDL中通过TO_TIMESTAMP_LTZ函数对事件时间列call_time由BIGINT类型转换为TIMESTAMP_LTZ类型,而后通过WATERMARK语句基于事件时间设置了watermark,指定时间延迟时间为2秒。此外,代码中我们还指定了table.exec.source.idle-timeout参数,避免多并行度下某个并行度没有数据watermark不推进问题。

代码编写完成后,我们可以向socket-9999中输入如下数据,输出结果如下:

复制代码
#socket-9999中输入数据
001,181,182,success,1000,10
002,182,183,success,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,40
003,181,183,success,4000,50
#此条数据输入后,窗口触发
001,183,184,success,7000,60

#控制台输出结果
+----+-------------------------+-------------------------+-----+
| op |            window_start |              window_end | cnt |
+----+-------------------------+-------------------------+-----+
| +I | 1970-01-01 08:00:00.000 | 1970-01-01 08:00:05.000 |   4 |

2. 在DataStream到Table转换时定义

在DataStream转换到Table对象时,可以通过TableEnvironment.fromDataStream(DataStream, Schema)方法中的Schema指定EventTime列,往往该EventTime列可以通过TO_TIMESTAMP_LTZ函数对事件中存在的时间列进行转换得到,该列名不能与现已存在Table对象中的列名重复。通过这种方式指定EventTime的使用形式如下:

复制代码
DataStream<Tuple2<String, String>> stream = ...;
// 声明一个额外的字段作为时间属性字段
Table table = tEnv.fromDataStream(
stream,
Schema.newBuilder()
.columnByExpression("event_time","TO_TIMESTAMP_LTZ(timecol,3)")
.watermark("event_time","event_time - INTERVAL '2' SECOND")
.build()
);

WindowedTable windowedTable = table.window(
        Tumble.over(lit(10).minutes())
            .on($("user_action_time"))
            .as("userActionWindow"));

案例:读取Socket中基站日志数据,每隔5秒进行数据条数统计。

  • Java代码
复制代码
//创建流处理执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

//创建TableEnv
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000");

//读取socket中基站日志数据并转换为StationgLog类型DataStream
SingleOutputStreamOperator<StationLog> stationLogDS = env.socketTextStream("node5", 9999)
        .map(line -> {
            String[] split = line.split(",");
            return new StationLog(split[0], split[1], split[2], split[3], Long.valueOf(split[4]), Long.valueOf(split[5]));
        });

Table table = tableEnv.fromDataStream(stationLogDS,
        Schema.newBuilder()
                //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
                .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
                //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
                .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
                .build());

//通过SQL每隔5秒统计一次数据条数
Table resultTbl = tableEnv.sqlQuery("" +
        "select " +
        "TUMBLE_START(rowtime,INTERVAL '5' SECOND) as window_start," +
        "TUMBLE_END(rowtime,INTERVAL '5' SECOND) as window_end," +
        "count(sid) as cnt " +
        "from " +table+
        " group by TUMBLE(rowtime,INTERVAL '5' SECOND)");

//打印结果
resultTbl.execute().print();
  • Scala代码
复制代码
//获取DataStream的运行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment

//导入隐式转换
import org.apache.flink.streaming.api.scala._

//获取Taebl API的运行环境
val tableEnv = StreamTableEnvironment.create(env)

//当某个并行度5秒没有数据输入时,自动推进watermark
tableEnv.getConfig().set("table.exec.source.idle-timeout","5000")

val ds: DataStream[StationLog] = env.socketTextStream("node5", 9999)
  .map(line => {
    val arr = line.split(",")
    StationLog(arr(0).trim, arr(1).trim, arr(2).trim, arr(3).trim, arr(4).trim.toLong, arr(5).trim.toLong)
  })

val table: Table = tableEnv.fromDataStream(ds,
  Schema.newBuilder()
    //添加新列,第一个参数是新列名,第二个参数是表达式,这里是根据callTime字段转换为时间戳
    .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(callTime,3)")
    //指定字段的水位线,第一个参数是选取的事件时间字段,第二个参数是延迟时间
    .watermark("rowtime", "rowtime - INTERVAL '2' SECOND")
    .build())

//通过SQL每隔5秒统计一次数据条数
val resultTbl: Table = tableEnv.sqlQuery("" +
  "select " +
  "TUMBLE_START(rowtime,INTERVAL '5' SECOND) as window_start," +
  "TUMBLE_END(rowtime,INTERVAL '5' SECOND) as window_end," +
  "count(sid) as cnt " +
  "from " + table +
  " group by TUMBLE(rowtime,INTERVAL '5' SECOND)")

//打印结果
resultTbl.execute().print()

以上Java和Scala代码通过TableEnvironment.fromDataStream方法中的Schema对象对事件时间进行了转换以及设置watermark,指定时间延迟时间为2秒。此外,代码中我们还指定了table.exec.source.idle-timeout参数,避免多并行度下某个并行度没有数据watermark不推进问题。

代码编写完成后,我们可以向socket-9999中输入如下数据,输出结果如下:

复制代码
#socket-9999中输入数据
001,181,182,success,1000,10
002,182,183,success,3000,20
001,183,184,success,2000,30
002,184,185,success,6000,40
003,181,183,success,4000,50
#此条数据输入后,窗口触发
001,183,184,success,7000,60

#控制台输出结果
+----+-------------------------+-------------------------+-----+
| op |            window_start |              window_end | cnt |
+----+-------------------------+-------------------------+-----+
| +I | 1970-01-01 08:00:00.000 | 1970-01-01 08:00:05.000 |   4 |
时间属性总结

通过以上时间属性的学习,在Flink Table API和SQL编程中,我们可以设置不同的时区、ProcessTime/EventTime来满足不同用户的时间需求。对ProcessTime和EventTime时都可以通过两种方式来完成设置:基于SQL DDL语句设置和DataStream转换成Table设置。

时态表(Temporal Tables)

Flink Table API和SQL编程中有一种特殊的表叫做时态表(Temporal Table),时态表也是一种动态表,可以追踪表中的数据变化、访问数据历史版本数据。

例如,在Flink某实时业务中,我们需要关联MySQL中的产品价格表,该表中的数据会随着时间的变化产品价格实时变化,如下表所示,在00:01:00时刻产品"scooter"的价格为11.11,在12:00:00的时候涨价到了12.99,在18:00:00的时候这条产品价格记录被删除。

复制代码
(changelog kind)  update_time  product_id product_name price
================= ===========  ========== ============ ===== 
+(INSERT)         00:01:00     p_001      scooter      11.11
+(INSERT)         00:02:00     p_002      basketball   23.11
-(UPDATE_BEFORE)  12:00:00     p_001      scooter      11.11
+(UPDATE_AFTER)   12:00:00     p_001      scooter      12.99
-(UPDATE_BEFORE)  12:00:00     p_002      basketball   23.11 
+(UPDATE_AFTER)   12:00:00     p_002      basketball   19.99
-(DELETE)         18:00:00     p_001      scooter      12.99 

以上产品价格表可以看成是一种维度数据,在Flink主流中如果来了一条"10:00:00"时刻的"p_001"的数据,如果该条数据关联该产品价格表时产品价格表中的时间为"10:00:00"时刻,那么得到的"p_001"产品对应的价格为"11.11",但此刻如果主流中的数据迟到了,主流"10:00:00 p_001"数据严重迟到,该条数据在关联产品价格表时的时刻为"13:00:00",通过上表我们可以看到"p_001"的价格为只能为"12.99",因为此刻,产品价格表中的数据存储的"p_001"价格为"12.99",那么主流"10:00:00 p_001"数据就不能准确的关联到价格"11.11"。针对这种情况我们可以使用时态表来存储产品价格表,时态表中可以追踪表中的数据变化、访问数据历史版本数据,可以保证某时刻从时态表中查询数据时得到该时刻正确结果。

时态表(Temporal Table)在 Flink 中被视为一种动态表,随着数据插入,时态表中的数据也会逐渐增加,时态表可以被看作是一系列带有版本标记的表格快照的集合,这使得它能够有效地跟踪表中的所有变更记录。在 Flink 官方的英文文档中,时态表被称为 Versioned Table(版本表),这个名称恰如其分地传达了这一概念,所以时态表也可以叫做版本表。

所谓时态表中的"时"代表一个时间点,"态"代表的是这个时间点的状态,所以时态表中有时间列,可以存储某一时刻表中数据的状态(或者快照),这就是前面说的版本。时态表并不是把表中所有数据都会进行状态存储,而只是保存上一个watermark到当前时刻的所有版本数据,从而实现查询某个时刻时态表中数据的版本状态结果。

以上举例中,如果产品价格表时一张时态表,假设表中的watermark没有达到任何数据的事件时间,也就是说时态表中没有过期数据,那么我们想要查询该表在10:00:00时刻对应的版本,表的内容如下:

复制代码
update_time  product_id product_name price
===========  ========== ============ ===== 
00:01:00     p_001      scooter      11.11
00:02:00     p_002      basketball   23.1

如果我们想要查询该表在13:00:00时刻对应的版本,表的内容如下:

复制代码
update_time  product_id product_name price
===========  ========== ============ ===== 
12:00:00     p_001      scooter      12.99
12:00:00     p_002      basketball   19.99

在Flink Table 和SQL编程中,对于维度表数据随着时间实时变化的分析场景可以使用时态表 。创建时态表时需要使用主键约束和事件时间,只要定义一张表时包含事件时间和主键约束,那么这张表就是时态表,时态表不能单独查询使用,只能与其他表进行Join时使用。在后续小节中介绍Table API和 SQL使用时我们再来讲解时态表的使用。

相关推荐
2601_962072551 小时前
李梦娇常识4600问|题库|打印版
sql·华为od·华为·c#·华为云·.net·harmonyos
m0_380167142 小时前
面向开发者的Top10加密货币数据API(2026年最新)
大数据·人工智能·区块链
yyxx4121232 小时前
上海企业如何选择专业的钉钉服务商
java·大数据·人工智能·钉钉
QZ166560951592 小时前
动态感知·全覆盖管控·符合司法要求:通用行业知形数据库风险监测合规落地方案
大数据·人工智能
HackTwoHub3 小时前
Sqli-Scanner SQL注入SKILL自动化挖掘SQL注入,零依赖自动化SQL注入挖掘,赏金猎人
数据库·人工智能·sql·web安全·网络安全·自动化·系统安全
GEO优化小助手3 小时前
2026临沂GEO优化公司实测解析:3家本土机构适配性参考
大数据·人工智能·python
OceanBase数据库官方博客3 小时前
OceanBase + Flink 数据集成(第二部分):通过 JDBC 协议实现实时数据同步
大数据·flink·oceanbase
跨境摸鱼4 小时前
年中政策切换窗口临近跨境卖家如何安排新品测试与库存回收
大数据·人工智能·跨境电商·跨境·营销策略
更深兼春远5 小时前
第二部分:数据生成==》采集==》分析==》迁移
大数据