☰
Spark SQL 函数详解:内置函数、窗口函数与性能调优实战
2026/10/20 13:14:16 网站建设 项目流程

做数据开发这些年,Spark SQL 一直是我用得最多的计算引擎。不管是离线数仓的 ETL、实时数仓的明细加工,还是临时跑数验证,几乎天天跟它的函数打交道。网上关于 Spark SQL 函数的介绍很多,但大多是照着官方文档把函数罗列一遍,真正讲怎么选、怎么用、踩过哪些坑的内容不多。这篇博文我打算按照实际开发中的使用频率和场景,把 Spark SQL 各函数的使用系统整理一遍,既包含常用内置函数的用法细节,也包含聚合函数、窗口函数、自定义函数这些进阶内容,最后还会聊聊性能调优和排查问题时的经验。

不管你是刚接触 Spark SQL 的初学者,还是已经写了大半年 SQL 但想系统提升的开发者,这篇文章都值得花二十分钟好好读一遍。我会用实际可运行的代码示例来说明每个函数的作用,也会把我自己工作中遇到的坑点埋进去,尽量做到你读完就能直接用到生产环境里。

1. 先搞清楚 Spark SQL 函数体系

1.1 函数到底分几类

Spark SQL 的函数体系看着庞大,但捋清楚之后其实很有规律。按我的理解,可以分成四大类:

  • 内置标量函数:对一行数据做处理,返回一个值。比如字符串截取、数值计算、日期格式化、条件判断等。
  • 聚合函数:对多行数据做处理,返回一个值。比如 sum、count、avg、collect_list 等。
  • 窗口函数:在聚合函数基础上增加了"分组内跨行"的能力,可以计算累计值、排名、前后行差值等。
  • 用户自定义函数:当内置函数满足不了业务需求时,用 Java、Scala 或 Python 编写 UDF、UDAF、UDTF。

还有一个容易被忽略但非常实用的分类角度:是否是保留关键字、是否支持代码提示。比如在 DataGrip 或 Zeppelin 里写 SQL 时,有些函数是内建注册的,输入前几个字母就能提示出来;有些是 Hive 兼容的老函数,虽然能跑但性能可能不如新版的原生函数。

1.2 快速定位函数的方法

很多初学者喜欢把官方文档背下来,我建议别这么做。Spark 的版本迭代很快,函数列表一直在涨,靠背是背不完的。我的经验是掌握两个核心入口:

第一,Spark SQL 官方函数文档。打开后直接按分类浏览,搜索框输入关键字能快速定位。例如想找日期加减的函数,搜 data_add 就能出来。

第二,在 Spark REPL 或 SQL 编辑器里执行show functions命令,能看到当前 Spark 会话中注册的所有函数。加上show functions like '*date*'这种模糊查询,可以快速列出跟某个关键词相关的函数。

注意:不同 Spark 版本之间的函数行为可能有细微差异。我在某个线上环境遇到过split函数的正则行为跟本地测试不一致的情况,所以上线前一定要在目标集群的 Spark 版本上验证一遍。

2. 日常开发最常用的内置函数

2.1 字符串函数的使用细节

字符串处理是数据清洗中最常见的操作。我用得最频繁的有以下几组:

concat和concat_ws用于字符串拼接。区别在于concat_ws可以指定分隔符,而且能自动忽略null值。举个例子:

SELECT concat('a', 'b', 'c'); -- abc SELECT concat_ws('-', 'a', 'b', NULL, 'c'); -- a-b-c

这里有个非常容易踩的坑:MySQL 中concat遇到null会返回null,但 Spark 中也是返回null。如果业务上希望忽略null,直接使用concat_ws更省事。我当年用concat拼订单号,结果出现了一堆null值,排查了半天发现是某个字段没做空值处理。

substring用于截取字符串,索引从 1 开始。substring('spark', 2, 3)返回par。注意这里是从第 2 个字符开始,取 3 个字符。

split将字符串按正则切分成数组。比如split('a,b,c', ',')返回[a, b, c]。需要特别注意的是,如果分隔符是正则中的特殊字符(如点号.、竖线|),需要转义。我曾经用split(ip, '.')切分 IP 地址,结果返回的空字符串,查了文档才发现点号是正则通配符。

regexp_extract是正则提取的神器。日志解析、字段清洗都离不了它。假设我们有一串日志user=1001&action=click&page=home,想提取 action 的值:

SELECT regexp_extract('user=1001&action=click&page=home', 'action=(\\w+)', 1); -- 返回 click

第三个参数 1 表示提取第几个括号捕获组。这是日常开发中使用频率极高的函数,建议大家熟练掌握。

另外还有一组字符串函数也经常用到:length计算字符串长度、lower/upper大小写转换、trim去除两侧空格、lpad/rpad填充补齐、replace字符串替换。这些函数语义跟其他 SQL 引擎基本一致,但要注意 Spark 的trim默认只去除空格,不去除制表符和换行符。如果要去除换行符,需要用regexp_replace配合正则。

SELECT regexp_replace(line, '\\n', '');

2.2 数值函数与类型转换

数值计算在报表统计中很常见。Spark SQL 支持四则运算,同时内置了一批数学函数。round用于四舍五入,第二个参数控制小数位数。floor向下取整,ceil向上取整,这两个在分桶统计中经常用到。比如按年龄分桶:

SELECT CASE WHEN age < 20 THEN '0-19' WHEN age < 30 THEN '20-29' WHEN age < 40 THEN '30-39' ELSE '40+' END AS age_group, count(*) AS user_cnt FROM user_profile GROUP BY 1;

这里的CASE WHEN可以说是我用得最多的函数组之一,它属于条件函数,但放在数值场景非常合适。另外if函数也能实现简单的二选一逻辑,但可读性不如CASE WHEN。

类型转换是另一个高频操作。cast(expr AS targetType)是标准写法,例如cast(order_amount AS DECIMAL(10,2))。也可以使用简写:order_amount::decimal(10,2)(Spark 3.0+ 支持)。转换失败时cast会返回null而不是报错,这可能是个隐患。我有一次统计金额,某一行数据金额字段变成了非数字,cast直接返回null,导致求和结果少了几万。后来改成try_cast或者先用regexp过滤掉非法数据,问题才解决。

coalesce返回第一个非null值,是处理空值的利器。nullif返回null(当两个参数相等时),在某些数据对比场景中很有用。nvl是coalesce的 Hive 兼容版本,只支持两个参数,但用法更简洁。

2.3 日期时间处理函数

日期处理在数据分析中绝对是大头。数仓的 T+1 报表、时间窗口统计都依赖日期函数。Spark SQL 的日期时间函数非常多,这里只挑几个核心的讲。

current_date和current_timestamp获取当前时间。注意这两个函数在 Spark 中返回当前系统时间但跟时区配置有关系。如果你的集群配置了 UTC 时区,跑出来的日期可能比北京时间早 8 个小时。这个问题我在对接业务方时遇到过,他们看到报表日期差了一天,最后确认是时区配置的问题。

date_format按指定格式格式化日期,最常用的是按天、按月分组。比如:

SELECT date_format(event_time, 'yyyy-MM-dd') AS day, count(*) AS cnt FROM event_log GROUP BY date_format(event_time, 'yyyy-MM-dd');

datediff计算两个日期之间的天数差。date_add、date_sub是日期加减的常用函数,分别用于给日期加天数、减天数。add_months给日期增加月份,在计算自然月、上个月同期数据时特别好用。

to_date和to_timestamp用于字符串转日期。to_date('2024-05-01')返回 Date 类型。这里要注意,字符串格式必须能被识别,否则返回null。遇到非标准格式,先date_format转换结构,再转类型。

时间窗口计算还有一个非常实用的技巧:计算自然周。Spark SQL 不直接提供week函数,但可以通过date_format(event_time, 'yyyy-ww')或者date_trunc('week', event_time)实现。date_trunc会把指定时间截断到周的起始日(周一),这个方法做周维度聚合非常方便。

2.4 数组与 Map 函数的进阶使用

Spark SQL 对复杂类型的支持相当好,数组和 Map 类型有一套独立的函数体系。在埋点数据分析场景中,一条日志里往往包含多个事件、多个标签,这些数据用数组或 Map 存储比拆成多行更高效。

array函数用于构建数组,size获取数组长度,array_contains判断数组中是否包含某个元素。例如:

SELECT array('a', 'b', 'c') AS tags; SELECT size(tags) AS tag_cnt FROM event_log; SELECT array_contains(tags, 'click') AS is_click FROM event_log;

explode将数组展开成多行,是处理嵌套数据的核心函数。假设我们有一张表存储了用户访问过的页面列表,需要统计每个页面的访问次数:

SELECT page, count(*) AS visit_cnt FROM user_visit_log LATERAL VIEW explode(page_list) t AS page GROUP BY page;

collect_list和collect_set是聚合时把多行合并成一个数组或去重集合的函数,在行转列场景中非常有用。map_keys、map_values可以提取 Map 的键和值,map_from_arrays由两个数组构建 Map。

提示:collect_list的结果顺序不保证稳定,如果需要稳定顺序,可以在聚合前先对数据排序。

3. 聚合函数:从入门到进阶

3.1 基础聚合函数的正确用法

count、sum、avg、max、min是最基础的聚合函数,但里面也有一些细节值得注意。

count(*)统计所有行数,包括某个字段为null的行;count(column)只统计该列非null的行数。这个区别在统计有缺失值的数据集时非常关键。我当年统计订单量时用了count(order_id),结果发现订单表里有少量null的异常记录,报表数字跟业务方对不上,折腾半天才发现是count的语义问题。

sum和avg在遇到null时会自动忽略,不会报错。但要注意,如果所有参与计算的行的值都为null,sum返回null而不是 0。coalesce(sum(amount), 0)这种写法可以避免结果中出现null。

max和min同样忽略null。此外,max_by和min_by是进阶版本,可以获取最大值对应的另一个字段值。比如找出价格最高的商品名称:

SELECT max_by(product_name, price) AS most_expensive_product FROM product_sales;

这个函数在找"某个维度下极端值对应的信息"时非常高效,不用先求极值再 Join 回原表。

3.2 去重统计的几种方式

去重统计在数据分析和报表中经常遇到。最常用的是count(DISTINCT column):

SELECT count(DISTINCT user_id) AS uv FROM event_log WHERE dt = '2024-05-01';

但这种写法在数据量极大时性能可能成为问题,因为count(DISTINCT)需要将所有去重值收集到同一个聚合节点。我们曾用这个方法统计千万级 UV,任务跑了半小时。后来改用approx_count_distinct做近似去重统计,误差控制在 1% 以内,但速度提升了 10 倍。在很多场景下,UV 等指标并不需要绝对精确,用近似函数能节约大量资源。

另一个思路是用GROUP BY子查询先获取去重明细,再在外层做count,这对后续需要复用去重结果的情况更友好。例如:

SELECT count(*) FROM ( SELECT user_id, product_id FROM event_log WHERE action = 'view' GROUP BY user_id, product_id ) t;

3.3 多维度聚合与分组集

日常报表经常需要多个维度的统计结果。GROUP BY GROUPING SETS、ROLLUP和CUBE可以一次性产出多种分组维度,避免写多条 SQL 再UNION ALL。

GROUPING SETS允许你显式指定需要聚合的维度组合;ROLLUP按层级从细到粗聚合;CUBE对所有维度做全组合。以销售数据为例:

SELECT region, channel, sum(amount) AS total_amount FROM sales GROUP BY ROLLUP(region, channel);

它一次返回(region, channel)、(region, null)、(null, null)三组聚合结果,实际上就是明细级、region 级和总体级。这个语法在做层级数据汇总时非常好用,比写三条 SQL 再合并要清晰简洁得多。

4. 窗口函数:数据分析利器

4.1 窗口函数的核心语法

窗口函数(Window Functions)是 Spark SQL 中非常强大的一类函数。它和普通聚合函数的区别在于:普通聚合会把多行压缩成一行,窗口函数不会减少行数,而是保留每一行的同时,计算窗口范围内的聚合或排名值。

语法骨架是:

SELECT columns, window_function() OVER (PARTITION BY cols ORDER BY cols [ROWS BETWEEN ...]) FROM table;

PARTITION BY决定窗口分组;ORDER BY决定组内排序;ROWS BETWEEN定义窗口帧范围。

先从最容易理解的排名类函数说起。row_number、rank、dense_rank是三个排名函数,差异在于并列值的处理方式。row_number不管是否并列,按顺序强制分配编号;rank有并列时跳过后续编号;dense_rank有并列时不跳过编号。举个例子:

SELECT student_name, score, row_number() OVER (ORDER BY score DESC) AS rn, rank() OVER (ORDER BY score DESC) AS rk, dense_rank() OVER (ORDER BY score DESC) AS dr FROM student_score;

假设有两个学生并列最高分 98 分,那么他们的rn分别是 1、2,rk都是 1,下一个学生的rk是 3;而dense_rank下一个学生的dr是 2。业务上如果需要唯一排名取row_number,如果要体现并列名次且名次连续取dense_rank,按体育比赛规则跳名次取rank。

lag和lead用于访问组内前一行或后一行的数据,非常适合做环比、同比计算。例如计算每个用户相邻两次访问之间的时间间隔:

SELECT user_id, visit_time, lag(visit_time) OVER (PARTITION BY user_id ORDER BY visit_time) AS prev_visit_time, datediff(visit_time, lag(visit_time) OVER (PARTITION BY user_id ORDER BY visit_time)) AS days_since_prev_visit FROM user_visits;

lag(column, offset, default)的第二参数表示回看几行,第三参数是回看超出范围时的默认值。

聚合窗口函数也很常用,比如求截至当前行的累计销售额:

SELECT sales_date, amount, sum(amount) OVER (ORDER BY sales_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_amount FROM daily_sales;

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW表示窗口从当前分区的第一行到当前行,是计算累计值的标准写法。

4.2 窗口函数实战场景

场景一:分组 TopN。统计每个类目下销量最高的前 5 个商品。

SELECT * FROM ( SELECT category_id, product_id, sales_amount, row_number() OVER (PARTITION BY category_id ORDER BY sales_amount DESC) AS rn FROM product_sales ) t WHERE rn <= 5;

这是非常经典的 TopN 写法。注意必须先在内层查询生成排名,外层再用WHERE过滤,因为 WHERE 不能直接使用窗口函数的结果。

场景二:会话时长计算。在用户行为数据中,通过窗口函数为每个用户按时间排序后,用session_id分组合并相邻事件,计算每个会话的起止时间和时长。

SELECT session_id, min(event_time) AS session_start, max(event_time) AS session_end, datediff(max(event_time), min(event_time)) AS session_duration FROM ( SELECT user_id, event_time, sum(new_session_flag) OVER (PARTITION BY user_id ORDER BY event_time) AS session_id FROM ( SELECT user_id, event_time, CASE WHEN datediff(event_time, lag(event_time) OVER (PARTITION BY user_id ORDER BY event_time)) > 30 THEN 1 ELSE 0 END AS new_session_flag FROM user_events ) t1 ) t2 GROUP BY session_id;

这段代码里把lag、sum窗口函数和datediff综合在一起,实现了会话聚合,是我实际工作中的常用套路。

场景三:同比环比计算。获取当月销售额与上月销售额的差值、增长率,直接利用lag函数即可,不用 Join 自己。

4.3 窗口函数的性能隐患

窗口函数虽强,但也要小心性能陷阱。最核心的优化点是减少PARTITION BY中的字段和ORDER BY中的排序字段。窗口函数通常触发全局排序,如果数据量巨大,shuffle 的开销会非常明显。

我的经验是,在使用窗口函数前先估算分区数量和数据分布。如果某个分区字段的高基数值太大(比如直接用用户 ID 分区),可能产生大量小分区,影响并行度。更合理的做法是先用GROUP BY收敛数据,再做窗口计算。

5. 扩展与自定义函数

5.1 UDF:快速扩展内置能力

当内置函数无法满足复杂业务逻辑时,UDF(User-Defined Function)是最直接的扩展手段。Spark SQL 支持用 Java、Scala、Python 写 UDF,其中 Python UDF 对 Python 开发背景的人最友好,但也存在性能开销。

Python UDF 的一个典型问题是性能瓶颈。因为 Spark 原生执行器是 JVM,调用 Python UDF 需要进程间通信和序列化。如果 UDF 处理的是海量数据,性能会大打折扣。我在某项目中把一个纯字符串处理逻辑写成 Python UDF,跑 200G 数据要 3 小时,后来优化成原生 SQL 表达式,时间缩短到 40 分钟。这不是说不要用 UDF,而是要用在刀刃上:先试着用内置函数组合实现,遇到实在绕不过去的复杂逻辑再上 UDF。

一个简单的 Python UDF 示例:

from pyspark.sql.functions import udf from pyspark.sql.types import StringType @udf(returnType=StringType()) def category_name(category_code): if category_code.startswith('A'): return '电子产品' elif category_code.startswith('B'): return '服装' else: return '其他' df.withColumn('category_name', category_name(df['category_code']))

写完 UDF 后需要注册才能在 SQL 中使用:

spark.udf.register('category_name_udf', category_name, StringType())

5.2 UDAF 和 UDTF 的应用场景

UDAF(User-Defined Aggregate Function)用于自定义聚合逻辑。比如要开发一个计算众数、计算分位数等内置函数不直接支持的聚合逻辑,就需要写 UDAF。Spark 3.0 之后更推荐用Aggregator接口或者 Python 的 Pandas UDAF。

UDTF(User-Defined Table-Generating Function)用于一进多出,即一行输入产生多行输出,explode就是内置的 UDTF。自定义 UDTF 可以实现比如把一段日志按专有协议解析成多条结构化记录的功能。

不过说实话,UDAF 和 UDTF 的开发成本比 UDF 高不少,而且调试难度大。在日常开发中,我建议优先用内置函数和 SQL 组合解决问题,确实需要再上自定义扩展。某次我为了实现一个复杂的并行化评分逻辑写了一个 Java UDAF,后来发现用窗口函数加 CASE WHEN 组合也能达到类似效果,虽然代码长了点,但可维护性和性能都更好。

5.3 注册函数的高效方式

不管是内置函数还是自定义函数,在生产环境的 Spark 任务中,频繁手动注册是很麻烦的。推荐将常用 UDF 封装在一个统一的 Spark Session 初始化工具中,启动任务时自动注册。这样所有团队成员写 SQL 时可以直接使用这些函数,也方便统一维护和升级。

6. 性能调优与常见坑

6.1 函数使用中的性能注意点

第一个注意点:尽量避免在过滤条件中使用 UDF。这会破坏 Spark 的谓词下推优化,导致全表扫描。如果必须用 UDF 过滤,可以先用内置函数做初步过滤,缩小数据量后再用 UDF 精筛。

第二个注意点:count(DISTINCT)和collect_set都会将所有值集中到一个 reducer,内存压力大。处理超大规模去重统计时,优先考虑approx_count_distinct或先用 BloomFilter 相关技术做预估。

第三个注意点:正则函数比较耗资源。regexp_extract、regexp_replace对每条记录都会做一次正则编译和匹配。如果正则表达式复杂且数据量大,性能可能成为瓶颈。优化方式是把正则模式中不变的部分提取出来,比如先split再substring,可读性和性能可能更好。

第四个注意点:字段类型选择对函数性能有影响。在日期字段上做字符串比较,会导致全表扫描时无法有效利用文件大小裁剪。建议在数仓建模阶段就把日期字段设置为 Date 类型,而不是 String 类型。

6.2 常见问题排查实录

问题一:日期差一天。排查思路是检查 Spark 时区配置,确认spark.sql.session.timeZone是否设置为目标时区。

问题二:regexp_extract返回空字符串。排查思路是确认正则是否被正确转义。在 SQL 字符串中表示正则反斜杠时需要写两个反斜杠,示例中经常因为一个是转义一个没有而踩坑。

问题三:窗口函数结果与预期不符。排查思路是先检查ORDER BY的字段类型。如果是字符串类型的日期字段,排序是按字符序,'2024-02-01'和'2024-01-31'比较结果可能跟日期序不一致。

问题四:collect_list结果顺序不稳定。排查思路是确认在聚合前是否做了排序。collect_list不保证顺序,它只负责收集;要保证顺序,先在子查询中ORDER BY,外层再collect_list。

问题五:数据倾斜导致聚合缓慢。排查思路是检查 group by 的维度字段是否存在热点值,比如渠道字段中某个渠道占比极高。常用解决策略是加盐(salting)打散,把一个大 Key 拆成多个随机 Key 聚合再合并。

我把这些问题汇总成一个速查表,后续排查问题可以直接对照。

现象可能原因解决思路
日期差一天时区配置问题检查并统一 timeZone
正则提取结果为空正则转义错误SQL 中双反斜杠转义
排序结果不对字段类型为字符串先 cast 为日期再排序
聚合结果里 nullsum/avg 忽略 nullcoalesce 包裹
窗口函数之后 ORDER BY 乱重复执行 ORDER BY 位置不对在窗口函数外再包一层排序
collect_list 无顺序collect_list 本身不保证有序先 ORDER BY 再 collect
去重统计极慢count(DISTINCT) 数据量大使用 approx_count_distinct

7. 一些建议

Spark SQL 的函数体系覆盖面广、迭代快,今天整理的内容只能覆盖我工作中最高频的部分。但函数学得再多,最终还是要落到怎么解决问题上。我的体会是,拿到一个数据需求时,先不要急着抄函数,先明确几个问题:数据量有多大、需要明细还是聚合、可接受的延迟是多少、准确性要求是精确还是近似。回答了这些问题,再去选函数就有章法了。

另外建议大家维护一份自己工作领域的常用函数速查笔记。不同领域对函数的需求差别很大,做用户画像的人可能整天跟字符串函数打交道,做财务数仓的人则更多用数值函数和日期函数。把自己用过、验证过的函数记录下来,比收藏任何别人的总结都有用。

最后分享一个小技巧:在编写复杂 SQL 时,学会拆解问题。一个大需求拆成若干个可以单独验证的小步骤,每个步骤先用 SELECT 加 LIMIT 验证结果,确认无误后再组合成完整 SQL。这个习惯帮我避免了大量返工,也让我对每个函数的行为特征越来越有把握。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询