订阅更新

PySpark UDF为什么比Spark SQL慢10倍?——GIL和序列化开销

Python手册 第 4 题 约 8 分钟读完

本文目录
  1. 先说结论
  2. Python UDF的执行流程——为什么慢?
  3. GIL——Python的全局解释器锁
  4. 三种UDF的性能对比
  5. Pandas UDF——Python UDF的正确替代
  6. 实战经验:Python UDF到Spark SQL内置函数的迁移
  7. 面试高频追问
  8. 总结

先说结论

PySpark的Python UDF慢的根本原因是JVM和Python之间的数据序列化开销。Spark的数据在JVM中(Java对象),调用Python UDF时要:① 把JVM数据序列化成字节流;② 通过Socket发给Python工作进程;③ Python反序列化+执行UDF逻辑;④ 把结果序列化回JVM。每条数据都要经历这个来回,开销远大于UDF本身的计算。优化方案:尽量用Spark SQL内置函数,必须用UDF时用Pandas UDF(Arrow格式批量传输)。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

Python UDF的执行流程——为什么慢?

# Spark SQL内置函数的执行流程(快)
# 数据在JVM内部 ==直接处理==> 结果在JVM内部
# 全程JVM,零序列化开销
 
# Python UDF的执行流程(慢)
# JVM数据 ==序列化==> Socket ==传输==> Python进程
#   Python反序列化 ==执行UDF==> Python结果
#   Python结果 ==序列化==> Socket ==传输==> JVM
#   JVM反序列化 ==得到结果==>
# 每条数据都要经历2次序列化+2次反序列化+2次Socket传输!
 
# Pandas UDF(Arrow格式,较快)
# JVM数据 ==Arrow批量序列化==> Python进程
#   Python接收Arrow格式的Pandas Series(批量操作)
#   结果用Arrow格式批量返回
# Arrow是列式内存格式,批量传输比逐行传输快10-100倍

GIL——Python的全局解释器锁

GIL(Global Interpreter Lock)是CPython的一把全局互斥锁,确保同一时刻只有一个线程执行Python字节码。这意味着Python的多线程无法利用多核CPU做并行计算。

维度有GIL的影响大数据场景
CPU密集任务多线程无法并行,只能用多进程PySpark UDF在单个Python进程中是单线程的
IO密集任务GIL在等IO时释放,多线程有效网络请求/文件读写可以用多线程
多进程每个进程有独立GIL,真正并行Spark给每个Executor启动独立Python进程
# GIL的影响演示
 
# CPU密集任务:多线程无加速(GIL限制)
import threading
def compute():
    sum(range(10**7))
 
# 单线程:约0.5秒
# 2个线程:还是约0.5秒(GIL限制,交替执行)
# 2个进程(multiprocessing):约0.25秒(真正并行)
 
# 对PySpark UDF的影响:
# 每个Executor启动一个Python工作进程
# 这个进程内部受GIL限制,是单线程处理数据
# 所以Python UDF的吞吐量 < JVM原生函数的吞吐量
# Spark通过增加Executor数量(多进程)来弥补

三种UDF的性能对比

UDF类型数据传输方式处理方式速度推荐度
Spark SQL内置函数无(JVM内部)JVM原生最快(基准)首选
Python UDF逐行序列化(Pickle)逐行处理慢10-100倍尽量避免
Pandas UDF批量序列化(Arrow)Pandas向量化慢2-3倍需要UDF时推荐

Pandas UDF——Python UDF的正确替代

# 普通Python UDF(慢——逐行序列化)
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType
 
@udf(StringType())
def parse_city(lat, lon):
    return geocoder.reverse(lat, lon).city
 
df.withColumn('city', parse_city('lat', 'lon'))
# 500万行:15分钟
 
# Pandas UDF(快——Arrow批量传输+Pandas向量化)
from pyspark.sql.functions import pandas_udf
import pandas as pd
 
@pandas_udf(StringType())
def parse_city_batch(lat_series: pd.Series, lon_series: pd.Series) -> pd.Series:
    # 接收的是Pandas Series(一批数据),不是单个值
    return pd.Series([geocoder.reverse(lat, lon).city
                      for lat, lon in zip(lat_series, lon_series)])
 
df.withColumn('city', parse_city_batch('lat', 'lon'))
# 500万行:3分钟(快5倍)
 
# 最佳方案:用Spark SQL内置函数替代UDF
# 如果有内置函数能实现同样逻辑,永远不要写UDF

实战经验:Python UDF到Spark SQL内置函数的迁移

在某知名出海硬件品牌,有一批Python UDF做数据清洗——日期格式转换、字符串截取、条件映射等。我把这些UDF逐个替换成Spark SQL的内置函数:

UDF功能Python UDF写法Spark SQL替代加速比
日期格式转换datetime.strptime(s, fmt)to_date(col, fmt)15倍
字符串截取s[:10]substring(col, 1, 10)10倍
条件映射if-elif-elsewhen().when().otherwise()12倍
JSON解析json.loads(s)['key']get_json_object(col, '$.key')20倍
正则提取re.search(pattern, s)regexp_extract(col, pattern, 1)15倍

效果:替换了15个Python UDF后,整体ETL执行时间从45分钟降到12分钟,快了将近4倍。其中JSON解析UDF的替换效果最明显——因为json.loads()在Python中每行都要解析一次完整的JSON字符串,而get_json_object()在JVM内部用Jackson解析,快得多。

教训:写PySpark代码时,第一反应应该是'有没有Spark SQL的内置函数能实现这个逻辑',而不是'我写个Python函数来处理'。内置函数永远是性能最优解。

面试高频追问

追问一:Pandas UDF比普通UDF快多少?原理是什么?

快5-10倍。原理是Apache Arrow——一种列式内存格式。普通UDF用Pickle逐行序列化(每行一次),Pandas UDF用Arrow批量序列化(一批几千行一次)。Arrow的列式格式让JVM和Python之间传输数据像传输一个数组而不是逐个传对象,序列化开销大幅降低。而且Pandas UDF在Python端可以用Pandas/NumPy的向量化操作处理整批数据,比逐行for循环快很多。

追问二:Python 3.12+去掉了GIL吗?

Python 3.12引入了per-interpreter GIL(每个子解释器独立GIL),3.13实验性地支持了no-GIL模式(PEP 703)。但目前还不成熟——很多C扩展库(NumPy/Pandas等)还没适配no-GIL。对于PySpark来说,GIL不是主要瓶颈(主要瓶颈是JVM-Python序列化),即使去掉GIL提升也有限。

追问三:Scala UDF和Python UDF有什么区别?

Scala/Java UDF直接在JVM内部执行,零序列化开销,性能和内置函数几乎一样。Python UDF需要JVM-Python跨进程通信。如果你的UDF逻辑必须自定义且性能敏感,用Scala写UDF注册到Spark中,PySpark也可以调用。这是性能最优的自定义函数方案。

总结

PySpark UDF面试讲清楚三层:① 慢的原因(JVM-Python跨进程序列化,每条数据2次序列化+2次反序列化+2次Socket传输);② GIL的影响(Python进程内部单线程执行UDF逻辑);③ 三种方案的性能对比(内置函数最快→Pandas UDF次之→普通UDF最慢)。能讲出15个Python UDF替换成内置函数后ETL从45分钟降到12分钟的实际案例,面试官会非常满意。

下一篇聊Python脚本性能优化:100万行数据跑了10分钟——怎么让Python快起来?

你们PySpark用Python UDF多吗?有没有遇到过性能问题?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,只讲大数据面试干货

刷题进度保存在当前浏览器