云计算百科
云计算领域专业知识百科平台

第14课:PySpark|内置函数全解【字符串日期聚合条件窗口函数实战】

在这里插入图片描述

文章目录

    • 一、课前导读
    • 二、学习目标
    • 三、核心理论知识点
    • 四、原理通俗讲解
      • 4.1 内置函数为何比UDF快得多?
      • 4.2 函数组合与链式调用
      • 4.3 窗口函数的物理执行
    • 五、重点概念拆解
      • 5.1 字符串函数详解
      • 5.2 日期时间函数详解
      • 5.3 聚合函数详解
      • 5.4 条件函数详解
      • 5.5 窗口函数详解
      • 5.6 集合函数(数组与Map)
    • 六、易错点避坑
      • 6.1 混淆`substring`的索引起始
      • 6.2 日期格式字符串大小写敏感
      • 6.3 窗口函数中`order by`缺少会导致整个分区作为一个窗口
      • 6.4 `collect_list`结果无序且可能受分区影响
      • 6.5 在聚合前使用`explode`导致数据膨胀
      • 6.6 使用`when`时遗漏`otherwise`
      • 6.7 `coalesce`与`repartition`混淆
    • 七、完整实战案例
    • 八、代码逐行解析
      • 8.1 正则提取字段
      • 8.2 字符串分割与取元素
      • 8.3 日期转换
      • 8.4 条件分类
      • 8.5 近似去重计数
      • 8.6 透视(pivot)
      • 8.7 窗口函数
      • 8.8 数组展开
      • 8.9 UDF vs 内置函数性能对比
    • 九、业务场景落地应用
      • 9.1 场景一:用户注册日志中的设备信息解析
      • 9.2 场景二:电商订单的RFM分层
      • 9.3 场景三:实时流处理中的JSON解析
      • 9.4 场景四:日志路径分析中的路径拆分
    • 十、常见报错排查
      • 10.1 `to_timestamp`返回null
      • 10.2 窗口函数报错`Window function without frame`
      • 10.3 `regexp_extract`提取不到值返回空字符串
      • 10.4 使用`collect_list`导致内存溢出
      • 10.5 `explode`后数据膨胀导致Shuffle数据量巨大
    • 十一、本节课知识点总结
      • 函数分类速查表
      • 性能优化要点
    • 十二、课后思考作业
      • 作业一:理论理解题
      • 作业二:代码实践题
      • 作业三:场景应用题
      • 作业四:拓展研究
  • 🔗《20节课 PySpark 从入门到精通》系列课程导航

一、课前导读

在前面的课程中,我们已经学习了DataFrame的基础操作和Spark SQL的完整语法。你可能已经发现,在编写DataFrame代码时,大量的数据处理逻辑都依赖于pyspark.sql.functions模块中的内置函数。例如,用when实现条件判断,用date_add处理日期,用row_number进行排名。这些内置函数不仅让代码更简洁,更重要的是——它们的执行效率远超自定义UDF,因为内置函数是直接在JVM中执行的,不需要Python和JVM之间的序列化开销。

然而,内置函数的数量极其庞大(超过200个),很多初学者只会用其中几个(如col、sum、avg),遇到稍微复杂的需求就束手无策,甚至去写低效的UDF。例如,想要提取字符串中的日期、计算两个日期的差值、实现行转列(pivot)、计算累计和等,实际上都有对应的内置函数或组合用法。

本节课将系统性地讲解PySpark中最常用的内置函数,按照功能模块分为:字符串函数、日期时间函数、聚合函数、条件函数、窗口函数、集合函数等。每个函数都会配合实战代码和业务场景,帮助你彻底掌握并能在实际工作中灵活运用。学完这节课,你将不再需要为简单的数据转换编写UDF,代码性能将提升一个数量级。

二、学习目标

完成本节课的学习后,你将能够:

  • 掌握核心字符串函数:concat、split、regexp_extract、substring、trim、upper/lower、length、replace等
  • 掌握核心日期函数:to_date/to_timestamp、date_format、date_add/date_sub、datediff、year/month/day、current_date、unix_timestamp等
  • 掌握高级聚合函数:collect_list、collect_set、first/last、grouping、pivot等
  • 掌握条件函数:when、otherwise、coalesce、ifnull、nanvl等
  • 掌握窗口函数:row_number、rank、dense_rank、lag/lead、ntile、聚合窗口函数(sum over等)
  • 掌握集合函数:array_contains、size、explode、posexplode、array_distinct等处理数组和Map
  • 理解内置函数优于UDF的原理:避免序列化开销,利用Catalyst优化和代码生成
  • 能够将复杂业务逻辑用内置函数链式表达,写出高效可读的代码
  • 三、核心理论知识点

    函数类别常用函数说明
    字符串 concat, concat_ws, length, lower/upper, ltrim/rtrim/trim, reverse, substring, split, regexp_extract, regexp_replace, replace, instr, locate 字符串拼接、截取、正则匹配替换
    日期时间 current_date, current_timestamp, to_date, to_timestamp, date_format, year, month, dayofmonth, dayofweek, weekofyear, datediff, months_between, date_add, date_sub, add_months, last_day, trunc, from_unixtime, unix_timestamp 日期解析、格式化、计算差值
    聚合 sum, avg, count, max, min, stddev, variance, collect_list, collect_set, first, last, approx_count_distinct 常规聚合及高级聚合
    条件 when, otherwise, coalesce, isnull, isnan, if, ifnull, nullif, nanvl 条件判断、空值处理
    窗口 row_number, rank, dense_rank, percent_rank, ntile, cume_dist, lag, lead, 聚合函数配合over 排名、偏移、累计计算
    集合(数组/Map) array, array_contains, size, explode, posexplode, array_distinct, array_remove, array_union, array_intersect, array_join, map_keys, map_values 处理复杂数据类型
    数学 abs, ceil, floor, round, pow, sqrt, rand 数值计算
    其他 col, lit, expr, struct, to_json, from_json 辅助函数

    四、原理通俗讲解

    4.1 内置函数为何比UDF快得多?

    当你使用Python UDF时,例如:

    @udf("string")
    def my_upper(s):
    return s.upper()

    Spark在执行时需要:

  • 将DataFrame中的每一行(JVM中的Row对象)序列化为Python对象(反序列化)
  • 将数据传递给Python进程
  • Python函数执行计算
  • 将结果序列化回JVM
  • JVM进一步处理
  • 这个过程涉及多次序列化和跨进程通信,开销极大。

    而内置函数(如upper(col("name")))完全在JVM内部执行,直接操作二进制数据(Tungsten格式),无需序列化,并且可以参与Catalyst优化器的表达式重写和代码生成,最终生成高度优化的Java字节码。

    因此,能用内置函数解决的场景,绝对不要写UDF。

    4.2 函数组合与链式调用

    内置函数通常是表达式(Column类型),可以进行组合。例如:

    df.withColumn("domain", split(regexp_extract(col("email"), "@(.*)", 1), "\\\\.").getItem(0))

    这种链式调用在逻辑上构建了一棵表达式树,Catalyst优化器能够对这棵树进行常量折叠、谓词下推等优化。

    4.3 窗口函数的物理执行

    窗口函数需要将数据按照PARTITION BY分组,在每个分组内按照ORDER BY排序,然后计算排名或聚合。Spark会通过WindowExec物理算子来完成,它会对数据进行排序(可能触发Shuffle),然后在每个分区内逐行计算。合理使用窗口函数可以避免复杂的自连接。

    五、重点概念拆解

    5.1 字符串函数详解

    函数示例输出
    concat concat(col("first"), lit(" "), col("last")) “John Doe”
    concat_ws concat_ws("-", col("year"), col("month")) “2024-01”
    length length(col("name")) 5
    lower/upper upper(col("name")) “ALICE”
    trim trim(col("name")) 去除首尾空格
    substring substring(col("date"), 1, 10) 截取前10个字符
    split split(col("email"), "@") 返回数组
    regexp_extract regexp_extract(col("url"), "id=(\\\\d+)", 1) 提取数字
    regexp_replace regexp_replace(col("text"), "\\\\s+", " ") 替换空白
    replace replace(col("phone"), "-", "") 删除连字符
    instr instr(col("email"), "@") 返回@位置索引

    注意:字符串索引从1开始(与SQL一致,与Python不同)。

    5.2 日期时间函数详解

    函数示例说明
    current_date() 无参数 返回当前日期
    current_timestamp() 返回当前时间戳
    to_date to_date(col("date_str"), "yyyy-MM-dd") 字符串转日期
    to_timestamp to_timestamp(col("ts_str"), "yyyy-MM-dd HH:mm:ss") 字符串转时间戳
    date_format date_format(col("date"), "yyyyMM") 格式化为字符串
    year/month/day year(col("date")) 提取部分
    datediff datediff(col("end"), col("start")) 日期差(天)
    months_between months_between(col("end"), col("start")) 月份差
    date_add date_add(col("date"), 7) 加7天
    date_sub date_sub(col("date"), 7) 减7天
    add_months add_months(col("date"), 3) 加3个月
    last_day last_day(col("date")) 当月最后一天
    trunc trunc(col("date"), "MM") 截断到月初
    unix_timestamp unix_timestamp(col("ts")) 转为Unix秒数
    from_unixtime from_unixtime(col("unix")) 秒数转字符串

    注意:to_date和to_timestamp在格式不匹配时会返回null,可以通过cast或try_cast(Spark 3.0+)处理。

    5.3 聚合函数详解

    函数示例说明
    常规聚合 sum, avg, count, max, min 配合groupBy使用
    collect_list collect_list(col("name")) 收集为数组(保留顺序)
    collect_set collect_set(col("city")) 收集为数组(去重)
    first/last first(col("value")) 第一个/最后一个值(可能不确定顺序)
    approx_count_distinct approx_count_distinct(col("user_id"), 0.05) 近似去重计数(HyperLogLog)
    grouping grouping(col("year")) 用于GROUP BY CUBE/ROLLUP判断是否为聚合行

    5.4 条件函数详解

    • when(condition, value).otherwise(default):类似CASE WHEN,多个when可链式调用。
    • coalesce(col1, col2, …):返回第一个非空值。
    • isnull(col):判断是否为空,返回布尔值。
    • if(cond, true_val, false_val):类似三元运算符。
    • nullif(expr1, expr2):如果相等返回NULL,否则返回expr1。
    • nanvl(col1, col2):如果col1是NaN则返回col2,否则col1。

    5.5 窗口函数详解

    窗口函数语法:函数().over(Window.partitionBy("col1").orderBy("col2"))

    常用窗口函数:

    • row_number():连续唯一编号,从1开始。
    • rank():有间隔排名(1,1,3,…)。
    • dense_rank():无间隔排名(1,1,2,…)。
    • percent_rank():相对排名 (rank-1)/(总行数-1)。
    • ntile(n):将分区内数据分成n个桶,返回桶编号。
    • lag(col, offset, default):向前偏移offset行。
    • lead(col, offset, default):向后偏移。
    • 聚合函数如sum(col).over(…):计算累计和、移动平均等。

    窗口范围定义:

    from pyspark.sql import Window
    Window.partitionBy("user").orderBy("date").rowsBetween(Window.unboundedPreceding, Window.currentRow)

    • rowsBetween(start, end):按行数定义窗口范围。
    • rangeBetween(start, end):按排序值的差值定义。

    5.6 集合函数(数组与Map)

    处理ArrayType列的函数:

    • array_contains(array, value):是否包含。
    • size(array):数组长度。
    • explode(array):将数组每个元素展开成多行(一对多)。
    • posexplode(array):同时展开索引和元素。
    • array_distinct(array):去重。
    • array_remove(array, value):删除指定元素。
    • array_union(array1, array2):并集。
    • array_intersect(array1, array2):交集。
    • array_join(array, delimiter):将数组元素拼接为字符串。

    处理MapType列的函数:

    • map_keys(map):返回所有键的数组。
    • map_values(map):返回所有值的数组。
    • map_from_arrays(keys, values):从两个数组创建Map。

    六、易错点避坑

    6.1 混淆substring的索引起始

    SQL中substring(str, pos, len)的pos从1开始,而Python字符串切片从0开始。错误习惯会导致截取结果错误。

    6.2 日期格式字符串大小写敏感

    date_format中格式必须严格匹配:yyyy-MM-dd(小写y表示年,大写M表示月,小写m表示分钟)。常见错误:用YYYY(这是周年周数)导致年份错误。

    6.3 窗口函数中order by缺少会导致整个分区作为一个窗口

    如果不指定orderBy,窗口默认为整个分区(无滑动窗口)。但某些窗口函数(如row_number)要求必须指定orderBy,否则报错。

    6.4 collect_list结果无序且可能受分区影响

    collect_list不保证顺序,如果需要有序,应先orderBy再收集。

    6.5 在聚合前使用explode导致数据膨胀

    explode会为数组每个元素生成一行,如果数组很大,会大幅增加数据量。在groupBy前使用explode可能导致Shuffle数据量爆炸。

    6.6 使用when时遗漏otherwise

    如果没有otherwise,不满足条件的分支会返回null,这可能是期望的结果,但也可能无意中引入空值。

    6.7 coalesce与repartition混淆

    coalesce作为函数用于返回第一个非空值,而DataFrame的coalesce方法用于减少分区。注意上下文。

    七、完整实战案例

    本案例将使用一个模拟的用户行为日志数据集,综合运用字符串、日期、聚合、条件、窗口、集合等内置函数,完成以下分析任务:

  • 日志解析:从原始字符串中提取IP、URL、状态码、响应时间
  • 数据清洗:转换日期格式、处理空值
  • 统计每小时的PV、UV、平均响应时间
  • 使用窗口函数计算每个IP的访问次数排名、累计响应时间
  • 对URL进行分类标记(使用条件函数)
  • 将访问路径拆分为数组并统计路径深度分布
  • # ============== builtin_functions_demo.py ==============
    # 功能:PySpark内置函数全解实战(字符串、日期、聚合、条件、窗口、集合)
    # 数据量:模拟100万条Apache日志
    # 环境:PySpark 3.x

    from pyspark.sql import SparkSession
    from pyspark.sql.functions import *
    from pyspark.sql.types import *
    from pyspark.sql import Window
    import random
    from datetime import datetime, timedelta

    # ========== 1. 创建SparkSession ==========
    spark = SparkSession.builder \\
    .appName("BuiltinFunctionsDemo") \\
    .master("local[4]) \\
    .config("spark.sql.shuffle.partitions", "8") \\
    .getOrCreate()

    sc = spark.sparkContext
    sc.setLogLevel("WARN")

    print("=" * 80)
    print("PySpark 内置函数全解实战")
    print("=" * 80)

    # ========== 2. 生成模拟日志数据 ==========
    # 原始日志格式:IP – – [日期:时间 时区] "METHOD URL PROTOCOL" STATUS BYTES
    # 示例:192.168.1.1 – – [01/Jan/2024:12:34:56 +0800] "GET /api/user HTTP/1.1" 200 1234
    print("\\n生成模拟访问日志…")
    ips = [f"192.168.1.{i}" for i in range(1, 101)]
    urls = ["/api/user", "/api/order", "/api/product", "/home", "/login", "/static/style.css", "/api/order/create"]
    methods = ["GET", "POST"]
    status_codes = [200, 200, 200, 404, 500, 302]

    def generate_log_line(timestamp):
    ip = random.choice(ips)
    method = random.choice(methods)
    url = random.choice(urls)
    status = random.choice(status_codes)
    bytes_sent = random.randint(100, 10000)
    log_line = f'{ip} – – [{timestamp.strftime("%d/%b/%Y:%H:%M:%S +0800")}] "{method} {url} HTTP/1.1" {status} {bytes_sent}'
    return log_line

    # 生成最近7天的日志,共100万条
    num_records = 1_000_000
    start_date = datetime.now() timedelta(days=7)
    logs = []
    for i in range(num_records):
    ts = start_date + timedelta(seconds=random.randint(0, 7*24*3600))
    logs.append(generate_log_line(ts))

    # 创建DataFrame(单列)
    log_df = spark.createDataFrame([(log,) for log in logs], ["raw_log"])
    print(f"生成了 {log_df.count():,} 条日志记录")
    log_df.show(5, truncate=False)

    # ========== 3. 字符串函数:解析日志 ==========
    print("\\n" + "=" * 80)
    print("步骤1: 使用字符串函数解析日志")
    print("=" * 80)

    # 定义正则表达式提取字段
    parsed_df = log_df.select(
    regexp_extract(col("raw_log"), r'^(\\S+)', 1).alias("ip"), # IP
    regexp_extract(col("raw_log"), r'\\[(.*?)\\]', 1).alias("timestamp_str"), # 时间戳
    regexp_extract(col("raw_log"), r'"(\\S+) (\\S+) \\S+"', 1).alias("method"), # 方法
    regexp_extract(col("raw_log"), r'"\\S+ (\\S+) \\S+"', 1).alias("url"), # URL
    regexp_extract(col("raw_log"), r'"\\S+ \\S+ \\S+" (\\d{3})', 1).cast("int").alias("status"), # 状态码
    regexp_extract(col("raw_log"), r'"\\S+ \\S+ \\S+" \\d{3} (\\d+)', 1).cast("int").alias("bytes") # 字节数
    )

    # 使用字符串函数进一步清洗
    # 去除URL中的查询参数(?后面的部分)
    parsed_df = parsed_df.withColumn(
    "url_path",
    split(col("url"), "\\\\?").getItem(0)
    )

    # 对IP进行分类(IP段)
    parsed_df = parsed_df.withColumn(
    "ip_segment",
    concat(split(col("ip"), "\\\\.").getItem(0), lit("."),
    split(col("ip"), "\\\\.").getItem(1), lit("."),
    split(col("ip"), "\\\\.").getItem(2))
    )

    print("解析后的DataFrame:")
    parsed_df.printSchema()
    parsed_df.show(10, truncate=False)

    # ========== 4. 日期函数:转换时间戳、提取小时/星期几 ==========
    print("\\n" + "=" * 80)
    print("步骤2: 使用日期函数处理时间")
    print("=" * 80)

    # 将字符串时间戳转为timestamp类型,格式:01/Jan/2024:12:34:56 +0800
    # 需要先去掉时区部分
    parsed_with_date = parsed_df.withColumn(
    "timestamp_clean",
    regexp_replace(col("timestamp_str"), " \\\\+\\\\d{4}$", "")
    ).withColumn(
    "log_time",
    to_timestamp(col("timestamp_clean"), "dd/MMM/yyyy:HH:mm:ss")
    ).withColumn(
    "date",
    to_date(col("log_time"))
    ).withColumn(
    "hour",
    hour(col("log_time"))
    ).withColumn(
    "day_of_week",
    dayofweek(col("log_time")) # 1=Sunday, 7=Saturday
    ).withColumn(
    "weekday",
    when(dayofweek(col("log_time")).isin(1, 7), "Weekend").otherwise("Weekday")
    )

    print("日期处理后的Schema:")
    parsed_with_date.printSchema()
    parsed_with_date.select("timestamp_str", "log_time", "date", "hour", "weekday").show(5, truncate=False)

    # ========== 5. 条件函数:状态码分类、空值处理 ==========
    print("\\n" + "=" * 80)
    print("步骤3: 使用条件函数 (when/otherwise, coalesce)")
    print("=" * 80)

    # 根据状态码分类
    classified_df = parsed_with_date.withColumn(
    "status_category",
    when(col("status") < 300, "Success")
    .when(col("status") < 400, "Redirect")
    .when(col("status") < 500, "Client Error")
    .otherwise("Server Error")
    ).withColumn(
    "is_error",
    when(col("status") >= 400, True).otherwise(False)
    )

    # 处理可能为空的bytes字段(使用coalesce)
    classified_df = classified_df.withColumn(
    "bytes_sent",
    coalesce(col("bytes"), lit(0))
    )

    print("条件分类结果:")
    classified_df.select("status", "status_category", "is_error", "bytes_sent").show(10)

    # ========== 6. 聚合函数 + 分组 + 透视 ==========
    print("\\n" + "=" * 80)
    print("步骤4: 聚合函数与透视(pivot)")
    print("=" * 80)

    # 按小时统计PV、UV、平均响应时间
    hourly_stats = classified_df.groupBy("date", "hour").agg(
    count("*").alias("pv"),
    approx_count_distinct("ip").alias("uv"),
    avg("bytes").alias("avg_bytes")
    ).orderBy("date", "hour")

    print("每小时访问统计:")
    hourly_stats.show(10)

    # 使用pivot:按状态类别统计每天的数量
    pivot_df = classified_df.groupBy("date").pivot("status_category", ["Success", "Redirect", "Client Error", "Server Error"]).count()
    print("每天各状态类别数量(透视表):")
    pivot_df.show(5)

    # 使用collect_list收集每个小时的URL列表(用于后续分析)
    url_list_per_hour = classified_df.groupBy("date", "hour").agg(
    collect_list("url_path").alias("urls")
    )
    print("每小时URL列表(collect_list示例):")
    url_list_per_hour.show(3, truncate=False)

    # ========== 7. 窗口函数:排名、累计、滞后 ==========
    print("\\n" + "=" * 80)
    print("步骤5: 窗口函数实战")
    print("=" * 80)

    # 窗口定义:按IP分区,按时间排序
    window_spec = Window.partitionBy("ip").orderBy("log_time")

    # 为每个IP的请求按时间编号
    df_with_row_num = classified_df.withColumn(
    "request_seq",
    row_number().over(window_spec)
    )

    # 每个IP的累计响应字节数(累计和)
    df_with_cumsum = classified_df.withColumn(
    "cumulative_bytes",
    sum("bytes").over(window_spec.rowsBetween(Window.unboundedPreceding, Window.currentRow))
    )

    # 每个IP上一次请求的时间(LAG)
    df_with_lag = classified_df.withColumn(
    "prev_log_time",
    lag("log_time", 1).over(window_spec)
    ).withColumn(
    "time_since_last",
    datediff(col("log_time"), col("prev_log_time"))
    )

    # 每个IP的请求次数排名(全局窗口)
    ip_rank_window = Window.orderBy(desc("cnt"))
    ip_rank_df = classified_df.groupBy("ip").count().withColumn(
    "rank",
    rank().over(ip_rank_window)
    ).filter(col("rank") <= 10)

    print("访问次数Top10的IP:")
    ip_rank_df.show()

    # 使用ntile将每个IP的请求分为5个桶(用于百分位数分析)
    df_with_ntile = classified_df.withColumn(
    "bytes_ntile",
    ntile(5).over(Window.partitionBy("ip").orderBy("bytes"))
    )

    print("每个IP的请求按响应字节数分桶:")
    df_with_ntile.select("ip", "bytes", "bytes_ntile").show(20)

    # ========== 8. 集合函数(数组/Map) ==========
    print("\\n" + "=" * 80)
    print("步骤6: 集合函数(数组操作)")
    print("=" * 80)

    # 将URL路径按'/'拆分成数组,并分析路径深度
    df_with_path = classified_df.withColumn(
    "path_parts",
    split(col("url_path"), "/")
    ).withColumn(
    "path_depth",
    size(col("path_parts")) 1 # 减去空字符串
    ).withColumn(
    "first_level",
    when(size(col("path_parts")) > 1, col("path_parts").getItem(1)).otherwise(lit("root"))
    )

    # 使用explode展开路径组件,统计每个路径组件的访问频率
    exploded_df = classified_df.withColumn(
    "path_parts_array",
    split(col("url_path"), "/")
    ).withColumn(
    "part",
    explode(col("path_parts_array"))
    ).filter(col("part") != "")

    path_component_stats = exploded_df.groupBy("part").count().orderBy(desc("count"))
    print("URL路径组件访问频率(Top10):")
    path_component_stats.show(10)

    # 使用array_contains判断URL是否包含特定字符串
    classified_df.withColumn(
    "is_api_call",
    array_contains(split(col("url_path"), "/"), "api")
    ).select("url_path", "is_api_call").show(5)

    # 收集每个IP访问的所有URL(去重)并拼接
    ip_unique_urls = classified_df.groupBy("ip").agg(
    collect_set("url_path").alias("unique_urls")
    ).withColumn(
    "urls_string",
    array_join(col("unique_urls"), ", ")
    )
    print("每个IP访问的不同URL(前10个IP):")
    ip_unique_urls.show(10, truncate=False)

    # ========== 9. 综合案例:用户行为漏斗分析 ==========
    print("\\n" + "=" * 80)
    print("步骤7: 综合案例 – 页面访问漏斗分析")
    print("=" * 80)

    # 假设我们关注的路径顺序:/home -> /api/product -> /api/order/create -> /api/order/pay
    funnel_steps = ["/home", "/api/product", "/api/order/create", "/api/order/pay"]

    # 为每个IP的访问路径按时间排序,并获取路径序列
    funnel_df = classified_df.select("ip", "log_time", "url_path") \\
    .withColumn("rn", row_number().over(Window.partitionBy("ip").orderBy("log_time")))

    # 简化漏斗计算:统计每个IP是否按顺序访问了这些路径(使用窗口函数的lag)
    # 更复杂可使用pivot或序列匹配,这里演示思路
    print("漏斗分析(简化): 统计每个步骤的独立访客数")
    for i, step in enumerate(funnel_steps):
    step_count = classified_df.filter(col("url_path") == step).select("ip").distinct().count()
    print(f"步骤{i+1}: {step} – 访客数 {step_count}")

    # ========== 10. 性能优化:比较UDF与内置函数 ==========
    print("\\n" + "=" * 80)
    print("步骤8: 性能对比 – 内置函数 vs UDF")
    print("=" * 80)

    from pyspark.sql.functions import udf
    import time

    # UDF版本:提取URL的第一级路径
    def extract_first_level_udf(url):
    if not url:
    return ""
    parts = url.split("/")
    return parts[1] if len(parts) > 1 else ""

    extract_first_level = udf(extract_first_level_udf, StringType())

    # 内置函数版本
    start_time = time.time()
    df_builtin = classified_df.withColumn("first_level", split(col("url_path"), "/").getItem(1))
    df_builtin.count()
    builtin_time = time.time() start_time

    start_time = time.time()
    df_udf = classified_df.withColumn("first_level", extract_first_level(col("url_path")))
    df_udf.count()
    udf_time = time.time() start_time

    print(f"内置函数耗时: {builtin_time:.2f} 秒")
    print(f"UDF耗时: {udf_time:.2f} 秒")
    print(f"内置函数比UDF快 {udf_time/builtin_time:.1f} 倍")

    # ========== 11. 展示常用函数示例汇总 ==========
    print("\\n" + "=" * 80)
    print("附录: 常用函数快速参考")
    print("=" * 80)
    print("""
    字符串: concat, concat_ws, length, upper, lower, trim, substring, split, regexp_extract, regexp_replace
    日期: current_date, current_timestamp, to_date, to_timestamp, date_format, year, month, datediff, date_add
    聚合: sum, avg, count, max, min, collect_list, collect_set, approx_count_distinct
    条件: when, otherwise, coalesce, isnull, if
    窗口: row_number, rank, dense_rank, lag, lead, ntile, sum().over()
    集合: explode, posexplode, size, array_contains, array_distinct, array_join
    """
    )

    # ========== 12. 清理 ==========
    spark.stop()
    print("\\n✅ 内置函数演示完成")

    八、代码逐行解析

    8.1 正则提取字段

    regexp_extract(col("raw_log"), r'^(\\S+)', 1).alias("ip")

    使用正则捕获组提取IP,^(\\S+)匹配行首非空白字符序列。1表示返回第一个捕获组。类似地提取其他字段。注意状态码和字节数在提取后使用.cast("int")转换为整型。

    8.2 字符串分割与取元素

    split(col("url"), "\\\\?").getItem(0)

    split返回数组,getItem(0)取第一个元素。注意正则表达式中的?需要转义。

    8.3 日期转换

    regexp_replace(col("timestamp_str"), " \\\\+\\\\d{4}$", "")

    先去掉时区部分(如+0800),然后使用to_timestamp指定格式dd/MMM/yyyy:HH:mm:ss。注意月份是MMM(英文缩写)。hour、dayofweek等提取部分。

    8.4 条件分类

    when(col("status") < 300, "Success")
    .when(col("status") < 400, "Redirect")
    ...

    多个when链式调用,类似CASE WHEN。otherwise可选。

    8.5 近似去重计数

    approx_count_distinct("ip").alias("uv")

    对于超大基数去重(如UV),使用approx_count_distinct可以提高速度,误差可接受(默认为5%)。

    8.6 透视(pivot)

    pivot("status_category", ["Success", "Redirect", ...])

    将行转列,第二个参数指定列值列表(可选,但指定后性能更好)。

    8.7 窗口函数

    Window.partitionBy("ip").orderBy("log_time")
    row_number().over(window_spec)

    为每个IP的请求按时间编号。sum("bytes").over(window_spec.rowsBetween(Window.unboundedPreceding, Window.currentRow)) 计算累计和。

    8.8 数组展开

    explode(col("path_parts_array"))

    将数组每个元素展开成一行,常用于日志解析中的标签拆分。

    8.9 UDF vs 内置函数性能对比

    内置函数直接操作JVM数据,而UDF需要序列化和进程间通信,耗时高出数十倍。因此强烈建议优先使用内置函数。

    九、业务场景落地应用

    9.1 场景一:用户注册日志中的设备信息解析

    原始日志中用户代理字符串(User-Agent)需要提取操作系统、浏览器版本。使用regexp_extract和when组合。

    df.withColumn("os",
    when(col("ua").rlike("Windows"), "Windows")
    .when(col("ua").rlike("Mac"), "macOS")
    .when(col("ua").rlike("Linux"), "Linux")
    .otherwise("Other")
    ).withColumn("browser",
    regexp_extract(col("ua"), "(Chrome|Firefox|Safari|Edge)", 1)
    )

    9.2 场景二:电商订单的RFM分层

    使用窗口函数对用户进行消费金额分桶,计算每个用户的排名。

    rfm_df = order_df.groupBy("user_id").agg(
    max("order_date").alias("last_order"),
    count("*").alias("frequency"),
    sum("amount").alias("monetary")
    ).withColumn("recency", datediff(current_date(), col("last_order")))

    # 分桶打分
    rfm_df.withColumn("r_score", ntile(5).over(Window.orderBy(col("recency").desc()))) \\
    .withColumn("f_score", ntile(5).over(Window.orderBy(col("frequency")))) \\
    .withColumn("m_score", ntile(5).over(Window.orderBy(col("monetary"))))

    9.3 场景三:实时流处理中的JSON解析

    使用from_json函数解析Kafka消息中的JSON字符串。

    from pyspark.sql.types import StructType, StructField, StringType, IntegerType

    schema = StructType([StructField("user_id", IntegerType()), StructField("action", StringType())])
    df = df.withColumn("json", from_json(col("value"), schema)).select("json.*")

    9.4 场景四:日志路径分析中的路径拆分

    分析用户访问路径,统计每个页面的深度。

    df.withColumn("depth", size(split(col("url"), "/")) 1) \\
    .groupBy("depth").count().orderBy("depth")

    十、常见报错排查

    10.1 to_timestamp返回null

    原因:日期字符串格式不匹配,或时区问题。

    解决:使用date_format先转换为标准格式,或尝试多种格式(Spark 3.0+支持to_timestamp自动解析常见格式)。

    10.2 窗口函数报错Window function without frame

    原因:某些函数(如sum over窗口)需要指定范围(rowsBetween)或默认范围,但若同时使用order by而没有指定范围,Spark会使用默认范围(range between unbounded preceding and current row),某些情况下可能出错。对于排名函数不需要范围。

    解决:对于聚合窗口函数,显式指定rowsBetween。

    10.3 regexp_extract提取不到值返回空字符串

    原因:正则未匹配,返回空字符串而非null。

    解决:使用when判断长度或结合regexp检查。

    10.4 使用collect_list导致内存溢出

    原因:分组内数据量太大,数组占用内存过大。

    解决:限制每组大小,或使用approx_collect_list(第三方库),或改用其他聚合方式。

    10.5 explode后数据膨胀导致Shuffle数据量巨大

    原因:每行数组长度很大,explode后行数乘倍数增加。

    解决:在explode前先过滤掉空数组,或考虑其他设计避免爆炸。

    十一、本节课知识点总结

    函数分类速查表

    类别常用函数使用场景
    字符串 split, regexp_extract, concat, trim, substring 日志解析、数据清洗、格式化
    日期 to_date, datediff, date_add, year/month, date_format 时间窗口、年龄计算、时序分析
    聚合 collect_list, collect_set, approx_count_distinct, pivot 列表聚合、去重计数、行列转换
    条件 when, coalesce, isnull 数据分类、空值填充
    窗口 row_number, rank, lag, lead, sum().over() 排名、环比、累计计算、TopN
    集合 explode, size, array_contains, array_join 标签展开、数组操作

    性能优化要点

  • 优先使用内置函数,避免UDF
  • 对于大数组的collect_list要谨慎
  • 窗口函数会触发排序,可考虑分区减少数据量
  • approx_count_distinct适合精确度要求不高的场景
  • 使用pivot时指定列值列表可优化性能
  • 十二、课后思考作业

    作业一:理论理解题

  • 为什么regexp_extract比使用split+getItem在某些场景下更灵活?请举例说明。

  • 窗口函数中rowsBetween和rangeBetween的区别是什么?什么时候应该用rangeBetween?

  • 简述collect_set和array_distinct的区别及适用场景。

  • 作业二:代码实践题

  • 使用内置函数从以下字符串中提取有效信息:

    • 用户代理:“Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36”
    • 提取操作系统、浏览器名称和版本
    • 使用regexp_extract和when组合
  • 给定一个订单表(order_id, user_id, amount, order_time),使用窗口函数计算:

    • 每个用户的累计消费金额(按时间排序)
    • 每个用户当前订单金额与前一笔订单金额的差值(LAG)
    • 每个用户消费金额的30天移动平均(窗口范围按时间,使用rangeBetween)
  • 使用explode和pivot实现标签分析:有表user_tags(user_id, tags)其中tags是逗号分隔的字符串。统计每个标签的用户数,并输出每个用户的标签列表(数组形式)。

  • 作业三:场景应用题

    某资讯App的用户点击日志包含字段:user_id, article_id, event_time, read_duration(阅读时长秒)。需要分析:

    • 用户阅读时长的分布(按分钟分桶)
    • 每个用户最后一次阅读的文章ID(使用窗口函数)
    • 每天阅读时长最长的前10篇文章
    • 将文章ID列表按用户聚合,并输出每个用户阅读过的不同文章数(去重)

    请使用内置函数实现上述分析,并说明关键步骤使用的函数。

    作业四:拓展研究

  • 深入研究Spark 3.0+引入的to_timestamp的格式自动推断特性,测试不同格式字符串的解析效果。

  • 比较approx_count_distinct与精确count(distinct)在百万、千万、亿级数据量上的性能和误差,撰写实验报告。

  • 阅读Spark源码中Window算子的实现,分析其内存和排序开销,提出优化策略。


  • 提交方式:本次作业要求提交代码脚本和运行结果截图,以及理论题的答案。鼓励将常用的内置函数封装成工具类。

    扩展阅读:

    • PySpark官方函数文档:pyspark.sql.functions
    • 《Spark SQL 性能优化》第6章
    • 开源项目:pyspark-builtin-functions-cheatsheet

    通过本节课的学习,你已经掌握了PySpark内置函数的精髓,能够高效地进行数据转换和分析。在实际工作中,遇到数据处理需求,应第一时间想到内置函数,而不是UDF。下一节课我们将学习PySpark自定义函数(UDF、UDAF、UDTF)的开发原理与企业实战,了解在必须使用自定义逻辑时如何写出高性能的扩展函数。我们下节课见!


    🔗《20节课 PySpark 从入门到精通》系列课程导航

    去订阅

    🌟 感谢您耐心阅读到这里! 💡 如果本文对您有所启发欢迎: 👍 点赞📌 收藏 📤 分享给更多需要的伙伴。 🗣️ 期待在评论区看到您的想法, 共同进步。 🔔 关注我,持续获取更多干货内容~ 🤗 我们下篇文章见~

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 第14课:PySpark|内置函数全解【字符串日期聚合条件窗口函数实战】
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!