Flink从入门到精通系列(九)

11.7、函数

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

11.7.1、系统函数

系统函数(System Functions)也叫内置函数(Built-in Functions),是在系统中预先实现好的功能模块。我们可以通过固定的函数名直接调用,实现想要的转换操作。

Flink SQL 提供了大量的系统函数,几乎支持所有的标准 SQL 中的操作,这为我们使用 SQL 编写流处理程序提供了极大的方便。Flink SQL 中的系统函数又主要可以分为两大类:标量函数(Scalar Functions)和聚合函数(Aggregate Functions)。

11.7.1.1、标量函数(Scalar Functions)

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

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

  • 比较函数(Comparison Functions)
    比较函数其实就是一个比较表达式,用来判断两个值之间的关系,返回一个布尔类型的值。这个比较表达式可以是用 <、>、= 等符号连接两个值,也可以是用关键字定义的某种判断。例如:
    • value1 = value2 判断两个值相等;
    • value1 <> value2 判断两个值不相等
    • value IS NOT NULL 判断 value 不为空
  • 逻辑函数(Logical Functions)
    逻辑函数就是一个逻辑表达式,也就是用与(AND)、或(OR)、非(NOT)将布尔类型的值连接起来,也可以用判断语句(IS、IS NOT)进行真值判断;返回的还是一个布尔类型的值。例如:
    • boolean1 OR boolean2 布尔值 boolean1 与布尔值 boolean2 取逻辑或
    • boolean IS FALSE 判断布尔值 boolean 是否为 false
    • NOT boolean 布尔值 boolean 取逻辑非
  • 算术函数(Arithmetic Functions)

进行算术计算的函数,包括用算术符号连接的运算,和复杂的数学运算。例如:

  • numeric1 + numeric2 两数相加
  • POWER(numeric1, numeric2) 幂运算,取数 numeric1 的 numeric2 次方
  • RAND() 返回(0.0, 1.0)区间内的一个 double 类型的伪随机数
  • 字符串函数(String Functions)

进行字符串处理的函数。例如:

  • string1 || string2 两个字符串的连接
  • UPPER(string) 将字符串 string 转为全部大写
  • CHAR_LENGTH(string) 计算字符串 string 的长度
  • 时间函数(Temporal Functions)

进行与时间相关操作的函数。例如:

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

11.7.1.2、聚合函数(Aggregate Functions)

聚合函数是以表中多个行作为输入,提取字段进行聚合操作的函数,会将唯一的聚合值作为结果返回。聚合函数应用非常广泛,不论分组聚合、窗口聚合还是开窗(Over)聚合,对数据的聚合操作都可以用相同的函数来定义。
标准 SQL 中常见的聚合函数 Flink SQL 都是支持的,目前也在不断扩展,为流处理应用提供更强大的功能。例如:

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

11.7.1.2、自定义函数(UDF)

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

  • 标量函数(Scalar Functions):将输入的标量值转换成一个新的标量值;
  • 表函数(Table Functions):将标量值转换成一个或多个新的行数据,也就是扩展成一个表;
  • 聚合函数(Aggregate Functions):将多行数据里的标量值转换成一个新的标量值;
  • 表聚合函数(Table Aggregate Functions):将多行数据里的标量值转换成一个或多个新的行数据。
11.7.1.2.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 注册为系统函数就可以了。

  1. 使用 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对象了。

  1. 在 SQL 中调用函数

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

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

可见,SQL 的调用方式更加方便,我们后续依然会以 SQL 为例介绍 UDF 的用法。

11.7.1.2.2、标量函数(Scalar Functions)

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

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

这里需要特别说明的是,ScalarFunction 抽象类中并没有定义 eval()方法,所以我们不能直接在代码中重写(override);但 Table API 的框架底层又要求了求值方法必须名字为 eval()。
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 的参数可以是任意类型。

11.7.1.2.3、表函数(Table Functions)

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

类似地,要实现自定义的表函数,需要自定义类来继承抽象类 TableFunction,内部必须要实现的也是一个名为 eval 的求值方法。与标量函数不同的是,TableFunction 类本身是有一个泛型参数T 的,这就是表函数返回数据的类型;而 eval()方法没有返回类型,内部也没有 return语句,是通过调用 collect()方法来发送想要输出的行数据的。

DataStream API 中的 FlatMapFunction 和 ProcessFunction,它们的 flatMap 和 processElement 方法也没有返回值,也是通过 out.collect()来向下游发送数据的。

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

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

// 注意这里的类型标注,输出是 Row 类型,Row 中包含两个字段:word 和 length。
@FunctionHint(output = @DataTypeHint("ROW<word STRING, length INT>"))
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 中进行了调用,还可以对侧向表的中字段进行重命名。

11.7.1.2.4、聚合函数(Aggregate Functions)

用户自定义聚合函数(User Defined AGGregate function,UDAGG)会把一行或多行数据(也就是一个表)聚合成一个标量值。这是一个标准的“多对一”的转换,聚合函数的概念我们之前已经接触过多次,如 SUM()、MAX()、MIN()、AVG()、COUNT()都是常见的系统内置聚合函数。而如果有些需求无法直接调用系统函数解决,我们就必须自定义聚合函数来实现功能了。

自定义聚合函数需要继承抽象类 AggregateFunction。AggregateFunction 有两个泛型参数<T, ACC>,T 表示聚合输出的结果类型,ACC 则表示聚合的中间状态类型。Flink SQL 中的聚合函数的工作原理如下:

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

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

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

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

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

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

resetAccumulator()方法则是重置累加器,这在一些批处理场景中会比较有用。AggregateFunction 的所有方法都必须是 公有的(public),不能是静态的(static),而且名字必须跟上面写的完全一样。 createAccumulator 、 getValue 、 getResultType 以 及getAccumulatorType 这几个方法是在抽象类 AggregateFunction 中定义的,可以 override;而其他则都是底层架构约定的方法。

例如我们要从学生的分数表 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。

11.7.1.2.5、表聚合函数(Table Aggregate Functions)

自定义表聚合函数需要继承抽象类 TableAggregateFunction。TableAggregateFunction 的结构和原理与 AggregateFunction 非常类似,同样有两个泛型参数<T, ACC>,用一个 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()将它们提取出来了。

11.9、连接到外部系统

11.9.1、Kafka

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

11.9.1.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 包。

11.9.1.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。

11.9.1.3、Upsert Kafka

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

为了解决这个问题,Flink 专门增加了一个“更新插入 Kafka”(Upsert Kafka)连接器。这个连接器支持以更新插入(UPSERT)的方式向 Kafka 的 topic 中读写数据。具体来说,Upsert Kafka 连接器处理的是更新日志(changlog)流。如果作为 TableSource,连接器会将读取到的 topic中的数据(key, alue),解释为对当前 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的序列化格式。

11.9.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 对数据进行了分区操作。文件系统连接器支持对分区文件的访问。

11.9.3、JDBC

关系型数据表本身就是 SQL 最初应用的地方,所以我们也会希望能直接向关系型数据库中读写表数据。Flink 提供的 JDBC 连接器可以通过 JDBC 驱动程序(driver)向任意的关系型数据库读写数据,比如 MySQL、PostgreSQL、Derby 等。
作为 TableSink 向数据库写入数据时,运行的模式取决于创建表的 DDL 是否定义了主键(primary key)。如果有主键,那么 JDBC 连接器就将以更新插入(Upsert)模式运行,可以向外部数据库发送按照指定键(key)的更新(UPDATE)和删除(DELETE)操作;如果没有定义主键,那么就将在追加(Append)模式下运行,不支持更新和删除操作。

11.9.3.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 版本来进行选择。

11.9.3.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 表环境中的表。

11.9.4、Elasticsearch

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

11.9.4.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>

11.9.4.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 写入数据。

11.9.5、HBase

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

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

11.9.5.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>

11.9.5.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 的表。

猜你喜欢

转载自blog.csdn.net/prefect_start/article/details/129570461