本文为您介绍如何为 Flink 自定义处理表值函数(UDPTF)开发、注册和使用流程。
定义
自定义处理表值函数(User-Defined Process Table Function,简称 UDPTF)是一类面向 Flink SQL 和 Table API 的高级自定义函数。PTF 可以接收零个、一个或多个表参数以及标量参数,并输出零行、一行或多行数据。与 UDTF 相比,PTF 不仅能逐行转换数据,还可以按键处理一组相关数据,并使用 Flink 托管状态、事件时间、定时器和 Changelog,实现状态聚合、事件匹配、超时处理、自定义关联等逻辑。详情请参见 Process Table Functions(PTFs)。
仅实时计算引擎 VVR 11.9 及以上版本支持 UDPTF。
UDPTF 开发
UDPTF 的实现原则与其他自定义函数类似。更多详情请参阅用户自定义函数的实现指南。
开发 UDPTF 时,需要继承 org.apache.flink.table.functions.ProcessTableFunction<T>,并实现名为 eval(...) 的方法。eval() 必须为 public 且不能为 static,不支持方法重载。方法签名顺序如下:
eval( <context>? , <state entry>* , <call argument>* )输入和输出类型默认通过反射推导。也可以使用 @FunctionHint、@ArgumentHint、@StateHint 和 @DataTypeHint 显式声明。eval() 不返回结果,而是通过 collect(T) 输出零行、一行或多行数据。
有关 Context、状态参数、调用参数,详情请参考 PTF 实现指南。
在 Maven 项目中,将对应的 Flink 依赖设置为 provided。最小依赖示例如下:
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-common</artifactId>
<version>2.3.0</version>
<scope>provided</scope>
</dependency>支持能力一览
PTF 支持以下特性:
特性 | 说明 |
定义行语义和集合语义的表参数 | 不同语义决定了每条数据是可以被独立处理的,还是在某个键上存在关联、需要被一起处理 |
访问状态 | 集合语义的 PTF 可以声明和使用状态 |
时间和定时器 | PTF 可以使用事件时间(Event Time);集合语义的 PTF 可以使用被水印驱动的定时器 |
排序 | 集合语义的 PTF 可以定义排序键,将数据按指定顺序依次触发 |
多表处理 | 所有表参数都需要声明为集合语义且采用相同的分区。可以实现自定义连接等业务逻辑。 |
透传列 | 是否在 PTF 的输出中,加入输入表的所有列 |
处理更新和变更日志 | PTF 可以消费、产生更新流 |
下面将逐个介绍这些特性:
定义行语义和集合语义的表参数
定义了行语义表参数 ArgumentTrait.ROW_SEMANTIC_TABLE 的 PTF 假定各行之间不存在关联,因此每一行都可以独立处理。框架可以自由决定如何将当前行分配到某个 PTF 实例处理。
定义了集合语义表参数 ArgumentTrait.SET_SEMANTIC_TABLE 的 PTF 假定各行之间存在关联,PARTITION BY 子句定义用于关联的列,框架将会把数据按照分区键进行 shuffle,确保属于同一分区键的所有行将会被分配到同一个并发上处理。也可以通过声明参数 ArgumentTrait.OPTIONAL_PARTITION_BY 不定义分区键,此时所有数据将会被同一个 PTF 实例处理。
例如:
// 采用了行语义的 UDPTF
// 使用方式为 SELECT * FROM f(r => TABLE t)
public void eval(
@ArgumentHint(ArgumentTrait.ROW_SEMANTIC_TABLE) Row input) {
...
}
// 采用了集合语义的 UDPTF
// 使用方式为 SELECT * FROM f(r => TABLE t PARTITION BY name)
// 或 SELECT * FROM f(r => TABLE t)
public void eval(
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input) {
...
}详情请参考 表语义和虚拟处理器。
访问状态
一个被定义成集合语义的 PTF 可以是有状态的。框架将会在故障或者重启期间存储和恢复这些状态。状态按照 PARTITION BY 子句定义的分区键分区,如果未定义分区键,则为单个分区(即为 Flink 中的 keyed state)。
状态可以在 PTF 中用 StateHint定义,并且可以显示声明状态类型、状态过期时间等参数。例如:
class StateFunction extends ProcessTableFunction<String> {
/** POJO for state. All fields nullable. */
public static class Score {
public String s;
public Integer i;
}
public void eval(
Context ctx,
// 显式声明该状态的数据结构
@StateHint(type = @DataTypeHint("ROW<emitted BOOLEAN>")) Row s0,
// 显式声明该状态过期时间为 5 天
@StateHint(ttl = "5 days") Score s1,
// 显式声明该状态过期时间为 0 毫秒(即永不过期),无时间单位即为毫秒
@StateHint(ttl = "0") Score s2,
// 该状态过期时间为参数 table.exec.state.ttl 的值
@StateHint Score s3,
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row r) {
...
// 可以通过 ctx.clearState("s1") 或 ctx.clearAllState()
// 来清理状态,防止状态过大
}
}目前 PTF 内可以使用以下 3 种状态类型
Value State:表示单个值。是 PTF 中的默认状态类型。
List State:表示值列表,可以进行追加、删除、迭代操作。
Map State:表示一个键值对的映射,用于高效查询、修改和删除某个条目。
默认情况下, PTF 中的状态为 Value State,每次访问都需要从状态后端读取和写入完整的数据。为了优化状态访问和不必要的序列化和反序列化,状态对象可以声明为:
org.apache.flink.table.api.dataview.ListView(即为 List State)org.apache.flink.table.api.dataview.MapView(即为 Map State)
详情请参考状态。
时间和定时器
PTF 原生支持事件时间。您可以借助 PTF 中的事件时间和定时器,自定义处理延迟触发、迟到数据处理等业务逻辑。
时间
每个 PTF 在调用时都有一个可选的 on_time 来声明被定义成事件时间的时间属性列,也可以通过声明参数 ArgumentTrait.REQUIRE_ON_TIME 来强制要求调用时必须声明 on_time 列。
SELECT * FROM f(..., on_time => DESCRIPTOR(`my_timestamp`));您可以通过 Context#timeContext(Class) 获取 TimeContext 来访问时间进行自定义操作。该时间上下文的作用域始终限定于当前正在处理的事件。该事件可以是当前输入行, 也可以是正在触发的定时器。时间和定时器的时间戳可以表示为 java.time.Instant、java.time.LocalDateTime 或 Long。 这些时间戳以纪元以来的毫秒数为基础,不考虑本地会话时区。可以将时间类作为参数传给 timeContext()。
/** 为给定键发送 ping 的函数。在观察到该键的最后一个事件一分钟后发送 ping。 */
public static class PingLaterFunction
extends ProcessTableFunction<String> {
public void eval(
Context ctx,
@ArgumentHint({ArgumentTrait.SET_SEMANTIC_TABLE,
ArgumentTrait.REQUIRE_ON_TIME})
Row input
) {
TimeContext<Instant> timeCtx = ctx.timeContext(Instant.class);
// 替换现有定时器,因此必要时可能会重新开始一分钟倒计时
timeCtx.registerOnTime("ping", timeCtx.time().plus(Duration.ofMinutes(1)));
}
public void onTimer(OnTimerContext onTimerCtx) {
collect("ping");
}
}TimeContext 提供了多个函数,可以获取到当前处理数据的事件时间、触发定时器的时间、当前输入侧的水印时间、当前 PTF 实例的水印时间。详情请参考时间。
定时器
集合语义的 PTF 支持使用定时器。定时器允许在稍后的时间点处理数据,从而实现等待、同步或超时等操作。当水位线推进时,定时器会在注册的时间被触发。
需要使用定时器的 PTF 必须声明 onTimer() 方法,以处理定时器事件。 onTimer() 的方法签名如下:
onTimer( <on timer context>? , <state entry>* )框架会在故障或重启期间存储和恢复定时器。因此,定时器是一种特殊的状态。 同样,定时器的作用域限定于 PARTITION BY 子句定义的状态分区中。定时器只能在当前分区键中注册和删除。当定时器过多影响性能时,您可以通过 Context#clearAllTimers() 或 TimeContext#clearTimer(String) 清理不再需要的定时器。详情请参考定时器。
排序
集合语义的 PTF 可以选择在调用时指定 ORDER BY 子句,以定义每个分区内各行的处理顺序。ORDER BY 子句保证各行按指定顺序传递给 eval() 方法。
ORDER BY 子句要求第一列是时间属性列(即已声明水印的 TIMESTAMP 或 TIMESTAMP_LTZ 列)。第一个 ORDER BY 列必须按升序指定,以确保按事件时间顺序处理各行。 还可以指定其他列作为次级排序键,以定义具有相同时间戳的各行的顺序。详情请参考排序。
SELECT * FROM my_ptf(
input_table => TABLE source_table
PARTITION BY user_id
ORDER BY (event_time ASC, priority DESC NULLS FIRST)
)框架会为 PTF 维护一个由水位线驱动的排序缓冲区,以重排乱序事件。当水位线推进时,所有时间戳小于或等于水位线的已缓冲行会按排序顺序传递给 eval() 方法,后续迟到事件(事件时间小于水位线的数据)会被丢弃。
多表处理
PTF 可以同时处理多张表。eval() 方法可以指定多个表参数以支持多个输入。所有表参数都必须声明 为集合语义并采用一致的分区方式,即所有相关表参数的 PARTITION BY 子句中的列数及其数据类型必须一致。
来自任一输入的行每次只向函数传递一行。因此,同一时刻只有一个表参数非 null。使用 null 检查来确定当前正在处理哪个输入。
以下是自定义连接的一个例子:
/** 向客户问候,并在有可用信息时推荐上次所购物品的函数。 */
public static class GreetingWithLastPurchase extends ProcessTableFunction<String> {
/** 在状态中保留最近购买的物品。 */
public static class LastItemState {
public String lastItem;
}
/** eval() 方法接收两个 @ArgumentHint(SET_SEMANTIC_TABLE) 参数 */
public void eval(
@StateHint LastItemState state,
@ArgumentHint(SET_SEMANTIC_TABLE) Row visit,
@ArgumentHint(SET_SEMANTIC_TABLE) Row purchase) {
// 处理来自 Purchases 表的行
if (purchase != null) {
state.lastItem = purchase.getFieldAs("item");
}
// 处理来自 Visits 表的行
else if (visit != null) {
if (state.lastItem == null) {
collect("Hello " + visit.getFieldAs("name") + ", let me know if I can help!");
} else {
collect("Hello " + visit.getFieldAs("name") + ", here to buy " + state.lastItem + " again?");
}
}
}
}详情请参考多表。
输入表过多会影响作业的性能,因此输入表的数量上限为 20 张表。
透传列
根据表语义以及是否定义了 on_time 参数,框架会为 PTF 函数的输出添加附加列。
对于集合语义的 PTF,输出会以PARTITION BY 列为前缀。对于带有 on_time 参数的调用,输出会以 <rowtime> 为后缀。因此,默认的输出格式为:
<PARTITION BY keys> | <function output> | <rowtime>您可以定义 ArgumentTrait.PASS_COLUMNS_THROUGH 参数,来让 PTF 输出所有的输入表的列。例如有一张输入表 t(包含列 k 和 v),和一个 PTF 函数 f()(产生列 c1 和 c2),查询 SELECT * FROM f(table_arg => TABLE t PARTITION BY k) 的输出类型为:
默认:
| k | c1 | c2 |
定义 ArgumentTrait.PASS_COLUMNS_THROUGH 后:
| k | v | c1 | c2 |详情请参考透传列。
当前ArgumentTrait.PASS_COLUMNS_THROUGH 参数仅适用于接收单个表参数、不使用定时器且仅消费和产生仅追加流的 PTF。
更新和变更日志
默认情况下,PTF 仅支持消费、产生仅追加流。下面这些参数或接口,可以让 PTF 处理更新流数据
参数或接口 | 说明 |
| 允许 PTF 消费更新流 |
| 强制让上游使用带有完整 默认情况下,对上游无要求。 |
| 允许 PTF 产生更新流 |
以下是 PTF 消费更新流的示例:
@DataTypeHint("ROW<flag STRING, sum INT>")
public static class ToChangelogFunction extends ProcessTableFunction<Row> {
public void eval(@ArgumentHint({SET_SEMANTIC_TABLE, SUPPORT_UPDATES}) Row input) {
// 转发 sum 列,并将行类型作为字符串列包含进来。
Row changelogRow =
Row.of(
input.getKind().toString(),
input.getField("sum"));
collect(changelogRow);
}
}以下是 PTF 产生更新流的示例:
@DataTypeHint("ROW<sum INT>")
public static class CustomAggregation
extends ProcessTableFunction<Row>
implements ChangelogFunction {
@Override
public ChangelogMode getChangelogMode(ChangelogContext changelogContext) {
// 告知框架 PTF 生成以回撤流
return ChangelogMode.all();
}
public static class Accumulator {
public Integer sum = 0;
}
public void eval(
@StateHint Accumulator state,
@ArgumentHint(SET_SEMANTIC_TABLE) Row input) {
int score = input.getFieldAs("score");
// 负数状态表示该分区
// 键此前已被标记为无效
if (state.sum == -1) {
return;
}
// 负分数会将整个聚合结果标记为无效。
if (score < 0) {
// 为受影响的分区键发出 -D,并
// 在状态中标记失效。所有后续操作
// 和接收器都将删除该聚合结果。
collect(Row.ofKind(RowKind.DELETE, state.sum));
state.sum = -1;
} else {
if (state.sum == 0) {
// 为第一个有效聚合结果发出 +I。
state.sum += score;
collect(Row.ofKind(RowKind.INSERT, state.sum));
} else {
// 发出 -U(包含旧聚合结果)和 +U(包含新聚合结果)
// 以编码更新。
collect(Row.ofKind(RowKind.UPDATE_BEFORE, state.sum));
state.sum += score;
collect(Row.ofKind(RowKind.UPDATE_AFTER, state.sum));
}
}
}
}详情请参考更新和变更日志。
ArgumentTrait.SUPPORT_UPDATES参数不支持和ArgumentTrait.PASS_COLUMNS_THROUGH参数同时使用。带有
on_time参数的 PTF 不支持消费更新流。与社区不同,在实时计算引擎 VVR 11.9 的 PTF 上
REQUIRE_FULL_DELETE参数不会生效。
UDPTF 注册
UDPTF 注册过程,请参见管理自定义函数(UDF)。
UDPTF 使用
可以在 SQL 作业中直接调用 UDPTF。
以下列按用户统计访问次数的 UDPTF 为例:
package com.example.ptf;
import org.apache.flink.table.annotation.ArgumentHint;
import org.apache.flink.table.annotation.ArgumentTrait;
import org.apache.flink.table.annotation.DataTypeHint;
import org.apache.flink.table.annotation.StateHint;
import org.apache.flink.table.functions.ProcessTableFunction;
import org.apache.flink.types.Row;
@DataTypeHint("ROW<visit_count BIGINT>")
public class VisitCount extends ProcessTableFunction<Row> {
public static class CountState {
public Long count;
}
public void eval(
@StateHint CountState state,
@ArgumentHint(
value = ArgumentTrait.SET_SEMANTIC_TABLE,
name = "input")
Row input) {
if (state.count == null) {
state.count = 0L;
}
state.count++;
collect(Row.of(state.count));
}
}可以在 SQL 中直接使用上述 UDPTF:
CREATE TEMPORARY TABLE UserEvents (
user_id STRING,
event_name STRING
) WITH (
'connector' = 'datagen',
-- 限定 STRING 类型字段的长度,生成重复 user_id
'fields.user_id.length' = '3'
);
CREATE TEMPORARY TABLE ResultSink (
user_id STRING,
visit_count BIGINT
) WITH (
'connector' = 'blackhole'
);
CREATE TEMPORARY FUNCTION VisitCount AS 'com.example.ptf.VisitCount';
INSERT INTO ResultSink
SELECT user_id, visit_count
FROM VisitCount(
input => TABLE UserEvents PARTITION BY user_id
);
调用后,VisitCount 按 user_id 隔离状态。系统会在函数输出前自动添加分区列,因此结果包含 user_id 和 visit_count。
使用限制
由于 Flink 社区 PTF 仍在演进中,目前存在以下限制:
PTF 仅支持流模式,不支持批模式。
PTF 目前不支持 Broadcast State。