史上最全干货!Flink SQL 成神之路(三)

crazeblue
发布于 2022-9-21 10:56
浏览
0收藏

作者 | antigeneral了呀

来源 | 大数据羊说(ID:young_say)

转载请联系授权(微信ID:___antigeneral)

3.SQL UDF 篇

Flink Table\SQL API 允许用户使用函数进行数据处理、字段标准化等处理。

3.1SQL 函数的归类

Flink 中的函数有两个维度的归类标准。

  1. ⭐ 一个归类标准是:系统(内置)函数和 Catalog 函数。系统函数没有命名空间,只能通过其名称来进行引用。Catalog 函数属于 Catalog 和数据库,因此它们拥有 Catalog 和数据库的命名空间。用户可以通过全/部分限定名(catalog.db.func 或 db.func)或者函数来对 Catalog 函数进行引用。
  2. ⭐ 另一个归类标准是:临时函数和持久化函数。临时函数由用户创建,它仅在会话的生命周期(也就是一个 Flink 任务的一次运行生命周期内)内有效。持久化函数不是由系统提供的,是存储在 Catalog 中,它在不同会话的生命周期内都有效。

这两个维度归类标准组合下,Flink SQL 总共提供了 4 种函数:

  1. ⭐ 临时性系统内置函数
  2. ⭐ 系统内置函数
  3. ⭐ 临时性 Catalog 函数(例如:Create Temporary Function)
  4. ⭐ Catalog 函数(例如:Create Function)

请注意,在用户使用函数时,系统函数始终优先于 Catalog 函数解析,临时函数始终优先于持久化函数解析。

3.2SQL 函数的引用方式

用户在 Flink 中可以通过精确、模糊两种引用方式引用函数。

3.2.1.精确函数

精确函数引用是让用户限定 Catalog,数据库名称进行精准定位一个 UDF 然后调用。

例如:select mycatalog.mydb.myfunc(x) from mytable 或者 select mydb.myfunc(x) from mytable。

3.2.2.模糊函数

在模糊函数引用中,用户只需在 SQL 查询中指定函数名就可以引用 UDF,例如:select myfunc(x) from mytable。

当然小伙伴萌问到,如果系统函数和 Catalog 函数的名称是重复的,Flink 体系是会使用哪一个函数呢?这就是下文要介绍的 UDF 解析顺序

3.3.SQL 函数的解析顺序

3.3.1.精确函数

由于精确函数应用一定会带上 Catalog 或者数据库名称,所以 Flink 中的精确函数引用一定是指向临时性 Catalog 函数或 Catalog 函数的。

比如:​​select mycatalog.mydb.myfunc(x) from mytable​​。

那么 Flink 对其解析顺序以及使用顺序如下:

  1. ⭐ 临时性 catalog 函数
  2. ⭐ Catalog 函数

3.3.2.模糊函数

比如 ​​select myfunc(x) from mytable​​。

解析顺序以及使用顺序如下:

  1. ⭐ 临时性系统内置函数
  2. ⭐ 系统内置函数
  3. ⭐ 临时性 Catalog 函数, 只会在当前会话的当前 Catalog 和当前数据库中查找函数及解析函数
  4. ⭐ Catalog 函数, 在当前 Catalog 和当前数据库中查找函数及解析函数

3.4.系统内置函数

系统内置函数小伙伴萌可以直接在 Flink 官网进行查询,博主这里就不多进行介绍。

​https://nightlies.apache.org/flink/flink-docs-release-1.13/zh/docs/dev/table/functions/systemfunctions/#hash-functions​

注意:

在目前 1.13 版本的 Flink 体系中,内置的系统函数没有像 Hive 内置的函数那么丰富,比如 Hive 中常见的 get_json_object 之类的,Flink 都是没有的,但是 Flink 提供了插件化 Module 的能力,能扩充一些 UDF,下文会进行介绍。

3.5.SQL 自定义函数(UDF)

!!!Flink 体系也提供了类似于其他大数据引擎的 UDF 体系。

自定义函数(UDF)是一种扩展开发机制,可以用来在查询语句里调用难以用 SQL 进行 ​​直接​​ 表达的频繁使用或自定义的逻辑。

目前 Flink 自定义函数可以基于 JVM 语言(例如 Java 或 Scala)或 Python 实现,实现者可以在 UDF 中使用任意第三方库,本章聚焦于使用 Java 语言开发自定义函数。

当前 Flink 提供了一下几种 UDF 能力:

  1. 标量函数(Scalar functions 或​​UDAF​​):输入一条输出一条,将标量值转换成一个新标量值,对标 Hive 中的 UDF;
  2. 表值函数(Table functions 或​​UDTF​​):输入一条条输出多条,对标 Hive 中的 UDTF;
  3. 聚合函数(Aggregate functions 或​​UDAF​​):输入多条输出一条,对标 Hive 中的 UDAF;
  4. 表值聚合函数(Table aggregate functions 或​​UDTAF​​):仅仅支持 Table API,不支持 SQL API,其可以将多行转为多行;
  5. 异步表值函数(Async table functions):这是一种特殊的 UDF,支持异步查询外部数据系统,用在前文介绍到的 lookup join 中作为查询外部系统的函数。

先直接给一个案例看看,怎么创建并在 Flink SQL 中使用一个 UDF:

import org.apache.flink.table.api.*;
import org.apache.flink.table.functions.ScalarFunction;
import static org.apache.flink.table.api.Expressions.*;

// 定义一个标量函数
public static class SubstringFunction extends ScalarFunction {
  public String eval(String s, Integer begin, Integer end){
    return s.substring(begin, end);
  }
}

TableEnvironment env = TableEnvironment.create(...);

// 在 Table API 可以直接以引用 class 方式使用 UDF
env.from("MyTable").select(call(SubstringFunction.class, $("myField"), 5, 12));

// 注册 UDF
env.createTemporarySystemFunction("SubstringFunction", SubstringFunction.class);

// Table API 调用 UDF
env.from("MyTable").select(call("SubstringFunction", $("myField"), 5, 12));

// SQL API 调用 UDF
env.sqlQuery("SELECT SubstringFunction(myField, 5, 12) FROM MyTable");

注意:如果你的函数在初始化时,是有入参的,那么需要你的入参是 ​​Serializable​​​ 的。即 Java 中需要继承 ​​Serializable​​ 接口。

案例如下:

import org.apache.flink.table.api.*;
import org.apache.flink.table.functions.ScalarFunction;
import static org.apache.flink.table.api.Expressions.*;

// 定义一个带有输入参数的标量函数
public static class SubstringFunction extends ScalarFunction {

  -- boolean 默认就是 Serializable 的
  private boolean endInclusive;

  public SubstringFunction(boolean endInclusive){
    this.endInclusive = endInclusive;
  }

  public String eval(String s, Integer begin, Integer end){
    return s.substring(begin, endInclusive ? end + 1 : end);
  }
}

TableEnvironment env = TableEnvironment.create(...);

// Table API 调用 UDF
env.from("MyTable").select(call(new SubstringFunction(true), $("myField"), 5, 12));

// 注册 UDF
env.createTemporarySystemFunction("SubstringFunction", new SubstringFunction(true));

3.6.开发 UDF 之前的需知事项

总结这几个事项主要包含以下步骤:

  1. 首先需要继承 Flink SQL UDF 体系提供的基类,每种 UDF 实现都有不同的基类
  2. 实现 UDF 执行逻辑函数,不同类型的 UDF 需要实现不同的执行逻辑函数
  3. 注意 UDF 入参、出参类型推导,Flink 在一些基础类型上的是可以直接推导出类型信息的,但是一些复杂类型就无能为力了,这里需要用户主动介入
  4. 明确 UDF 输出结果是否是定值,如果是定值则 Flink 会在生成计划时就执行一遍,得出结果,然后使用这个定值的结果作为后续的执行逻辑的参数,这样可以做到不用在 Flink SQL 任务运行时每次都执行一次,会有性能优化
  5. 巧妙运用运行时上下文,可以在任务运行前加载到一些外部资源、上下文配置信息,扩展 UDF 能力

3.6.1.继承 UDF 基类

和 Hive UDF 实现思路类似,在 Flink UDF 体系中,需要注意一下事项:

  1. ⭐ Flink UDF 要继承一个基类(比如标量 UDF 要继承​​org.apache.flink.table.functions.ScalarFunction​​)。
  2. ⭐ 类必须声明为​​public​​​,不能是​​abstract​​ 类,不能使用非静态内部类或匿名类。
  3. ⭐ 为了在 Catalog 中存储此类,该类必须要有默认构造函数并且在运行时可以进行实例化。

3.6.2.实现 UDF 执行逻辑函数

基类提供了一组可以被重写的方法,来给用户进行使用,这些可被重写的方法就是主要承担 UDF 自定义执行逻辑的地方。

举例在 ​​ScalarFunction​​ 中:

  1. ⭐​​open()​​:用于初始化资源(比如连接外部资源),程序初始化时进行调用
  2. ⭐​​close()​​:用于关闭资源,程序结束时进行调用
  3. ⭐​​isDeterministic()​​:用于判断返回结果是否是确定的,如果是确定的,结果会被直接执行
  4. ⭐​​eval(xxx)​​:Flink 用于处理每一条数据的主要处理逻辑函数

你可以自定义 eval 的入参,比如:

  • eval(Integer) 和 eval(LocalDateTime);
  • 使用变长参数,例如 eval(Integer...);
  • 使用对象,例如 eval(Object) 可接受 LocalDateTime、Integer 作为参数,只要是 Object 都可以;
  • 也可组合使用,例如 eval(Object...) 可接受所有类型的参数。

并且你可以在一个 UDF 中重载 eval 函数来实现不同的逻辑,比如:

import org.apache.flink.table.functions.ScalarFunction;

// 有多个重载求和方法的函数
public static class SumFunction extends ScalarFunction {

  // 入参为 Integer
  public Integer eval(Integer a, Integer b){
    return a + b;
  }

  // 入参为 String
  public Integer eval(String a, String b){
    return Integer.valueOf(a) + Integer.valueOf(b);
  }

  // 入参为多个 Double
  public Integer eval(Double... d){
    double result = 0;
    for (double value : d)
      result += value;
    return (int) result;
  }
}

注意:由于 Flink 在运行时会调用这些方法,所以这些方法必须声明为 public,并且包含明确的输入和输出参数。

3.6.3.注意 UDF 入参、出参类型推导

从两个角度来说,为什么函数的入参、出参类型会对 UDF 这么重要。

  1. ⭐ 从开发人员角度讲,在设计 UDF 的时候,肯定会涉及到 UDF 预期的入参、出参类型信息、也包括一些数据的精度、小数位数等信息
  2. ⭐ 从程序运行角度讲,Flink SQL 程序运行时,肯定也需要知道怎么将 SQL 中的类型数据与 UDF 的入参、出参类型,这样才能做数据序列化等操作

而 Flink 也提供了三种方式帮助 Flink 程序获取参数类型信息。

  1. ⭐ 自动类型推导功能:Flink 具备 UDF 自动类型推导功能,该功能可以通过反射从函数的类及其求值方法派生数据类型。比如如果你的 UDF 的方法或者类的签名中已经有了对应的入参、出参的类型,Flink 一般都可以推导并获取到这些类型信息。
  2. ⭐ 添加类型注解:当 1 中的隐式反射提取方法不成功,则可以通过使用 Flink 提供的​​@DataTypeHint​​​ 和​​@FunctionHint​​ 注解对应的参数、类或方法来显示的支持 Flink 参数类型提取。
  3. ⭐ 重写​​getTypeInference()​​​:你可以使用 Flink 提供的更高级的类型推导方法,你可以在 UDF 实现类中重写​​getTypeInference()​​ 方法去显示声明函数的参数类型信息

接下来介绍几个例子。

  1. ⭐ 自动类型推导案例:

自动类型推导会检查函数的 ​​类​​​ 签名和 ​​eval​​​ 方法签名,从而推导出函数入参和出参的数据类型,​​@DataTypeHint​​​ 和 ​​@FunctionHint​​ 注解也可以辅助支持自动类型推导。

关于自动类型推导具体将 Java 的对象会映射成 SQL 的具体哪个数据类型,可以参考 https://nightlies.apache.org/flink/flink-docs-release-1.13/docs/dev/table/types/#data-type-extraction

案例如下:

import org.apache.flink.table.annotation.DataTypeHint;
import org.apache.flink.table.annotation.InputGroup;
import org.apache.flink.table.functions.ScalarFunction;
import org.apache.flink.types.Row;

// 有多个重载求值方法的函数
public static class OverloadedFunction extends ScalarFunction {

  // 不需要任何声明,可以直接推导出类型信息,即入参和出参对应到 SQL 中的 bigint 类型
  public Long eval(long a, long b){
    return a + b;
  }

  // 使用 @DataTypeHint("DECIMAL(12, 3)") 定义 decimal 的精度和小数位
  public @DataTypeHint("DECIMAL(12, 3)") BigDecimal eval(double a, double b){
    return BigDecimal.valueOf(a + b);
  }

  // 使用注解定义嵌套数据类型
  @DataTypeHint("ROW<s STRING, t TIMESTAMP_LTZ(3)>")
  public Row eval(int i){
    return Row.of(String.valueOf(i), Instant.ofEpochSecond(i));
  }

  // 允许任意类型的输入,并输出序列化定制后的值
  @DataTypeHint(value = "RAW", bridgedTo = ByteBuffer.class)
  public ByteBuffer eval(@DataTypeHint(inputGroup = InputGroup.ANY) Object o) {
    return MyUtils.serializeToByteBuffer(o);
  }
}
  1. ⭐ 根据 @FunctionHint 注解自动推导类型案例:

使用 @DataTypeHint 注解虽好,但是有些场景下,使用起来比较复杂,比如:

  • ⭐ 我们不希望 eval 函数的入参和出参都是一个非常具体的类型,比如 long,int,double 等。我们希望它是一个通用的类型,比如 Object。这样的话就不用重载那么多的函数,可以直接使用一个 eval 函数实现不同的处理逻辑,返回不同类型的结果
  • ⭐ 多个 eval 方法的返回结果类型都是相同的,我们懒得写多次 @DataTypeHint

那么就可以使用 @FunctionHint 实现,@FunctionHint 是声明在类上面的,举例如下:

import org.apache.flink.table.annotation.DataTypeHint;
import org.apache.flink.table.annotation.FunctionHint;
import org.apache.flink.table.functions.TableFunction;
import org.apache.flink.types.Row;

// 1. 解耦类型推导与 eval 方法,类型推导根据 FunctionHint 注解中的信息来,下面的案例说明当前这个 UDF 有三种输入输出类型信息组合
@FunctionHint(
  input = {@DataTypeHint("INT"), @DataTypeHint("INT")},
  output = @DataTypeHint("INT")
)
@FunctionHint(
  input = {@DataTypeHint("BIGINT"), @DataTypeHint("BIGINT")},
  output = @DataTypeHint("BIGINT")
)
@FunctionHint(
  input = {},
  output = @DataTypeHint("BOOLEAN")
)
public static class OverloadedFunction extends TableFunction<Object> {

  public void eval(Object... o){
    if (o.length == 0) {
      collect(false);
    }
    collect(o[0]);
  }
}

// 2. 为函数类的所有 eval 方法指定同一个输出类型
@FunctionHint(output = @DataTypeHint("ROW<s STRING, i INT>"))
public static class OverloadedFunction extends TableFunction<Row> {

  public void eval(int a, int b){
    collect(Row.of("Sum", a + b));
  }

  public void eval(){
    collect(Row.of("Empty args", -1));
  }
}
  1. ⭐ getTypeInference()

getTypeInference() 可以做到根据小伙伴萌自定义的方式去定义类型推导过程及结果,具有高度自定义的能力。举例如下:

import org.apache.flink.table.api.DataTypes;
import org.apache.flink.table.catalog.DataTypeFactory;
import org.apache.flink.table.functions.ScalarFunction;
import org.apache.flink.table.types.inference.TypeInference;
import org.apache.flink.types.Row;

public static class LiteralFunction extends ScalarFunction {
  public Object eval(String s, String type){
    switch (type) {
      case "INT":
        return Integer.valueOf(s);
      case "DOUBLE":
        return Double.valueOf(s);
      case "STRING":
      default:
        return s;
    }
  }

  // 如果实现了 getTypeInference,则会禁用自动的反射式类型推导,使用如下逻辑进行类型推导
  @Override
  public TypeInference getTypeInference(DataTypeFactory typeFactory){
    return TypeInference.newBuilder()
      // 指定输入参数的类型,必要时参数会被隐式转换
      .typedArguments(DataTypes.STRING(), DataTypes.STRING())
      // 用户高度自定义的类型推导逻辑
      .outputTypeStrategy(callContext -> {
        if (!callContext.isArgumentLiteral(1) || callContext.isArgumentNull(1)) {
          throw callContext.newValidationError("Literal expected for second argument.");
        }
        // 基于第一个入参决定具体的返回数据类型
        final String literal = callContext.getArgumentValue(1, String.class).orElse("STRING");
        switch (literal) {
          case "INT":
            return Optional.of(DataTypes.INT().notNull());
          case "DOUBLE":
            return Optional.of(DataTypes.DOUBLE().notNull());
          case "STRING":
          default:
            return Optional.of(DataTypes.STRING());
        }
      })
      .build();
  }
}

3.6.4.明确 UDF 输出结果是否是定值

用户可以通过重写 ​​isDeterministic()​​ 函数来声明这个 UDF 产出的结果是否是一个定值。

对于纯函数(即没有入参的函数,比如 random(), date(), or now() 等)来说,默认情况下 ​​isDeterministic()​​ 返回 true,小伙伴萌可以自定义返回 false。

如果函数不是一个纯函数(即没有入参的函数,比如 random(), date(), or now() 等),这个方法必须返回 ​​false​​。

那么 ​​isDeterministic()​​ 方法的返回值到底影响什么呢?

答案:影响 Flink 任务在什么时候就直接执行这个 UDF。主要在以下两个方面体现:

  1. ⭐ Flink 在生成计划期间直接执行 UDF 获得结果:如果使用常量表达式调用函数,或者使用常量作为函数的入参,则 Flink 任务可能不会在任务正式运行时执行该函数。举个例子,​​SELECT ABS(-1) FROM t​​​,​​SELECT ABS(field) FROM t WHERE field = -1​​​,这两种都会被 Flink 进行优化,直接把 ABS(-1) 的结果在客户端生成执行计划时就将结果运行出来。如果不想在生成执行计划阶段直接将结果运行出来,可以实现​​isDeterministic()​​ 返回 false。
  2. ⭐ Flink 在程序运行期间执行 UDF 获得结果:如果 UDF 的入参不是常量表达式,或者​​isDeterministic()​​ 返回 false,则 Flink 会在程序运行期间执行 UDF。

那么小伙伴会问到,有些场景下 Flink SQL 是做了各种优化之后然后推断出表达式是否是常量,我怎么判断能够更加方便的判断出这个 Flink 是否将这个 UDF 的优化为固定结果了呢?

结论:这些都是可以在 Flink SQL 生成的算子图中看到,在 Flink web ui 中,每一个算子上面都可以详细看到 Flink 最终生成的算子执行逻辑。

3.6.5.巧妙运用运行时上下文

有时候我们想在 UDF 需要获取一些 Flink 任务运行的全局信息,或者在 UDF 真正处理数据之前做一些配置(setup)/清理(clean-up)的工作。UDF 为我们提供了 open() 和 close() 方法,你可以重写这两个方法做到类似于 DataStream API 中 RichFunction 的功能。

  1. ⭐​​open()​​ 方法:在任务初始化时被调用,常常用于加载一些外部资源;
  2. ⭐​​close()​​ 方法:在任务结束时被调用,常常用于关闭一些外部资源;

其中 open() 方法提供了一个 FunctionContext,它包含了一些 UDF 被执行时的上下文信息,比如 metric group、分布式文件缓存,或者是全局的作业参数等。

比如可以获取到下面的信息:

  1. ⭐ getMetricGroup():执行该函数的 subtask 的 Metric Group
  2. ⭐ getCachedFile(name):分布式文件缓存的本地临时文件副本
  3. ⭐ getJobParameter(name, defaultValue):获取 Flink 任务的全局作业参数
  4. ⭐ getExternalResourceInfos(resourceName):获取一些外部资源

下面的例子展示了如何在一个标量函数中通过 FunctionContext 来获取一个全局的任务参数:

import org.apache.flink.table.api.*;
import org.apache.flink.table.functions.FunctionContext;
import org.apache.flink.table.functions.ScalarFunction;

public static class HashCodeFunction extends ScalarFunction {

    private int factor = 0;

    @Override
    public void open(FunctionContext context) throws Exception {
        // 4. 在 UDF 中获取全局参数 hashcode_factor
        // 用户可以配置全局作业参数 "hashcode_factor"
        // 获取参数 "hashcode_factor"
        // 如果不存在,则使用默认值 "12"
        factor = Integer.parseInt(context.getJobParameter("hashcode_factor", "12"));
    }

    public int eval(String s){
        return s.hashCode() * factor;
    }
}

TableEnvironment env = TableEnvironment.create(...);

// 1. 设置任务参数
env.getConfig().addJobParameter("hashcode_factor", "31");

// 2. 注册函数
env.createTemporarySystemFunction("hashCode", HashCodeFunction.class);

// 3. 调用函数
env.sqlQuery("SELECT myField, hashCode(myField) FROM MyTable");

以上就是关于开发一个 UDF 之前,你需要注意的一些事项,这些内容不但包含了一些基础必备知识,也包含了一些扩展知识,帮助我们开发更强大的 UDF。

3.7.SQL 标量函数(Scalar Function)

标量函数即 UDF,常常用于进一条数据出一条数据的场景。

使用 Java\Scala 开发一个 Scalar Function 必须包含以下几点:

  1. ⭐ 实现​​org.apache.flink.table.functions.ScalarFunction​​ 接口
  2. ⭐ 实现一个或者多个自定义的 eval 函数,名称必须叫做 eval,eval 方法签名必须是 public 的
  3. ⭐ eval 方法的入参、出参都是直接体现在 eval 函数的签名中

举例:

import org.apache.flink.table.annotation.InputGroup;
import org.apache.flink.table.api.*;
import org.apache.flink.table.functions.ScalarFunction;
import static org.apache.flink.table.api.Expressions.*;

public static class HashFunction extends ScalarFunction {

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

TableEnvironment env = TableEnvironment.create(...);

// 在 Table API 里不经注册直接调用函数
env.from("MyTable").select(call(HashFunction.class, $("myField")));

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

// 在 Table API 里调用注册好的函数
env.from("MyTable").select(call("HashFunction", $("myField")));

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

3.8.SQL 表值函数(Table Function)

表值函数即 UDTF,常用于进一条数据,出多条数据的场景。

使用 Java\Scala 开发一个 Table Function 必须包含以下几点:

  1. ⭐ 实现​​org.apache.flink.table.functions.TableFunction​​ 接口
  2. ⭐ 实现一个或者多个自定义的 eval 函数,名称必须叫做 eval,eval 方法签名必须是 public 的
  3. ⭐ eval 方法的入参是直接体现在 eval 函数签名中,出参是体现在 TableFunction 类的泛型参数 T 中,eval 是没有返回值的,这一点是和标量函数不同的,Flink TableFunction 接口提供了​​collect(T)​​​ 来发送输出的数据。这一点也比较好理解,如果都体现在函数签名上,那就成了标量函数了,而使用​​collect(T)​​​ 才能体现出​​进一条数据​​​​出多条数据​

在 SQL 中是用 SQL 中的 ​​LATERAL TABLE(<TableFunction>)​​​ 配合 ​​JOIN​​​、​​LEFT JOIN​​​ xxx ​​ON TRUE​​ 使用。

举例:

import org.apache.flink.table.annotation.DataTypeHint;
import org.apache.flink.table.annotation.FunctionHint;
import org.apache.flink.table.api.*;
import org.apache.flink.table.functions.TableFunction;
import org.apache.flink.types.Row;
import static org.apache.flink.table.api.Expressions.*;

@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(Row.of(s, s.length()));
    }
  }
}

TableEnvironment env = TableEnvironment.create(...);

// 在 Table API 里可以直接调用 UDF
env
  .from("MyTable")
  .joinLateral(call(SplitFunction.class, $("myField")))
  .select($("myField"), $("word"), $("length"));

env
  .from("MyTable")
  .leftOuterJoinLateral(call(SplitFunction.class, $("myField")))
  .select($("myField"), $("word"), $("length"));

// 在 Table API 里重命名 UDF 的结果字段
env
  .from("MyTable")
  .leftOuterJoinLateral(call(SplitFunction.class, $("myField")).as("newWord", "newLength"))
  .select($("myField"), $("newWord"), $("newLength"));

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

// 在 Table API 里调用注册好的 UDF
env
  .from("MyTable")
  .joinLateral(call("SplitFunction", $("myField")))
  .select($("myField"), $("word"), $("length"));

env
  .from("MyTable")
  .leftOuterJoinLateral(call("SplitFunction", $("myField")))
  .select($("myField"), $("word"), $("length"));

// 在 SQL 里调用注册好的 UDF
env.sqlQuery(
  "SELECT myField, word, length " +
  "FROM MyTable, LATERAL TABLE(SplitFunction(myField))");

env.sqlQuery(
  "SELECT myField, word, length " +
  "FROM MyTable " +
  "LEFT JOIN LATERAL TABLE(SplitFunction(myField)) ON TRUE");

// 在 SQL 里重命名 UDF 字段
env.sqlQuery(
  "SELECT myField, newWord, newLength " +
  "FROM MyTable " +
  "LEFT JOIN LATERAL TABLE(SplitFunction(myField)) AS T(newWord, newLength) ON TRUE");

注意:

如果你是使用 Scala 实现函数,不要使用 Scala 中 object 实现 UDF,Scala object 是单例的,有可能会导致并发问题。

3.9.SQL 聚合函数(Aggregate Function)

聚合函数即 UDAF,常用于进多条数据,出一条数据的场景。

史上最全干货!Flink SQL 成神之路(三)-鸿蒙开发者社区

UDAF

上面的图片展示了一个聚合函数的例子以及聚合函数包含的几个重要方法。

假设你有一个关于饮料的表。表里面有三个字段,分别是 id、name、price,表里有 5 行数据。

假设你需要找到所有饮料里最贵的饮料的价格,即执行一个 max() 聚合就能拿到结果。那么 max() 聚合的执行旧需要遍历所有 5 行数据,最终结果就只有一个数值。

使用 Java\Scala 开发一个 Aggregate Function 必须包含以下几点:

  1. ⭐ 实现​​AggregateFunction​​ 接口,其中所有的方法必须是 public 的、非 static 的
  2. ⭐ 必须实现以下几个方法:
  • ⭐​​Acc聚合中间结果 createAccumulator()​​:为当前 Key 初始化一个空的 accumulator,其存储了聚合的中间结果,比如在执行 max() 时会存储当前的 max 值
  • ⭐​​accumulate(Acc accumulator, Input输入参数)​​:对于每一行数据,都会调用 accumulate() 方法来更新 accumulator,这个方法就是用于处理每一条输入数据;这个方法必须声明为 public 和非 static 的。accumulate 方法可以重载,每个方法的参数类型可以不同,并且支持变长参数。
  • ⭐​​Output输出参数 getValue(Acc accumulator)​​:通过调用 getValue 方法来计算和返回最终的结果
  1. ⭐ 还有几个方法是在某些场景下才必须实现的:
  • ⭐​​retract(Acc accumulator, Input输入参数)​​:在回撤流的场景下必须要实现,Flink 在计算回撤数据时需要进行调用,如果没有实现则会直接报错
  • ⭐​​merge(Acc accumulator, Iterable<Acc> it)​​:在许多批式聚合以及流式聚合中的 Session、Hop 窗口聚合场景下都是必须要实现的。除此之外,这个方法对于优化也很多帮助。例如,如果你打开了两阶段聚合优化,就需要 AggregateFunction 实现 merge 方法,从而可以做到在数据进行 shuffle 前先进行一次聚合计算。
  • ⭐​​resetAccumulator()​​:在批式聚合中是必须实现的。
  1. ⭐ 还有几个关于入参、出参数据类型信息的方法,默认情况下,用户的​​Input输入参数​​​(​​accumulate(Acc accumulator, Input输入参数)​​​ 的入参​​Input输入参数​​​)、accumulator(​​Acc聚合中间结果 createAccumulator()​​​ 的返回结果)、​​Output输出参数​​​ 数据类型(​​Output输出参数 getValue(Acc accumulator)​​​ 的​​Output输出参数​​​)都会被 Flink 使用反射获取到。但是对于​​accumulator​​​ 和​​Output输出参数​​​ 类型来说,Flink SQL 的类型推导在遇到复杂类型的时候可能会推导出错误的结果(注意:​​Input输入参数​​​ 因为是上游算子传入的,所以类型信息是确认的,不会出现推导错误的情况),比如那些非基本类型 POJO 的复杂类型。所以跟 ScalarFunction 和 TableFunction 一样,AggregateFunction 提供了​​AggregateFunction#getResultType()​​​ 和​​AggregateFunction#getAccumulatorType()​​ 来分别指定最终返回值类型和 accumulator 的类型,两个函数的返回值类型都是 TypeInformation,所以熟悉 DataStream 的小伙伴很容易上手。
  • ⭐​​getResultType()​​​:即​​Output输出参数 getValue(Acc accumulator)​​ 的输出结果数据类型
  • ⭐​​getAccumulatorType()​​​:即​​Acc聚合中间结果 createAccumulator()​​ 的返回结果数据类型

这个时候,我们直接来举一个加权平均值的例子看下,总共 3 个步骤:

  • ⭐ 定义一个聚合函数来计算某一列的加权平均
  • ⭐ 在 TableEnvironment 中注册函数
  • ⭐ 在查询中使用函数

为了计算加权平均值,accumulator 需要存储加权总和以及数据的条数。在我们的例子里,我们定义了一个类 WeightedAvgAccumulator 来作为 accumulator。

Flink 的 checkpoint 机制会自动保存 accumulator,在失败时进行恢复,以此来保证精确一次的语义。

我们的 WeightedAvg(聚合函数)的 accumulate 方法有三个输入参数。第一个是 WeightedAvgAccum accumulator,另外两个是用户自定义的输入:输入的值 ivalue 和 输入的权重 iweight。

尽管 retract()、merge()、resetAccumulator() 这几个方法在大多数聚合类型中都不是必须实现的,博主也在样例中提供了他们的实现。并且定义了 getResultType() 和 getAccumulatorType()。

import org.apache.flink.table.api.*;
import org.apache.flink.table.functions.AggregateFunction;
import static org.apache.flink.table.api.Expressions.*;

// 自定义一个计算权重 avg 的 accmulator
public static class WeightedAvgAccumulator {
  public long sum = 0;
  public int count = 0;
}

// 输入:Long iValue, Integer iWeight
public static class WeightedAvg extends AggregateFunction<Long, WeightedAvgAccumulator> {

  @Override 
  // 创建一个 accumulator
  public WeightedAvgAccumulator createAccumulator(){
    return new WeightedAvgAccumulator();
  }

  public void accumulate(WeightedAvgAccumulator acc, Long iValue, Integer iWeight){
    acc.sum += iValue * iWeight;
    acc.count += iWeight;
  }

  public void retract(WeightedAvgAccumulator acc, Long iValue, Integer iWeight){
    acc.sum -= iValue * iWeight;
    acc.count -= iWeight;
  }

  @Override
  // 获取返回结果
  public Long getValue(WeightedAvgAccumulator acc){
    if (acc.count == 0) {
      return null;
    } else {
      return acc.sum / acc.count;
    }
  }

  // Session window 可以使用这个方法将几个单独窗口的结果合并
  public void merge(WeightedAvgAccumulator acc, Iterable<WeightedAvgAccumulator> it){
    for (WeightedAvgAccumulator a : it) {
      acc.count += a.count;
      acc.sum += a.sum;
    }
  }

  public void resetAccumulator(WeightedAvgAccumulator acc){
    acc.count = 0;
    acc.sum = 0L;
  }
}

TableEnvironment env = TableEnvironment.create(...);

env
  .from("MyTable")
  .groupBy($("myField"))
  .select($("myField"), call(WeightedAvg.class, $("value"), $("weight")));

// 注册函数
env.createTemporarySystemFunction("WeightedAvg", WeightedAvg.class);

// Table API 调用函数
env
  .from("MyTable")
  .groupBy($("myField"))
  .select($("myField"), call("WeightedAvg", $("value"), $("weight")));

// SQL API 调用函数
env.sqlQuery(
  "SELECT myField, WeightedAvg(`value`, weight) FROM MyTable GROUP BY myField"
);

3.10.SQL 表值聚合函数(Table Aggregate Function)

表值聚合函数即 UDTAF。首先说明这个函数目前只能在 Table API 中进行使用,不能在 SQL API 中使用。那么这个函数有什么作用呢,为什么被创建出来?

因为在 SQL 表达式中,如果我们想对数据先分组再进行聚合取值,能选择的就是 ​​select max(xxx) from source_table group by key1, key2​​。但是上面这个 SQL 的 max 语义最后产出的结果只有一条最终结果,如果我想取聚合结果最大的 n 条数据,并且 n 条数据,每一条都要输出一次结果数据,上面的 SQL 就没有办法实现了(因为在聚合的情况下还输出多条,从上述 SQL 语义上来说就是不正确的)。

所以 UDTAF 就是为了处理这种场景,他可以让我们自定义 ​​怎么去​​​,​​取多少条​​ 最终的聚合结果。所以可以看到 UDTAF 和 UDAF 是类似的。如下图所示:

史上最全干货!Flink SQL 成神之路(三)-鸿蒙开发者社区

UDTAF

上图展示了一个表值聚合函数的例子。

假设你有一个饮料的表,这个表有 3 列,分别是 id、name 和 price,一共有 5 行。

假设你需要找到价格最高的两个饮料,类似于 top2() 表值聚合函数。你需要遍历所有 5 行数据,输出结果为 2 行数据的一个表。

使用 Java\Scala 开发一个 Table Aggregate Function 必须包含以下几点:

  1. ⭐ 实现​​TableAggregateFunction​​ 接口,其中所有的方法必须是 public 的、非 static 的
  2. ⭐ 必须实现以下几个方法:
  • ⭐​​Acc聚合中间结果 createAccumulator()​​:为当前 Key 初始化一个空的 accumulator,其存储了聚合的中间结果,比如在执行 max() 时会存储每一条中间结果的 max 值
  • ⭐​​accumulate(Acc accumulator, Input输入参数)​​:对于每一行数据,都会调用 accumulate() 方法来更新 accumulator,这个方法就是对每一条输入数据进行执行,比如执行 max() 时,遍历每一条数据执行;在实现这个方法是必须声明为 public 和非 static 的。accumulate 方法可以重载,每个方法的参数类型不同,并且支持变长参数。
  • ⭐​​emitValue(Acc accumulator, Collector<OutPut> collector)​​​ 或者​​emitUpdateWithRetract(Acc accumulator, RetractableCollector<OutPut> collector)​​:当遍历所有的数据,当所有的数据都处理完了之后,通过调用 emit 方法来计算和输出最终的结果,在这里你就可以自定义到底输出多条少以及怎么样去输出结果。那么对于 emitValue 以及 emitUpdateWithRetract 的区别来说,拿 TopN 实现来说,emitValue 每次都会发送所有的最大的 n 个值,而这在流式任务中可能会有一些性能问题。为了提升性能,用户可以实现 emitUpdateWithRetract 方法。这个方法在 retract 模式下会增量的输出结果,比如只在有数据更新时,可以做到撤回老的数据,然后再发送新的数据,而不需要每次都发出全量的最新数据。如果我们同时定义了 emitUpdateWithRetract、emitValue 方法,那 emitUpdateWithRetract 会优先于 emitValue 方法被使用,因为引擎会认为 emitUpdateWithRetract 会更加高效,因为它的输出是增量的。
  1. ⭐ 还有几个方法是在某些场景下才必须实现的:
  • ⭐​​retract(Acc accumulator, Input输入参数)​​:在回撤流的场景下必须要实现,Flink 在计算回撤数据时需要进行调用,如果没有实现则会直接报错
  • ⭐​​merge(Acc accumulator, Iterable<Acc> it)​​:在许多批式聚合以及流式聚合中的 Session、Hop 窗口聚合场景下都是必须要实现的。除此之外,这个方法对于优化也很多帮助。例如,如果你打开了两阶段聚合优化,就需要 AggregateFunction 实现 merge 方法,从而在第一阶段先进行数据聚合。
  • ⭐​​resetAccumulator()​​:在批式聚合中是必须实现的。
  1. ⭐ 还有几个关于入参、出参数据类型信息的方法,默认情况下,用户的​​Input输入参数​​​(​​accumulate(Acc accumulator, Input输入参数)​​​ 的入参​​Input输入参数​​​)、accumulator(​​Acc聚合中间结果 createAccumulator()​​​ 的返回结果)、​​Output输出参数​​​ 数据类型(​​emitValue(Acc acc, Collector<Output输出参数> out)​​​ 的​​Output输出参数​​​)都会被 Flink 使用反射获取到。但是对于​​accumulator​​​ 和​​Output输出参数​​​ 类型来说,Flink SQL 的类型推导在遇到复杂类型的时候可能会推导出错误的结果(注意:​​Input输入参数​​​ 因为是上游算子传入的,所以类型信息是确认的,不会出现推导错误的情况),比如那些非基本类型 POJO 的复杂类型。所以跟 ScalarFunction 和 TableFunction 一样,AggregateFunction 提供了​​TableAggregateFunction#getResultType()​​​ 和​​TableAggregateFunction#getAccumulatorType()​​ 来分别指定最终返回值类型和 accumulator 的类型,两个函数的返回值类型都是 TypeInformation,所以熟悉 DataStream 的小伙伴很容易上手。
  • ⭐​​getResultType()​​​:即​​emitValue(Acc acc, Collector<Output输出参数> out)​​ 的输出结果数据类型
  • ⭐​​getAccumulatorType()​​​:即​​Acc聚合中间结果 createAccumulator()​​ 的返回结果数据类型

这个时候,我们直接来举一个 Top2 的例子看下吧:

  • ⭐ 定义一个 TableAggregateFunction 来计算给定列的最大的 2 个值
  • ⭐ 在 TableEnvironment 中注册函数
  • ⭐ 在 Table API 查询中使用函数(当前只在 Table API 中支持 TableAggregateFunction)

为了计算最大的 2 个值,accumulator 需要保存当前看到的最大的 2 个值。

在我们的例子中,我们定义了类 Top2Accum 来作为 accumulator。

Flink 的 checkpoint 机制会自动保存 accumulator,并且在失败时进行恢复,来保证精确一次的语义。

我们的 Top2 表值聚合函数(TableAggregateFunction)的 accumulate() 方法有两个输入,第一个是 Top2Accum accumulator,另一个是用户定义的输入:输入的值 v。尽管 merge() 方法在大多数聚合类型中不是必须的,我们也在样例中提供了它的实现。并且定义了 getResultType() 和 getAccumulatorType() 方法。

/**
 * Accumulator for Top2.
 */
public class Top2Accum {
    public Integer first;
    public Integer second;
}

public static class Top2 extends TableAggregateFunction<Tuple2<Integer, Integer>, Top2Accum> {

    @Override
    public Top2Accum createAccumulator(){
        Top2Accum acc = new Top2Accum();
        acc.first = Integer.MIN_VALUE;
        acc.second = Integer.MIN_VALUE;
        return acc;
    }


    public void accumulate(Top2Accum acc, Integer v){
        if (v > acc.first) {
            acc.second = acc.first;
            acc.first = v;
        } else if (v > acc.second) {
            acc.second = v;
        }
    }

    public void merge(Top2Accum acc, java.lang.Iterable<Top2Accum> iterable){
        for (Top2Accum otherAcc : iterable) {
            accumulate(acc, otherAcc.first);
            accumulate(acc, otherAcc.second);
        }
    }

    public void emitValue(Top2Accum acc, Collector<Tuple2<Integer, Integer>> out){
        // emit the value and rank
        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));
        }
    }
}

// 注册函数
StreamTableEnvironment tEnv = ...
tEnv.registerFunction("top2", new Top2());

// 初始化表
Table tab = ...;

// 使用函数
tab.groupBy("key")
    .flatAggregate("top2(a) as (v, rank)")
    .select("key, v, rank");

下面的例子展示了如何使用 emitUpdateWithRetract 方法来只发送更新的数据。

为了只发送更新的结果,accumulator 保存了上一次的最大的 2 个值,也保存了当前最大的 2 个值。

/**
 * Accumulator for Top2.
 */
public class Top2Accum {
    public Integer first;
    public Integer second;
    public Integer oldFirst;
    public Integer oldSecond;
}

public static class Top2 extends TableAggregateFunction<Tuple2<Integer, Integer>, Top2Accum> {

    @Override
    public Top2Accum createAccumulator(){
        Top2Accum acc = new Top2Accum();
        acc.first = Integer.MIN_VALUE;
        acc.second = Integer.MIN_VALUE;
        acc.oldFirst = Integer.MIN_VALUE;
        acc.oldSecond = Integer.MIN_VALUE;
        return acc;
    }

    public void accumulate(Top2Accum acc, Integer v){
        if (v > acc.first) {
            acc.second = acc.first;
            acc.first = v;
        } else if (v > acc.second) {
            acc.second = v;
        }
    }

    public void emitUpdateWithRetract(Top2Accum acc, RetractableCollector<Tuple2<Integer, Integer>> out){
        if (!acc.first.equals(acc.oldFirst)) {
            // if there is an update, retract old value then emit new value.
            if (acc.oldFirst != Integer.MIN_VALUE) {
                out.retract(Tuple2.of(acc.oldFirst, 1));
            }
            out.collect(Tuple2.of(acc.first, 1));
            acc.oldFirst = acc.first;
        }

        if (!acc.second.equals(acc.oldSecond)) {
            // if there is an update, retract old value then emit new value.
            if (acc.oldSecond != Integer.MIN_VALUE) {
                out.retract(Tuple2.of(acc.oldSecond, 2));
            }
            out.collect(Tuple2.of(acc.second, 2));
            acc.oldSecond = acc.second;
        }
    }
}

// 注册函数
StreamTableEnvironment tEnv = ...
tEnv.registerFunction("top2", new Top2());

// 初始化表
Table tab = ...;

// 使用函数
tab.groupBy("key")
    .flatAggregate("top2(a) as (v, rank)")
    .select("key, v, rank");


分类
标签
已于2022-9-21 10:56:02修改
收藏
回复
举报
回复
    相关推荐