ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyFlink 1.11.0 UDF实战:标量、向量化与SQL/Table API注册全解析

PyFlink 1.11.0 UDF实战:标量、向量化与SQL/Table API注册全解析

1. 为什么要在 PyFlink 里折腾 UDF

PyFlink 从 1.11.0 版本开始,对 Python 自定义函数(UDF)的支持才算真正进入可用阶段。在此之前,想在 Flink 的 Python 表 API 里写一个自定义逻辑,要么绕道 Java/Scala 写 UDF 再注册,要么干脆放弃 Table API 走 DataStream 的map。1.11.0 这个节点很关键,它把python.fn_execution相关的执行链路、udf装饰器、向量化执行(pandas UDF)这几块拼图基本凑齐了,让 Python 开发者能比较自然地把自己写的函数塞进 SQL 或 Table API 的查询里。

我最初接触这个版本,是因为一个日志清洗的活儿:原始数据里有一列是半结构化的字符串,需要按业务规则拆成几个字段,规则还经常变。用 Java 写 UDF 每次改规则都要重新打包、走一遍上线流程,太笨重。换成 PyFlink 之后,规则逻辑用 Python 写,改完直接重启作业就行,迭代速度完全不是一个量级。但真上手才发现,1.11.0 的 UDF 有好几种写法,每种写法的适用场景、性能特征、踩坑点都不一样,官方文档又写得比较散。这篇就把我当时摸清楚的几种方式完整梳理一遍,包括普通 UDF、向量化 UDF、在 SQL 里注册、在 Table API 里注册,以及它们各自的坑。

先明确一下这篇文章适合谁看:如果你已经在用 PyFlink 做批处理或流处理,想把手头的 Python 函数接进 Table API / SQL 查询里,那这篇基本能覆盖你 90% 的需求。如果你还没搭过 PyFlink 环境,建议先把pyflink装好、跑通一个TableEnvironment的 hello world 再回来,不然下面有些细节会看得云里雾里。

提示:1.11.0 是一个相对早期的版本,后续版本在 UDF 的 API 上有过调整(比如udf装饰器的参数、向量化执行的默认行为)。本文所有结论都以 1.11.0 为准,如果你用的是更高版本,遇到行为不一致时优先查对应版本的 release note。

2. 环境准备里那些容易翻车的细节

2.1 Python 版本与依赖的硬约束

PyFlink 1.11.0 对 Python 版本的要求是 3.5、3.6、3.7 三个版本,3.8 在当时还没被正式支持。我一开始图省事用了 3.8,本地跑单测没问题,一提交到集群就报Unsupported Python version,排查了半天才反应过来是版本问题。所以第一件事,确认你的 Python 版本落在支持区间内。

依赖方面,核心就一个:

pip install apache-flink==1.11.0

但这里有个隐藏坑:apache-flink这个包会连带装一堆东西,包括pyflink、pemja(1.11 时代还没完全切到 pemja,部分链路还是 jython 风格)、pandas、numpy等。如果你本地已经有一个特定版本的 pandas,pip 可能会把它升级或降级,导致你其他项目跑不起来。我的做法是给 PyFlink 单独建一个虚拟环境,别和主环境混用。

python -m venv pyflink-env source pyflink-env/bin/activate pip install apache-flink==1.11.0

2.2 向量化 UDF 对 pandas 和 pyarrow 的额外要求

如果你打算用向量化 UDF(也就是 pandas UDF),那 pandas 和 pyarrow 的版本就变得敏感了。1.11.0 的向量化执行底层依赖 Arrow 做 Python 和 JVM 之间的数据交换,pyarrow 版本不匹配会直接抛ArrowInvalid或者序列化失败。我当时用的组合是 pandas 1.0.x + pyarrow 0.17.x,实测能跑通。太新的 pyarrow(比如 1.x 之后)在 1.11.0 上大概率会出问题,因为 Arrow 的 IPC 格式在版本间有过不兼容的改动。

一个实用的检查手段是,在写 UDF 之前先跑一段最小验证:

import pandas as pd import pyarrow as pa print(pd.__version__, pa.__version__)

把这两个版本记下来,后面出问题的时候至少知道自己在什么组合上。

2.3 本地执行与集群执行的差异

PyFlink 有个很容易让人误判的点:本地TableEnvironment跑通了,不代表集群上能跑。本地模式下,Python 进程和 JVM 在同一个机器上,很多路径、依赖都能直接找到;到了集群,Python worker 是独立拉起的进程,它需要的那套 Python 环境(包括你 UDF 里 import 的第三方库)必须在每个 TaskManager 节点上都存在。

我踩过的坑是:UDF 里 import 了一个自己写的工具模块,本地跑没问题,集群上报ModuleNotFoundError。解决办法有两个,一是把依赖打成 zip 通过env.add_python_archive()分发,二是把 UDF 依赖的库提前装到集群每个节点的 Python 环境里。前者更干净,推荐。

# 把本地目录打包分发 table_env.add_python_archive("my_utils.zip", "my_utils") table_env.get_config().set_python_executable("python3")

注意:add_python_archive的第二个参数是解压后的目标目录名,UDF 里 import 的时候要按这个目录名来,别按 zip 文件名来,否则找不到。

3. 普通 UDF:最直接但也最容易写错的写法

3.1 用 udf 装饰器定义一个标量函数

最基础的用法是@udf装饰器。它把一个普通的 Python 函数标记成 UDF,函数的输入输出都是标量(一行一个值)。

from pyflink.table import TableEnvironment, EnvironmentSettings from pyflink.table.udf import udf from pyflink.table.types import DataTypes @udf(input_types=[DataTypes.STRING()], result_type=DataTypes.STRING()) def to_upper(s): if s is None: return None return s.upper()

这里有几个细节值得说。第一,input_types和result_type强烈建议显式声明,虽然 1.11.0 支持类型推断,但推断逻辑在复杂类型(比如嵌套的 Row、Array)上经常不准,显式声明能省掉大量调试时间。第二,函数内部一定要处理None。Flink 的数据里 null 是常态,Python 函数收到None之后如果直接调.upper()会抛异常,整个作业挂掉。我见过太多人栽在这个点上。

定义好之后,注册到 TableEnvironment:

t_env = TableEnvironment.create(EnvironmentSettings.new_instance().in_batch_mode().build()) t_env.register_function("to_upper", to_upper)

然后就能在 SQL 里用了:

t_env.sql_update(""" CREATE TABLE source_table ( name STRING ) WITH ( 'connector' = 'filesystem', 'path' = '/tmp/input.csv', 'format' = 'csv' ) """) t_env.sql_update(""" CREATE TABLE sink_table ( name STRING ) WITH ( 'connector' = 'print' ) """) t_env.sql_update("INSERT INTO sink_table SELECT to_upper(name) FROM source_table")

3.2 为什么显式声明类型这么重要

我专门做过对比测试。同一个函数,不声明类型让 Flink 推断,和显式声明类型,在简单 STRING 场景下结果一样。但一旦输入是ROW<a INT, b STRING>这种结构,推断出来的类型经常和实际数据对不上,表现为运行时报ClassCastException或者字段错位。原因是 1.11.0 的 Python 类型推断依赖inspect拿函数签名,而 Python 的注解在复杂类型上表达能力有限,Flink 只能猜。

显式声明的另一个好处是,它让 UDF 的契约变得清晰。团队协作时,别人看你的 UDF 定义就知道输入输出是什么,不用去读函数体。这在规则频繁变更的场景下尤其重要。

3.3 标量 UDF 的性能特征与适用边界

普通标量 UDF 是逐行调用的,Python 和 JVM 之间每一行都要过一次序列化/反序列化。数据量小的时候无所谓,一旦上到百万行级别,这个开销就很可观了。我实测过一个简单的字符串处理 UDF,在 100 万行数据上,纯 Python 处理逻辑本身耗时不到 2 秒,但加上跨进程通信的开销,整体跑了将近 40 秒。

所以标量 UDF 的适用边界很明确:逻辑复杂、数据量不大、或者对延迟不敏感的场景。如果你的场景是简单逻辑 + 大数据量,那应该考虑向量化 UDF,这个后面会讲。

还有一个容易被忽略的点:标量 UDF 里不要做重初始化操作。比如每次调用都去建一个数据库连接、读一次配置文件,这种写法在逐行调用下会把性能拖垮。正确的做法是把初始化逻辑放到模块级别,或者用闭包缓存。

# 不推荐:每次调用都读配置 @udf(input_types=[DataTypes.STRING()], result_type=DataTypes.STRING()) def bad_udf(s): config = load_config() # 每行都读一次 return process(s, config) # 推荐:模块级别初始化一次 CONFIG = load_config() @udf(input_types=[DataTypes.STRING()], result_type=DataTypes.STRING()) def good_udf(s): return process(s, CONFIG)

4. 向量化 UDF:把 pandas 搬进 Flink 的正确姿势

4.1 向量化执行的底层逻辑

向量化 UDF(官方叫 pandas UDF)的核心思路是:不再一行一行地传数据,而是一批一批地传。Flink 把一批数据攒成一个 pandas Series 或 DataFrame,一次性交给你的 Python 函数,你的函数返回一个同长度的 Series 或 DataFrame。这样跨进程通信的次数从"行数"降到了"批数",性能提升非常明显。

在 1.11.0 里,向量化 UDF 的写法是在@udf装饰器里指定func_type="pandas":

from pyflink.table.udf import udf from pyflink.table.types import DataTypes import pandas as pd @udf(input_types=[DataTypes.STRING()], result_type=DataTypes.STRING(), func_type="pandas") def vectorized_upper(s: pd.Series) -> pd.Series: return s.str.upper()

注意这里的函数签名变了:输入是pd.Series,输出也是pd.Series。函数体里用的是 pandas 的向量化操作(s.str.upper()),而不是 Python 的循环。这一点很关键,如果你在向量化 UDF 里写for循环逐元素处理,那向量化的性能优势就全没了,还不如用标量 UDF。

4.2 向量化 UDF 的批大小与内存权衡

向量化执行有一个批大小的概念,Flink 会把数据攒到一定量再交给 Python。这个批大小不是直接暴露给用户的配置项,它和 Arrow 的 batch size、以及 Flink 的table.exec.resource.default-parallelism等参数有关。实际效果是,批越大,通信开销摊得越薄,但单批占用的内存也越大。

我在一个内存受限的环境里遇到过向量化 UDF 把 TaskManager 打爆的情况。原因是批太大,pandas DataFrame 在 Python 侧占了一大块内存,加上 Arrow 序列化的副本,峰值内存直接超了。解决办法是调小并行度、或者控制单批数据量。1.11.0 没有直接给"批大小"的开关,但可以通过调整python.fn-execution.bundle.size和python.fn-execution.bundle.time来间接影响攒批行为。

# 在 flink-conf.yaml 或代码里配置 config = t_env.get_config().get_configuration() config.set_string("python.fn-execution.bundle.size", "1000") config.set_string("python.fn-execution.bundle.time", "1000")

bundle.size是攒够多少条就发一批,bundle.time是等多久就发一批(毫秒)。两个条件是"或"的关系,谁先满足就触发。调小 size 能降低内存峰值,但会增加通信次数;调大 size 反过来。这个值需要根据你的数据特征和内存情况实测调优。

4.3 向量化 UDF 里不能做的事

向量化 UDF 虽然快,但限制也不少。第一,函数必须是纯向量化的,不能有跨行的状态。比如你想做"当前行依赖上一行"的计算,向量化 UDF 做不了,得用别的方式。第二,返回值的长度必须和输入一致,不能过滤行(想过滤得用别的手段)。第三,pandas 的某些操作在向量化上下文里行为会变,比如apply在某些情况下会退化成逐行,性能反而更差。

我踩过的一个坑是:在向量化 UDF 里用了s.apply(lambda x: ...),本意是想写个简单的转换,结果发现性能比标量 UDF 还差。原因是apply在 pandas 内部就是逐元素循环,加上向量化 UDF 的批处理开销,双重浪费。后来改成s.str.replace()这种真正的向量化方法,性能立刻上来了。

提示:判断一个操作是不是真向量化,看它是不是 pandas 内置的、基于 numpy 的批量操作。凡是带apply、map(对 Series 逐元素)的,基本都要警惕。

5. 在 SQL 和 Table API 里注册 UDF 的两条路径

5.1 SQL 注册:适合规则频繁变更的场景

SQL 注册 UDF 的好处是,业务逻辑和查询语句解耦。UDF 定义好、注册进去,SQL 里直接当普通函数用。规则变了,改 UDF 重新注册就行,SQL 不用动。

t_env.register_function("my_udf", my_udf) t_env.sql_update("SELECT my_udf(col_a) FROM t")

这里有个细节:register_function的名字是 SQL 里用的名字,可以和 Python 函数名不一样。我习惯用下划线命名 SQL 函数名,和 Python 的命名风格保持一致,减少认知负担。

SQL 注册的另一个好处是,它能和 Flink 内置的函数、其他 UDF 自由组合。比如SELECT my_udf(UPPER(col_a)) FROM t,这种嵌套调用在 SQL 里写起来很自然。

5.2 Table API 注册:适合程序化构建查询的场景

如果你是用 Table API 的链式调用构建查询,那 UDF 可以直接作为方法调用:

from pyflink.table.expressions import call result = t_env.from_path("source_table") \ .select(call("my_udf", "name").alias("upper_name"))

或者用call函数显式调用。Table API 的好处是类型安全(相对而言)、IDE 能补全,适合查询逻辑复杂、需要动态拼接的场景。缺点是写起来比 SQL 啰嗦,尤其是嵌套调用的时候。

两种方式在功能上是等价的,选哪个主要看你的查询是怎么构建的。我的经验是:查询结构固定、以 SQL 为主的项目用 SQL 注册;查询需要根据配置动态生成的项目用 Table API。

5.3 注册时的类型匹配陷阱

不管用哪种方式注册,类型匹配都是最容易出问题的地方。UDF 声明的input_types必须和实际传入的列类型一致,否则运行时报类型转换错误。比如你声明input_types=[DataTypes.INT()],但 SQL 里传了一个 STRING 列,Flink 不会自动帮你转,直接报错。

更隐蔽的是result_type和下游的匹配。如果 UDF 返回 STRING,但下游期望 INT,同样会报错。我建议在注册 UDF 之后,先用一小批数据跑一遍SELECT,确认类型链路通了,再上全量数据。

# 快速验证类型链路 t_env.sql_update("INSERT INTO sink_table SELECT my_udf(name) FROM source_table LIMIT 10")

6. 几种实现方式的横向对比与选型建议

6.1 性能、灵活性、复杂度的三角权衡

把前面讲的几种方式放一起对比,能看得更清楚:

实现方式性能灵活性实现复杂度适用场景
标量 UDF(SQL 注册)低高低逻辑复杂、数据量小、规则常变
标量 UDF(Table API)低高中查询动态构建、需要程序化控制
向量化 UDF(SQL 注册)高中中逻辑可向量化、数据量大
向量化 UDF(Table API)高中高大数据量 + 动态查询

这个表不是绝对的,但能帮你快速定位。核心判断逻辑是:先看数据量,数据量大就优先向量化;再看逻辑能不能向量化,不能就只能标量;最后看查询怎么构建,决定用 SQL 还是 Table API。

6.2 一个真实场景的选型过程

回到我开头说的日志清洗场景。原始数据是半结构化字符串,需要按规则拆字段。规则的特点是:字段数量不固定、拆分逻辑依赖多个字段的组合判断、规则每周都在变。

我一开始用标量 UDF,因为逻辑复杂、向量化不好写。数据量是每天几百万行,标量 UDF 跑下来要十几分钟,勉强能接受。后来规则稳定了一些,我把其中几个可以向量化的部分(比如字符串大小写转换、简单的正则替换)拆出来做成向量化 UDF,和标量 UDF 混用,整体耗时降到了三分钟左右。

这个案例的启示是:不必强求全向量化或全标量,混用往往是最优解。把能向量化的部分向量化,剩下的用标量兜底,既拿到了性能,又保住了灵活性。

6.3 混用时的注意事项

混用标量和向量化 UDF 时,要注意它们在同一个查询里的执行顺序。Flink 的优化器会尽量把操作下推、合并,但 UDF 是不透明的,优化器不知道 UDF 内部在干什么,所以不会跨 UDF 做优化。这意味着 UDF 之间的中间结果会实实在在地物化,数据量大的时候要注意中间结果的大小。

另一个注意点是,向量化 UDF 和标量 UDF 的批处理边界不一样。向量化 UDF 按批处理,标量 UDF 按行处理,两者混用时,数据会在批和行之间来回转换,有一定的开销。如果混用带来的性能收益抵不过转换开销,那还不如统一用一种。

7. 调试 UDF 时我踩过的那些坑

7.1 本地能跑、集群报错的排查链路

这是 PyFlink UDF 最经典的坑。排查思路我总结成一条链路:

第一步,确认 Python 版本。集群每个节点的 Python 版本必须一致,且都在支持区间内。用python --version在节点上确认。

第二步,确认依赖。UDF 里 import 的所有第三方库,集群节点上都要有。用add_python_archive分发,或者提前装好。

第三步,确认路径。UDF 里如果有文件读写,路径必须是集群上真实存在的路径,不能是本地开发机的路径。

第四步,看日志。PyFlink 的 Python worker 日志默认不在主日志里,需要单独配置python.fn-execution相关的日志输出,或者去 TaskManager 的日志目录里找python相关的日志文件。

我遇到过一次特别隐蔽的:UDF 里用了datetime.now(),本地跑没问题,集群上因为时区配置不同,结果差了几个小时。这种问题不会报错,只会让结果悄悄不对。所以 UDF 里涉及时间、随机数、环境相关的操作,一定要显式指定,不要依赖默认行为。

7.2 序列化失败的典型表现与修复

序列化失败通常表现为PickleError或者ArrowInvalid。标量 UDF 走的是 pickle 序列化,向量化 UDF 走的是 Arrow。pickle 失败一般是 UDF 里引用了不可序列化的对象,比如数据库连接、文件句柄、lambda 闭包里的某些东西。

修复思路是把不可序列化的部分从 UDF 定义里剥离出去。比如不要在 UDF 闭包里捕获一个连接对象,而是在函数内部按需创建、用完关闭,或者用模块级别的全局对象(但要注意全局对象在 worker 进程里的初始化时机)。

Arrow 失败一般是 pandas 或 pyarrow 版本不匹配,或者数据类型 Arrow 不支持。比如 pandas 的object类型里混了多种 Python 类型,Arrow 序列化时就会懵。修复办法是提前把列的类型统一,或者在 UDF 里做类型清洗。

7.3 空值与类型不一致导致的静默错误

最危险的错误是静默错误:不报错,但结果不对。空值处理不当是重灾区。比如 UDF 里对None做了某种默认处理,但下游逻辑期望的是None原样传递,结果数据就被悄悄改了。

我的做法是,UDF 的第一行永远是空值检查,并且把空值处理策略写清楚。是返回None、返回默认值、还是抛异常,都要有明确意图,不能含糊。

@udf(input_types=[DataTypes.STRING()], result_type=DataTypes.STRING()) def safe_udf(s): if s is None: return None # 明确:空值原样传递 return s.strip()

类型不一致的静默错误也很常见。比如 UDF 声明返回 STRING,但实际返回了 INT,Flink 在某些情况下会做隐式转换,转换失败才报错,转换成功但语义不对就静默了。所以result_type一定要和函数实际返回的类型严格一致。

8. 把 UDF 用稳的几个工程化习惯

8.1 给 UDF 写单元测试

UDF 本质就是一个 Python 函数,完全可以脱离 Flink 单独测试。我的习惯是把 UDF 的业务逻辑抽成一个纯函数,UDF 装饰器只做一层薄薄的包装。这样纯函数可以用 pytest 直接测,不用起 Flink 环境。

# 业务逻辑 def _clean(s): if s is None: return None return s.strip().lower() # UDF 包装 @udf(input_types=[DataTypes.STRING()], result_type=DataTypes.STRING()) def clean_udf(s): return _clean(s) # 测试 def test_clean(): assert _clean(" ABC ") == "abc" assert _clean(None) is None

这个习惯帮我省了大量时间。以前改 UDF 逻辑,要起一个完整的 Flink 作业才能验证,一次几分钟。现在纯函数测试秒级出结果,只有集成阶段才需要起 Flink。

8.2 版本管理与依赖锁定

PyFlink 作业的依赖管理是个老大难。我的做法是:把 UDF 相关的代码和依赖单独放一个目录,用requirements.txt锁定版本,打包成 zip 分发。这样集群上跑的就是我本地验证过的依赖组合,不会因为节点环境差异出问题。

# 打包 zip -r udf_package.zip udf/ requirements.txt
table_env.add_python_archive("udf_package.zip", "udf_package") table_env.get_config().set_python_executable("python3")

然后在 UDF 里按udf_package这个目录名来 import。

8.3 监控 UDF 的执行指标

UDF 跑起来之后,要关注几个指标:Python worker 的 CPU 和内存、跨进程通信的数据量、UDF 的调用次数。这些指标能帮你判断 UDF 是不是瓶颈。如果 Python worker 的 CPU 一直打满,说明 UDF 逻辑本身重;如果通信数据量大但 CPU 不高,说明瓶颈在序列化。

1.11.0 的指标暴露不如后续版本完善,但基础的 TaskManager 指标、以及 Python worker 的日志,足够做初步判断。我习惯在 UDF 里加一些轻量的计数日志(比如每处理一万行打一条),既能看进度,也能估算吞吐。

8.4 规则变更时的灰度策略

UDF 逻辑变更最怕的是"改完直接全量上线,结果发现规则写错了"。我的做法是保留新旧两个版本的 UDF,用参数控制走哪个版本,先小流量验证,确认没问题再切全量。

@udf(input_types=[DataTypes.STRING()], result_type=DataTypes.STRING()) def clean_v2(s): return _clean_v2(s) # SQL 里通过配置切换 # SELECT clean_v1(col) FROM t -- 旧版本 # SELECT clean_v2(col) FROM t -- 新版本

这个策略在规则频繁变更的场景下特别有用,能避免"改一个规则挂一个作业"的惨剧。

9. 关于 1.11.0 这个版本的一些个人体会

1.11.0 的 PyFlink UDF 支持,放在今天看肯定不算完善,向量化执行的配置项少、类型系统还有粗糙的地方、错误信息也不够友好。但它的价值在于,它让 Python 开发者第一次能比较完整地在 Flink 的 Table API / SQL 体系里表达自定义逻辑,而不用被迫去写 Java。

我在实际项目里用下来,最深的体会是:UDF 的选型不是技术问题,是场景问题。同一个逻辑,数据量小的时候标量 UDF 最省事,数据量大了向量化 UDF 才划算,规则老变的时候 SQL 注册最灵活。没有银弹,只有权衡。

另外一个体会是,UDF 的稳定性很大程度上取决于工程习惯,而不是 API 本身。单元测试、依赖锁定、灰度上线、指标监控,这些看起来和 UDF 无关的东西,才是让 UDF 在生产环境里跑得稳的关键。API 用错顶多报个错,工程习惯不到位,作业会在你最不希望的时候挂掉。

最后分享一个小技巧:如果你不确定一个逻辑该用标量还是向量化,先写标量版本跑通,用真实数据量测一下耗时。如果耗时能接受,就别折腾向量化了;如果耗时是瓶颈,再考虑向量化,并且优先把逻辑里能向量化的部分拆出来。这个"先跑通再优化"的顺序,能帮你避免过早优化带来的复杂度。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进