Skip to content
Published on

PySpark 4.2,Python UDF 默认搭上 Arrow — 类型强制转换225格里132格发生变化的故事

分享
Authors

引言 — 两天前悄悄反转的默认值

Apache Spark 4.2.0 已于2026年7月14日发布。这是4.x系列的第三个版本,按发布说明自己的说法,涵盖了超过1,700个 Jira 工单和250多位贡献者。标题条目很亮眼 — GEOMETRY/GEOGRAPHY 地理空间类型、用 SQL CHANGES 子句做 CDC、NEAREST BY 的 top-K 连接、Data Source V2 事务。

但对用 PySpark 跑批的人来说,真正会让人今晚睡不着的那一项,是清单里毫不起眼得多的一行。

[SPARK-54555] Enable Arrow-optimized Python UDFs and Arrow-based PySpark IPC by default

只是反转了一个默认值。没有新 API,也没有新语法。但这一行可能会改变你运行了三年的 Python UDF 的输出数据,没有报错,也没有警告。

本文只依据实际检入 Spark 仓库的资料,追踪这个默认值到底改变了什么。先说结论:有意思的部分不是速度,而是类型强制转换(type coercion)。

到底改了什么 — 三个配置项

先来核实事实。SPARK-54555 是通过 PR #53264 合入的,提交 ea0a35e065d3 于2025年12月2日合并。Jira 工单的 fixVersion 是 4.2.0,这个提交存在于 v4.2.0 标签中,但不在 v4.1.0 里。

改变的是 SQLConf.scala 里的默认值。直接对比 branch-4.1 和 branch-4.2 的同一个文件,结果是这样:

配置项                                            4.1     4.2
------------------------------------------------------------
spark.sql.execution.pythonUDF.arrow.enabled     false -> true
spark.sql.execution.pythonUDTF.arrow.enabled    false -> true
spark.sql.execution.arrow.enabled               false -> true

第三项有点让人困惑:文档和迁移指南里提到的 spark.sql.execution.arrow.pyspark.enabled 本身没有默认值,它通过 fallbackConf 指向 spark.sql.execution.arrow.enabled。所以源码里真正反转的是后者。效果是一样的 — DataFrame.toPandas 以及接收 pandas/NumPy 输入的 SparkSession.createDataFrame,现在默认会使用 Arrow 列式传输。

也就是说,这次发布让 Python 这一侧同时在三条战线上搭上了 Arrow:普通 Python UDF、Python UDTF,以及 JVM 与 Python 之间的列式数据交换(IPC)。

到底能快多少 — 没有人给出过数字

这一部分我会说老实话。

PR #53264 的 "Why are the changes needed?" 一节说,Arrow 的列式 IPC "significantly improves" 了 JVM 和 Python 之间的吞吐量,并降低了序列化/反序列化的开销。但这份 PR 里一个基准数字都没有。Databricks 的 Spark 4.2 介绍博客 也只说"现有的 UDF 无需重写代码就能用上更快的列式路径",同样没有给出数字。

Spark 仓库里确实有一份检入的基准测试结果 sql/core/benchmarks/UDFBenchmark-results.txt,但打开一看,是 Scala UDF 的 whole-stage codegen 开/关对比,并不是拿来衡量 Arrow 与 pickle 版 Python UDF 的。python/pyspark/sql/tests/pandas/bench_arrow_columnar_udf.py 这样的基准测试脚手架仓库里是有的,但结果文件没有被检入。

所以本文不会说"快了 N 倍"。这样的数字从未随这次发布公开过。 方向性上是说得通的 — 为什么传递列式批次比逐行 pickle 序列化更有利,这套逻辑和Catalyst 与 Tungsten一文里讲的一样,也和 Polars 战胜 pandas 的原因 是同一类故事。但你的 UDF 到底能快百分之多少,得你自己去测。这就是这次发布能确认的全部内容。

而且说到底,这次默认值变更里真正重要的东西根本不是速度。

真正的故事是类型强制转换

写 Python UDF 时,我们会声明返回类型。

@udf("int")
def f(x):
    return some_value

问题在于,当 some_value 的类型和声明的 int 不一样时,会发生什么。pickle 方式和 Arrow 方式在这一点上规则不同。这是已知的事实,甚至就写在 PySpark 源码的注释里 —— 在 python/pyspark/sql/udf.py_create_py_udf 函数上方。

# Arrow and Pickle have different type coercion rules, so a UDF might have a
# different result with/without Arrow optimization. That's the main reason the
# Arrow optimization for Python UDFs is disabled by default.

有意思的是,这条注释原封不动地留在了 branch-4.2 里。上面写着"所以默认关闭",可到了 4.2 却被打开了 —— 注释没有跟上发布的脚步。而且这条注释还指引说强制转换表在 python/pyspark/sql/tests/udf_type_tests,但那个路径在 branch-4.2 里根本不存在。

表被挪走了。真正的位置是 python/pyspark/sql/tests/coercion/,里面放着 CI 会校验的黄金文件。

golden_python_udf_return_type_coercion_vanilla.csv           <- pickle (4.1 默认)
golden_python_udf_return_type_coercion_with_arrow.csv        <- Arrow  (4.2 默认)
golden_python_udf_return_type_coercion_with_arrow_and_pandas.csv

第三份文件是 4.1 之前的 Arrow 行为(那时候中间还要多经过一次 pandas 转换)。由于 spark.sql.legacy.execution.pythonUDF.pandas.conversion.enabled 在 4.2 里默认是 false,4.2 上真正生效的表是第二份文件。顺带一提,PySpark 官方文档到现在还把强制转换差异指向 2023 年开的 PR #41706 —— 一个标着 "DO NOT MERGE" 的纯文档 PR,但仓库里的黄金文件更新,也是 CI 真正遵守的资料。

于是我把这两份黄金文件实际 diff 了一遍。

225格里的132格

这张表是15种声明的 SQL 类型 × 15种 Python 返回值 = 225格。按格逐一比较两份文件,有132格不一样,超过了一半。X 表示报错。

按类别拆开是这样:

分类                                     格数
---------------------------------------------
1. None  -> 报错  (曾经悄悄变NULL的现在失败)     99
2. None  -> 值    (曾经悄悄变NULL的现在有数据)    18
3. 值    -> 报错  (原本能跑的现在失败)             2
4. 报错  -> 值    (原本失败的现在变成了值)         7
5. 值    -> 另一个值 (输出字符串变了)              6
---------------------------------------------
合计                                      132

第1类(99格)占了绝对多数。 pickle 版 Python UDF 的设计是"声明类型对不上就悄悄变成 None"。这一点没有了 —— 现在大多数不匹配都会抛异常。方向上这明显是一种改进,因为把类型错误吞成 NULL,是数据管道里最恶劣的一类 bug。但你原本运行良好的作业如果在 4.2 上突然挂掉,十有八九就是这99格里的一格。挂掉是对的,问题只在于你是靠发布说明里的一行字才知道的。

挑几个实际的格子看看,值都照黄金文件原样抄录。

声明类型          返回的 Python 值           pickle(4.1)                        Arrow(4.2)
----------------------------------------------------------------------------------------------
int             1.0        (float)       None                               1
int             1970-01-01 (date)        None                               0
bigint          1970-01-01 00:00:00      None                               0
boolean         1          (int)         None                               True
double          1          (Decimal)     None                               1.0
int             'a'        (str)         None                               X  (报错)
tinyint         True       (bool)        None                               X  (报错)
binary          'a'        (str)         b'a'                               X  (报错)
struct<_1:int>  [1]        (list)        Row(_1=1)                          X  (报错)
date            1          (int)         X  (报错)                           datetime.date(1970, 1, 2)
string          1970-01-01 (date)        'java.util.GregorianCalendar[...'  '1970-01-01'
string          (1,)       (tuple)       '[Ljava.lang.Object;@<hash>'       '(1,)'
string          bytearray(b'ABC')        '[B@<hash>'                        "bytearray(b'ABC')"

最后三行纯粹是 bug 修复。在 pickle 路径上,把 date 声明成 string,得到的是 java.util.GregorianCalendar[time=?,... —— 一个 JVM 内部对象的 toString 就这样原样流进了数据里。 bytearray 那一行是 [B@<hash>,是 Java 字节数组的 identity hash。要是这种东西已经进了生产表,4.2 会把它修成 '1970-01-01'。值得庆幸,但如果有下游在读那一列,它的值也会跟着变。

第4类(7格)也值得留意。声明 date、返回整数 1,在 pickle 下会报错,但在 Arrow 下会变成 1970-01-02 —— epoch 加一天。逻辑上是一致的,但这也意味着,一个原本会被当作错误拦下的失误,现在会变成一个看似合理的日期蒙混过关。

最危险的18格 —— 什么都不会失败

99格失败得很吵。吵闹的东西可以管理 —— 部署、炸掉、回滚或者修好就行。

问题出在第2类,那18格。在这里,什么都不会发生。 没有异常,没有警告,没有日志。只是昨天还是 NULL 的格子,今天里面有了值。

全部列出来是这样:

声明        返回值                   pickle -> Arrow
--------------------------------------------------
boolean    1        (int)          None -> True
boolean    1.0      (float)        None -> True
tinyint    1.0      (float)        None -> 1
tinyint    1        (Decimal)      None -> 1
smallint   1.0      (float)        None -> 1
smallint   1        (Decimal)      None -> 1
int        1970-01-01 (date)       None -> 0
int        1.0      (float)        None -> 1
int        1        (Decimal)      None -> 1
bigint     1970-01-01 00:00:00     None -> 0
bigint     1.0      (float)        None -> 1
bigint     1        (Decimal)      None -> 1
float      True     (bool)         None -> 1.0
float      1        (int)          None -> 1.0
float      1        (Decimal)      None -> 1.0
double     True     (bool)         None -> 1.0
double     1        (int)          None -> 1.0
double     1        (Decimal)      None -> 1.0

看这份清单能看出一个规律:大多数都是"声明了一个数值类型,返回了另一个数值类型"。而这正是实务中最容易出错的组合。给函数挂上 @udf("int"),里面做一次除法,Python 返回的就是 float;挂上 @udf("double")、返回一个 Decimal,同样很常见。

一直到 4.1,这类 UDF 全都在悄悄吐出 NULL 列。如果一直没人发现,那个 NULL 早就已经融进了下游的一切 —— COUNT(*)WHERE x IS NULL 过滤、AVG 聚合、连接键、看板上的数字,全都是。升到 4.2 的那一刻,这些格子会被真实值填满,而所有这一切都会悄无声息地跟着变动。

这不是回归,4.2 这一侧才是对的 —— 声明 int、返回 1.0,正确的输出就该是 1,不该是 NULL。不过,"被修复"和"输出发生变化"在运营上是同一件事,如果不把它当作后者对待就会吃亏,尤其是在跑重跑(backfill)、新旧批次结果不一样的时候。

你的 UDF 可能根本没用上 Arrow —— import 顺序的陷阱

这里真正讽刺的地方来了:这个默认值并不是均匀生效的。

python/pyspark/sql/udf.py 里的 _create_py_udf,判断逻辑是这样的:

is_arrow_enabled = False
if useArrow is None:
    from pyspark.sql import SparkSession

    session = SparkSession._instantiatedSession
    is_arrow_enabled = (
        False
        if session is None
        else session.conf.get("spark.sql.execution.pythonUDF.arrow.enabled") == "true"
    )
else:
    is_arrow_enabled = useArrow

如果 SparkSession._instantiatedSessionNone,那就连配置都不读,直接是 False 而且这个判断不是在调用 UDF 的时候发生的,而是在创建 UDF 对象的那一刻发生一次,之后就固化成了 eval type。

也就是说,写在模块最顶端的 UDF,比如:

# udfs.py — 装饰器在 import 时就会被求值
from pyspark.sql.functions import udf

@udf("int")
def parse_score(s):
    return float(s) / 2

如果在 SparkSession.builder.getOrCreate() 之前就被 import,因为此时会话还不存在,就会被固化成 pickle UDF,哪怕是在 4.2 上也一样。把同一段代码挪到会话创建之后再 import,它就会变成 Arrow UDF。

也就是说,在同一个作业里,不同的 UDF 可能遵循不同的强制转换规则,取决于 import 的先后顺序。上面这个例子声明了 int 却返回 float,正好落在那18格中的一格(int + 1.0(float))—— 按 import 顺序的不同,得到的要么是 NULL,要么是一个值。

这段逻辑本身并不新。branch-4.1 的 udf.py 里也是一字不差的同一段代码,只是在 4.1 上,配置的默认值本来就是 false,所以 session is None 这个分支给出的答案碰巧一样。到了 4.2,默认值反转之后,这个分支就变成了一条悄悄让新默认值失效的路径。

还有一处类似的悄悄脱轨。同一个函数里,如果 PyArrow 或 pandas 不存在,处理方式是这样:

except ImportError:
    is_arrow_enabled = False
    warnings.warn(
        "Arrow optimization failed to enable because PyArrow or Pandas is not installed. "
        "Falling back to a non-Arrow-optimized UDF.",
        RuntimeWarning,
    )

弹出一个 RuntimeWarning,然后退回 pickle。如果驱动镜像里有 PyArrow,而其他环境没有,同一段代码就会因为环境不同而落到不同的强制转换规则下。顺带一提,4.2 把 PyArrow 的最低版本从 15.0.0 提到了18.0.0,pandas 的最低版本仍是 2.2.0。

还有一点值得指出:这个回退机制并不存在于 UDF 里。有一个配置项 spark.sql.execution.arrow.pyspark.fallback.enabled(默认 true),会做到"Arrow 报错就自动回退到非优化实现",但仔细读配置文档就会发现,这只适用于 spark.sql.execution.arrow.pyspark.enabled 打开的那部分优化,也就是 toPandascreateDataFrame 的路径。UDF 执行期间发生的强制转换错误不会回退,只会直接把作业干掉。

顺带带来的其他变化

4.2 的迁移指南里还有几处 Python 相关的改动,跟 Arrow 默认值无关,但绑在同一次升级里一起过来。

  • pandas UDF 里的可空整数列。 批次里混有 null 的可空整数列,现在传过去的不再是 float64,而是 pandas 的可空整数扩展 dtype(Int8/Int16/Int32/Int64)。假设输入是 float64 写的 UDF 代码需要调整。
  • 停止支持 PyPy。 从 4.2 起,PyPy 不再是官方支持对象,指南建议改用 CPython。
  • 用 NumPy ndarray 做 createDataFrame。 现在要求的是 PyArrow 而不是 pandas,不经过 pandas,直接转换成 Arrow。如果之前在关闭 Arrow 的情况下依赖基于 NumPy dtype 的模式推断,指南明确警告要重新核对推断出来的 schema。
  • Python Data Source 的模式不匹配。 如果返回的 Arrow 数据的列类型和声明的 schema 不一样,现在会以 DATA_SOURCE_RETURN_SCHEMA_MISMATCH 失败。以前只有列数和列名不匹配才会触发这个报错。

Arrow 批次大小由 spark.sql.execution.arrow.maxRecordsPerBatch 调节,默认值是 10000。如果你的 UDF 每行占用的内存比较多,这个值现在会影响 executor 的内存画像 —— 因为以前是逐行流动的数据,现在会打包成批次到来。

那么该怎么升级

归纳一下,比较合理的做法是这样。

1. 先关着升级。 升到 4.2 和切换到 Arrow UDF 是两件独立的事,不要一次性做完。

spark.sql.execution.pythonUDF.arrow.enabled   false
spark.sql.execution.pythonUDTF.arrow.enabled  false
spark.sql.execution.arrow.pyspark.enabled     false

这样你就能拿到 4.2 剩下的一切(地理空间类型、CDC、DSv2 事务、Java 25),同时保持和 4.1 一样的 Python 语义。

2. 然后做一份 UDF 清单。 真正有风险的只有"声明类型和返回类型不一致的 UDF"。两者一致的话,那132格跟你无关 —— 黄金表的对角线(声明类型 = 返回类型)在两份表里是一样的。所以问题只有一个 —— 我的 UDF 是不是按声明的类型在返回?

这件事光靠肉眼读代码看不出来,一次数值型 UDF 里的除法、一个 Decimal,就足以让它中招。对一小份样本,用两种配置各跑一遍同一个作业,然后比较输出,是唯一靠得住的办法。

3. 比较的时候看 NULL 的数量。 那18格这一类是悄无声息的,光看错误日志绝对抓不到。如果某一列的 NULL 计数变少了,那正是那18格在起作用。变少也许是件好事,但那该由你来判断,不该由这次发布替你决定。

4. 把 import 顺序钉死。 因为前面那个陷阱,"我把配置打开了"并不能保证 Arrow 真的生效了。要确保这一点,就显式声明 udf(..., useArrow=True)。一旦显式指定了 useArrow,它会无视会话是否存在、也无视配置,直接用这个值。反过来,如果有 UDF 你确实想让它留在 pickle 上,就用 useArrow=False 钉死。最糟糕的做法,就是含糊地把它交给默认值。

什么时候应该继续把这个默认值关着

说实话,有些情况下,一直关着才是对的。

  • UDF 数量不多,而且都很轻量。 Arrow 的收益出现在 JVM 和 Python 之间来回搬运大量数据的时候。如果一个作业一天只处理几万行,重新验证的成本会超过收益。
  • 有大量声明类型和返回类型对不上的 UDF,而现在没有精力把它们全部审计一遍。 那132格说的就是你。先关掉,排个日程,以后再做。
  • 输出被合同性质的约束绑定着。 如果下游不仅期待 schema,还期待值的分布(比如监管报告、财务结账批次),那么悄悄减少的 NULL 本身就是一次事故。先达成一致,再打开。

反过来,现在就值得打开的情况是这样:

  • UDF 很重,数据量很大。这种情况下列式路径大概率有价值 —— 不过如前所述,数字还是得自己去测。
  • 能用测试保证声明类型和返回类型一致。那132格就跟你无关,纯粹是白拿的好处。
  • 别忘了还有一种情况,最好的答案是压根不用 UDF。如果你一直在用 Python UDF 写本可以用内置函数或 SQL 表达的逻辑,去掉这个 UDF 带来的收益,远比 Arrow 带来的加速大得多。4.2 也进一步增加了内置函数,其中包括 time_bucket

结语

Spark 4.2.0 在 Python 这一侧的故事,靠发布说明里的一行字是传达不出来的。"Arrow-optimized Python UDFs enabled by default" 读起来像是一项性能改进,但实际打开仓库里检入的黄金表就会发现,225格里有132格的语义变了。其中99格是曾经悄悄的 NULL 现在变成了报错,18格是没有任何信号的情况下 NULL 变成了值。

Apache 的迁移指南把这些老老实实写了出来 —— 连配置名和回退的方法都写了。相比之下,厂商博客只介绍成"无需重写代码就能获得更快的列式路径",压根没提强制转换的事。两边都没撒谎,但制定升级计划时该读的是前者。

而且我认为这次变更背后的判断本身是对的。"类型对不上就悄悄变 NULL",本来就是一个说不过去的默认值。只是有些管道已经在这个错误的默认值之上跑了大约三年,对它们来说,这次修正到达时不是修正,而是输出变化。先关着升级,测一测,再打开。顺序就是一切。

参考资料