1. PTF 是什么:UDF 的"超集"
Process Table Functions(PTFs)是 Flink SQL & Table API 中最强的函数类型,可以实现接近内置算子的能力:
- 输入:零/一/多张表(也可混合 scalar 参数)
- 输出:零/一/多行(任意 Row 或结构化类型)
- 能力:Flink 托管状态(managed state)、事件时间(event time)、Timer、底层 changelog(CDC)
一句话:PTF 让你用"函数"写一个可状态化、可计时、可处理更新的表算子。
2. PTF 与 SQL:2016 PTF 的关系
文档里提到 SQL:2016 的 Polymorphic Table Functions(同样简称 PTF)。Flink 的 Process Table Functions 在语义上对齐 SQL 标准的一些调用特征(表参数、row/set 语义、descriptor 参数等),但同时增强了 Flink 的流式能力:
- 状态管理(Flink state backend)
- 时间与 watermark
- Timer 服务
- 运行时 Changelog 能力
你可以理解为:Flink 在 SQL 标准 PTF 上叠加了流式计算"必须的三件套":state、time、changelog。
3. PTF 最核心的概念:Row 语义 vs Set 语义
PTF 的 eval() 不是"只接受一行",它可以接受一个"表参数",并声明该表如何被理解:
3.1 Row Semantics(行语义)
- 认为每行彼此独立
- 系统可自由分发,每个虚拟处理器一次只看到当前行
- 通常无状态(或者不依赖历史)
示例:给每个 name 加个 greeting(逐行处理)
java
public static class Greeting extends ProcessTableFunction<String> {
public void eval(@ArgumentHint(ArgumentTrait.ROW_SEMANTIC_TABLE) Row input) {
collect("Hello " + input.getFieldAs("name") + "!");
}
}
3.2 Set Semantics(集合语义)
- 认为行之间有关联,需要按 key 聚合成一个"集合"
- 调用时必须(或可选)指定 PARTITION BY
- 允许状态:同一个 key 下的历史行可通过 state 记忆
示例:同一个 name 来过几次
java
public static class GreetingWithMemory extends ProcessTableFunction<String> {
public static class CountState { public long counter = 0L; }
public void eval(
@StateHint CountState state,
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input
) {
state.counter++;
collect("Hello " + input.getFieldAs("name") + ", your " + state.counter + " time?");
}
}
调用(Table API):
java
env.fromValues("Bob","Alice","Bob")
.as("name")
.partitionBy($("name"))
.process(GreetingWithMemory.class)
.execute()
.print();
4. Virtual Processor:为什么 PTF 既能扩展又能有状态
PTF 会把输入表分布到所谓"虚拟处理器(virtual processor)"上执行。你可以理解为:一个 virtual processor 对应一个 key 的处理上下文(或者 row 语义下随机分发)。
- Row 语义:processor 只看到当前 row
- Set 语义:processor 被 PARTITION BY key "圈定",同 key 的数据共定位,state/timer 也都在这个 key 上生效
这就是 PTF 既能 scale-out,又能做到 per-key 状态机的根本原因。
5. 调用语法:隐式参数 on_time 与 uid
PTF 调用时,除了你定义的参数,系统还会"隐式补两类参数":
- on_time:用于事件时间语义(DESCRIPTOR)
- uid:用于 stateful query evolution(保证 savepoint 恢复、fan-out 优化等)
推荐 name-based 调用方式,后续演进更稳:
SQL:
sql
SELECT * FROM TableFilter(input => TABLE t, threshold => 100, uid => 'my-ptf');
Table API:
java
env.from("t")
.process(TableFilter.class,
lit(100).asArgument("threshold"),
lit("my-ptf").asArgument("uid"));
6. 实现规则:eval() 方法签名是"铁律"
PTF 只支持一个 eval()(不支持重载),签名模式:
eval( <context>? , <state entry>* , <call argument>* )
- Context(可选)必须是第一个
- State entries 必须在用户参数之前
- eval 必须 public,不能 static
7. State:PTF 的灵魂(含 TTL / 大状态)
7.1 基本 state(Value State)
通过 @StateHint 声明一个可变参数作为 state:
java
class CountingFunction extends ProcessTableFunction<String> {
public static class CountState { public long count = 0L; }
public void eval(
@StateHint CountState memory,
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input
) {
memory.count++;
collect("Seen rows: " + memory.count);
}
}
7.2 State TTL(建议默认就设计)
java
public void eval(
Context ctx,
@StateHint(ttl = "1 day") SeenState memory,
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input
) { ... }
TTL 基于 processing time,能有效避免"开 keyspace"导致 state 无限增长。
7.3 大状态:ListView / MapView(避免整块反序列化)
- ListView:列表 state
- MapView:map state,按 key 读取更省
java
class LargeHistoryFunction extends ProcessTableFunction<String> {
public void eval(
@StateHint MapView<String, Integer> largeMemory,
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input
) {
String eventId = input.getFieldAs("eventId");
Integer count = largeMemory.get(eventId);
largeMemory.put(eventId, count == null ? 1 : count + 1);
}
}
8. Time & Timers:让 PTF 变成"事件时间状态机"
8.1 on_time 与 rowtime 输出
声明 on_time 后,PTF 输出会自动带一个 rowtime 列,用于下游继续做时间计算。
SQL:
sql
SELECT * FROM PingLaterFunction(
input => TABLE Events PARTITION BY id,
on_time => DESCRIPTOR(ts)
);
8.2 定时器使用模式:eval 注册,onTimer 响应
典型例子:最后一次事件后 1 分钟发 ping
java
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");
}
}
设计建议:Timer 也会占 state,尽量减少 timer 数量,及时 clearAllTimers/clearAllState。
9. 多表输入:PTF 可以做"自定义 Join"
PTF 可以同时接收多张表(都必须 set semantics,且 PARTITION BY 结构一致)。一次 eval 只会有一个表参数非空,通过 null 判断来源。
示例:访问表 Visits + 购买表 Purchases,按用户关联,记住 last purchase:
java
public static class GreetingWithLastPurchase extends ProcessTableFunction<String> {
public static class LastItemState { public String lastItem; }
public void eval(
@StateHint LastItemState state,
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row visit,
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row purchase
) {
if (purchase != null) {
state.lastItem = purchase.getFieldAs("item");
} 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?");
}
}
}
}
注意:多输入的到达顺序可能导致非确定性,要么用 watermark 做"时间驱动",要么用条件缓冲来保证逻辑严谨。
10. UID:PTF 独有的"状态化查询演进"能力
PTF 是可持久化状态块,周围 SQL 变了也可能恢复,只要 state schema 不变。为此,Flink 要求 set semantics 的 PTF 有唯一 UID:
- 未指定 uid:默认用函数名(同一个 statement 中只能出现一次)
- 多次调用:必须手动指定 uid,确保全局唯一
- 同 uid:优化器可做 fan-out(共享一个 stateful PTF)
这对"一个状态机输出分流到多个 sink"非常重要。
11. Changelog(更新/撤回)支持:PTF 可以玩 CDC
默认 PTF 假设输入是 append-only(+I),输出也是 append-only,这对 watermark 与时间语义最友好。
若要接更新表,必须声明:
SUPPORTS_UPDATES:允许更新进入REQUIRE_UPDATE_BEFORE:强制 retract 模式(-U/+U)REQUIRE_FULL_DELETE:强制 full delete(-D 全字段)
示例:把更新表转成 append-only(把 RowKind 写进 payload,输出始终 +I)
java
@DataTypeHint("ROW<flag STRING, sum INT>")
public static class ToChangelogFunction extends ProcessTableFunction<Row> {
public void eval(@ArgumentHint({ArgumentTrait.SET_SEMANTIC_TABLE, ArgumentTrait.SUPPORTS_UPDATES}) Row input) {
collect(Row.of(input.getKind().toString(), input.getField("sum")));
}
}
更高级:实现 ChangelogFunction,自己声明输出模式(retract / upsert / delete 规则)。但要非常谨慎:输出 changelog 声明错了会导致整条 pipeline 行为未定义。
12. 高级案例:购物车状态机(最典型 PTF)
购物车本质就是 per-user 状态机:ADD/REMOVE/CHECKOUT + REMINDER/TIMEOUT。
PTF 用 state 存 cart,用 timer 做 reminder/timeout,CHECKOUT 后 clear state------这就是 PTF 的"正确打开方式"。
这类场景用传统 SQL + UDF 很难优雅实现,但 PTF 非常顺。
13. 当前限制(务必注意)
文档明确提到一些限制(你贴的结尾也有):
- PTF 不能跑 batch mode
- 部分能力在早期阶段:例如 broadcast state 等(文档后面还会列更多限制)
- 如果 PTF 接 updates:很多功能会受限(例如 on_time 不支持等,文档中也强调了)
建议:PTF 目前适合"流式、事件驱动、状态机类"问题。
14. 什么时候该用 PTF
用一句很实际的话总结:
- 你只是做字段变换 → ScalarFunction
- 一行拆多行 / 维表 lookup → TableFunction / AsyncTableFunction
- 多行聚一值 → AggregateFunction(UDAGG)
- 多行聚多行 → TableAggregateFunction(UDTAGG)
- 你要状态机 + timer + 复杂 state + 多表协同 + 处理更新 → PTF