Flink流式计算从入门到实战 四

Flink流式计算实战专题四

Flink的Table API和SQL

==楼兰

六、Flink Table API 和Flink SQL

1、Table API和SQL是什么?

​ 接下来理解下Flink的整个客户端API体系,Flink为流式/批量处理应用程序提供了不同级别的抽象:

在这里插入图片描述

​ 这四层API是一个依次向上支撑的关系。

  • Flin API最底层的抽象就是有状态实时流处理 Stateful Stream Processing,是最底层的Low-Level API。实际上就是基于ProcessFunction提供的一整套API。在上面侧输出流部分,已经接触到了一个示例。这是最灵活,功能最全面的一层客户端API,允许应用程序可以定制复杂的计算过程。但是这一层大部分的常用的功能都已经封装在了上层的Core API当中,大部分的应用都不会需要使用到这一层API。
  • Core APIs主要是DataStream API以及针对批处理的DataSet API。这是最为常用的一套API。其中,又以DataStream API为主。他们其实就是基于一系列ProcessFunction做的一些高层次的封装,可以极大的简化客户端应用程序的开发。
  • Table API主要是表(Table)为中心的声明式编程API。他允许应用程序像操作关系型数据库一样对数据进行一些select\join\groupby等典型的逻辑操作,并且也可以通过用户自定义函数进行功能扩展,而不用确切地指定程序指定的代码。当然,Table API的表达能力还是不如Core API灵活。大部分情况下,用户程序应该将Table API和DataStream API混合使用。
  • SQL是Flink API中最顶层的抽象。功能类似于Table API,只是程序实现的是直接的SQL语句支持。本质上还是基于Table API的一层抽象。

​ Table API和Flink SQL是一套给Java和Scalal语言提供的快速查询数据的API,在Python语言客户端中也可以使用。他们是集成在一起的一整套API。通过Table API,用户可以像操作数据库中的表一样查询流式数据。 这里注意Table API主要是针对数据查询操作,而"表"中数据的本质还是对流式数据的抽象。而SQL则是直接在"表"上提供SQL语句支持。

​ 其实这种思路在流式计算中是非常常见的,像kafka Streams中提供了KTable封装,Spark中也提供了SparkSQL进行表操作。不过目前版本的Flink中的Table API和SQL还处在活跃开发阶段,很多特性还并没有完全稳固。

2、如何使用Table API

​ 使用Table API和SQL,需要引入maven依赖。

​ 首先需要引入一个语言包

<!-- java客户端 -->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-api-java-bridge_2.11</artifactId>
  <version>1.12.3</version>
  <scope>provided</scope>
</dependency>

另外也提供了scala语言的依赖版本

<!-- scala客户端 -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-api-scala-bridge_2.11</artifactId>
<version>1.12.3</version>
<scope>provided</scope>
</dependency>

​ 然后需要引入一个Planner

<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-planner-blink_2.11</artifactId>
  <version>1.12.3</version>
  <scope>provided</scope>
</dependency>

1.9版本之前还有另一个老版本的planner,但是从1.11版本开始官方就已经不建议使用了。

<!-- 针对Flink 1.9以前的老版本,1.11之后官方不建议使用 -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-planner_2.11</artifactId>
<version>1.12.3</version>
<scope>provided</scope>
</dependency>

​ 接下来如果要使用一些自定义函数的话,还需要引入一个扩展依赖

<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-table-common</artifactId>
  <version>1.12.3</version>
  <scope>provided</scope>
</dependency>

注意下,为什么这些依赖都使用了provided的scope呢?因为这些maven依赖的jara包,在flink的部署环境中都有。如果需要添加一些新的jar包,那就需要手动把jar包复制进去。

3、基础编程框架

​ Flink中对批处理和流处理的Table API 和SQL 程序都遵循一个相同的模式,都是象下面示例中的这种结构。

// create a TableEnvironment for specific planner batch or streaming
TableEnvironment tableEnv = ...;

// create an input Table
tableEnv.executeSql("CREATE TEMPORARY TABLE table1 ... WITH ( 'connector' = ... )");
// register an output Table
tableEnv.executeSql("CREATE TEMPORARY TABLE outputTable ... WITH ( 'connector' = ... )");

// create a Table object from a Table API query
Table table2 = tableEnv.from("table1").select(...);
// create a Table object from a SQL query
Table table3 = tableEnv.sqlQuery("SELECT ... FROM table1 ... ");

// emit a Table API result Table to a TableSink, same for SQL result
TableResult tableResult = table2.executeInsert("outputTable");
tableResult...

​ 基本的步骤都是这么几个:

  • 创建TableEnvironment
  • 将流数据转换成动态表 Table
  • 在动态表上计算一个连续查询,生成一个新的动态表
  • 生成的动态表再次转换回流数据

3.1 创建TableEnvironment

​ TableEnvironment是Table API 和SQL 的核心概念。未来的所有重要操作,例如窗口注册,自定义函数(UDF)注册等,都需要用到这个环境。

​ 对于流式数据,直接通过StreamExecutionEnvironment就可以创建。

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

​ 在构建Table运行环境时,还可以指定一个配置对象。

final EnvironmentSettings environmentSettings = EnvironmentSettings.newInstance()
                .useBlinkPlanner()
                .withBuiltInCatalogName("default_catalog")
                .withBuiltInDatabaseName("default_database").build();
        final StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, environmentSettings);

​ 示例中这个配置对象,设置了三个属性,都是取的默认值。

​ 首先关于Planner,Flink从1.11版本开始,就已经将默认的Planner改为了Blink。

​ 然后在配置中指定了Catalog和Database的名字。在Flink中,表对象的层次结构是Catalog -> Database -> Table。这就相当于是MySQL中的schema。示例中指定的两个值就是Flink提供的默认值,也可以自行进行指定。

3.2 将流数据转换成动态表 Table

​ Flink中的表Table与关系型数据库中的表Table是有区别的。Flink中的表是随时间不短变化的,流中的每条记录都被解释为对结果表的insert操作。而Flink的TableAPI是让应用程序可以像查询静态表一样查询这些动态表。但是基于动态表的查询,其结果也是动态的,这个查询永远不会停止。所以,也需要用一个动态表来接收动态的查询结果。

final URL resource = FileRead.class.getResource("/stock.txt");
        final String filePath = resource.getFile();
//        final DataStreamSource<String> stream = env.readTextFile(filePath);
        final DataStreamSource<String> dataStream = env.readFile(new TextInputFormat(new Path(filePath)), filePath);
        final SingleOutputStreamOperator<Stock> stockStream = dataStream
                .map((MapFunction<String, Stock>) value -> {
                    final String[] split = value.split(",");
                    return new Stock(split[0], Double.parseDouble(split[1]), split[2], Long.parseLong(split[3]));
                });
final Table stockTable = tableEnv.fromDataStream(stockStream);

​ 其实关键的就是最后这一行。将一个DataStream转换成了一个stockTable。接下来,就可以使用Table API来对stockTable进行类似关系型数据库的操作了。

  final Table table = stockTable.groupBy($("id"), $("stockName"))
                .select($("id"), $("stockName"), $("price").avg().as("priceavg"))
                .where($("stockName").isEqual("UDFStock"));

​ 整个操作过程跟操作一个关系型数据库非常类似。例如示例中的代码,应该一看就能明白。这里需要注意下,对于groupBy,select,where这些操作算子,老版本支持传入字符串,但是在1.12版本中已经标注为过时了。当前版本需要传入一个由$转换成的Expression对象。这个$不是一个特殊的符号,而是Flink中提供的一个静态API。

​ 另外,Flink提供了SQL方式来简化上面的查询过程。

tableEnv.createTemporaryView("stock",stockTable);
        String sql = "select id,stockName,avg(price) as priceavg from stock where stockName='UDFStock' group by id,stockName";
        final Table sqlTable = tableEnv.sqlQuery(sql);

​ 使用SQL需要先注册一个表,然后才能针对表进行SQL查询。注册时,createTemporaryView表示注册一个只与当前任务相关联的临时表。这些临时表在多个Flink会话和集群中都是可见的。

3.3 将Table重新转换为DataStream

​ 通过SQL查询到对应的数据后,通常有两种处理方式:

​ 一种是将查询结果转换回DataStream,进行后续的操作。

 //转换成流
        final DataStream<Tuple2<Boolean, Tuple3<String, String, Double>>> sqlTableDataStream = tableEnv.toRetractStream(sqlTable, TypeInformation.of(new TypeHint<Tuple3<String, String, Double>>() {
        }));
        sqlTableDataStream.print("sql");

​ 另一种是将查询结果插入到另一个表中,并通过另一张表对应Sink将结果输出到目标Sink中。

示例代码 com.roy.flink.table.FileTableDemo 演示了一个基本的对文件系统数据进行索引的Table和SQL操作。

4、扩展编程框架

​ 下面将针对上一章节的几个步骤,进行一部分扩展。

4.1 临时表与永久表

​ 在3.2章节注册动态表时,可以选择注册为临时表或者是永久表。临时表只能在当前任务中访问。任务相关的所有Flink的会话Session和集群Cluster都能够访问表中的数据。但是任务结束后,这个表就会删除。

​ 而永久表则是在Flink集群的整个运行过程中都存在的表。所有任务都可以像访问数据库一样访问这些永久表,直到这个表被显示的删除。

​ 表注册完成之后,可以将Table对象中的数据直接插入到表中。

//创建临时表
tableEnv.createTemporatyView("Order",orders)
//创建永久表
Table orders = tableEnv.from("Orders");
orders.executeInsert("OutOrders");
//老版本的insertInto方法已经过期,不建议使用。

示例代码 com.roy.flink.table.PermanentFileTableDemo 演示了一个基于文件的永久表。

​ Flink的永久表需要一个catalog来维护表的元数据。一旦永久表被创建,任何连接到这个catalog的Flink会话都可见并且持续存在。直到这个表被明确删除。也就是说,永久表是在Flink的会话之间共享的。

​ 而临时表则通常保存于内存中,并且只在创建他的Flink会话中存在。这些表对于其他会话是不可见的。他们也不需要与catalog绑定。临时表是不共享的。

​ 在Table对象中也能对表做一些结构化管理的工作,例如对表中的列进行增加、修改、删除、重命名等操作,但是通常都不建议这样做。原因还是因为Flink针对的是流式数据计算,他的表保存的应该只是计算过程中的临时数据,频繁的表结构变动只是增加计算过程的复杂性。

​ 最后,当一个会话里有两个重名的临时表和永久表时,将会只有临时表生效。如果临时表没有删除,那么永久表就无法访问。这个特性在做开发测试时是非常好用的。可以很容易的做Shadowing影子库测试。

4.2 AppendStream和RetractStream

​ 在3.3章节将Table转换成为DataStream时,我们用的是tableEnv.toRetractStream方法。另外还有一个方法是tableEnv.toAppendStream方法。这两个方法都是将Table转换成为DataStream。但是在我们这个示例com.roy.flink.table.FileTableDemo中如果使用toAppendStream方法,则会报错:

//代码
final DataStream<Tuple3<String, String, Double>> tuple3DataStream 
                = tableEnv.toAppendStream(sqlTable, TypeInformation.of(new TypeHint<Tuple3<String, String, Double>>() {}));
//异常
Exception in thread "main" org.apache.flink.table.api.TableException: toAppendStream doesn't support consuming update changes which is produced by node GroupAggregate(groupBy=[id, stockName], select=[id, stockName, AVG(price) AS priceavg])

​ 异常信息很明显,groupby语句不支持toAppendStream。这是为什么呢?要理解这个异常,就要从这两种结果流模式说起。

​ 我们现在的代码虽然看起来是在用SQL处理批量数据,但是本质上,数据依然是流式的,是一条一条不断进来的。这时,当处理增量数据时,将表的查询结果转换成DataStream时,就有两种不同的方式。

​ 一种是将新来的数据作为新数据,不断的追加到Flink的表中。这种方式就是toApppendStream。

​ 另一种方式是用新来的数据覆盖Flink表中原始的数据。这种方式就是toRestractStream。在他的返回类型中可以看到,他会将boolean与原始结果类型拼装成一个Tuple2组合。前面的这个boolean结果就表示这条数据是覆盖还是插入。true表示插入,false表示覆盖。

​ 很显然,经过groupby这种统计方式后,我们需要的处理结果是分组计算后的一个统计值。这个统计值只能覆盖,不能追加,所以才会有上面的错误。

4.3 内置函数与自定义函数

​ 在SQL操作时,我们经常会调用一些函数,像count()、max()等等。 Flink也提供了非常丰富的内置函数。这些函数即可以在Table API中调用,也可以在SQL中直接调用。调用的方式跟平常在关系型数据库中调用方式差不多。

​ 具体内置函数就不再一一梳理了,可以参见官方文档 https://ci.apache.org/projects/flink/flink-docs-release-1.12/zh/dev/table/functions/systemFunctions.html

​ 我们这里重点介绍下自定义函数,UDF。这些自定义函数显著扩展了查询的表达能力。使用自定义函数时需要注意以下两点:

**1、大多数情况下,用户自定义的函数需要先注册,然后才能在查询中使用。**注册的方法有两种

//注册一个临时函数 
tableEnv.createTemporaryFunction(String path, Class<? extends UserDefinedFunction> functionClass);
//注册一个临时的系统函数
 tableEnv.createTemporarySystemFunction(String name, Class<? extends UserDefinedFunction> functionClass);

​ 这两者的区别在于,用户函数只在当前Catalog和Database中生效。而系统函数能由独立于Catalog和Database的全局名称进行标识。所以使用系统函数可以继承Flink的一些内置函数,比如trim,max等

老版本使用的TableEnvironment的registerFunction()方法已经过期。

**2、自定义函数需要按照函数类型继承一个Flink中指定的函数基类。**Flink中有有以下几种函数基类:

  • 标量函数 org.apache.flink.table.functions.ScalarFunction。

    标量函数可以将0个或者多个标量值,映射成一个新的标量值。例如常见的获取当前时间、字符串转大写、加减法、多个字符串拼接,都是属于标量函数。例如下面定义一个hash方法

    public static class HashCode extends ScalarFunction {
    	private int factor = 13;
    	public HashCode(int factor) {
    		this.factor = factor;
    	}
    	public int eval(String s) {
    		return s.hashCode() * factor;
    	}
    }
    
    

示例代码 com.roy.flink.table.ScalarUDFDemo

  • 表函数 org.apache.flink.table.functions.TableFunction

    表函数同样以0个或者多个标量作为输入,但是他可以返回任意数量的行作为输出,而不是单个值。例如下面这个简单的字符串拆分函数

    public class Split extends TableFunction<String> {
       private String separator = ",";
       public Split(String separator) {
          this.separator = separator;
       }
       public void eval(String str) {
         for (String s : str.split(" ")) {
           collect(s);   // use collect(...) to emit an output row
         }
       }
     }
    
    

示例代码 com.roy.flink.table.TableUDFDemo

  • 聚合函数 org.apache.flink.table.functions.AggregateFunction

    聚合函数可以把一个表中一列的数据,聚合成一个标量值。例如常用的max、min、count这些都是聚合函数。定义聚合函数时,首先需要定义个累加器Accumulator,用来保存聚合中间结果的数据结构,可以通过createAccumulator()方法构建空累加器。然后通过accumulate()方法来对每一个输入行进行累加值更新。最后调用getValue()方法来计算并返回最终结果。例如下面是一个计算字符串出现次数的count方法。

    public static class CountFunction extends AggregateFunction<String, CountFunction.MyAccumulator> {
       public static class MyAccumulator {
         public long count = 0L;
       }
    
       public MyAccumulator createAccumulator() {
         return new MyAccumulator();
       }
        
       public void accumulate(MyAccumulator accumulator, Integer i) {
         if (i != null) {
           accumulator.count += i;
         }
       }
    
       public String getValue(MyAccumulator accumulator) {
         return "Result: " + accumulator.count;
       }
     }
    
    

    ​ 常用的自定义函数这些,Flink中也还提供了其他一些函数基类,有兴趣可以再深入了解。另外,这些函数基类都是实现了UserDefinedFunction这个接口,也就是说,应用程序完全可以基于UserDefinedFunction接口进行更深入的函数定制。这里就不再多做介绍了。

​ 另外也可以通过aggregate()函数进行一些聚合操作,例如sum 、max等等。这样将获得一个AggregatedTable。例如

tab.aggregate(call(MyAggregateFunction.class, $("a"), $("b")).as("f0", "f1", "f2"))
   .select($("f0"), $("f1"));

示例代码 com.roy.flink.table.TableUDFDemo

这一块的特性和API都还处在活跃开发阶段,也就是不稳定阶段。所以,相比学习这些代码示例,更重要的是要学会Flink的Table 和 SQL的基础处理思想,并且要学会如何查看源码猜测使用方式。

官方文档通常是最靠谱的资料。具体操作可以参见官方文档:https://ci.apache.org/projects/flink/flink-docs-release-1.12/zh/dev/table/tableApi.html

4.4 基于Connector进行数据流转

​ 由于Flink中的流数据,大部分情况下,都是映射的一个外部的数据源,所以,通常创建表时,也需要通过connector映射外部的数据源。关于Connector,之前已经介绍过。基于Connector来注册表的通用方式是这样:

tableEnv
.connect(...) // 定义表的数据来源,和外部系统建立连接
.withFormat(...) // 定义数据格式化方法
.withSchema(...) // 定义表结构
.createTemporaryTable("MyTable"); // 创建临时表

​ 例如,针对文本数据

tableEnv
.connect(
new FileSystem().path(YOUR_Path/sensor.txt”)
) // 定义到文件系统的连接
.withFormat(new Csv()) // 定义以csv格式进行数据格式化
.withSchema( new Schema()
.field("id", DataTypes.STRING())
.field("timestamp", DataTypes.BIGINT())
.field("temperature", DataTypes.DOUBLE())
) // 定义表结构
.createTemporaryTable("sensorTable"); // 创建临时表

针对kafa数据

tableEnv.connect(
    new Kafka()
    .version("0.11")
    .topic("sinkTest")
    .property("zookeeper.connect", "localhost:2181")
    .property("bootstrap.servers", "localhost:9092")
    )
.withFormat( new Csv() )
.withSchema( new Schema()
    .field("id", DataTypes.STRING())
    .field("temp", DataTypes.DOUBLE())
    )
.createTemporaryTable("kafkaOutputTable");

针对ES数据: 需要引入相应的connector依赖

tableEnv.connect(
    new Elasticsearch()
    .version("6")
    .host("localhost", 9200, "http")
    .index("stock")
    .documentType("temp")
    )
.inUpsertMode()
.withFormat(new Json())
.withSchema( new Schema()
    .field("id", DataTypes.STRING())
    .field("count", DataTypes.BIGINT())
    )
.createTemporaryTable("esOutputTable");
aggResultTable.insertInto("esOutputTable");

或者针对MySQL,可以直接用SQL语句来管理

String sinkDDL=
"create table jdbcOutputTable (" +
" id varchar(20) not null, " +
" cnt bigint not null " +
") with (" +
" 'connector.type' = 'jdbc', " +
" 'connector.url' = 'jdbc:mysql://localhost:3306/test', " +
" 'connector.table' = 'sensor_count', " +
" 'connector.driver' = 'com.mysql.jdbc.Driver', " +
" 'connector.username' = 'root', " +
" 'connector.password' = '123456' )";
tableEnv.executeSql(sinkDDL) // 执行 DDL创建表
//操作表。
aggResultSqlTable.executeInsert("jdbcOutputTable");

​ 另外,也可以直接将DataStream转换成表

DataStream<Tuple2<String, Long>> stream = ...
//直接创建一个与stream结构相同的表。
Table table = fsTableEnv.from("stream");
//或者进行一些列名转换
 Table table = tableEnv.fromDataStream(
    stream,
    $("f1"), // 使用原有的列名 (f1是tuple中的第二列)
    $("rowtime").rowtime(), // 增加一个rowtime列,列的值是当前事件的EventTime
    $("f0").as("name") // 转换一个列名 (f0是tuple中的第一列)
 );

2、将结果输出到另一张动态表的操作也在上面的文档中有介绍。可以直接使用insertinto方法。例如

Table orders = tableEnv.from("Orders");
orders.executeInsert("OutOrders");
//老版本的insertInto方法已经过期,不建议使用。

4.5 Flink Table API&SQL的时间语义

​ Flink对于时间语义的定义和处理是非常惊艳的,整个时间语义机制对于乱序数据流的处理非常有力。但是在Table API和SQL这一部分,时间语义似乎没有什么太大的作用。通常并不会对一个表进行开窗处理。所以在Flink的Table API&SQL这一部分,对于时间语义的处理思想就比较简单。就是将时间语义作为Table中的一个字段引入进来,由应用程序去决定要怎么使用时间。关于这一部分的时间语义,就不再去做过多深入的分析,只关注最常用的情况,使用EventTime事件时间机制,将Watermark添加到Table中。

​ 在DataStream转换成为Table时,可以用.rowtime后缀在定义Schema时定义。这种方式一定需要在DataStream上已经定义好时间戳和watermark。使用.rowtime修饰的,可以是一个已有的字段,也可以是一个不存在的字段。如果不存在,会在schema的结尾追加一个新的字段。然后就可以像使用一个普通的Timestamp类型的字段一样使用这个字段。不管在哪种情况下,事件时间字段的值都是DataStream中定义的事件时间。

// Option 1:
// 基于 stream 中的事件产生时间戳和 watermark
DataStream<Tuple2<String, String>> stream = inputStream.assignTimestampsAndWatermarks(...);

// 声明一个额外的逻辑字段作为事件时间属性
Table table = tEnv.fromDataStream(stream, $("user_name"), $("data"), $("user_action_time").rowtime());


// Option 2:
// 从第一个字段获取事件时间,并且产生 watermark
DataStream<Tuple3<Long, String, String>> stream = inputStream.assignTimestampsAndWatermarks(...);

// 第一个字段已经用作事件时间抽取了,不用再用一个新字段来表示事件时间了
Table table = tEnv.fromDataStream(stream, $("user_action_time").rowtime(), $("user_name"), $("data"));


示例代码:com.roy.flink.table.TableWatermarkDemo2。大部分情况下,就用这一种方式来处理EventTime就可以了。

关于Flink Table API&SQL部分的时间语义详细说明,可以参见官方文档:https://ci.apache.org/projects/flink/flink-docs-release-1.12/zh/dev/table/streaming/time_attributes.html

在官网示例中,对于EventTime时间事件,提供了三种不同的声明方式。 当前演示的是第二种,在DataStream转换Table时定义。另外两种方式编码测试不通过,就不多做介绍了。

4.6 查看SQL执行计划

​ 最后补充一个查看SQL执行计划的API

 final String explaination = tableEnv.explainSql(sql);
        System.out.println(explaination);

​ 在explainSql方法中,还可以传入一组可选的ExplainDetail参数,以展示更多的执行计划的细节。这是一个枚举值

/** ExplainDetail defines the types of details for explain result. */
@PublicEvolving
public enum ExplainDetail {
    /**
     * The cost information on physical rel node estimated by optimizer. e.g. TableSourceScan(...,
     * cumulative cost = {1.0E8 rows, 1.0E8 cpu, 2.4E9 io, 0.0 network, 0.0 memory}
     */
    ESTIMATED_COST,

    /**
     * The changelog mode produced by a physical rel node. e.g. GroupAggregate(...,
     * changelogMode=[I,UA,D])
     */
    CHANGELOG_MODE

示例代码: com.roy.flink.table.SqlExplainDemo

章节总结

​ Flink的Table API&SQL这一部分是提供了一组高级的抽象API,最常用的场景还是用在简化对流式数据的检索过程。但是,在示例用的1.12版本以及最新的1.13版本中,这一组抽象API还都处在活跃开发期,很多高级特性以及API都会经常发生变动。很多在1.11版本还非常实用的API,到当前1.12版本就被移除或者标记为过时,不建议使用。所以在学习这一章节时,即要理解这一组API的实现思路,也要学会如何查看API。至少要学会如何去尝试客户端API的使用方式。而他的功能,都可以用DataStream/DataSet API来实现,并且在大部分的场景下,这种功能转换并不会太难。因此,在生产环境中,还不建议进行深度的使用。

七、Flink状态机制

​ 在学习Flink的状态机制之前,我们需要理解什么是状态。回顾我们之前介绍的很多流计算的计算过程,有些计算方法,比如说我们之前多次使用的将stock.txt中的一行文本数据转换成Stock股票对象的map操作。来一个数据,就计算一个数据,这些操作,只需要依赖于当前输入的数据就够了,不需要其他的辅助数据。输入相同的文本数据,输出的肯定是一个相同的Stock股票对象。

​ 而另外一些操作,比如在WindowFunctionDemo中介绍的计算平均值的方法。同样是来一个数据处理一个数据,但是,他每次计算出来的结果,除了依赖于当前输入的数据,还需要依赖于accumulate累加器中的数据。输入相同的股票数据,由于累加器中的数据不同,输出的股票平均价格也就不同。

​ 像累加器这种由一个任务来维护,并且要参与到数据计算过程中的数据,就称为状态。这一类计算任务,也称为有状态的任务。比如reduce、sum、min、minby等等操作,都是典型的有状态的算子。而与之对应的,只依赖于输入数据的计算任务,就称为无状态的任务。多个任务叠加在一起,就组成了一个客户端应用。

​ 对于状态,也可以认为就是一个本地变量,他可以被一个客户端应用中的所有计算任务都访问到。对于状态的管理通常是比较复杂的,尤其在分布式流式计算场景下。任务是并行计算的,所以状态也需要分开保存。集群故障恢复后又需要合并读取。在算子并行度发生变化时又要维护状态的一致性。再考虑到状态数据要尽量高效的存储与访问,等等。Flink的状态机制提供了对这类状态数据的统一管理。开发人员可以专注于开发业务逻辑,而不用时刻考虑状态的各种复杂管理机制。

​ 对于状态,有两种管理机制,一种是managed state,就是Flink管理的状态机制,对之前提到的一些状态管理的问题提供了统一的管理机制。另一种是raw state,就是用户自己管理的状态机制。只需要Flink提供一个本地变量空间,由应用程序自己去管理这一部分状态。Flink的状态管理机制非常强大,所以在大部分的开发场景下,我们使用Flink提供的状态管理机制就足够了。

​ Flink中管理的状态都是跟特定计算任务关联在一起的。他的状态主要有两种,一种是operator state 算子状态,一种是keyed State 键控状态。

1、Operator State 算子状态

​ 算子状态的作用范围限定为当前计算任务内,这种状态是跟一个特定的计算任务绑定的。算子状态的作用范围只限定在算子任务内,由同一并行任务所处理的所有数据都可以访问到相同的状态。并且这个算子状态不能由其他子任务访问。比如WindowFunctionDemo中计算股票平均价格的MyAvg计算任务里的累加器,就只能在当前计算任务中访问。即使在多个不同的应用程序中都可以使用MyAvg这个计算任务,但是每个应用程序中访问到的累加器都是不同的。

​ 这一类算子需要按任务分开保存,而当任务的并行度发生变化时,还需要支持在并行运算实例之间,重新分配状态。

​ 例如下面我们定义一个带状态的求和算子,在这个示例中就给一个简单的求和算子保存了一个状态。

示例代码: com.roy.flink.state.SumOperatorState

public class SumOperatorState {
    public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        final DataStreamSource<Integer> stream = env.fromElements(1, 2, 3, 4, 5);
        final SingleOutputStreamOperator<Integer> stream2 = stream.map(new MySumMapper("mysummapper"));
        stream2.print();
        final DataStream<Integer> union = stream.union(stream2);
        env.execute("stream");
    }
	//带状态的求和算子
    public static class MySumMapper implements MapFunction<Integer,Integer>, CheckpointedFunction {
        private int sum;
        private String stateKey;
        //状态
        private ListState<Integer> checkpointedState;

        public MySumMapper(String stateKey){
            this.stateKey = stateKey;
        }
		//计算方法
        @Override
        public Integer map(Integer value) throws Exception {
            return sum += value;
        }
		//算子保存状态的方法
        @Override
        public void snapshotState(FunctionSnapshotContext context) throws Exception {
            checkpointedState.clear();
            checkpointedState.add(sum);
        }
		//算子启动时会加载状态
        @Override
        public void initializeState(FunctionInitializationContext context) throws Exception {
            ListStateDescriptor<Integer> descriptor = new ListStateDescriptor<Integer>(
                    stateKey,
                    TypeInformation.of(new TypeHint<Integer>() {
            }));
            checkpointedState = context.getOperatorStateStore().getListState(descriptor);
            if(context.isRestored()){
                for (Integer subSum : checkpointedState.get()) {
                    sum += subSum;
                }
            }
        }
    }
}

​ 可以看到,Flink中的算子状态操作还是比较简单的,可以给算子继承一个CheckpointedFunction接口,这个接口有两个方法,snapshotState方法会在算子执行过程中调用,进行状态保存。initializeState方法是在任务启动时加载初始化的状态。这样,算子在执行过程中,就可以将中间结果保存到checkpointedState状态中。当算子异常终止时,下一次启动又可以从这个checkpointedState状态中加载之前的计算结果。用户程序只需要定义逻辑,而不需要管触发的时机。

之间介绍过的kafka connector中,他的FlinkKafkaConsumer消费者就是关于CheckedpointFunction接口的一个典型的实现。FlinkKafkaProducer生产者稍微复杂一点。

关于不同的状态类型
在获取状态的地方: context.getOperatorStateStore()这个方法有几个重载的方法:getListState,getUnionListState,getBroadcastState。

​ 其中,getListState和getUnionListState,这两个方法都是处理ListState,也就是不同的任务节点,他的状态也不相同。只是这两种状态的底层状态分配机制不同。ListState是将不同的子状态分配好了之后,分给不同的算子实例去处理。而UnionListState则是将所有的子状态都分配给所有的算子实例,由算子实例自行调节每个实例获取哪些状态。FlinkKafkaConsumer就是使用的UnionListState。

​ 最后一个getBoradcastState,是处理广播状态,也就是所有任务节点的状态都是益阳的。

​ 其他的算子,包括function,source,sink都可以自行添加状态管理。这其中需要理解的就是checkpointedState的形式,为什么是一个集合状态ListState?

在这里插入图片描述

​ 这是因为Flink的计算任务都是并行执行的,那么在计算过程中,每一个并行的实例都会有一个自己的状态,所以在snapshotState保存状态时,是将每个并行实例内的状态进行保存,那整个任务整体就会保存成一个集合。所以,示例中保存的其实是每个子任务内计算到的sum和。

​ 当任务重新启动时,Flink可能还需要对子任务的状态进行重新分配,因为任务的并行度有可能进行了调整。所以示例中initializeState方法加载状态时,也是将各个子状态的sum加到一起,才是一个完整的求和计算。

2、keyed State 键控状态

​ 算子状态针对的是普通算子,在任何DataStream和DataSet中都可以使用。但是,如果针对KeyedStream,情况又有所不同。相比算子状态,keyedState键控状态是针对keyby产生的KeyedStream。KeyedStream的计算任务都跟当前分配的key直接关联。相对应的KeyedState状态也就跟key有关。而key是在计算任务运行时分配的。这一类状态,无法在任务启动过程中完成状态的分配。需要在任务执行过程中,根据key的分配不同而进行不同的分配。Flink针对keyedStream,会在内部根据每个key维护一个键控状态。在具体运算过程中,根据key的分配情况,将状态分配给不同的计算任务。

​ 针对键控状态, Flink提供了一系列Rich开头的富计算因子抽象类,这些抽象类提供了更丰富的计算任务生命周期管理。用户程序通过继承这些抽象类,就可以获取到与当前分配的key相关的状态。

​ 我们先来看一个关于KeyedStream的状态示例。下面实现了一个自定义的求word count的算子。

示例代码 : com.roy.flink.state.WCKeyedState

public class WCKeyedState {
    public static void main(String[] args) throws Exception {
        //创建执行环境
        final StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();

        final DataStreamSource<Tuple2<String, Integer>> stream = environment.fromCollection(Arrays.asList(Tuple2.of("a", 1), Tuple2.of("a", 5), Tuple2.of("a", 7), Tuple2.of("a", 2)
                , Tuple2.of("b", 2), Tuple2.of("b", 6), Tuple2.of("b", 3), Tuple2.of("b", 8)
                , Tuple2.of("c", 4), Tuple2.of("c", 8), Tuple2.of("c", 4), Tuple2.of("c", 6)));
        //按照字符分组
        final KeyedStream<Tuple2<String, Integer>, String> keyedStream = stream.keyBy((key) -> key.f0);
        //按照word进行分组求和,计算每个单词出现的总次数
        keyedStream.flatMap(new WCFlatMapFunction("WCKeyedState")).print();

        environment.execute("WCKeyedState");
    }

    public static class WCFlatMapFunction extends RichFlatMapFunction<Tuple2<String,Integer>, Tuple2<String,Integer>>{

        private String stateDesc;
        ValueState<Tuple2<String, Integer>> valueState;

        public WCFlatMapFunction(String stateDesc) {
            this.stateDesc = stateDesc;
        }

        @Override
        public void flatMap(Tuple2<String, Integer> input, Collector<Tuple2<String, Integer>> out) throws Exception {
            Tuple2<String, Integer> wordCountList = valueState.value();
            if(null == wordCountList){
                wordCountList = input;
            }else{
                wordCountList.f1+= input.f1;
            }
            valueState.update(wordCountList);
            out.collect(wordCountList);
        }

        @Override
        public void open(Configuration parameters) {
            ValueStateDescriptor<Tuple2<String,Integer>> descriptor =
                new ValueStateDescriptor<>(stateDesc,
                        TypeInformation.of(new TypeHint<Tuple2<String, Integer>>() {}));
            valueState = this.getRuntimeContext().getState(descriptor);//另外几种state类型
//            this.getRuntimeContext().getMapState(MapStateDescriptor);
//            this.getRuntimeContext().getListState(ListStateDescriptor);
//            this.getRuntimeContext().getAggregatingState(ReducingStateDescriptor);
//            this.getRuntimeContext().getReducingState(ReducingStateDescriptor);
        }
    }
}

​ 在这个示例中看到,其实键控状态与算子状态在应用代码层面最大的区别在于获取状态的方法。算子状态可以通过FunctionInitializationContext直接拿到状态,而键控状态需要实现Rich***Function接口,在open方法中通过getRuntimeContext()获取。

​ 而更深层次的区别在于运行机制上。其实你可以把这个状态近似的理解为一个(key,value)结构的本地缓存。算子状态的缓存是一个固定的key,只是这个key跟当前计算任务有关,只有当前这一个算子能够读到,不管在哪个taskmanager上计算,如果能读到这个缓存的话,那读到的缓存就是一个固定的。而键控状态的缓存是一组(key,value)的缓存,这一组缓存的key就是KeyedStream中的key分区键。而键控状态获取到的状态值都是取决于当前输入元素所代表的key分区键的,因此,每次任务时taskmanager上分配的key不同,那就可能读取到不同的值。

​ 另外,根据状态类型不同, Flink也提供了几种不同的状态:

  • ValueState: 保存一个可以更新和检索的值。 这个值可以通过 update(T) 进行更新,通过 T value() 进行检索。
  • ListState: 保存一个元素的列表。可以往这个列表中追加数据,并在当前的列表上进行检索。可以通过 add(T) 或者 addAll(List) 进行添加元素,通过 Iterable get() 获得整个列表。还可以通过 update(List) 覆盖当前的列表。
  • ReducingState: 保存一个单值,表示添加到状态的所有值的聚合。接口与 ListState 类似,但使用 add(T) 增加元素,会使用提供的 ReduceFunction 进行聚合。
  • AggregatingState: 保留一个单值,表示添加到状态的所有值的聚合。和 ReducingState 相反的是, 聚合类型可能与 添加到状态的元素的类型不同。 接口与 ListState 类似,但使用 add(IN) 添加的元素会用指定的 AggregateFunction 进行聚合。
  • MapState: 维护了一个映射列表。 你可以添加键值对到状态中,也可以获得反映当前所有映射的迭代器。使用 put(UK,UV) 或者 putAll(Map) 添加映射。 使用 get(UK) 检索特定 key。 使用 entries()keys()values() 分别检索映射、键和值的可迭代视图。你还可以通过 isEmpty() 来判断是否包含任何键值对。

这些不同的状态都是跟Key相关的。使用时,都需要通过构建一个对应的StateDescriptor,然后通过getRuntimeContext获取。

3、Checkpointing 检查点

​ Flink中的每个算子都可以是有状态的,这些状态化的方法和算子可以使Flink的计算过程更为精确,在实际开发中,应该尽量使用带状态的算子。而对于这些状态,除了可以通过算子状态和键控状态进行扩展外,Flink也提供了另外一种自动的兜底机制,CheckPointing检查点。

​ Checkpointing检查点是一种由Flink自动执行的一种状态备份机制,其目的是能够从故障中恢复。快照中包含了每个数据源Source的指针(例如,到文件或者kafka分区的偏移量)以及每个有状态算子的状态副本。

​ 默认情况下,检查点机制是禁用的,需要在应用中通过 StreamExecutionEnvironment 进行配置。基础的配置方式是通过StreamExecutionEnvironment的enableCheckpointing方法开启,开启时需要传入一个参数,表示多长时间执行一次快照。另外有一些高级的选项,可以参见下面的示例。

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 每 1000ms 开始一次 checkpoint
env.enableCheckpointing(1000);

// 高级选项:
// 设置模式为精确一次 (这是默认值)
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 确认 checkpoints 之间的时间会进行 500 ms
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);
// Checkpoint 必须在一分钟内完成,否则就会被抛弃
env.getCheckpointConfig().setCheckpointTimeout(60000);
// 同一时间只允许一个 checkpoint 进行
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
// 开启在 job 中止后仍然保留的 externalized checkpoints
env.getCheckpointConfig().enableExternalizedCheckpoints(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);

4、Flink的容错重启机制

​ 当某一个task发生故障时,Flink需要重启出错的task以及其他收到影响的Task,以使得作业恢复到正常执行的状态。Flink通过重启策略和故障恢复策略来控制Task重启:重启策略决定是否可以重启以及重启的间隔;故障恢复策略决定哪些Task需要重启。

​ 重启策略可以通过配置文件flink-conf.yaml中通过restart-strategy属性进行配置,同样,也可以在应用程序中覆盖配置文件中的配置。如果没有启用checkpoint,那就采用"不重启"的策略。如果启用了checkpoint并且没有配置重启策略,那么就采用固定延时重启策略,这种情况下最大尝试重启次数是Integer.MAX_VALUE,基本就可以认为是会不停的尝试重启。

​ restart-strategy属性可选的配置有以下几种:

  • none 或 off 或 disable: 不重启。checkpointing关闭后的默认值
  • fixeddelay, fixed-delay: 固定延迟重启策略。checkpointing启用时的默认值
  • failurerate, failure-rate: 失败率重启策略

​ 这些配置项同样可以在应用程序中定制。例如

ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
env.setRestartStrategy(RestartStrategies.fixedDelayRestart(
  3, // 尝试重启的次数
  Time.of(10, TimeUnit.SECONDS) // 延时
));

fixeddelay策略,还可以定制两个参数,restart-strategy.fixed-delay.attempts 重试次数以及 restart-strategy.fixed-delay.delay延迟时间。第一个参数表示重启任务的尝试次数,第二个参数表示重启失败后,再次尝试重启的间隔时间。可以配置为 “1 min”,"20 s"这样。 例如在配置文件中

restart-strategy.fixed-delay.attempts: 3
restart-strategy.fixed-delay.delay: 10 s

​ 或者在应用程序中

ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
env.setRestartStrategy(RestartStrategies.fixedDelayRestart(
  3, // 尝试重启的次数
  Time.of(10, TimeUnit.SECONDS) // 延时
));

Failure Rate 策略表示当故障率(每个时间假根发生故障的次数)超过设定的限制时,作业就会最终失败。 在连续的两次重启尝试之间,重启策略会等待一段固定长度的时间。

​ 这种策略下,可以定义三个详细的参数。

  • restart-strategy.failure-rate.max-failures-per-interval: 任务失败之前,在固定时间间隔内的最大重启尝试次数。
  • restart-strategy.failure-rate.failure-rate-interval: 检测失败率的窗口间隔。
  • restart-strategy.failure-rate.delay 两次重启尝试之间的间隔时间

​ 例如在配置文件中

restart-strategy: failure-rate
restart-strategy.failure-rate.max-failures-per-interval: 3
restart-strategy.failure-rate.failure-rate-interval: 5 min
restart-strategy.failure-rate.delay: 10 s

​ 或者在应用中

ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
env.setRestartStrategy(RestartStrategies.failureRateRestart(
  3, // 每个时间间隔的最大故障次数
  Time.of(5, TimeUnit.MINUTES), // 测量故障率的时间间隔
  Time.of(10, TimeUnit.SECONDS) // 延时
));

5、State Backend 状态存储方式与位置

​ 通过算子状态,键控状态以及检查点,我们可以对计算过程中的中间状态进行保存。这些保存下来的状态即可以在计算中使用,也可以在计算程序异常终止后恢复计算状态时使用。但是,到目前为止,我们都是直接拿来用,而并没有去关系这些状态数据是以何种方式保存并且是保存在什么地方的。

​ 针对这些状态,Flink提供了多种State Backend 状态后端,用来管理状态数据具体的存储方式与位置。Flink默认提供了三种状态后端: jobmanager,filesystem,rocksdb。设置的方式可以在file-conf.yaml中,通过state.backend属性进行配置。也可以在程序中通过StreamExecutionEnvironment配置。例如:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStateBackend(...);

​ 另外,在flink-conf.yaml中,关于state.backend还有一些扩展的属性,这些属性同样即可以在配置文件中配置,也可以在程序中设置。应用程序中的配置优先级更高。

那这三种状态后端要如何取舍呢?

  • jobmanager:

    jobmanager在后台由一个MemoryStateBackend类来实现,从名字看出,是基于内存实现的。状态信息会保存到TaskManager的JVM堆内存中,而检查点信息则会直接保存到JobManager的内存中。这些检查点信息虽然都是基于内存工作,但是也依然会持久化到文件系统当中。

    由于检查点保存在Jobmanager中,会加大taskmanager和jobmanager之间的网络请求,并且也会加大jobmanager的负担,所以这种方式通常只用于实验场景或者小状态的本地计算场景。

  • filesystem:

    filesystem在后台由一个FsStateBackend类来实现。他依然是基于内存和文件系统进行状态保存。但是检查点信息是由taskmanager进行保存的。保存的文件地址是可以自行配置的。由于taskmanager上执行的任务是动态分配的,所以通常这个保存地址需要配置成所有taskmanager都能访问到的地方,例如hdfs。而taskmanager上由于会有多个并行任务,所以他们的文件存储地址也会用数字进行版本区分,例如hdfs://namenode:port/flink-checkpoints/chk-17/.

    filesystem的状态访问很快速,适合那些需要大的堆内存的场景。但是fliesystem是受限于内存和GC的,所以他支持的状态数据大小优先。

  • rocksdb:

    rocksdb在后台是由一个 RocksDBStateBackend 类来实现的。RocksDB是一个访问快速的key-value本地缓存,你可以把他理解为一个本地的Redis。但是他能够基于文件系统提供非常高效的访问。所以是一个非常常用的流式计算持久化工具。使用RocketDB后,状态数据就不再受限于内存,转而受限于硬盘。RocketDBStateBackend适合支持非常大的状态信息存储。但是RocksDB毕竟是基于文件系统的,所以他的执行速度会比filesystem稍慢,官方提供的经验是大概比filesystem慢10倍,但是这个速度在大多数场景下,也依然够用了。

注:如果在应用中使用rocksdb,需要引入一个依赖

<dependency>
         <groupId>org.apache.flink</groupId>
         <artifactId>flink-statebackend-rocksdb_2.12</artifactId>
         <version>${flink.version}</version>
     </dependency>

然后使用StreamExecuteEnvironment设置

env.setStateBackend(new RocksDBStateBackend("key"));

章节总结

​ 在流式计算的场景下,应用程序通常是无法预知数据何时到来的,只能一直运行随时等待数据接入。这时一旦应用程序突然出错终止,就很容易造成数据丢失。所以在流式计算场景下,我们需要对程序的健壮性做更多的考量。Flink提供了一系列的状态机制来加强程序的健壮性。但是在重要的生产环境中,我们对程序健壮性做再多的考量都是不过分的,因此通常还需要加上一些基于运维的监控机制,例如监控flink的进程,监控yarn中的任务状态等,来了进一步保证流式计算程序的安全。

​ 课程到目前为止,关于Flink的一些常用特性就已经介绍完了,Flink作为一个框架,他为流式计算场景做了非常多的设计,可以极大的简化流式计算应用开发。

​ 但是作为一个计算框架,用好Flink所需要学习的知识还远不止如此。我们这个课程也还只是一个开始。如何布局一个更高效的流式计算流程,以及如何进行更高效的数据计算,才是流式计算真正的重点。这些都需要不断的积累。课程过程中一直在强调官网资料的重要性,也是希望你能够学会自我学习,因为Flink的功能还处在快速开发阶段,只有学会自己学习,才能应对以后的版本变更。例如Flink还提供了针对图计算的Graph组件Gelly。另外,在他的官方示例中,还有线性回归、Kmeans等机器学习的一些计算示例,这又是很庞大的一个计算体系。这些内容就不再多做介绍了,有兴趣的同学可以自行关注总结。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

roykingw

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值