Doris 里自定义函数不是新东西。Java UDF 用了很久:Hive / Spark 那套 evaluate、打成 Jar、在 SQL 里当普通函数调。4.1.3 又加了 Python UDF / UDAF / UDTF。
问题因此很具体:Java 已经能在引擎里跑自定义逻辑了,为什么还要再接一套 CPython?
结论先说:Java UDF 解决的是引擎可扩展 。Python UDF 解决的是逻辑已经写在 Python 里,不要为了进 SQL 再导一次数,或重写成 Java。两者不是替代。内置 C++ 函数仍然最快。
为了帮助大家更好的理解,全文使用这个案例:订单明细上的手机号要脱敏,金额要按业务规则分桶。规则现在在 Python 脚本里。
1.Java UDF 已经覆盖什么
Java UDF 覆盖的是这一类需求:SQL 内置函数写不完,但团队能维护 Java,依赖可以打进一个 Jar。
它做成了三件事。
-
和数仓生态对齐。 入口函数叫
evaluate,和 Hive / Spark 同一套习惯。已有 Jar 往往能迁过来,不必为 Doris 再写一遍。 -
失败边界清楚。 函数出错打在 JVM 上,不把 BE 进程直接打挂。
-
跨语言调用按批进行。 Java 和 C++ 执行引擎之间走 JNI,一次处理一批行,不是每一行单独切一次栈。
所以引擎里能不能跑自定义代码这件事,Java 已经回答了。它默认的作者是能发 Jar 的人;默认的逻辑,用 JDK 和打进包里的依赖就够。
另一类代码还是进不来:脱敏规则在 Pandas 里维护、分桶边界在 notebook 里改、有人已经写好了 .py。没有人会为了在 Doris 里查一张表,把这套重写成 Java。

2.Python 补的是代码位置
订单表在 Doris。脱敏脚本在 Python 仓库。过去常见三条路。
导出再导回。 把当天明细卸到对象存储,脚本跑完再 load 回来。数据出域,结果按作业周期落后,排障要同时看 SQL、调度日志和 Python。规则改一次,还要补历史。
查询里逐行调 HTTP。 逻辑仍在原服务里,延迟取决于网络和对方限流。扫描一大段明细时,这已经不是函数调用,而是大量远程请求。
改写成 SQL 或 Java。 数据还在仓里,但 Python 侧的实现和测试都要重写。pandas.cut 那种分桶、已有的脱敏函数,都要再实现一遍,还要两套用例保持一致。
4.1.3 给的是第四条:把函数注册进 Doris,在读表的同一条 SELECT 里算完。数据不出仓,语言不用换。
bash
SELECT
order_id,
py_mask_phone(phone) AS phone_masked,
py_amount_bucket(amount) AS bucket
FROM orders
WHERE dt = '2026-08-01';
py_mask_phone / py_amount_bucket 是 Python。周围是普通 SQL。原先那三段导出作业可以拿掉。
内联写法适合验证。下面只保留结构,runtime_version 必须写完整三位版本号,例如 3.10.12,不能写成 3.10。
bash
CREATE FUNCTION py_mask_phone(STRING)
RETURNS STRING
PROPERTIES (
"type" = "PYTHON_UDF",
"symbol" = "evaluate",
"runtime_version" = "3.10.12",
"always_nullable" = "true",
"volatility" = "immutable"
)
AS $$
def evaluate(phone):
if phone is None or len(phone) < 7:
return phone
return phone[:3] + "****" + phone[-4:]
$$;
分桶这种列计算,把入参写成 pd.Series,走 Pandas,而不是在 Python 里 for 每一行。生产代码打成 zip,用 file + symbol 指向模块入口,走评审和发版;AS $$ ... $$ 只适合试。

3.和 Java 的差别在执行位置
差别在进程,不在函数能不能写。
Java UDF:调用发生在 BE 进程里,经 JNI 进 JVM,跑完把结果交回向量化引擎。
Python UDF:调用发生在独立的 Python Server 进程里。BE 把输入列打成 Arrow RecordBatch,经 Arrow Flight 送过去,函数整批算完再以列存回来。

由此有三点。
不是一行调一次 Python。 一批可以是几千行。进程切换和序列化发生在批上,开销仍在,但不会随行数线性放大。定性地说:内置函数最快,Java JNI 次之,Python 跨进程再次之。不要用它和 substr 比微秒。
可以按列算。 声明 pd.Series,分桶、字符串处理走 Pandas / PyArrow,而不是解释器里的 for。这是 Python 路径值得用的前提之一;写成逐行 Python,批处理就没有意义。
Python 崩了不应拖死 BE。 用户代码在单独进程里,按 runtime_version 做进程池,挂了会补。日志在 BE 的 python_udf_output.log。Java 的隔离在 JVM;Python 的隔离在 CPython 进程。运维对象也不同:Java 管 Jar 是否在所有 FE/BE 同一路径、校验和一致;Python 管每个 BE 的解释器、conda/venv,以及预先装好的 pandas、pyarrow。
默认 enable_python_udf_support = false。每个 BE 都要打开并配好环境,改 be.conf 后重启才生效。

4.三种形态对上三种作业
三种形态差在输入输出形状,注册语句也不一样:UDF 用 CREATE FUNCTION,UDAF 用 CREATE AGGREGATE FUNCTION,UDTF 用 CREATE TABLES FUNCTION(是 TABLES,不是 TABLE)。
| 形态 | 形状 | 在贯穿例子里 | | --- | --- | --- | | UDF | 一行进,一行出 | 脱敏、分桶 | | UDAF | 多行进,一行出 | 按渠道做自定义聚合(规则不便写成 SUM/COUNT 时) | | UDTF | 一行进,零到多行出 | 把标签字符串拆成多行 |
UDTF 必须用 CREATE TABLES FUNCTION 注册,查询侧再 LATERAL VIEW。拆标签可以是:
bash
CREATE TABLES FUNCTION py_split(STRING, STRING)
RETURNS ARRAY<STRING>
PROPERTIES (
"type" = "PYTHON_UDF",
"symbol" = "split_string_udtf",
"runtime_version" = "3.10.12",
"always_nullable" = "true",
"volatility" = "immutable"
)
AS $$
def split_string_udtf(text, delimiter):
if text is not None and delimiter is not None:
for part in text.split(delimiter):
yield part.strip()
$$;
bash
SELECT order_id, tag
FROM orders
LATERAL VIEW py_split(tags, ',') tmp AS tag;
UDAF 要在 Python 类里实现累加、合并和收尾(accumulate / merge / finish),以及属性 aggregate_state。该属性返回的状态必须能被 pickle 序列化,分布式合并才跑得起来。适合内置聚合函数表达不了、但数据量还扛得住跨进程的指标。
5.该怎么选择
选型按下面四条。
-
已经有 Hive / Spark UDF Jar,行为必须对齐 → Java。 继续走
type = JAVA_UDF,Jar 放在所有 FE/BE 同一绝对路径。这是 Java 仍然最省事的理由。 -
热路径、每行都算、对延迟敏感、没有科学计算库 → Java。 还不够就推动写成内置函数。不要把 Python 放进 QPS 敏感的点查。
-
逻辑已经是 Pandas / 现成
.py,改语言的成本高于跨进程 → Python。 先向量化;如果每天算一遍、结果要复用,更该预计算落表,而不是每次查询现算。 -
用 SQL 内置函数能写清楚 → 不要 UDF。
substr、regexp_replace、普通CASE分桶,优化器能看到函数语义。自定义函数对优化器是黑盒,Java 和 Python 都一样。
另外两句约束:Python UDF 在 4.1.3 是实验特性,生产前注意测试完整。数据量大、逻辑又稳定的,优先落表或内置函数;UDF 只用于内置函数写不清的逻辑,不要作为默认计算方式。
