Flink中Table API和SQL 完整使用中 (第十一章)

Flink中Table API和SQL 完整使用下

  • 一、联结(Join)查询
    • 1、常规联结查询
      • 1. 等值内联结(INNER Equi-JOIN)
      • 2. 等值外联结(OUTER Equi-JOIN)
    • 2、间隔联结查询
      • ● 两表的联结
      • ● 联结条件
      • ● 时间间隔限制
  • 二、函数
    • 1、系统函数
      • 1. 标量函数(Scalar Functions)
          • ● 比较函数(Comparison Functions)
          • ● 逻辑函数(Logical Functions)
          • ● 算术函数(Arithmetic Functions)
          • ● 字符串函数(String Functions)
          • ● 时间函数(Temporal Functions)
      • 2. 聚合函数(Aggregate Functions)
    • 2、自定义函数(UDF)
      • 1. 整体调用流程
        • (1)注册函数
        • (2)使用Table API调用函数
        • (3)在SQL中调用函数
      • 2. 标量函数(Scalar Functions)
      • 3. 表函数(Table Functions)
      • 4. 聚合函数(Aggregate Functions)
        • ● createAccumulator()
        • ● accumulate()
        • ● getValue()
      • 5. 表聚合函数(Table Aggregate Functions)
  • 三、SQL客户端
    • (1)首先启动本地集群
    • (2)启动Flink SQL客户端
    • (3)设置运行模式
    • (4)执行SQL查询
  • 四、连接到外部系统
    • 1、Kafka
      • 1. 引入依赖
      • 2. 创建连接到Kafka的表
      • 3. Upsert Kafka
    • 2、文件系统
    • 3、JDBC
      • 1. 引入依赖
      • 2. 创建JDBC表
      • 3、Elasticsearch
        • 1. 引入依赖
        • 2. 创建连接到Elasticsearch的表
      • 4、HBase
        • 1. 引入依赖
        • 2. 创建连接到HBase的表
      • 5、Hive
        • 1. 引入依赖
        • 2. 连接到Hive
        • 3. 设置SQL方言
            • (1)SQL中设置
          • (2)Table API中设置
        • 4. 读写Hive表
  • 五、本章总结

一、联结(Join)查询

按照数据库理论,关系型表的设计往往至少需要满足第三范式(3NF),表中的列都直接依赖于主键,这样就可以避免数据冗余和更新异常。例如商品的订单信息,我们会保存在一个“订单表”中,而这个表中只有商品ID,详情则需要到“商品表”按照ID去查询;这样的好处是当商品信息发生变化时,只要更新商品表即可,而不需要在订单表中对所有这个商品的所有订单进行修改。不过这样一来,我们就无法从一个单独的表中提取所有想要的数据了。

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

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

1、常规联结查询

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

在两个动态表的联结中,任何一侧表的插入(INSERT)或更改(UPDATE)操作都会让联结的结果表发生改变。例如,如果左侧有新数据到来,那么它会与右侧表中所有之前的数据进行联结合并,右侧表之后到来的新数据也会与这条数据连接合并。所以,常规联结查询一般是更新(Update)查询。

与标准SQL一致,Flink SQL的常规联结也可以分为内联结(INNER JOIN)和外联结(OUTER JOIN),区别在于结果中是否包含不符合联结条件的行。目前仅支持“等值条件”作为联结条件,也就是关键字ON后面必须是判断两表中字段相等的逻辑表达式。

1. 等值内联结(INNER Equi-JOIN)

内联结用INNER JOIN来定义,会返回两表中符合联接条件的所有行的组合,也就是所谓的笛卡尔积(Cartesian product)。目前仅支持等值联结条件。

例如之前提到的“订单表”(定义为Order)和“商品表”(定义为Product)的联结查询,就可以用以下SQL实现:

SELECT *
FROM Order
INNER JOIN Product
ON Order.product_id = Product.id

这里是一个内联结,联结条件是订单数据的product_id和商品数据的id相等。由于订单表中出现的商品id一定会在商品表中出现,因此这样得到的联结结果表,就包含了订单表Order中所有订单数据对应的详细信息。

2. 等值外联结(OUTER Equi-JOIN)

与内联结类似,外联结也会返回符合联结条件的所有行的笛卡尔积;另外,还可以将某一侧表中找不到任何匹配的行也单独返回。Flink SQL支持左外(LEFT JOIN)、右外(RIGHT JOIN)和全外(FULL OUTER JOIN),分别表示会将左侧表、右侧表以及双侧表中没有任何匹配的行返回。例如,订单表中未必包含了商品表中的所有ID,为了将哪些没有任何订单的商品信息也查询出来,我们就可以使用右外联结(RIGHT JOIN)。当然,外联结查询目前也仅支持等值联结条件。具体用法如下:

SELECT *
FROM Order
LEFT JOIN Product
ON Order.product_id = Product.id

SELECT *
FROM Order
RIGHT JOIN Product
ON Order.product_id = Product.id

SELECT *
FROM Order
FULL OUTER JOIN Product
ON Order.product_id = Product.id

这部分知识与标准SQL中是完全一样的,这里不再赘述。

2、间隔联结查询

在8.3节中,我们曾经学习过DataStream API中的双流Join,包括窗口联结(window join)和间隔联结(interval join)。两条流的Join就对应着SQL中两个表的Join,这是流处理中特有的联结方式。目前Flink SQL还不支持窗口联结,而间隔联结则已经实现。

间隔联结(Interval Join)返回的,同样是符合约束条件的两条中数据的笛卡尔积。只不过这里的“约束条件”除了常规的联结条件外,还多了一个时间间隔的限制。具体语法有以下要点:

● 两表的联结

间隔联结不需要用JOIN关键字,直接在FROM后将要联结的两表列出来就可以,用逗号分隔。这与标准SQL中的语法一致,表示一个“交叉联结”(Cross Join),会返回两表中所有行的笛卡尔积。

● 联结条件

联结条件用WHERE子句来定义,用一个等值表达式描述。交叉联结之后再用WHERE进行条件筛选,效果跟内联结INNER JOIN … ON …非常类似。

● 时间间隔限制

我们可以在WHERE子句中,联结条件后用AND追加一个时间间隔的限制条件;做法是提取左右两侧表中的时间字段,然后用一个表达式来指明两者需要满足的间隔限制。具体定义方式有下面三种,这里分别用ltime和rtime表示左右表中的时间字段:

(1)ltime = rtime
(2)ltime >= rtime AND ltime < rtime + INTERVAL ‘10’ MINUTE
(3)ltime BETWEEN rtime - INTERVAL ‘10’ SECOND AND rtime + INTERVAL ‘5’ SECOND

判断两者相等,这是最强的时间约束,要求两表中数据的时间必须完全一致才能匹配;一般情况下,我们还是会放宽一些,给出一个间隔。间隔的定义可以用<,<=,>=,>这一类的关系不等式,也可以用BETWEEN … AND …这样的表达式。

例如,我们现在除了订单表Order外,还有一个“发货表”Shipment,要求在收到订单后四个小时内发货。那么我们就可以用一个间隔联结查询,把所有订单与它对应的发货信息连接合并在一起返回。

SELECT *
FROM Order o, Shipment s
WHERE o.id = s.order_id
AND o.order_time BETWEEN s.ship_time - INTERVAL '4' HOUR AND s.ship_time

在流处理中,间隔联结查询只支持具有时间属性的“仅追加”(Append-only)表。

那对于有更新操作的表,又怎么办呢?除了间隔联结之外,Flink SQL还支持时间联结(Temporal Join),这主要是针对“版本表”(versioned table)而言的。所谓版本表,就是记录了数据随着时间推移版本变化的表,可以理解成一个“更新日志”(change log),它就是具有时间属性、还会进行更新操作的表。当我们联结某个版本表时,并不是把当前的数据连接合并起来就行了,而是希望能够根据数据发生的时间,找到当时的“版本”;这种根据更新时间提取当时的值进行联结的操作,就叫作“时间联结”(Temporal Join)。这部分内容由于涉及版本表的定义,我们就不详细展开了,感兴趣的读者可以查阅官网资料。

二、函数

在SQL中,我们可以把一些数据的转换操作包装起来,嵌入到SQL查询中统一调用,这就是“函数”(functions)。

Flink的Table API和SQL同样提供了函数的功能。两者在调用时略有不同:Table API中的函数是通过数据对象的方法调用来实现的;而SQL则是直接引用函数名称,传入数据作为参数。例如,要把一个字符串str转换成全大写的形式,Table API的写法是调用str这个String对象的upperCase()方法:

str.upperCase();

而SQL中的写法就是直接引用UPPER()函数,将str作为参数传入:

UPPER(str)

由于Table API是内嵌在Java语言中的,很多方法需要在类中额外添加,因此扩展功能比较麻烦,目前支持的函数比较少;而且Table API也不如SQL的通用性强,所以一般情况下较少使用。下面我们主要介绍Flink SQL中函数的使用。

Flink SQL中的函数可以分为两类:一类是SQL中内置的系统函数,直接通过函数名调用就可以,能够实现一些常用的转换操作,比如之前我们用到的COUNT()、CHAR_LENGTH()、UPPER()等等;而另一类函数则是用户自定义的函数(UDF),需要在表环境中注册才能使用。接下来我们就对这两类函数分别进行介绍。

1、系统函数

系统函数(System Functions)也叫内置函数(Built-in Functions),是在系统中预先实现好的功能模块。我们可以通过固定的函数名直接调用,实现想要的转换操作。Flink SQL提供了大量的系统函数,几乎支持所有的标准SQL中的操作,这为我们使用SQL编写流处理程序提供了极大的方便。

Flink SQL中的系统函数又主要可以分为两大类:标量函数(Scalar Functions)和聚合函数(Aggregate Functions)。

1. 标量函数(Scalar Functions)

所谓的“标量”,是指只有数值大小、没有方向的量;所以标量函数指的就是只对输入数据做转换操作、返回一个值的函数。这里的输入数据对应在表中,一般就是一行数据中1个或多个字段,因此这种操作有点像流处理转换算子中的map。另外,对于一些没有输入参数、直接可以得到唯一结果的函数,也属于标量函数。

标量函数是最常见、也最简单的一类系统函数,数量非常庞大,很多在标准SQL中也有定义。所以我们这里只对一些常见类型列举部分函数,做一个简单概述,具体应用可以查看官网的完整函数列表。

● 比较函数(Comparison Functions)

比较函数其实就是一个比较表达式,用来判断两个值之间的关系,返回一个布尔类型的值。这个比较表达式可以是用 <、>、= 等符号连接两个值,也可以是用关键字定义的某种判断。例如:
(1)value1 = value2 判断两个值相等;
(2)value1 <> value2 判断两个值不相等
(3)value IS NOT NULL 判断value不为空

● 逻辑函数(Logical Functions)

逻辑函数就是一个逻辑表达式,也就是用与(AND)、或(OR)、非(NOT)将布尔类型的值连接起来,也可以用判断语句(IS、IS NOT)进行真值判断;返回的还是一个布尔类型的值。例如:
(1)boolean1 OR boolean2 布尔值boolean1与布尔值boolean2取逻辑或
(2)boolean IS FALSE 判断布尔值boolean是否为false
(3)NOT boolean 布尔值boolean取逻辑非

● 算术函数(Arithmetic Functions)

进行算术计算的函数,包括用算术符号连接的运算,和复杂的数学运算。例如:
(1)numeric1 + numeric2 两数相加
(2)POWER(numeric1, numeric2) 幂运算,取数numeric1的numeric2次方
(3)RAND() 返回(0.0, 1.0)区间内的一个double类型的伪随机数

● 字符串函数(String Functions)

进行字符串处理的函数。例如:
(1)string1 || string2 两个字符串的连接
(2)UPPER(string) 将字符串string转为全部大写
(3)CHAR_LENGTH(string) 计算字符串string的长度

● 时间函数(Temporal Functions)

进行与时间相关操作的函数。例如:
(1)DATE string 按格式"yyyy-MM-dd"解析字符串string,返回类型为SQL Date
(2)TIMESTAMP string 按格式"yyyy-MM-dd HH:mm:ss[.SSS]"解析,返回类型为SQL timestamp
(3)CURRENT_TIME 返回本地时区的当前时间,类型为SQL time(与LOCALTIME等价)
(4)INTERVAL string range 返回一个时间间隔。string表示数值;range可以是DAY,MINUTE,DAT TO HOUR等单位,也可以是YEAR TO MONTH这样的复合单位。如“2年10个月”可以写成:INTERVAL ‘2-10’ YEAR TO MONTH

2. 聚合函数(Aggregate Functions)

聚合函数是以表中多个行作为输入,提取字段进行聚合操作的函数,会将唯一的聚合值作为结果返回。聚合函数应用非常广泛,不论分组聚合、窗口聚合还是开窗(Over)聚合,对数据的聚合操作都可以用相同的函数来定义。

标准SQL中常见的聚合函数Flink SQL都是支持的,目前也在不断扩展,为流处理应用提供更强大的功能。例如:

● COUNT(*) 返回所有行的数量,统计个数
● SUM([ ALL | DISTINCT ] expression) 对某个字段进行求和操作。默认情况下省略了关键字ALL,表示对所有行求和;如果指定DISTINCT,则会对数据进行去重,每个值只叠加一次。
● RANK() 返回当前值在一组值中的排名
● ROW_NUMBER() 对一组值排序后,返回当前值的行号。与RANK()的功能相似
其中,RANK()和ROW_NUMBER()一般用在OVER窗口中,在之前11.5.4小节实现Top N的过程中起到了非常重要的作用。

2、自定义函数(UDF)

系统函数尽管庞大,也不可能涵盖所有的功能;如果有系统函数不支持的需求,我们就需要用自定义函数(User Defined Functions,UDF)来实现了。事实上,系统内置函数仍然在不断扩充,如果我们认为自己实现的自定义函数足够通用、应用非常广泛,也可以在项目跟踪工具JIRA上向Flink开发团队提出“议题”(issue),请求将新的函数添加到系统函数中。

Flink的Table API和SQL提供了多种自定义函数的接口,以抽象类的形式定义。当前UDF主要有以下几类:

● 标量函数(Scalar Functions):将输入的标量值转换成一个新的标量值;
● 表函数(Table Functions):将标量值转换成一个或多个新的行数据,也就是扩展成一个表;
● 聚合函数(Aggregate Functions):将多行数据里的标量值转换成一个新的标量值;
● 表聚合函数(Table Aggregate Functions):将多行数据里的标量值转换成一个或多个新的行数据。

1. 整体调用流程

要想在代码中使用自定义的函数,我们需要首先自定义对应UDF抽象类的实现,并在表环境中注册这个函数,然后就可以在Table API和SQL中调用了。

(1)注册函数

注册函数时需要调用表环境的createTemporarySystemFunction()方法,传入注册的函数名以及UDF类的Class对象:

// 注册函数
tableEnv.createTemporarySystemFunction("MyFunction", MyFunction.class);

我们自定义的UDF类叫作MyFunction,它应该是上面四种UDF抽象类中某一个的具体实现;在环境中将它注册为名叫MyFunction的函数。

这里createTemporarySystemFunction()方法的意思是创建了一个“临时系统函数”,所以MyFunction函数名是全局的,可以当作系统函数来使用;我们也可以用createTemporaryFunction()方法,注册的函数就依赖于当前的数据库(database)和目录(catalog)了,所以这就不是系统函数,而是“目录函数”(catalog function),它的完整名称应该包括所属的database和catalog。

一般情况下,我们直接用createTemporarySystemFunction()方法将UDF注册为系统函数就可以了。

(2)使用Table API调用函数

在Table API中,需要使用call()方法来调用自定义函数:
tableEnv.from(“MyTable”).select(call(“MyFunction”, $(“myField”)));

这里call()方法有两个参数,一个是注册好的函数名MyFunction,另一个则是函数调用时本身的参数。这里我们定义MyFunction在调用时,需要传入的参数是myField字段。

此外,在Table API中也可以不注册函数,直接用“内联”(inline)的方式调用UDF:

tableEnv.from("MyTable").select(call(SubstringFunction.class, $("myField")));

区别只是在于call()方法第一个参数不再是注册好的函数名,而直接就是函数类的Class对象了。

(3)在SQL中调用函数

当我们将函数注册为系统函数之后,在SQL中的调用就与内置系统函数完全一样了:

tableEnv.sqlQuery("SELECT MyFunction(myField) FROM MyTable");

可见,SQL的调用方式更加方便,我们后续依然会以SQL为例介绍UDF的用法。
接下来我们就对不同类型的UDF进行展开介绍。

2. 标量函数(Scalar Functions)

自定义标量函数可以把0个、 1个或多个标量值转换成一个标量值,它对应的输入是一行数据中的字段,输出则是唯一的值。所以从输入和输出表中行数据的对应关系看,标量函数是“一对一”的转换。

想要实现自定义的标量函数,我们需要自定义一个类来继承抽象类ScalarFunction,并实现叫作eval() 的求值方法。标量函数的行为就取决于求值方法的定义,它必须是公有的(public),而且名字必须是eval。求值方法eval可以重载多次,任何数据类型都可作为求值方法的参数和返回值类型。

这里需要特别说明的是,ScalarFunction抽象类中并没有定义eval()方法,所以我们不能直接在代码中重写(override);但Table API的框架底层又要求了求值方法必须名字为eval()。这是Table API和SQL目前还显得不够完善的地方,未来的版本应该会有所改进。

ScalarFunction以及其它所有的UDF接口,都在org.apache.flink.table.functions 中。

下面我们来看一个具体的例子。我们实现一个自定义的哈希(hash)函数HashFunction,用来求传入对象的哈希值。

public static class HashFunction extends ScalarFunction {
  // 接受任意类型输入,返回 INT 型输出
  public int eval(@DataTypeHint(inputGroup = InputGroup.ANY) Object o) {
    return o.hashCode();
  }
}

// 注册函数
tableEnv.createTemporarySystemFunction("HashFunction", HashFunction.class);

// 在 SQL 里调用注册好的函数
tableEnv.sqlQuery("SELECT HashFunction(myField) FROM MyTable");

这里我们自定义了一个ScalarFunction,实现了eval()求值方法,将任意类型的对象传入,得到一个Int类型的哈希值返回。当然,具体的求哈希操作就省略了,直接调用对象的hashCode()方法即可。

另外注意,由于Table API在对函数进行解析时需要提取求值方法参数的类型引用,所以我们用DataTypeHint(inputGroup = InputGroup.ANY)对输入参数的类型做了标注,表示eval的参数可以是任意类型。

3. 表函数(Table Functions)

跟标量函数一样,表函数的输入参数也可以是 0个、1个或多个标量值;不同的是,它可以返回任意多行数据。“多行数据”事实上就构成了一个表,所以“表函数”可以认为就是返回一个表的函数,这是一个“一对多”的转换关系。之前我们介绍过的窗口TVF,本质上就是表函数。

类似地,要实现自定义的表函数,需要自定义类来继承抽象类TableFunction,内部必须要实现的也是一个名为 eval 的求值方法。与标量函数不同的是,TableFunction类本身是有一个泛型参数T的,这就是表函数返回数据的类型;而eval()方法没有返回类型,内部也没有return语句,是通过调用collect()方法来发送想要输出的行数据的。多么熟悉的感觉——回忆一下DataStream API中的FlatMapFunction和ProcessFunction,它们的flatMap和processElement方法也没有返回值,也是通过out.collect()来向下游发送数据的。

我们使用表函数,可以对一行数据得到一个表,这和Hive中的UDTF非常相似。那对于原先输入的整张表来说,又该得到什么呢?一个简单的想法是,就让输入表中的每一行,与它转换得到的表进行联结(join),然后再拼成一个完整的大表,这就相当于对原来的表进行了扩展。在Hive的SQL语法中,提供了“侧向视图”(lateral view,也叫横向视图)的功能,可以将表中的一行数据拆分成多行;Flink SQL也有类似的功能,是用LATERAL TABLE语法来实现的。

在SQL中调用表函数,需要使用LATERAL TABLE()来生成扩展的“侧向表”,然后与原始表进行联结(Join)。这里的Join操作可以是直接做交叉联结(cross join),在FROM后用逗号分隔两个表就可以;也可以是以ON TRUE为条件的左联结(LEFT JOIN)。

下面是表函数的一个具体示例。我们实现了一个分隔字符串的函数SplitFunction,可以将一个字符串转换成(字符串,长度)的二元组。

// 注意这里的类型标注,输出是Row类型,Row中包含两个字段:word和length。
@FunctionHint(output = @DataTypeHint("ROW"))
public static class SplitFunction extends TableFunction<Row> {

  public void eval(String str) {
    for (String s : str.split(" ")) {
      // 使用collect()方法发送一行数据
      collect(Row.of(s, s.length()));
    }
  }
}

// 注册函数
tableEnv.createTemporarySystemFunction("SplitFunction", SplitFunction.class);

// 在 SQL 里调用注册好的函数
// 1. 交叉联结
tableEnv.sqlQuery(
  "SELECT myField, word, length " +
  "FROM MyTable, LATERAL TABLE(SplitFunction(myField))");
// 2. 带ON TRUE条件的左联结
tableEnv.sqlQuery(
  "SELECT myField, word, length " +
  "FROM MyTable " +
  "LEFT JOIN LATERAL TABLE(SplitFunction(myField)) ON TRUE");

// 重命名侧向表中的字段
tableEnv.sqlQuery(
  "SELECT myField, newWord, newLength " +
  "FROM MyTable " +
  "LEFT JOIN LATERAL TABLE(SplitFunction(myField)) AS T(newWord, newLength) ON TRUE");

这里我们直接将表函数的输出类型定义成了ROW,这就是得到的侧向表中的数据类型;每行数据转换后也只有一行。我们分别用交叉联结和左联结两种方式在SQL中进行了调用,还可以对侧向表的中字段进行重命名。

4. 聚合函数(Aggregate Functions)

用户自定义聚合函数(User Defined AGGregate function,UDAGG)会把一行或多行数据(也就是一个表)聚合成一个标量值。这是一个标准的“多对一”的转换。

聚合函数的概念我们之前已经接触过多次,如SUM()、MAX()、MIN()、AVG()、COUNT()都是常见的系统内置聚合函数。而如果有些需求无法直接调用系统函数解决,我们就必须自定义聚合函数来实现功能了。

自定义聚合函数需要继承抽象类AggregateFunction。AggregateFunction有两个泛型参数,T表示聚合输出的结果类型,ACC则表示聚合的中间状态类型。

Flink SQL中的聚合函数的工作原理如下:

(1)首先,它需要创建一个累加器(accumulator),用来存储聚合的中间结果。这与DataStream API中的AggregateFunction非常类似,累加器就可以看作是一个聚合状态。调用createAccumulator()方法可以创建一个空的累加器。
(2)对于输入的每一行数据,都会调用accumulate()方法来更新累加器,这是聚合的核心过程。
(3)当所有的数据都处理完之后,通过调用getValue()方法来计算并返回最终的结果。

所以,每个 AggregateFunction 都必须实现以下几个方法:

● createAccumulator()

这是创建累加器的方法。没有输入参数,返回类型为累加器类型ACC。

● accumulate()

这是进行聚合计算的核心方法,每来一行数据都会调用。它的第一个参数是确定的,就是当前的累加器,类型为ACC,表示当前聚合的中间状态;后面的参数则是聚合函数调用时传入的参数,可以有多个,类型也可以不同。这个方法主要是更新聚合状态,所以没有返回类型。需要注意的是,accumulate()与之前的求值方法eval()类似,也是底层架构要求的,必须为public,方法名必须为accumulate,且无法直接override、只能手动实现。

● getValue()

这是得到最终返回结果的方法。输入参数是ACC类型的累加器,输出类型为T。

在遇到复杂类型时,Flink 的类型推导可能会无法得到正确的结果。所以AggregateFunction也可以专门对累加器和返回结果的类型进行声明,这是通过 getAccumulatorType()和getResultType()两个方法来指定的。

除了上面的方法,还有几个方法是可选的。这些方法有些可以让查询更加高效,有些是在某些特定场景下必须要实现的。比如,如果是对会话窗口进行聚合,merge()方法就是必须要实现的,它会定义累加器的合并操作,而且这个方法对一些场景的优化也很有用;而如果聚合函数用在OVER窗口聚合中,就必须实现retract()方法,保证数据可以进行撤回操作;resetAccumulator()方法则是重置累加器,这在一些批处理场景中会比较有用。

AggregateFunction 的所有方法都必须是 公有的(public),不能是静态的(static),而且名字必须跟上面写的完全一样。createAccumulator、getValue、getResultType 以及 getAccumulatorType 这几个方法是在抽象类 AggregateFunction 中定义的,可以override;而其他则都是底层架构约定的方法。

下面举一个具体的示例。在常用的系统内置聚合函数里,可以用AVG()来计算平均值;如果我们现在希望计算的是某个字段的“加权平均值”,又该怎么做呢?系统函数里没有现成的实现,所以只能自定义一个聚合函数WeightedAvg来计算了。

比如我们要从学生的分数表ScoreTable中计算每个学生的加权平均分。为了计算加权平均值,应该从输入的每行数据中提取两个值作为参数:要计算的分数值score,以及它的权重weight。而在聚合过程中,累加器(accumulator)需要存储当前的加权总和sum,以及目前数据的个数count。这可以用一个二元组来表示,也可以单独定义一个类 WeightedAvgAccum,里面包含sum和count两个属性,用它的对象实例来作为聚合的累加器。

具体代码如下:

// 累加器类型定义
public static class WeightedAvgAccumulator {
    public long sum = 0;    // 加权和
    public int count = 0;    // 数据个数
}

// 自定义聚合函数,输出为长整型的平均值,累加器类型为 WeightedAvgAccumulator
public static class WeightedAvg extends AggregateFunction<Long, WeightedAvgAccumulator> {

    @Override
    public WeightedAvgAccumulator createAccumulator() {
        return new WeightedAvgAccumulator();    // 创建累加器
    }

    @Override
    public Long getValue(WeightedAvgAccumulator acc) {
        if (acc.count == 0) {
            return null;    // 防止除数为0
        } else {
            return acc.sum / acc.count;    // 计算平均值并返回
        }
    }

    // 累加计算方法,每来一行数据都会调用
    public void accumulate(WeightedAvgAccumulator acc, Long iValue, Integer iWeight) {
        acc.sum += iValue * iWeight;
        acc.count += iWeight;
    }
}

// 注册自定义聚合函数
tableEnv.createTemporarySystemFunction("WeightedAvg", WeightedAvg.class);
// 调用函数计算加权平均值
Table result = tableEnv.sqlQuery(
        "SELECT student, WeightedAvg(score, weight) FROM ScoreTable GROUP BY student"
);

聚合函数的accumulate()方法有三个输入参数。第一个是WeightedAvgAccum类型的累加器;另外两个则是函数调用时输入的字段:要计算的值 ivalue 和 对应的权重 iweight。这里我们并不考虑其它方法的实现,只要有必须的三个方法就可以了。

5. 表聚合函数(Table Aggregate Functions)

用户自定义表聚合函数(UDTAGG)可以把一行或多行数据(也就是一个表)聚合成另一张表,结果表中可以有多行多列。很明显,这就像表函数和聚合函数的结合体,是一个“多对多”的转换。

自定义表聚合函数需要继承抽象类TableAggregateFunction。TableAggregateFunction的结构和原理与AggregateFunction非常类似,同样有两个泛型参数,用一个ACC类型的累加器(accumulator)来存储聚合的中间结果。聚合函数中必须实现的三个方法,在TableAggregateFunction中也必须对应实现:

● createAccumulator()

创建累加器的方法,与AggregateFunction中用法相同。

● accumulate()

聚合计算的核心方法,与AggregateFunction中用法相同。

● emitValue()

所有输入行处理完成后,输出最终计算结果的方法。这个方法对应着AggregateFunction中的getValue()方法;区别在于emitValue没有输出类型,而输入参数有两个:第一个是ACC类型的累加器,第二个则是用于输出数据的“收集器”out,它的类型为Collect。所以很明显,表聚合函数输出数据不是直接return,而是调用out.collect()方法,调用多次就可以输出多行数据了;这一点与表函数非常相似。另外,emitValue()在抽象类中也没有定义,无法override,必须手动实现。

表聚合函数得到的是一张表;在流处理中做持续查询,应该每次都会把这个表重新计算输出。如果输入一条数据后,只是对结果表里一行或几行进行了更新(Update),这时我们重新计算整个表、全部输出显然就不够高效了。为了提高处理效率,TableAggregateFunction还提供了一个emitUpdateWithRetract()方法,它可以在结果表发生变化时,以“撤回”(retract)老数据、发送新数据的方式增量地进行更新。如果同时定义了emitValue()和emitUpdateWithRetract()两个方法,在进行更新操作时会优先调用emitUpdateWithRetract()。

表聚合函数相对比较复杂,它的一个典型应用场景就是Top N查询。比如我们希望选出一组数据排序后的前两名,这就是最简单的TOP-2查询。没有线程的系统函数,那么我们就可以自定义一个表聚合函数来实现这个功能。在累加器中应该能够保存当前最大的两个值,每当来一条新数据就在accumulate()方法中进行比较更新,最终在emitValue()中调用两次out.collect()将前两名数据输出。

具体代码如下:

// 聚合累加器的类型定义,包含最大的第一和第二两个数据
public static class Top2Accumulator {
    public Integer first;
    public Integer second;
}

// 自定义表聚合函数,查询一组数中最大的两个,返回值为(数值,排名)的二元组
public static class Top2 extends TableAggregateFunction<Tuple2<Integer, Integer>, Top2Accumulator> {

    @Override
    public Top2Accumulator createAccumulator() {
        Top2Accumulator acc = new Top2Accumulator();
        acc.first = Integer.MIN_VALUE;    // 为方便比较,初始值给最小值
        acc.second = Integer.MIN_VALUE;
        return acc;
    }

    // 每来一个数据调用一次,判断是否更新累加器
    public void accumulate(Top2Accumulator acc, Integer value) {
        if (value > acc.first) {
            acc.second = acc.first;
            acc.first = value;
        } else if (value > acc.second) {
            acc.second = value;
        }
    }

    // 输出(数值,排名)的二元组,输出两行数据
    public void emitValue(Top2Accumulator acc, Collector<Tuple2<Integer, Integer>> out) {
        if (acc.first != Integer.MIN_VALUE) {
            out.collect(Tuple2.of(acc.first, 1));
        }
        if (acc.second != Integer.MIN_VALUE) {
            out.collect(Tuple2.of(acc.second, 2));
        }
    }
}

目前SQL中没有直接使用表聚合函数的方式,所以需要使用Table API的方式来调用:

// 注册表聚合函数函数
tableEnv.createTemporarySystemFunction("Top2", Top2.class);

// 在Table API中调用函数
tableEnv.from("MyTable")
  .groupBy($("myField"))
  .flatAggregate(call("Top2", $("value")).as("value", "rank"))
  .select($("myField"), $("value"), $("rank"));

这里使用了flatAggregate()方法,它就是专门用来调用表聚合函数的接口。对MyTable中数据按myField字段进行分组聚合,统计value值最大的两个;并将聚合结果的两个字段重命名为value和rank,之后就可以使用select()将它们提取出来了。

三、SQL客户端

有了Table API和SQL,我们就可以使用熟悉的SQL来编写查询语句进行流处理了。不过,这种方式还是将SQL语句嵌入到Java/Scala代码中进行的;另外,写完的代码后想要提交作业还需要使用工具进行打包。这都给Flink的使用设置了门槛,如果不是Java/Scala程序员,即使是非常熟悉SQL的工程师恐怕也会望而生畏了。

基于这样的考虑,Flink为我们提供了一个工具来进行Flink程序的编写、测试和提交,这工具叫作“SQL客户端”。SQL客户端提供了一个命令行交互界面(CLI),我们可以在里面非常容易地编写SQL进行查询,就像使用MySQL一样;整个Flink应用编写、提交的过程全变成了写SQL,不需要写一行Java/Scala代码。

具体使用流程如下:

(1)首先启动本地集群

./bin/start-cluster.sh

(2)启动Flink SQL客户端

./bin/sql-client.sh

SQL客户端的启动脚本同样位于Flink的bin目录下。默认的启动模式是embedded,也就是说客户端是一个嵌入在本地的进程,这是目前唯一支持的模式。未来会支持连接到远程SQL客户端的模式。

(3)设置运行模式

启动客户端后,就进入了命令行界面,这时就可以开始写SQL了。一般我们会在开始之前对环境做一些设置,比较重要的就是运行模式。

首先是表环境的运行时模式,有流处理和批处理两个选项。默认为流处理:

Flink SQL> SET ‘execution.runtime-mode’ = ‘streaming’;

其次是SQL客户端的“执行结果模式”,主要有table、changelog、tableau三种,默认为table模式:

Flink SQL> SET ‘sql-client.execution.result-mode’ = ‘table’;

table模式就是最普通的表处理模式,结果会以逗号分隔每个字段;changelog则是更新日志模式,会在数据前加上“+”(表示插入)或“-”(表示撤回)的前缀;而tableau则是经典的可视化表模式,结果会是一个虚线框的表格。

此外我们还可以做一些其它可选的设置,比如之前提到的空闲状态生存时间(TTL):

Flink SQL> SET 'table.exec.state.ttl' = '1000';

除了在命令行进行设置,我们也可以直接在SQL客户端的配置文件sql-cli-defaults.yaml中进行各种配置,甚至还可以在这个yaml文件里预定义表、函数和catalog。关于配置文件的更多用法,大家可以查阅官网的详细说明。

(4)执行SQL查询

接下来就可以愉快的编写SQL语句了,这跟操作MySQL、Oracle等关系型数据库没什么区别。

我们可以尝试把一开始举的简单聚合例子写一下:

Flink SQL> CREATE TABLE EventTable(
>   user STRING,
>   url STRING,
>   `timestamp` BIGINT
> ) WITH (
>   'connector' = 'filesystem',
>   'path'      = 'events.csv',
>   'format'    = 'csv'
> );

Flink SQL> CREATE TABLE ResultTable (
>   user STRING,
>   cnt BIGINT
> ) WITH (
>   'connector' = 'print'
> );

Flink SQL> INSERT INTO ResultTable SELECT user, COUNT(url) as cnt FROM EventTable
 GROUP BY user;

这里我们直接用DDL创建两张表,注意需要有WITH定义的外部连接。一张表叫作EventTable,是从外部文件events.csv中读取数据的,这是输入数据表;另一张叫作ResultTable,连接器为“print”,其实就是标准控制台打印,当然就是输出表了。所以接下来就可以直接执行SQL查询,并将查询结果INSERT写入结果表中了。

在SQL客户端中,每定义一个SQL查询,就会把它作为一个Flink作业提交到集群上执行。所以通过这种方式,我们可以快速地对流处理程序进行开发测试。

四、连接到外部系统

在Table API和SQL编写的Flink程序中,可以在创建表的时候用WITH子句指定连接器(connector),这样就可以连接到外部系统进行数据交互了。

架构中的TableSource负责从外部系统中读取数据并转换成表,TableSink则负责将结果表写入外部系统。在Flink 1.13的API调用中,已经不去区分TableSource和TableSink,我们只要建立到外部系统的连接并创建表就可以,Flink自动会从程序的处理逻辑中解析出它们的用途。

Flink的Table API和SQL支持了各种不同的连接器。当然,最简单的其实就是上一节中提到的连接到控制台打印输出:

CREATE TABLE ResultTable (
user STRING,
cnt BIGINT
WITH (
'connector' = 'print'
);

这里只需要在WITH中定义connector为print就可以了。而对于其它的外部系统,则需要增加一些配置项。下面我们就分别进行讲解。

1、Kafka

Kafka的SQL连接器可以从Kafka的主题(topic)读取数据转换成表,也可以将表数据写入Kafka的主题。换句话说,创建表的时候指定连接器为Kafka,则这个表既可以作为输入表,也可以作为输出表。

1. 引入依赖

想要在Flink程序中使用Kafka连接器,需要引入如下依赖:

<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-connector-kafka_${scala.binary.version}</artifactId>
  <version>${flink.version}</version>
</dependency>

这里我们引入的Flink和Kafka的连接器,与之前DataStream API中引入的连接器是一样的。如果想在SQL客户端里使用Kafka连接器,还需要下载对应的jar包放到lib目录下。

另外,Flink为各种连接器提供了一系列的“表格式”(table formats),比如CSV、JSON、Avro、Parquet等等。这些表格式定义了底层存储的二进制数据和表的列之间的转换方式,相当于表的序列化工具。对于Kafka而言,CSV、JSON、Avro等主要格式都是支持的,

根据Kafka连接器中配置的格式,我们可能需要引入对应的依赖支持。以CSV为例:

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

由于SQL客户端中已经内置了CSV、JSON的支持,因此使用时无需专门引入;而对于没有内置支持的格式(比如Avro),则仍然要下载相应的jar包。关于连接器的格式细节详见官网说明,我们后面就不再讨论了。

2. 创建连接到Kafka的表

创建一个连接到Kafka表,需要在CREATE TABLE的DDL中在WITH子句里指定连接器为Kafka,并定义必要的配置参数。

下面是一个具体示例:

CREATE TABLE KafkaTable (
  `user` STRING,
  `url` STRING,
  `ts` TIMESTAMP(3) METADATA FROM 'timestamp'
) WITH (
  'connector' = 'kafka',
  'topic' = 'events',
  'properties.bootstrap.servers' = 'localhost:9092',
  'properties.group.id' = 'testGroup',
  'scan.startup.mode' = 'earliest-offset',
  'format' = 'csv'
)

这里定义了Kafka连接器对应的主题(topic),Kafka服务器,消费者组ID,消费者起始模式以及表格式。需要特别说明的是,在KafkaTable的字段中有一个ts,它的声明中用到了METADATA FROM,这是表示一个“元数据列”(metadata column),它是由Kafka连接器的元数据“timestamp”生成的。这里的timestamp其实就是Kafka中数据自带的时间戳,我们把它直接作为元数据提取出来,转换成一个新的字段ts。

3. Upsert Kafka

正常情况下,Kafka作为保持数据顺序的消息队列,读取和写入都应该是流式的数据,对应在表中就是仅追加(append-only)模式。如果我们想要将有更新操作(比如分组聚合)的结果表写入Kafka,就会因为Kafka无法识别撤回(retract)或更新插入(upsert)消息而导致异常。

为了解决这个问题,Flink专门增加了一个“更新插入Kafka”(Upsert Kafka)连接器。这个连接器支持以更新插入(UPSERT)的方式向Kafka的topic中读写数据。

具体来说,Upsert Kafka连接器处理的是更新日志(changlog)流。如果作为TableSource,连接器会将读取到的topic中的数据(key, value),解释为对当前key的数据值的更新(UPDATE),也就是查找动态表中key对应的一行数据,将value更新为最新的值;因为是Upsert操作,所以如果没有key对应的行,那么也会执行插入(INSERT)操作。另外,如果遇到value为空(null),连接器就把这条数据理解为对相应key那一行的删除(DELETE)操作。

如果作为TableSink,Upsert Kafka连接器会将有更新操作的结果表,转换成更新日志(changelog)流。如果遇到插入(INSERT)或者更新后(UPDATE_AFTER)的数据,对应的是一个添加(add)消息,那么就直接正常写入Kafka主题;如果是删除(DELETE)或者更新前的数据,对应是一个撤回(retract)消息,那么就把value为空(null)的数据写入Kafka。由于Flink是根据键(key)的值对数据进行分区的,这样就可以保证同一个key上的更新和删除消息都会落到同一个分区中。

下面是一个创建和使用Upsert Kafka表的例子:

CREATE TABLE pageviews_per_region (
  user_region STRING,
  pv BIGINT,
  uv BIGINT,
  PRIMARY KEY (user_region) NOT ENFORCED
) WITH (
  'connector' = 'upsert-kafka',
  'topic' = 'pageviews_per_region',
  'properties.bootstrap.servers' = '...',
  'key.format' = 'avro',
  'value.format' = 'avro'
);

CREATE TABLE pageviews (
  user_id BIGINT,
  page_id BIGINT,
  viewtime TIMESTAMP,
  user_region STRING,
  WATERMARK FOR viewtime AS viewtime - INTERVAL '2' SECOND
) WITH (
  'connector' = 'kafka',
  'topic' = 'pageviews',
  'properties.bootstrap.servers' = '...',
  'format' = 'json'
);

-- 计算 pv、uv 并插入到 upsert-kafka表中
INSERT INTO pageviews_per_region
SELECT
  user_region,
  COUNT(*),
  COUNT(DISTINCT user_id)
FROM pageviews
GROUP BY user_region;

这里我们从Kafka表pageviews中读取数据,统计每个区域的PV(全部浏览量)和UV(对用户去重),这是一个分组聚合的更新查询,得到的结果表会不停地更新数据。为了将结果表写入Kafka的pageviews_per_region主题,我们定义了一个Upsert Kafka表,它的字段中需要用PRIMARY KEY来指定主键,并且在WITH子句中分别指定key和value的序列化格式。

2、文件系统

另一类非常常见的外部系统就是文件系统(File System)了。Flink提供了文件系统的连接器,支持从本地或者分布式的文件系统中读写数据。这个连接器是内置在Flink中的,所以使用它并不需要额外引入依赖。

下面是一个连接到文件系统的示例:

CREATE TABLE MyTable (
  column_name1 INT,
  column_name2 STRING,
  ...
  part_name1 INT,
  part_name2 STRING
) PARTITIONED BY (part_name1, part_name2) WITH (
  'connector' = 'filesystem',           -- 连接器类型
  'path' = '...',  -- 文件路径
  'format' = '...'                      -- 文件格式
)

这里在WITH前使用了PARTITIONED BY对数据进行了分区操作。文件系统连接器支持对分区文件的访问。

3、JDBC

关系型数据表本身就是SQL最初应用的地方,所以我们也会希望能直接向关系型数据库中读写表数据。Flink提供的JDBC连接器可以通过JDBC驱动程序(driver)向任意的关系型数据库读写数据,比如MySQL、PostgreSQL、Derby等。

作为TableSink向数据库写入数据时,运行的模式取决于创建表的DDL是否定义了主键(primary key)。如果有主键,那么JDBC连接器就将以更新插入(Upsert)模式运行,可以向外部数据库发送按照指定键(key)的更新(UPDATE)和删除(DELETE)操作;如果没有定义主键,那么就将在追加(Append)模式下运行,不支持更新和删除操作。

1. 引入依赖

想要在Flink程序中使用JDBC连接器,需要引入如下依赖:

<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-connector-jdbc_${scala.binary.version}</artifactId>
  <version>${flink.version}</version>
</dependency>

此外,为了连接到特定的数据库,我们还用引入相关的驱动器依赖,比如MySQL:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.38</version>
</dependency>

这里引入的驱动器版本是5.1.38,读者可以依据自己的MySQL版本来进行选择。

2. 创建JDBC表

创建JDBC表的方法与前面Upsert Kafka大同小异。下面是一个具体示例:

-- 创建一张连接到 MySQL的 表
CREATE TABLE MyTable (
  id BIGINT,
  name STRING,
  age INT,
  status BOOLEAN,
  PRIMARY KEY (id) NOT ENFORCED
) WITH (
   'connector' = 'jdbc',
   'url' = 'jdbc:mysql://localhost:3306/mydatabase',
   'table-name' = 'users'
);

-- 将另一张表 T的数据写入到 MyTable 表中
INSERT INTO MyTable
SELECT id, name, age, status FROM T;

这里创建表的DDL中定义了主键,所以数据会以Upsert模式写入到MySQL表中;而到MySQL的连接,是通过WITH子句中的url定义的。要注意写入MySQL中真正的表名称是users,而MyTable是注册在Flink表环境中的表。

3、Elasticsearch

Elasticsearch作为分布式搜索分析引擎,在大数据应用中有非常多的场景。Flink提供的Elasticsearch的SQL连接器只能作为TableSink,可以将表数据写入Elasticsearch的索引(index)。Elasticsearch连接器的使用与JDBC连接器非常相似,写入数据的模式同样是由创建表的DDL中是否有主键定义决定的。

1. 引入依赖

想要在Flink程序中使用Elasticsearch连接器,需要引入对应的依赖。具体的依赖与Elasticsearch服务器的版本有关,对于6.x版本引入依赖如下:

<dependency>
  <groupId>org.apache.flink</groupId>  <artifactId>flink-connector-elasticsearch6_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

对于Elasticsearch 7以上的版本,引入的依赖则是:

<dependency>
  <groupId>org.apache.flink</groupId>  <artifactId>flink-connector-elasticsearch7_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

2. 创建连接到Elasticsearch的表

创建Elasticsearch表的方法与JDBC表基本一致。下面是一个具体示例:

-- 创建一张连接到 Elasticsearch的 表
CREATE TABLE MyTable (
  user_id STRING,
  user_name STRING
  uv BIGINT,
  pv BIGINT,
  PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
  'connector' = 'elasticsearch-7',
  'hosts' = 'http://localhost:9200',
  'index' = 'users'
);

这里定义了主键,所以会以更新插入(Upsert)模式向Elasticsearch写入数据。

4、HBase

作为高性能、可伸缩的分布式列存储数据库,HBase在大数据分析中是一个非常重要的工具。Flink提供的HBase连接器支持面向HBase集群的读写操作。

在流处理场景下,连接器作为TableSink向HBase写入数据时,采用的始终是更新插入(Upsert)模式。也就是说,HBase要求连接器必须通过定义的主键(primary key)来发送更新日志(changelog)。所以在创建表的DDL中,我们必须要定义行键(rowkey)字段,并将它声明为主键;如果没有用PRIMARY KEY子句声明主键,连接器会默认把rowkey作为主键。

1. 引入依赖

想要在Flink程序中使用HBase连接器,需要引入对应的依赖。目前Flink只对HBase的1.4.x和2.2.x版本提供了连接器支持,而引入的依赖也应该与具体的HBase版本有关。对于1.4版本引入依赖如下:

<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-connector-hbase-1.4_${scala.binary.version}</artifactId>
  <version>${flink.version}</version>
</dependency>

对于HBase 2.2版本,引入的依赖则是:

<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-connector-hbase-2.2_${scala.binary.version}</artifactId>
  <version>${flink.version}</version>
</dependency>

2. 创建连接到HBase的表

由于HBase并不是关系型数据库,因此转换为Flink SQL中的表会稍有一些麻烦。在DDL创建出的HBase表中,所有的列族(column family)都必须声明为ROW类型,在表中占据一个字段;而每个family中的列(column qualifier)则对应着ROW里的嵌套字段。我们不需要将HBase中所有的family和qualifier都在Flink SQL的表中声明出来,只要把那些在查询中用到的声明出来就可以了。

除了所有ROW类型的字段(对应着HBase中的family),表中还应有一个原子类型的字段,它就会被识别为HBase的rowkey。在表中这个字段可以任意取名,不一定非要叫rowkey。

下面是一个具体示例:

-- 创建一张连接到 HBase的 表
CREATE TABLE MyTable (
 rowkey INT,
 family1 ROW<q1 INT>,
 family2 ROW<q2 STRING, q3 BIGINT>,
 family3 ROW<q4 DOUBLE, q5 BOOLEAN, q6 STRING>,
 PRIMARY KEY (rowkey) NOT ENFORCED
) WITH (
 'connector' = 'hbase-1.4',
 'table-name' = 'mytable',
 'zookeeper.quorum' = 'localhost:2181'
);

-- 假设表T的字段结构是 [rowkey, f1q1, f2q2, f2q3, f3q4, f3q5, f3q6]
INSERT INTO MyTable
SELECT rowkey, ROW(f1q1), ROW(f2q2, f2q3), ROW(f3q4, f3q5, f3q6) FROM T;

我们将另一张T中的数据提取出来,并用ROW()函数来构造出对应的column family,最终写入HBase中名为mytable的表。

5、Hive

Apache Hive作为一个基于Hadoop的数据仓库基础框架,可以说已经成为了进行海量数据分析的核心组件。Hive支持类SQL的查询语言,可以用来方便对数据进行处理和统计分析,而且基于HDFS的数据存储有非常好的可扩展性,是存储分析超大量数据集的唯一选择。Hive的主要缺点在于查询的延迟很高,几乎成了离线分析的代言人。而Flink的特点就是实时性强,所以Flink SQL与Hive的结合势在必行。

Flink与Hive的集成比较特别。Flink提供了“Hive目录”(HiveCatalog)功能,允许使用Hive的“元存储”(Metastore)来管理Flink的元数据。这带来的好处体现在两个方面:

(1)Metastore可以作为一个持久化的目录,因此使用HiveCatalog可以跨会话存储Flink特定的元数据。这样一来,我们在HiveCatalog中执行执行创建Kafka表或者ElasticSearch表,就可以把它们的元数据持久化存储在Hive的Metastore中;对于不同的作业会话就不需要重复创建了,直接在SQL查询中重用就可以。

(2)使用HiveCatalog,Flink可以作为读写Hive表的替代分析引擎。这样一来,在Hive中进行批处理会更加高效;与此同时,也有了连续在Hive中读写数据、进行流处理的能力,这也使得“实时数仓”(real-time data warehouse)成为了可能。

HiveCatalog被设计为“开箱即用”,与现有的Hive配置完全兼容,我们不需要做任何的修改与调整就可以直接使用。注意只有Blink的计划器(planner)提供了Hive集成的支持,所以需要在使用Flink SQL时选择Blink planner。下面我们就来看以下与Hive集成的具体步骤。

1. 引入依赖

Hive各版本特性变化比较大,所以使用时需要注意版本的兼容性。目前Flink支持的Hive版本包括:
● Hive 1.x:1.0.0~1.2.2;
● Hive 2.x:2.0.02.2.0,2.3.02.3.6;
● Hive 3.x:3.0.0~3.1.2;

目前Flink与Hive的集成程度在持续加强,支持的版本信息也会不停变化和调整,大家可以随着关注官网的更新信息。

由于Hive是基于Hadoop的组件,因此我们首先需要提供Hadoop的相关支持,在环境变量中设置HADOOP_CLASSPATH:

export HADOOP_CLASSPATH=`hadoop classpath`

在Flink程序中可以引入以下依赖:

<!-- FlinkHive连接器-->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-connector-hive_${scala.binary.version}</artifactId>
  <version>${flink.version}</version>
</dependency>

<!-- Hive 依赖 -->
<dependency>
    <groupId>org.apache.hive</groupId>
    <artifactId>hive-exec</artifactId>
    <version>${hive.version}</version>
</dependency>

建议不要把这些依赖打包到结果jar文件中,而是在运行时的集群环境中为不同的Hive版本添加不同的依赖支持。具体版本对应的依赖关系,可以查询官网说明。

2. 连接到Hive

在Flink中连接Hive,是通过在表环境中配置HiveCatalog来实现的。需要说明的是,配置HiveCatalog本身并不需要限定使用哪个planner,不过对Hive表的读写操作只有Blink的planner才支持。所以一般我们需要将表环境的planner设置为Blink。

下面是代码中配置Catalog的示例:

EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().build();
TableEnvironment tableEnv = TableEnvironment.create(settings);

String name            = "myhive";
String defaultDatabase = "mydatabase";
String hiveConfDir     = "/opt/hive-conf";

// 创建一个HiveCatalog,并在表环境中注册
HiveCatalog hive = new HiveCatalog(name, defaultDatabase, hiveConfDir);
tableEnv.registerCatalog("myhive", hive);

// 使用HiveCatalog作为当前会话的catalog
tableEnv.useCatalog("myhive");

当然,我们也可以直接启动SQL客户端,用CREATE CATALOG语句直接创建HiveCatalog:

Flink SQL> create catalog myhive with ('type' = 'hive', 'hive-conf-dir' = '/opt/hive-conf');
[INFO] Execute statement succeed.

Flink SQL> use catalog myhive;
[INFO] Execute statement succeed.

3. 设置SQL方言

我们知道,Hive内部提供了类SQL的查询语言,不过语法细节与标准SQL会有一些出入,相当于是SQL的一种“方言”(dialect)。为了提高与Hive集成时的兼容性,Flink SQL提供了一个非常有趣而强大的功能:可以使用方言来编写SQL语句。换句话说,我们可以直接在Flink中写Hive SQL来操作Hive表,这无疑给我们的读写处理带来了极大的方便。

Flink目前支持两种SQL方言的配置:default和hive。所谓的default就是Flink SQL默认的SQL语法了。我们需要先切换到hive方言,然后才能使用Hive SQL的语法。具体设置可以分为SQL和Table API两种方式。

(1)SQL中设置

我们可以通过配置table.sql-dialect属性来设置SQL方言:

set table.sql-dialect=hive;

当然,我们可以在代码中执行上面的SET语句,也可以直接启动SQL客户端来运行。如果使用SQL客户端,我们还可以在配置文件sql-cli-defaults.yaml中通过“configuration”模块来设置:

execution:
  planner: blink
  type: batch
  result-mode: table

configuration:
  table.sql-dialect: hive
(2)Table API中设置

另外一种方式就是在代码中,直接使用Table API获取表环境的配置项来进行设置:

// 配置hive方言
tableEnv.getConfig().setSqlDialect(SqlDialect.HIVE);
// 配置default方言
tableEnv.getConfig().setSqlDialect(SqlDialect.DEFAULT);

4. 读写Hive表

有了SQL方言的设置,我们就可以很方便的在Flink中创建Hive表并进行读写操作了。Flink支持以批处理和流处理模式向Hive中读写数据。在批处理模式下,Flink会在执行查询语句时对Hive表进行一次性读取,在作业完成时将结果数据向Hive表进行一次性写入;而在流处理模式下,Flink会持续监控Hive表,在新数据可用时增量读取,也可以持续写入新数据并增量式地让它们可见。

更灵活的是,我们可以随时切换SQL方言,从其它数据源(例如Kafka)读取数据、经转换后再写入Hive。下面是以纯SQL形式编写的一个示例,我们可以启动SQL客户端来运行:

-- 设置SQL方言为hive,创建Hive表
SET table.sql-dialect=hive;
CREATE TABLE hive_table (
  user_id STRING,
  order_amount DOUBLE
) PARTITIONED BY (dt STRING, hr STRING) STORED AS parquet TBLPROPERTIES (
  'partition.time-extractor.timestamp-pattern'='$dt $hr:00:00',
  'sink.partition-commit.trigger'='partition-time',
  'sink.partition-commit.delay'='1 h',
  'sink.partition-commit.policy.kind'='metastore,success-file'
);

-- 设置SQL方言为default,创建Kafka表
SET table.sql-dialect=default;
CREATE TABLE kafka_table (
  user_id STRING,
  order_amount DOUBLE,
  log_ts TIMESTAMP(3),
  WATERMARK FOR log_ts AS log_ts - INTERVAL '5' SECOND    – 定义水位线
) WITH (...);

--Kafka中读取的数据经转换后写入Hive 
INSERT INTO TABLE hive_table 
SELECT user_id, order_amount, DATE_FORMAT(log_ts, 'yyyy-MM-dd'), DATE_FORMAT(log_ts, 'HH')
FROM kafka_table;

这里我们创建Hive表时设置了通过分区时间来触发提交的策略。将Kafka中读取的数据经转换后写入Hive,这是一个流处理的Flink SQL程序。

五、本章总结

在本章中,我们从一个简单示例入手,由浅入深地介绍了Flink Table API和SQL的用法。由于这两套API底层原理一致,而Table API功能不够完善、应用不够方便,实际项目开发往往写SQL居多;因此本章内容是以Flink SQL的各种功能特性为主线贯穿始终,对Table API只做原理性讲解。

11.2节主要介绍Table API和SQL的基本用法,有了这部分知识,就可以写出完整的Flink SQL程序了。11.3节深入讲解了表和SQL在流处理中的一些核心概念,比如动态表和持续查询,更新查询和追加查询等;这些知识或许对于应用逻辑没有太大帮助,然而却是深入理解流式处理架构的关键,也是从程序员向着架构师迈进的路上必须跨越的门槛。11.4~11.6节主要介绍Flink SQL中的高级特性:窗口、聚合查询和联结查询,在这一部分中标准SQL语法和Flink的DataStream API彼此渗透融合,在流处理中使用SQL查询的特色体现得淋漓尽致;另一方面,这几节也是对SQL和DataStream API知识的一个总结。11.7节详细讲解了函数的用法,这部分主要是一个知识扩展,实际应用的场景较少,一般只需要知道系统函数的用法就够了。11.8、11.9两节介绍了SQL客户端工具和外部系统的连接器,内容相对比较简单,主要侧重于实际应用场景。

本章内容较多,如果仅以快速应用为目的,读者可以主要浏览11.1、11.2、11.4、11.5、11.9这五节内容;当然如果时间精力充沛,还是建议完整通读,并在官网详细浏览相关资料。Table API和SQL是Flink最上层的应用接口,目前尚不完善,但发展非常迅速,每个小版本都会有底层优化和功能扩展。可以想到不久的将来,Flink SQL将会是最为高效、最为普遍的开发手段,我们应该时刻保持跟进,随着框架的发展完善不断提升自己的技术能力。

你可能感兴趣的:(小坏讲大数据Flink第十一章,sql,flink,数据库)