【PySpark 学习笔记 四】DataFrame 进阶:窗口函数、高级聚合与复杂类型

第三篇的城市消费力排行榜能回答"各城市消费了多少",但运营团队的问题不止于此。本文用同一套电商数据,解锁更深度的分析能力。

本文要点

  • 多表 Join 进阶:三表关联、等值 vs 不等值 join、广播 join(Broadcast Join)、semi/anti join
  • 高级聚合:collect_list / pivot / cube / rollup
  • 窗口函数:排名、累计、同比环比------每行能看到同组其他行,但不像 groupBy 那样压缩成一行
  • 复杂数据类型:Array / Map / Struct 的创建与操作
  • JSON 解析:get_json_object 单字段提取、from_json 整行解析为 Struct
  • 完整案例:从单维度聚合升级为多维度深度分析

零 任务升级:从单维度到多维度深度分析

1 背景回顾

沿用第三篇的电商场景:三张 Paimon 表------用户表(500 万行)、订单表(8000 万行,近 6 个月)、商品表(50 万行)。第三篇我们完成了"各城市活跃用户近一个月消费力排行榜"。

2 新的分析需求

运营团队不满足于"每个城市一个总数",他们提出了更深度的问题:

问题 对应技术点
每个城市消费 Top 10 的用户是谁? 窗口函数 row_number
各品类(3C / 服饰 / 食品...)在不同城市的销售额分布如何? pivot 行转列
前 20% 的高消费用户贡献了多少 GMV?(帕累托分析) 窗口函数 sum() over()
各城市本月销售额同比上月是增长还是下降? 窗口函数 lag / lead
每个用户买过哪些品类?列成一个清单 collect_list + Array 类型
哪些用户只逛 3C 类、从来不下单? left anti join

这些问题的共同特点是:单纯的 groupBy + agg 已经不够用了,需要窗口函数、行转列、复杂类型等进阶能力。

3 解决思路

拿到这些需求后,先做三件事:

1. 判断需不需要第三张表(商品表)

前两篇都只用了 users + orders 两张表。一旦分析涉及"品类",就必须关联商品表------category 字段在商品表里。所以本篇会首次用到三表关联。

2. 分类问题类型

问题类型 解决手段 例子
"存在/不存在"判断 semi join / anti join 只逛不下单的用户
聚合为集合 / 列表 collect_list / collect_set 用户购买品类清单
行转列(多行变多列) pivot 各品类城市分布矩阵
组内排名 / 取 TopN 窗口函数 row_number 各城市消费 Top10 用户
累计 / 比例 窗口函数 sum() over() 帕累托分析
跨行比较(环比/同比) 窗口函数 lag / lead 月度环比

3. 规划计算顺序

从底向上:先做三表关联得到明细宽表,再在宽表上做各种聚合和窗口计算。

复制代码
Paimon 源表
  ├── dw.users(用户)
  ├── dw.orders(订单)
  └── dw.products(商品)
      ↓ join(三表关联,形成宽表)
  order_detail(订单明细宽表:订单 + 用户 + 商品信息)
      ↓
  ├── active_user_spending(活跃用户消费汇总:每用户一行)
  │     ↓
  │   ├── 窗口函数:城市 Top10 用户、帕累托、不等值 join
  │   └── Struct:消费信息打包
  ├── pivot:品类 × 城市 销售矩阵
  ├── collect_list:用户品类清单
  └── anti join:只逛不下单的用户

下文从三表关联开始,逐步解锁这些进阶能力。

另外定义一张贯穿全文的基准表------活跃用户消费汇总表,每个用户一行,包含城市和消费指标,后文窗口函数、不等值 join 等示例都基于它展开:

python 复制代码
from pyspark.sql.functions import col, sum as spark_sum, count as spark_count, avg as spark_avg

# 活跃用户 + 每用户消费汇总(left join 后填充 null)
active_user_spending = (
    active_users
    .join(
        recent_orders.groupBy("user_id").agg(
            spark_sum("amount").alias("total_amount"),
            spark_count("order_id").alias("order_count"),
            spark_avg("amount").alias("avg_order_amount"),
        ),
        on="user_id",
        how="left"
    )
    .na.fill({"total_amount": 0, "order_count": 0, "avg_order_amount": 0})
)

active_user_spending 的字段:user_idcitystatustotal_amountorder_countavg_order_amount。每个用户只出现一次,city 是注册城市。


一 多表 Join 进阶

第三篇讲了两表 join 的四种类型(inner / left / right / outer)。实际业务中常常需要关联更多表,或者做更复杂的关联条件。

先明确一个分类:按关联条件的性质,join 分为等值 join 和不等值 join

类型 关联条件 性能 例子
等值 join = 好(可哈希分区) on="user_id"on=["user_id", "dt"]
不等值 join > < >= <= 差(可能笛卡尔积) amount between low and high

我们平时用的绝大多数 join 都是等值 join------第三篇的 inner/left/right/outer,以及本文的 semi/anti join,都是等值 join。不等值 join 只在特殊场景用,性能代价高,能用等值的就别用不等值。

1.1 三表关联

要分析"各品类在不同城市的销售额",需要订单表关联用户表(拿 city)再关联商品表(拿 category):

python 复制代码
from pyspark.sql.functions import col

# 订单明细宽表:订单 + 用户 + 商品
order_detail = (
    orders
    .filter(col("order_status") == "completed")
    .join(users, on="user_id", how="left")          # 关联用户表,拿 city
    .join(products, on="product_id", how="left")     # 关联商品表,拿 category
    .select(
        col("order_id"),
        col("user_id"),
        col("city"),
        col("category"),
        col("amount"),
        col("order_time"),
    )
)

三表 join 就是两表 join 的串联,写法上没有新东西------.join().join() 链式调用即可。底层执行时,Catalyst 会决定 join 的顺序和策略(不一定按你写的顺序)。

常见误区:join 越多越慢。不是绝对的------如果每张表都做了充分的过滤和列裁剪,join 的数据量不大,性能完全可控。关键原则是"先过滤后 join",而不是"先 join 再过滤"。

1.2 广播 join(Broadcast Join)

当一张表很小、另一张表很大时,不需要让两张表都做 Shuffle------直接把小表发到每个 Executor 内存里,大表正常读,本地 join 即可。这就是广播 join。

在我们的例子中,商品表只有 50 万行(约几十 MB),完全放得进内存,适合广播:

python 复制代码
from pyspark.sql.functions import broadcast

# 用 broadcast() 标记小表,Spark 会把它发到每个 Executor
order_detail = (
    orders
    .filter(col("order_status") == "completed")
    .join(users, on="user_id", how="left")
    .join(broadcast(products.select("product_id", "category")), on="product_id", how="left")
    .select("order_id", "user_id", "city", "category", "amount", "order_time")
)

广播 join 的好处:大表(订单表 8000 万行)不需要 Shuffle,省去了一次昂贵的 Shuffle 操作。小表(商品表 50 万行)虽然每个 Executor 都复制一份,但总量不大,内存开销可接受。

方式 大表 Shuffle 小表 Shuffle 适用场景
普通 join(Shuffle join) 两张表都很大
广播 join 否(直接发小表) 一张小表(< 100MB)+ 一张大表

注意 :广播 join 的前提是小表能放进 Executor 的内存。Spark 默认阈值是 10MB,超过这个大小会自动降级为 Shuffle join。可以通过 spark.sql.autoBroadcastJoinThreshold 配置调整阈值,或者用 broadcast() 函数强制广播。

1.3 left semi join:只判断"存在"

场景:找出"有哪些城市有实际消费记录"------运营想确认哪些城市有真实订单,而不只是注册用户。

你可能会这么写:

python 复制代码
# 写法一:inner join 后去重(低效)
cities_with_orders = active_users.join(order_detail, on="user_id", how="inner").select("city").distinct()

但 inner join 会把两表的行拼在一起,然后 distinct 去重------多做了很多无用功。如果只关心"有没有匹配",不关心右表的具体数据,用 left semi join 更高效:

python 复制代码
# 写法二:left semi join(高效)
cities_with_orders = active_users.join(order_detail, on="user_id", how="left_semi")

left semi join 的行为:左表中匹配到右表的行被保留,匹配不到的丢弃。它只检查"存在",不会把右表的列带过来,也不会因为右表多行匹配而产生重复。

等价 SQL:SELECT * FROM active_users WHERE user_id IN (SELECT user_id FROM order_detail)

1.4 left anti join:只判断"不存在"

反过来------找出"没有下单记录的活跃用户"(只逛不下单):

python 复制代码
# 只逛不下单的用户(有账号、活跃、但近一个月没有有效订单)
no_order_users = active_users.join(order_detail, on="user_id", how="left_anti")

left anti join 的行为:左表中匹配不到右表的行被保留,匹配到的丢弃。

等价 SQL:SELECT * FROM active_users WHERE user_id NOT IN (SELECT user_id FROM order_detail)

semi / anti join 是一对非常实用但容易被忽略的 join 类型,记住一句话:

semi join 留下"匹配到的"行,anti join 留下"没匹配到的"行;需要右表数据才用 inner / left。

1.5 不等值 Join

本节示例基于开篇「解决思路」一节定义的 active_user_spending(活跃用户消费汇总表,每个用户一行)。

真正的不等值 join 是把不等条件(><>=<=)写在 join 条件里:

python 复制代码
# 真正的不等值 join:不等条件作为 join 条件
df1.join(df2, col("amount") > col("threshold"), "inner")

实际开发中遇到"关联后比较大小"的需求,推荐写法是先等值 join 再 filter------而不是直接用不等值 join:

python 复制代码
# 场景:找出"客单价低于城市平均水平"的用户
# 先算每个城市的平均客单价
city_avg = (
    active_user_spending
    .groupBy("city")
    .agg(spark_avg("avg_order_amount").alias("city_avg_amount"))
)

# 推荐写法:先等值 join(on="city"),再 filter
below_avg_users = (
    active_user_spending
    .join(city_avg, on="city", how="left")           # 等值 join
    .filter(col("avg_order_amount") < col("city_avg_amount"))  # join 后过滤
)

对应 SQL 就是普通的 JOIN ... ON a.city = b.city WHERE a.amount < b.avg_amount------join 条件是等值,不等比较放在 WHERE 里。

性能提示:不等值 join(不等条件写在 ON 里)往往比等值 join 慢很多。原因在于分区方式不同:

  • 等值 joina.product_id = b.product_id,可以按 hash(product_id) 给两张表分区,同一个 product_id 的行一定到同一个分区,匹配的行天然在一起,每个分区本地 join 即可。每行只传输到 1 个分区,比较次数 O(n)。
  • 不等值 joina.amount > b.threshold,没法 hash------一个 amount=5000 的行可能匹配 B 表中 threshold=4000、3000、2000 等多行,这些行散落在不同分区,不知道该把行发到哪个分区。结果就是 A 表每一行可能要和 B 表所有行比较,接近笛卡尔积,比较次数 O(n × m)。

实际开发中,能用"等值 join + filter"替代的,就不要用不等值 join。


二 高级聚合

第三篇讲了基础的 groupBy + agg(sum / count / avg)------每个组输出一个标量值(一个数字)。但实际分析中,输出需求不止"一个数字":

  • 每个用户买过哪些品类?→ 输出一个列表
  • 各品类在不同城市的销售额?→ 把品类从行变成
  • 同时看城市合计、品类合计、总计?→ 一次生成多层级汇总

这就是"更强大"的地方:输出形态从标量扩展到数组、矩阵、多维度汇总。

2.1 collect_list / collect_set:聚合成列表

场景:每个用户买过哪些品类?列出清单。

python 复制代码
from pyspark.sql.functions import collect_list, collect_set

# collect_list:收集所有值(保留重复、保留顺序)
user_categories = (
    order_detail
    .groupBy("user_id")
    .agg(
        collect_list("category").alias("category_list")
    )
)

user_categories.show(3)
# +-------+---------------------------+
# |user_id|category_list              |
# +-------+---------------------------+
# |u10001 |[3C, 服饰, 3C, 食品]        |  ← 有重复,顺序是出现顺序
# |u10002 |[图书, 家居]               |
# |...

collect_set 会去重:

python 复制代码
# collect_set:收集去重后的值(无重复、顺序不保证)
user_unique_categories = (
    order_detail
    .groupBy("user_id")
    .agg(
        collect_set("category").alias("category_set")
    )
)

user_unique_categories.show(3)
# +-------+-------------------+
# |user_id|category_set       |
# +-------+-------------------+
# |u10001 |[3C, 服饰, 食品]    |  ← 去重了
# |u10002 |[图书, 家居]        |
# |...
函数 是否去重 是否保证顺序 结果类型
collect_list 是(按出现顺序) Array
collect_set Array(去重)

注意collect_list / collect_set 会把组内所有值收集到一个数组里。如果某个组的数据量很大(比如头部用户有几十万条订单),生成的数组可能非常大,导致 Executor 内存溢出。使用时务必确认分组后的每组数据量在可控范围内。

2.2 pivot:行转列

场景:各品类在不同城市的销售额分布------行是城市、列是品类,交叉点是销售额。

转换前,数据是"长表"格式------每个城市每个品类一行:

city category amount
上海 3C 1234567.80
上海 服饰 2345678.90
上海 食品 876543.20
北京 3C 1123456.70
北京 服饰 2456789.00
北京 食品 765432.10
广州 3C 987654.30
广州 服饰 1876543.00
... ... ...

转换后,要变成"宽表"------品类从行变成列:

city 3C 服饰 食品 ...
上海 1234567.80 2345678.90 876543.20 ...
北京 1123456.70 2456789.00 765432.10 ...
广州 987654.30 1876543.00 ... ...

这就是 pivot(行转列)------更准确地说,是把 category 这一个分类列的取值展开成多个独立列,其他列(city)保持不变。不是整张表转置,而是一个属性的值变成多个属性。用 pivot 实现:

python 复制代码
# pivot 行转列:category 的值变成列名
city_category_matrix = (
    order_detail
    .groupBy("city")
    .pivot("category")        # 行转列,category 的每个值变成一列
    .sum("amount")            # 交叉点的聚合方式
)

city_category_matrix.show(5)
# +------+----------+----------+--------+--------+--------+
# |city  |3C        |服饰      |食品    |图书    |家居    |
# +------+----------+----------+--------+--------+--------+
# |上海  |1234567.80|2345678.90|876543.2|...     |...     |
# |北京  |1123456.70|2456789.00|...     |...     |...     |
# |广州  |987654.30 |...       |...     |...     |...     |
# |深圳  |...       |...       |...     |...     |...     |
# |成都  |...       |...       |...     |...     |...     |
# +------+----------+----------+--------+--------+--------+

pivot 执行时会先对 pivot 列做一次 distinct,找出所有可能的取值(3C、服饰、食品...),然后为每个值生成一列,列内做聚合。

pivot 不限于三列,它的结构是:

  • groupBy 列(锚点,可以多个)→ 保持为行
  • pivot 列(1 个)→ 取值展开成多个列
  • agg 列(1 个或多个)→ 交叉点的值

锚点可以多列,比如加上月份:

python 复制代码
# groupBy 两列 + pivot 一列 + agg 一列
order_detail.groupBy("city", "month").pivot("category").sum("amount")
# | city | month | 3C | 服饰 | 食品 | ...

聚合也可以多个,比如同时看销售额和订单数:

python 复制代码
# 两组交叉值:销售额 + 订单数
order_detail.groupBy("city").pivot("category").agg(
    spark_sum("amount"),     # → 3C_sum, 服饰_sum, ...
    spark_count("order_id") # → 3C_count, 服饰_count, ...
)
# | city | 3C_sum | 3C_count | 服饰_sum | 服饰_count | ...

pivot 列只能一个------它的取值变成列名。

性能提示:pivot 列的基数(不同取值的数量)直接影响性能。如果 pivot 列有几百上千个不同值,会生成几百上千列,开销极大。pivot 适合基数不大的维度(如品类、省份、性别等)。

2.3 cube / rollup:多维度汇总

普通 groupBy 只按一种维度聚合------比如按 city 分组,输出每个城市的合计。但实际报表常需要同时看多个层级的汇总:按城市合计、按品类合计、城市+品类的交叉合计、全国总计。如果手动写多个 groupBy 再 union,很麻烦。

cube 和 rollup 就是用一次调用生成这些多层级汇总:

  • cube:生成所有维度的任意组合------两个维度产生 2² = 4 种组合(城市+品类、仅城市、仅品类、总计)
  • rollup:只生成从左到右逐层下钻的组合------城市+品类、仅城市、总计(少了"仅品类"这一层)

下面分别看示例。

python 复制代码
# cube:所有维度组合的聚合
cube_sales = (
    order_detail
    .cube("city", "category")
    .agg(spark_sum("amount").alias("total_amount"))
    .orderBy("city", "category")
)

结果包含四种组合:

city category 含义
上海 3C 上海的 3C 品类销售额
上海 null 上海全品类合计
null 3C 全国 3C 品类合计
null null 全国全品类总计(grand total)

null 表示"这个维度不分组"。两个维度的 cube 会产生 2² = 4 种组合,n 个维度会产生 2ⁿ 种组合。

rollupcube 的子集------只生成"从左到右逐层下钻"的组合:

python 复制代码
# rollup:城市 → 城市+品类 的层级汇总
rollup_sales = (
    order_detail
    .rollup("city", "category")
    .agg(spark_sum("amount").alias("total_amount"))
)

rollup 的结果:

city category 含义
上海 3C 上海的 3C 品类销售额
上海 null 上海全品类合计
null null 全国总计

少了"全国 3C 合计"这一层------rollup 是有层次的,左边是高层级维度,右边是低层级。

一句话区分:cube 是所有维度的任意组合,rollup 是有层级的下钻组合。


三 窗口函数

窗口函数是 DataFrame 进阶中最强大也最常用的能力。它解决的核心问题是:每行都能"看到"同组的其他行,并基于它们计算出结果------但不需要像 groupBy 那样把多行压缩成一行。

用具体数字对比:

操作 输入 输出 能拿到"某个用户排第几"吗
groupBy("city").agg(sum("amount")) 500 万行用户消费记录 ~300 行(每个城市一行合计) 不能------500 万用户的个体信息已经丢失
Window.partitionBy("city") + row_number() 500 万行用户消费记录 还是 500 万行,每行多一列"城市内排名" 能------每个用户还在,带着自己的排名

换句话说:普通列操作(如 col("amount") * 0.9)只看得到当前行自己的数据;而窗口函数让每一行还能"跨行"看到同组其他行的数据------比如排名需要知道有多少人比你金额高,累计求和需要把前面的行加起来。这些都需要跨行,但又不能丢掉每行的个体信息,这就是窗口函数做的事。

3.1 什么是窗口函数

用一个问题引入:每个城市消费排名第 3 的用户是谁?

用 groupBy 做不到------groupBy 后每个城市只剩一行聚合结果,拿不到"第 3 名"这种组内排名信息。窗口函数就是解决这类问题的:

python 复制代码
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number

# 定义窗口:按城市分区,按消费金额降序排列
window_spec = Window.partitionBy("city").orderBy(col("total_amount").desc())

# 给每个用户在城市内排名
city_user_rank = (
    active_user_spending
    .withColumn("rank", row_number().over(window_spec))
)

city_user_rank.filter(col("rank") == 3).show(5)
# +------+-------+-------------+------+
# |city  |user_id|total_amount |rank  |
# +------+-------+-------------+------+
# |上海  |u34567 | 87654.32     | 3    |
# |北京  |u12345 | 76543.21     | 3    |
# |广州  |u89012 | 65432.10     | 3    |
# |...

窗口函数的写法有个固定模式:函数.over(窗口定义)。拆开看:

  • row_number() → 要做什么:生成序号
  • window_spec → 在什么范围内做:按城市分区、按金额排序
  • .over() → 把函数"贴到"窗口上,告诉它"在这个窗口范围内计算"

没有 .over()row_number() 只是一个函数定义,不知道在哪算、怎么排;有了 .over(window_spec),它才知道"每个城市的窗口内,按金额降序,给每行编号"。

所有窗口函数都是这个模式------rank().over(...)spark_sum("amount").over(...)lag("col", 1).over(...),区别只是换了前面的函数,.over(窗口定义) 始终一样。

窗口定义(WindowSpec)的标准结构有三个组成部分:

组成部分 回答的问题 不写时的默认值
partitionBy("city") 数据按什么分组、一组一个窗口 整个表一个窗口
orderBy(col("total_amount").desc()) 窗口内按什么顺序处理行 无顺序
frame(rowsBetween(...) 每一行计算时能"看到"哪些行 有 orderBy:从分区第一行到当前行;无 orderBy:整个分区

前两个直观好懂,第三个 frame(窗口范围) 最容易被忽略------它才真正决定"每行的计算范围"。row_number() 这类函数感觉不到它的存在,但聚合函数(sum / avg)的行为完全由它左右------3.3 节的累计求和,就是 frame 默认规则在起作用。

"无 orderBy 时默认整个分区"不是退而求其次的将就------"全部行"是唯一一个不依赖顺序就能定义的范围 。对整个分区求和、求平均,先算哪行后算哪行结果都一样,顺序根本不参与。顺序只在一种情况下必需:范围要相对当前行定义------"从第一行到当前行""当前行往前 2 行",这些说法必须先回答"当前行是谁、排在第几"。没有 orderBy,"当前行的前面"无从谈起,默认范围只能是整个分区。

无 orderBy 的聚合窗口有明确的用途------算占比。分子是每行的明细值,分母是分区汇总,一行代码同时拿到两者:

python 复制代码
# 每个用户的消费额占所在城市总额的比例(无 orderBy,frame = 整个城市分区)
user_share = active_user_spending.withColumn(
    "city_share",
    col("total_amount") / spark_sum("total_amount").over(Window.partitionBy("city"))
)

效果上相当于把 groupBy("city") 的合计结果"贴"回每一行------既拿到汇总数,又不丢明细。3.3 节帕累托的分母 spark_sum("total_amount").over(Window.partitionBy())(全局总计)就是同一个用法。

窗口函数和 groupBy 的区别:groupBy 是"压缩"------一组变成一行;窗口函数是"展开"------每行还在,只是多了一列计算结果。

3.2 排名类窗口函数

最常用的三个排名函数:row_numberrankdense_rank

python 复制代码
from pyspark.sql.functions import row_number, rank, dense_rank

window_spec = Window.partitionBy("city").orderBy(col("total_amount").desc())

city_user_rank = (
    active_user_spending
    .withColumn("row_num", row_number().over(window_spec))
    .withColumn("rank", rank().over(window_spec))
    .withColumn("dense_rank", dense_rank().over(window_spec))
)

三者的区别(以"金额相同怎么办"为核心):

函数 相同金额处理 示例(金额从高到低) 特点
row_number() 强行给不同序号 1, 2, 3, 4, 5 连续不重复,适合取 TopN
rank() 相同排名,跳过后续 1, 1, 3, 4, 4, 6 体育比赛式排名(并列第一后是第三名)
dense_rank() 相同排名,不跳过 1, 1, 2, 3, 3, 4 排名号连续,适合"第几档"的概念

取各城市 Top 10 用户row_number 最方便:

python 复制代码
top10_by_city = (
    city_user_rank
    .filter(col("row_num") <= 10)
    .orderBy("city", "row_num")
)

3.3 聚合类窗口函数:累计与移动平均

窗口函数不止可以排名,也可以做聚合------比如"累计消费额"、"移动平均"。

帕累托分析是什么

帕累托分析又叫 80/20 法则,最初由意大利经济学家帕累托提出------他发现 80% 的财富掌握在 20% 的人手里。这个规律在很多领域都成立:

  • 电商:20% 的用户贡献了 80% 的销售额
  • 产品:20% 的功能产生了 80% 的使用量
  • 库存:20% 的 SKU 贡献了 80% 的营收

用来干啥

  • 识别高价值用户:找到贡献 80% GMV 的那批头部用户,重点维护(VIP 服务、专属优惠)
  • 资源分配:运营精力有限时,优先投入到高贡献用户上,ROI 最高
  • 验证用户结构是否健康:如果前 1% 用户就贡献了 50% 销售额,说明收入过于集中,风险大;如果前 50% 用户才贡献 60%,说明用户结构比较平均

怎么做:按消费金额从高到低排序,计算累计消费额占总额的比例,看"前 N% 用户贡献了多少百分比的 GMV"。

python 复制代码
# 按消费金额降序排列的窗口(不 partitionBy,全表一个窗口)
# 不加 partitionBy("city"):帕累托分析看的是全站用户整体分布
window_cumsum = Window.orderBy(col("total_amount").desc())

pareto = (
    active_user_spending
    .withColumn("cumsum_amount", spark_sum("total_amount").over(window_cumsum))
    .withColumn("cumsum_ratio",
        round(col("cumsum_amount") / spark_sum("total_amount").over(Window.partitionBy()), 4)
    )
)

pareto.show(5)
# +-------+-------------+-------------+-------------+
# |user_id|total_amount |cumsum_amount|cumsum_ratio |
# +-------+-------------+-------------+-------------+
# |u99999 | 999999.99   | 999999.99    | 0.052       |  ← 第一名贡献了 5.2%
# |u88888 | 888888.88   | 1888888.87   | 0.098       |  ← 前两名累计 9.8%
# |u77777 | 777777.77   | 2666666.64   | 0.138       |  ← 前三名累计 13.8%
# |...

orderBy 的含义始终一致:定义窗口内行的处理顺序。 但"sum 变累计"并不是 orderBy 直接造成的------真正起作用的是 frame 的默认规则(3.1 节表格第三行):

  • 窗口 orderBy 时,frame 默认为整个分区 (第一行到最后一行)→ sum 每行返回同一个分区合计
  • 窗口 orderBy 时,frame 默认为从分区第一行到当前行RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)→ sum 逐行累加。细究差别:默认是 RANGE 语义------排序键取值相同的行(peer)会一起算进 frame;rowsBetween 显式指定的是 ROWS 语义,严格逐行。消费额这类几乎不重复的排序键两者结果一致,排序键大量重复时才有差别

换句话说:计算范围本来就由 frame 控制,orderBy 的作用是在 frame 未显式指定时决定取哪个默认值。不同类型的函数依赖 orderBy 的方式也不同:

函数类型 受 frame 影响吗 orderBy 对它的意义
聚合类:sum() / avg() / count() 改变 frame 默认值:整个分区 → 第一行到当前行
排名类:row_number() / rank() 不受 定义编号顺序------不写 orderBy,Spark 直接报错(requires an ORDER BY clause
偏移类:lag() / lead() 不受 定义"前一行""后一行"------同样是顺序概念,必须写 orderBy

帕累托分析中,window_cumsum 带了 orderByspark_sum 的 frame 于是默认成"从第一行到当前行"------第一名只算自己,第二名算前两人之和,第 N 行算前 N 行之和。窗口函数不改变表的结构,只是用 withColumn 给每行多加了一列。

Window.orderBy 约束的是"计算顺序",不是"输出顺序"。 active_user_spending 的行并没有被重排------withColumn 前后表里还是那些行(API 从不保证行的先后顺序),变的只是每行新增列的值:某行拿到的 cumsum_amount,等于"按金额降序排在它前面的所有行 + 它自己"的消费额之和。上面 show() 的输出恰好按金额降序,是物理执行的副产物------Spark 计算窗口时内部必须先按(分区键、排序键)把数据排一遍才能算,帕累托图里把行画成降序,展示的也是这个"计算视角"。但这个顺序不是对外的承诺,不能依赖;要保证输出有序,需要再显式调用 .orderBy()

spark_sum("total_amount").over(Window.partitionBy()) 表示"没有分区键,整个表一个窗口",计算的是全局总计。整句就是:cumsum_amount / 全局总计round(..., 4) 把结果保留 4 位小数(如 0.0521),避免一长串小数不便阅读。

有了累计比例,就能回答运营的核心问题------贡献 80% GMV 的用户到底有多少:

python 复制代码
# 筛出累计贡献不超过 80% 的用户(即"贡献了 80% GMV 的那批人")
pareto_80 = pareto.filter(col("cumsum_ratio") <= 0.8)

total_users = active_user_spending.count()
top_users = pareto_80.count()
print(f"贡献 80% GMV 的用户数:{top_users},占活跃用户比例:{top_users / total_users:.1%}")
# 贡献 80% GMV 的用户数:864000,占活跃用户比例:19.2%

结果验证了帕累托法则------约 19.2% 的用户贡献了 80% 的销售额。运营拿到这个名单后,可以针对这批头部用户做 VIP 维护和专属优惠。

帕累托法则(80/20 定律):在很多消费场景中,前 20% 的用户贡献了 80% 的销售额。用窗口函数的累计求和可以很方便地验证这个规律。

移动平均:比如"每个用户最近 3 笔订单的平均客单价"(需要按时间排序的窗口):

python 复制代码
# 按用户分区,按订单时间排序,取当前行和前 2 行(共 3 行)
window_moving = Window.partitionBy("user_id").orderBy("order_time").rowsBetween(-2, 0)

user_moving_avg = (
    orders
    .withColumn("moving_avg_3", spark_avg("amount").over(window_moving))
)

rowsBetween(-2, 0) 就是 3.1 节说的 frame ------显式指定"从当前行往前数 2 行,到当前行",共 3 行计算平均。不写的话,带 orderBy 的窗口 frame 默认是"从第一行到当前行",算出来的就是从第一笔订单开始的累计平均,而不是最近 3 笔。

3.4 偏移类窗口函数:lag / lead(同比环比)

lag 取同一分区内往前 第 n 行的值,lead往后第 n 行的值。最常用于计算同比环比。

场景:各城市本月销售额环比上月增长了多少?

python 复制代码
from pyspark.sql.functions import lag, year, month

# 先按城市 + 月份聚合
city_monthly = (
    order_detail
    .groupBy("city", year("order_time").alias("yr"), month("order_time").alias("mo"))
    .agg(spark_sum("amount").alias("monthly_amount"))
)

# 按城市分区,按年月排序,取上个月的销售额
window_city_month = Window.partitionBy("city").orderBy("yr", "mo")

city_monthly_growth = (
    city_monthly
    .withColumn("prev_month_amount", lag("monthly_amount", 1).over(window_city_month))
    .withColumn("mom_growth_rate",
        round(
            (col("monthly_amount") - col("prev_month_amount")) / col("prev_month_amount"),
            4
        )
    )
)

city_monthly_growth.show(6)
# +------+----+---+--------------+------------------+----------------+
# |city  |yr  |mo |monthly_amount|prev_month_amount |mom_growth_rate |
# +------+----+---+--------------+------------------+----------------+
# |上海  |2026|1  |1000000.00    |null              |null            |
# |上海  |2026|2  |1100000.00    |1000000.00        |0.1000          |  ← 环比增长 10%
# |上海  |2026|3  |1210000.00    |1100000.00        |0.1000          |
# |北京  |2026|1  |900000.00     |null              |null            |
# |北京  |2026|2  |950000.00     |900000.00         |0.0556          |
# |...

每个城市的第一个月没有上月数据,prev_month_amount 为 null,增长率也为 null------这是合理的。

函数 方向 常见用途
lag(col, n) 往前第 n 行 环比(lag 1 = 上月)、同比(lag 12 = 去年同月)
lead(col, n) 往后第 n 行 预测对比、下一期数据

窗口函数的 Shuffle 代价 :窗口函数需要把同一 partition 的数据汇到一起计算,这是一个 Shuffle 操作。partitionBy 的列决定了 Shuffle 的 key------key 分布越均匀,性能越好。


四 复杂数据类型

Spark DataFrame 不止支持 string、int、double 这类基本类型,还支持 Array(数组)、Map(字典)、Struct(结构体)等复杂类型。

4.1 Array 数组

前面 collect_list / collect_set 的结果就是 Array 类型。常用的数组操作:

python 复制代码
from pyspark.sql.functions import size, array_contains, array_join, explode

# 假设 user_categories 的 category_list 列是 Array<String>
df = user_categories

# 1. size:数组长度
df.withColumn("cat_count", size(col("category_list")))

# 2. array_contains:是否包含某个元素
df.withColumn("bought_3c", array_contains(col("category_list"), "3C"))

# 3. array_join:用分隔符拼接成字符串
df.withColumn("cat_str", array_join(col("category_list"), ", "))

4.2 explode:把数组展开成行

collect_list 是"多行变一行",explode 反过来------"一行变多行":

python 复制代码
# 把品类清单展开,每个品类一行
exploded = user_categories.select(
    col("user_id"),
    explode(col("category_set")).alias("category")
)

exploded.show(6)
# +-------+----------+
# |user_id|category  |
# +-------+----------+
# |u10001 |3C        |
# |u10001 |服饰      |
# |u10001 |食品      |
# |u10002 |图书      |
# |u10002 |家居      |
# |...

展开后就回到了普通的二维表,可以继续做 filter、join 等操作。collect_listexplode 是一对互逆操作。

4.3 Map 字典

Map 类型类似 Python 字典,有 key 和 value:

python 复制代码
from pyspark.sql.functions import create_map, lit, col

# 构造 Map 列:key 是品类,value 是销售额
city_category_map = (
    order_detail
    .groupBy("city")
    .agg(
        collect_list("category").alias("cats"),
        collect_list("amount").alias("amts")
    )
)
# 更简洁的方式是用 map_from_arrays:
from pyspark.sql.functions import map_from_arrays

city_category_map = (
    order_detail
    .groupBy("city")
    .agg(
        map_from_arrays(
            collect_list("category"),
            collect_list("amount")
        ).alias("category_sales")
    )
)

# 访问 Map 的某个 key
city_category_map.select(
    col("city"),
    col("category_sales")["3C"].alias("3c_sales"),   # 取 3C 品类的销售额
    col("category_sales")["服饰"].alias("cloth_sales")
)

4.4 Struct 结构体

Struct 类似 Python 的 namedtuple 或一个对象,把多个字段打包成一个字段:

python 复制代码
from pyspark.sql.functions import struct

# 把用户的消费信息打包成一个结构体
user_struct = (
    active_user_spending
    .select(
        col("user_id"),
        col("city"),
        struct(
            col("total_amount"),
            col("order_count"),
            col("avg_order_amount")
        ).alias("spending")
    )
)

# 访问 Struct 的子字段(用点号)
user_struct.select(
    col("user_id"),
    col("spending.total_amount").alias("amount"),
    col("spending.order_count").alias("count")
)

Struct 在需要"把一组字段作为整体传递"的场景很有用,比如 UDF 输入输出、嵌套结构处理等。

4.5 JSON 解析:from_json / get_json_object

订单表常有一个 extra_info 字段,里面存的是 JSON 字符串------比如收货地址、支付渠道、优惠信息等扩展字段。要从 JSON 里提取数据,有两种方式。

方式一:get_json_object --- 提取单个字段

python 复制代码
from pyspark.sql.functions import get_json_object

# extra_info 示例:{"province": "上海", "city": "上海", "payment": "alipay", "discount": 50}

orders_with_json = orders.select(
    col("order_id"),
    col("amount"),
    get_json_object(col("extra_info"), "$.province").alias("province"),
    get_json_object(col("extra_info"), "$.city").alias("recv_city"),
    get_json_object(col("extra_info"), "$.payment").alias("pay_channel"),
)

$.province 是 JSONPath 语法,$ 表示根对象,.province 表示取 province 字段。get_json_object 每次只能提取一个字段,返回字符串类型。

方式二:from_json --- 整行解析为 Struct

如果要提取的字段很多,反复写 get_json_object 很麻烦。用 from_json 一次性把 JSON 解析成 Struct,然后用点号访问:

python 复制代码
from pyspark.sql.functions import from_json
from pyspark.sql.types import StructType, StructField, StringType, IntegerType

# 先定义 JSON 的 Schema
extra_schema = StructType([
    StructField("province", StringType()),
    StructField("city", StringType()),
    StructField("payment", StringType()),
    StructField("discount", IntegerType()),
])

# 把 JSON 字符串解析成 Struct 列
orders_parsed = (
    orders
    .withColumn("extra", from_json(col("extra_info"), extra_schema))
    .select(
        col("order_id"),
        col("amount"),
        col("extra.province").alias("province"),
        col("extra.city").alias("recv_city"),
        col("extra.payment").alias("pay_channel"),
        col("extra.discount").alias("discount"),
    )
)
方法 特点 适用场景
get_json_object 每次提取一个字段,返回 string 只取 1-2 个字段,简单快捷
from_json 一次性解析为 Struct,可访问多个子字段 字段多、需要复用 Schema

为什么不用 Python 的 json.loads? --- 因为 extra_info 分布在 Executor 的各个分区上,Python 代码跑在 Driver 端,拿不到数据。必须用 Spark 内置的 JSON 函数,让解析在 JVM 里并行执行。这和第二篇讲的"Driver 描述计划,Executor 真正计算"是一致的。


五 完整代码串联

把本文所有内容串起来,从三表关联到各种进阶计算:

python 复制代码
from pyspark.sql import SparkSession
from pyspark.sql.functions import (
    col, sum as spark_sum, count as spark_count, avg as spark_avg,
    round, row_number, rank, dense_rank, lag,
    collect_list, collect_set, size, explode,
    year, month, current_date, date_sub,
    struct, create_map,
    broadcast, get_json_object, from_json
)
from pyspark.sql.types import StructType, StructField, StringType, IntegerType
from pyspark.sql.window import Window

# 1. 创建 SparkSession,注册 Paimon Catalog
spark = (
    SparkSession.builder
    .appName("dataframe-advanced-demo")
    .config("spark.sql.catalog.paimon", "org.apache.paimon.spark.PaimonCatalog")
    .config("spark.sql.catalog.paimon.warehouse", "s3://warehouse/")
    .config("spark.sql.adaptive.enabled", "true")
    .getOrCreate()
)

# 2. 读取三张 Paimon 表
users = spark.table("paimon.dw.users")
orders = spark.table("paimon.dw.orders")
products = spark.table("paimon.dw.products")

# 3. 过滤有效数据
active_users = users.filter(col("status") == "active")

recent_orders = orders.filter(
    (col("order_time") >= date_sub(current_date(), 30))
    & (col("order_status") == "completed")
)

# 4. 三表关联,形成订单明细宽表(商品表广播,避免大表 Shuffle)
order_detail = (
    recent_orders
    .join(active_users.select("user_id", "city"), on="user_id", how="left")
    .join(broadcast(products.select("product_id", "category")), on="product_id", how="left")
    .select("order_id", "user_id", "city", "category", "amount", "order_time")
)

# 5. 活跃用户消费汇总表(后文窗口函数、不等值 join 的基准表)
active_user_spending = (
    active_users
    .join(
        recent_orders.groupBy("user_id").agg(
            spark_sum("amount").alias("total_amount"),
            spark_count("order_id").alias("order_count"),
            spark_avg("amount").alias("avg_order_amount"),
        ),
        on="user_id",
        how="left"
    )
    .na.fill({"total_amount": 0, "order_count": 0, "avg_order_amount": 0})
)

# 6. 窗口函数:各城市用户消费排名
window_city_rank = Window.partitionBy("city").orderBy(col("total_amount").desc())

user_city_rank = (
    active_user_spending
    .withColumn("row_num", row_number().over(window_city_rank))
    .withColumn("rank", rank().over(window_city_rank))
    .withColumn("dense_rank", dense_rank().over(window_city_rank))
)

# 取各城市 Top10 用户
top10_by_city = user_city_rank.filter(col("row_num") <= 10)

# 7. pivot:品类 × 城市 销售矩阵
city_category_matrix = (
    order_detail
    .groupBy("city")
    .pivot("category")
    .sum("amount")
)

# 8. collect_list:用户购买品类清单
user_category_list = (
    order_detail
    .groupBy("user_id")
    .agg(
        collect_set("category").alias("category_set"),
        spark_sum("amount").alias("total_amount")
    )
)

# 9. left anti join:只逛不下单的活跃用户
no_order_users = active_users.join(
    order_detail.select("user_id").distinct(),
    on="user_id",
    how="left_anti"
)

# 10. JSON 解析:从订单扩展字段中提取省份和支付渠道
extra_schema = StructType([
    StructField("province", StringType()),
    StructField("city", StringType()),
    StructField("payment", StringType()),
    StructField("discount", IntegerType()),
])

orders_with_extra = (
    recent_orders
    .withColumn("extra", from_json(col("extra_info"), extra_schema))
    .select(
        col("order_id"),
        col("extra.province").alias("province"),
        col("extra.payment").alias("pay_channel"),
        get_json_object(col("extra_info"), "$.discount").alias("discount_str"),
    )
)

# 11. 窗口函数:月度环比
window_city_month = Window.partitionBy("city").orderBy("yr", "mo")

city_monthly_growth = (
    order_detail
    .groupBy("city", year("order_time").alias("yr"), month("order_time").alias("mo"))
    .agg(spark_sum("amount").alias("monthly_amount"))
    .withColumn("prev_month", lag("monthly_amount", 1).over(window_city_month))
    .withColumn("mom_rate",
        round((col("monthly_amount") - col("prev_month")) / col("prev_month"), 4)
    )
)

# 12. 写出结果
(
    top10_by_city
    .write
    .format("paimon")
    .mode("overwrite")
    .saveAsTable("paimon.report.city_top10_users")
)

spark.stop()

例子进展:从 8000 万条订单出发,经过三表关联、窗口排名、pivot、collect_list 等多种进阶操作,最终产出多份分析结果------各城市 Top10 用户、品类×城市销售矩阵、用户品类清单、只逛不下单用户清单、月度环比数据。这些都是运营团队日常决策的核心数据资产。


小结

本文覆盖了 DataFrame 的四类进阶能力:

能力 核心用途 代表函数
Join 进阶 多表关联、存在性判断 left_semi / left_anti
高级聚合 列表聚合、行转列、多维度汇总 collect_list / pivot / cube / rollup
窗口函数 组内排名、累计、跨行比较 row_number / sum() over() / lag
复杂类型 嵌套数据结构 Array / Map / Struct / explode

这些能力的共同特点是:仍然是 Transformation,仍然懒执行,仍然靠 Catalyst 优化。理解了它们,你就能用 DataFrame 解决绝大多数数据开发中的分析需求。

下一篇我们将进入 Spark SQL------把 DataFrame 操作换成 SQL 语法,看看同一份计算用两种范式分别怎么写。

相关推荐
Wang's Blog1 小时前
PostgreSQL笔记62: 分区表维护最佳实践——默认分区、锁策略与性能调优
数据库·笔记·postgresql
山甫aa1 小时前
日志技术 Logback + Slf4j —— 从零开始的 Web 后端学习
java·后端·学习·web·logback
炎武丶航1 小时前
汽车功能测试学习(2):LDW车道偏离预警
功能测试·学习·汽车
Logintern091 小时前
【AGV】openTCS学习
学习·物流·agv
wzq11_6662 小时前
云计算运维学习day22——Ansible-Roles
运维·学习·云计算
minglie12 小时前
espidf的esp32版的websocketServer
学习
j7~2 小时前
【C++】《C++二叉搜索树(BST)从入门到精通:概念、实现与Key/Value模型全解析》
开发语言·c++·学习·二叉搜索树
Wang's Blog2 小时前
PostgreSQL笔记58: 性能监控工具全景——从内核指标到操作系统诊断
数据库·笔记·postgresql
chushiyunen11 小时前
欧洲地理笔记
笔记