1.为什么要在 Flink SQL 里直接跑模型?
传统的实时推理架构一般是这样的链路:
- Flink 做特征聚合,把结果写入 Kafka / Redis;
- 线上服务(Java / Python 微服务)从 Kafka 或缓存拉特征;
- 调用模型服务(TensorFlow Serving、PyTorch Server 或自研 gRPC 接口);
- 把预测结果再写回 Kafka / DB。
这条链路的问题是:
- 组件多、链路长,延迟和稳定性都不太友好;
- 业务逻辑被拆散:一部分在 Flink SQL,一部分在后端服务里,不易维护;
- 实时数仓 / 实时决策希望"写一条 SQL 就搞定",却不得不再开整套服务化工程。
Flink 在 1.20 之后引入的 ML_PREDICT 表值函数,正是为了解决这件事:
直接在 SQL 里写:
SELECT * FROM ML_PREDICT(TABLE xxx, MODEL my_model, ...)把模型推理变成"一个 TVF + 几行 SQL"。
你可以在流式计算任务中:
- 上游是 Kafka / Pulsar / CDC 源;
- 中间用 SQL 做清洗、特征构造;
- 最后一层用
ML_PREDICT打上模型预测结果; - 整个过程只需要一条 Flink SQL Job,就能跑完整的在线特征 + 实时推理。
2. ML_PREDICT 的整体语义
ML_PREDICT 是一个 表值函数(TVF):
- 输入 :一张表(
input_table)+ 一个已注册的模型(model_name); - 输出:原表所有列 + 模型预测产生的列。
简单可以理解为:
text
input_table --(ML_PREDICT with model)--> input_columns + prediction_columns
从 SQL 写法上看,它和前面你已经用过的 TUMBLE / HOP / CUMULATE / SESSION 非常像,都是:
sql
SELECT ...
FROM ML_PREDICT(TABLE ..., MODEL ..., DESCRIPTOR(...), CONFIG => ...)
只不过,这次 TVF 做的事情是"调用模型推理"。
3. 语法拆解:把每个参数讲清楚
完整语法:
sql
SELECT * FROM
ML_PREDICT(
TABLE input_table,
MODEL model_name,
DESCRIPTOR(feature_columns),
[CONFIG => MAP['key', 'value']]
);
3.1 input_table:要推理的数据源
可以是:
- 物理表(比如 Kafka Source 定义出来的表);
- 视图 / 子查询结果(做完过滤、特征工程之后再喂给模型);
比如:
sql
WITH features AS (
SELECT
user_id,
device_id,
age,
log_ts,
score1,
score2
FROM RawEvents
WHERE event_type = 'click'
)
SELECT *
FROM ML_PREDICT(
TABLE features,
MODEL ctr_model,
DESCRIPTOR(age, score1, score2)
);
3.2 MODEL model_name:Catalog 中的模型
model_name 必须是已经 在 Catalog 中注册好的模型 。
模型的输入 / 输出 Schema 都是在 Model Creation 阶段定义的。
一旦注册完成,就可以在 SQL 里像引用一张表一样引用这个模型。
3.3 DESCRIPTOR(feature_columns):特征列映射
DESCRIPTOR 告诉 Flink:输入表里的哪几列是用来做特征的。
- 列的顺序、数量必须与模型输入 Schema 对齐;
- 否则会直接抛异常(很重要,防止线上悄悄算错)。
举例:
sql
DESCRIPTOR(age, score1, score2)
就表示模型的输入是 3 个特征,对应当前表里的这三列。
3.4 CONFIG:推理配置(重点是异步)
CONFIG 是一个 MAP['key','value'],常用配置包括:
-
async:是否使用异步推理('true'/'false'); -
max-concurrent-operations:异步并发度(异步 I/O 的 buffer 大小); -
output-mode:"ORDERED":按输入顺序输出(严格有序);"ALLOW_UNORDERED":允许无序输出(性能更好);
-
timeout:一次异步推理从发起到完成的超时时间。
例如:
sql
SELECT * FROM ML_PREDICT(
TABLE input_table,
MODEL ctr_model,
DESCRIPTOR(age, score1, score2),
MAP[
'async', 'true',
'max-concurrent-operations', '1000',
'output-mode', 'ALLOW_UNORDERED',
'timeout', '3s'
]
);
4. 同步 vs 异步推理:什么时候该 async?
在高吞吐实时场景下,模型推理通常是下游瓶颈:
- 如果每条记录都同步调用一次模型(RPC / HTTP),并等待返回;
- 吞吐、延迟都比较吃力。
Flink 提供的异步 I/O 能力,在 ML_PREDICT 里也被用上了:
-
async = false → PredictRuntimeProvider(同步)
- Provider 实现
createPredictFunction; - Flink 在算子内同步调用预测方法;
- 写法简单,但受单个请求延迟限制明显。
- Provider 实现
-
async = true → AsyncPredictRuntimeProvider(异步)
- Provider 实现
createAsyncPredictFunction; - 算子一次可以挂起多个请求,通过回调 / future 收结果;
max-concurrent-operations控制并发度;output-mode控制结果是否必须按输入顺序发射。
- Provider 实现
如果你不在配置里显式写 async:
- 系统会自己在 Provider 中选择同步或异步;
- 如果两种能力都存在,会优先选择异步 Provider。
实践经验:
- 高 QPS + 外部模型服务延迟明显 → 首选异步;
- 简单本地模型(如轻量 Embedding、树模型) → 同步就够,可简化实现;
- 如果结果顺序不会影响下游逻辑,可以考虑
ALLOW_UNORDERED进一步提升吞吐。
5. 输出 Schema 与列名冲突
输出表 = 输入表所有列 + 模型输出列。
注意两个细节:
-
模型输出的列结构由模型本身定义(输出 Schema);
-
如果输出列名与输入表已有列名冲突,会自动加索引后缀:
- 模型定义的输出列叫
prediction; - 输入表中已经有一个
prediction列; - 最终输出列会自动改名为
prediction0。
- 模型定义的输出列叫
这可以避免因为列名冲突导致 SQL 难以解析,但在写下游逻辑(比如 Sink 到某个表)时,记得用新名字。
6. ModelProvider:把你的模型"接"进 Flink
ML_PREDICT 本身只是个 SQL 壳子,真正执行推理的是 ModelProvider。
一个 Provider 的职责,可以简单理解为:
- 告诉 Flink:如何创建一个可被算子调用的预测函数。
按同步 / 异步能力不同,分为两类:
6.1 PredictRuntimeProvider(同步)
- 需要实现
createPredictFunction方法; - 方法返回的是一个普通的预测函数,算子会逐条调用它;
- 用于
async = false的情况。
6.2 AsyncPredictRuntimeProvider(异步)
-
实现
createAsyncPredictFunction; -
预测函数返回的是 Future / 回调形式结果;
-
对应 Flink 的
AsyncDataStream模式,需要配置:timeout:单条记录异步推理的超时时间;max-concurrent-operations:最多挂起多少未完成的预测请求。
只要你在 Model Creation 阶段把模型和对应 Provider 注册进 Catalog,
在 SQL 里就只需要写一个 MODEL my_model 即可,不用关心具体是 TensorFlow、PyTorch 还是自研服务。
7. 约束与错误处理:踩坑之前要知道的事
7.1 只支持 append-only 表
文档里有一句容易被忽视:
ML_PREDICT只支持追加流(append-only tables),不支持 CDC 表。
原因是模型推理往往是非确定性的:
- 比如模型版本变动、外部依赖的特征变化等等;
- 如果上游是 CDC(带 update / delete),很难对"旧结果"做正确补偿。
所以:
- 更推荐在已经完成 CDC 归并、只保留追加写的表上调用
ML_PREDICT; - 或者把 CDC 先聚合成"快照流 / 事实表",再作为 append-only 源输入。
7.2 常见报错场景
会抛异常的情况包括:
- 模型在 Catalog 中不存在;
DESCRIPTOR中特征列数量与模型输入 Schema 不一致;- 缺少
MODEL参数; - 参数个数错了(太多或太少);
async=true但使用的 Provider 不支持异步。
这些错误基本都发生在 SQL 校验阶段,能尽早暴露问题。
8. 性能调优小贴士
在实际项目里,如果你希望 ML_PREDICT 既好用又高效,可以从这几方面入手:
-
优先异步:
- 高 QPS、外部服务延迟 > 5--10ms 时,强烈建议
async = 'true'; - 同时配合
max-concurrent-operations(比如几百到几千)。
- 高 QPS、外部服务延迟 > 5--10ms 时,强烈建议
-
合理设置 timeout:
- 太小 → 频繁超时、重试,浪费资源;
- 太大 → 延迟尾部很高,backpressure 难以定位;
- 一般可以设置为"外部模型 P99 * 2~3"。
-
评估有序 / 无序输出:
- 如果只是把预测结果写回 Kafka / OLAP,结果顺序通常没那么重要,可以用
"ALLOW_UNORDERED"; - 如果下游逻辑依赖严格顺序(比如和原日志按 offset 合并),就老老实实保持
"ORDERED"。
- 如果只是把预测结果写回 Kafka / OLAP,结果顺序通常没那么重要,可以用
-
尽量在 Flink 内完成特征工程:
- 把原始事件在 SQL 层做完清洗 / 特征聚合后再喂给模型;
- 上游特征越干净简单,模型推理稳定性越好。
9. 一个完整的小例子:实时 CTR 预测
最后给一个稍完整一点的示意:
sql
-- 1. 原始点击流,来自 Kafka
CREATE TABLE raw_clicks (
user_id STRING,
item_id STRING,
age INT,
gender STRING,
log_ts TIMESTAMP_LTZ(3),
WATERMARK FOR log_ts AS log_ts - INTERVAL '5' SECOND
) WITH (...);
-- 2. 特征工程:做简单编码与组合
CREATE VIEW ctr_features AS
SELECT
user_id,
item_id,
CAST(age AS DOUBLE) AS age_f,
CASE gender WHEN 'M' THEN 1.0 ELSE 0.0 END AS gender_f,
log_ts
FROM raw_clicks;
-- 3. 调用 ML_PREDICT 做实时 CTR 推理
CREATE TABLE ctr_with_pred (
user_id STRING,
item_id STRING,
age_f DOUBLE,
gender_f DOUBLE,
log_ts TIMESTAMP_LTZ(3),
ctr DOUBLE -- 预测结果
) WITH (...);
INSERT INTO ctr_with_pred
SELECT
user_id,
item_id,
age_f,
gender_f,
log_ts,
prediction AS ctr -- 假设模型输出列名为 prediction
FROM ML_PREDICT(
TABLE ctr_features,
MODEL ctr_model,
DESCRIPTOR(age_f, gender_f),
MAP['async', 'true',
'max-concurrent-operations', '1000',
'output-mode', 'ALLOW_UNORDERED',
'timeout', '2s']
);
在这条 SQL Job 中:
- 上游 Kafka → Flink 负责特征加工;
ML_PREDICT直接把模型推理嵌入到 SQL 里;- 下游
ctr_with_pred就是一条"带预测结果的实时流",可以继续写进 Kafka / ClickHouse / Doris 等任意 Sink。
10. 小结
ML_PREDICT 把"模型推理"变成了 Flink SQL 世界里的一个普通 TVF:
- 使用方式与
TUMBLE / HOP / CUMULATE / SESSION非常一致; - 上手成本比自研一整套模型服务低得多;
- 又能通过 ModelProvider 挂接任意底层模型框架。