第三篇的城市消费力排行榜能回答"各城市消费了多少",但运营团队的问题不止于此。本文用同一套电商数据,解锁更深度的分析能力。
本文要点
- 多表 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_id、city、status、total_amount、order_count、avg_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 慢很多。原因在于分区方式不同:
- 等值 join :
a.product_id = b.product_id,可以按 hash(product_id) 给两张表分区,同一个 product_id 的行一定到同一个分区,匹配的行天然在一起,每个分区本地 join 即可。每行只传输到 1 个分区,比较次数 O(n)。- 不等值 join :
a.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ⁿ 种组合。
rollup 是 cube 的子集------只生成"从左到右逐层下钻"的组合:
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_number、rank、dense_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 带了 orderBy,spark_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_list 和 explode 是一对互逆操作。
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 语法,看看同一份计算用两种范式分别怎么写。