PySpark UDF为什么比Spark SQL慢10倍?——GIL和序列化开销
本文目录
先说结论
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-else | when().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年大数据老兵,只讲大数据面试干货