全文只回答一个问题:如何用 PySpark 的 DataFrame API 完成一次从读取、清洗、聚合、关联到写出的完整数据处理流程?
本文要点
- SparkSession:一切操作的入口,含 Paimon Catalog 配置
- 从 Paimon 表读取数据,Schema 与分区裁剪的底层机制
- select、filter、groupBy、agg、orderBy 的用法与常见组合
- 多表 join:inner / left / right,何时用哪种
- 列操作:列引用、表达式、派生字段
- 空值处理:na.drop / na.fill / na.replace
- 去重与采样:distinct / dropDuplicates / sample
- 数据写出:写回 Paimon 表与 Parquet 文件
零 开始之前:一个贯穿全文的例子
某电商平台有三张核心数据表,均存储在 Paimon 数据湖中:
用户表(dw.users) --- 500 万注册用户
| 字段 | 类型 | 说明 |
|---|---|---|
| user_id | string | 用户 ID |
| username | string | 用户名 |
| city | string | 所在城市 |
| age | int | 年龄 |
| gender | string | 性别 |
| register_date | date | 注册日期 |
| status | string | 账户状态(active / frozen / deleted) |
订单表(dw.orders) --- 近 6 个月 8000 万条订单
| 字段 | 类型 | 说明 |
|---|---|---|
| order_id | string | 订单 ID |
| user_id | string | 下单用户 ID |
| product_id | string | 商品 ID |
| quantity | int | 购买数量 |
| amount | double | 订单金额(元) |
| order_time | timestamp | 下单时间 |
| order_status | string | 订单状态(paid / shipped / completed / cancelled / refunded) |
商品表(dw.products) --- 50 万 SKU
| 字段 | 类型 | 说明 |
|---|---|---|
| product_id | string | 商品 ID |
| product_name | string | 商品名称 |
| category | string | 商品类目 |
| price | double | 商品单价 |
| brand | string | 品牌 |

本任务只需要用户表和订单表(通过 user_id 关联),商品表暂不参与------不涉及按商品类目分析的场景。
任务目标:统计各城市活跃用户近一个月的消费总金额、购买频次、客单价,生成城市消费力排行榜,写回 Paimon 表供运营团队查询。
python
# 最终要产出的结果长这样:
#
# | city | total_amount | order_count | avg_order_amount |
# |------|---------------|-------------|-------------------|
# | 上海 | 12,345,678.90 | 45,321 | 272.55 |
# | 北京 | 11,234,567.80 | 38,765 | 289.87 |
# | 广州 | 8,765,432.10 | 32,109 | 273.05 |
# | ... | ... | ... | ... |
# | 拉萨 | 234,567.80 | 1,023 | 229.39 |
解决思路
拿到这个任务后,先不急写代码,理清三个问题。
1. 数据从哪来,表之间什么关系?
三张 Paimon 表,用户表有城市和状态,订单表有消费金额和下单时间,商品表有类目信息。用户表和订单表通过 user_id 关联,订单表和商品表通过 product_id 关联。本任务只需要用户表和订单表,商品表暂不参与。
2. 从原始数据到结果,需要几步变换?
关键在于这是一个"两步聚合"问题------不能直接按城市聚合,因为 city 在用户表而不在订单表中:
- 第一步 :在订单表上按
user_id聚合,算出每个用户的消费总金额和下单次数 - 第二步 :关联用户表拿到
city字段,再按city聚合,把用户级数据汇总为城市级
3. 有哪些边界情况需要处理?
- 订单表可能有重复记录 → 按
order_id去重 - 不是所有活跃用户都有消费 → left join 保留所有活跃用户,空值填 0
- 客单价 = 总金额 / 下单次数,某城市总下单次数为 0 时除零 → 这种情况只会出现在全部用户都没消费的极端城市,fillna 后 0/0 仍为 null,排序时 null 自动排到末尾
理清这三点后,代码的逻辑链条就清晰了:

上图中,蓝色路径是用户表的处理链路(过滤活跃用户),红色路径是订单表的处理链路(过滤有效订单 → 用户级聚合),两条路径在 join 处汇合,之后按城市做第二次聚合,最终写出。
下文从创建 SparkSession 开始,逐步完成这条链路的每一个环节,每一步都落回这个电商场景。
一 SparkSession:一切操作的入口
SparkSession 是 Spark 2.0+ 引擎的统一入口,替代了旧版的 SparkContext + SQLContext + HiveContext 组合。所有 DataFrame API 的调用都以 SparkSession 为起点。
python
from pyspark.sql import SparkSession
spark = (
SparkSession.builder
.appName("city-consumption-report")
.config("spark.sql.catalog.paimon", "org.apache.paimon.spark.PaimonCatalog")
.config("spark.sql.catalog.paimon.warehouse", "s3://warehouse/")
.config("spark.sql.shuffle.partitions", 200)
.config("spark.sql.adaptive.enabled", "true")
.getOrCreate()
)
几个关键配置说明:
| 配置 | 默认值 | 说明 |
|---|---|---|
spark.sql.catalog.paimon |
--- | 注册 Paimon Catalog,之后可通过 spark.table("paimon.dw.users") 直接读取 |
spark.sql.catalog.paimon.warehouse |
--- | Paimon 数据湖的根路径 |
spark.sql.shuffle.partitions |
200 | Shuffle 后的默认分区数。本地调试可设小(如 4),生产环境按集群规模调整 |
spark.sql.adaptive.enabled |
false(3.2 前) | AQE 自适应执行:运行时动态合并小分区、切换 Join 策略,生产环境建议开启 |
Catalog 是什么:Catalog 是 Spark SQL 的表名注册机制------把"表名"映射到"存储路径",这样就不需要记住每个表的物理路径。理解上面的配置:
python
.config("spark.sql.catalog.paimon", "org.apache.paimon.spark.PaimonCatalog")
# ───────────┬────────── ──────────────┬──────────────────
# Catalog 名(自己起) Paimon 提供的固定类名(不能改)
.config("spark.sql.catalog.paimon.warehouse", "s3://warehouse/")
# ───────────┬────────────────────────── ──────┬──────────
# 给 paimon 这个 Catalog 配置 数据湖根路径
注册后,spark.table("paimon.dw.users") 的三段含义:
spark.table("paimon.dw.users")
──┬── ─┬─ ──┬──
│ │ └─ 表名(warehouse 下的子目录)
│ └────── 数据库名(warehouse 下的子目录)
└─────────── Catalog 名(config 里自己起的名字)
Catalog 名是自己起的------把 paimon 改成 prod,读取时就用 spark.table("prod.dw.users")。但类名 org.apache.paimon.spark.PaimonCatalog 是 Paimon 官方提供的,不能改。物理上,Paimon Catalog 去读取 s3://warehouse/dw/users/ 目录下的表元数据(Schema、分区、数据文件清单),返回一个 DataFrame,与直接写 spark.read.format("paimon").load("s3://warehouse/dw/users") 效果一样,区别只是用表名代替了路径。Hive Metastore、Iceberg、Delta Lake 都遵循同样的 Catalog 机制。
一个常见误区:SparkSession 不是"创建集群",它只是创建了一个 Driver 端的会话对象。真正的 Executor 进程由 Cluster Manager 在 Action 触发时按需启动。
例子进展:SparkSession 创建完成后,Driver 进程已就绪,但此时还没有读取任何数据------与上一篇所述的懒执行一致,接下来定义的 read / select / filter / join 都只是在构建执行计划,直到 write() 才真正触发计算。
二 数据读取:从 Paimon 表加载
三张表都存储在 Paimon 数据湖中,注册了 Catalog 后,读取方式与查表一样简单:
python
users = spark.table("paimon.dw.users")
orders = spark.table("paimon.dw.orders")
products = spark.table("paimon.dw.products")
Paimon 表自带 Schema 和分区信息,读取时无需指定 header、inferSchema、format 等参数------这是数据湖表相对于裸文件(CSV / JSON)的核心优势。
2.1 不配置 Catalog 时:按路径读取
如果没有注册 Catalog,也可以直接按路径读取:
python
users = spark.read.format("paimon").load("s3://warehouse/dw/users")
orders = spark.read.format("paimon").load("s3://warehouse/dw/orders")
products = spark.read.format("paimon").load("s3://warehouse/dw/products")
两种方式的区别:
| 方式 | 写法 | 特点 |
|---|---|---|
| Catalog 表名 | spark.table("paimon.dw.users") |
简洁,支持时间旅行、分区裁剪等表级优化 |
| 路径直读 | spark.read.format("paimon").load(path) |
灵活,适合临时探查无 Catalog 的表 |
2.2 Paimon 读取的底层机制
Paimon 表虽然用 spark.table("paimon.dw.users") 一行代码读取,像是"一张大表",但物理上它不是一个文件------而是一个目录结构,底层由大量分布式文件组成:
s3://warehouse/dw/users/ ← 表目录
├── dt=2026-08-01/ ← 分区目录(如果按日期分区)
│ ├── bucket-0/ ← 桶目录
│ │ ├── data-0.orc ← 数据文件(列式存储)
│ │ ├── data-1.orc
│ │ └── ...
│ ├── bucket-1/
│ └── ...
├── dt=2026-08-02/
└── ...
Spark 读取时,每个数据文件对应一个"split",每个 split 分配给一个 Task 读取。所以 spark.table("paimon.dw.users") 返回的 DataFrame 的初始分区数取决于底层文件数------500 万行的用户表如果存成 50 个文件,读取后就是约 50 个分区。
Paimon 作为列式存储格式,还具备以下优化能力:
- 自带 Schema:类型信息直接从表元数据读取,不需要推断
- 列裁剪 :
select("city", "amount")后,未被引用的列不会从存储中加载 - 谓词下推 :
filter(col("status") == "active")中的过滤条件会被下推到存储层,在读取时就跳过不匹配的文件 - 分区裁剪 :如果表按
dt(日期)分区,filter(col("dt") == "2026-08-01")只读取对应分区目录,不扫描全表
这些优化由 Catalyst 优化器在生成物理执行计划时自动完成,开发者无需关心------只需正常写 select / filter,Spark 会自动下推到 Paimon。
2.3 快速探查数据
读取完成后,用几个方法快速了解数据概况:
python
users.printSchema() # 查看字段名和类型
# root
# |-- user_id: string (nullable = true)
# |-- username: string (nullable = true)
# |-- city: string (nullable = true)
# |-- age: integer (nullable = true)
# |-- ...
users.show(5) # 查看前 5 行
# +-------+----------+------+------+---+----+------+
# |user_id|username |city |age |...|... |... |
# +-------+----------+------+------+---+----+------+
# |u00001 |张伟 |上海 |28 |...|... |active|
# |u00002 |李娜 |北京 |35 |...|... |active|
# |...
users.count() # 总行数 → 500 万
注意 show() 和 count() 都是 Action ,会触发实际计算。printSchema() 不是 Action,它只读取表元数据,不会触发计算。
例子进展 :三张 Paimon 表已加载为 DataFrame。用户表 500 万行,订单表 8000 万行(近 6 个月),商品表 50 万行。但此时数据尚未被真正读取------
show()和count()触发了少量计算,而后续的 select / filter / join 仍在构建计划阶段。
三 投影与过滤:select、filter
3.1 select:选取需要的列
python
from pyspark.sql.functions import col
# 纯选列名 --- 字符串写法最简洁
users_simple = users.select("user_id", "city", "age", "status")
# 需要列运算时 --- 用 col() 或 selectExpr
orders_with_discount = orders.select(
col("order_id"),
col("user_id"),
(col("amount") * 0.9).alias("discount_amount"), # 列运算
col("order_time")
)
# selectExpr --- 等价的 SQL 语法写法
orders_with_discount = orders.selectExpr(
"order_id",
"user_id",
"amount * 0.9 as discount_amount",
"order_time"
)
两种写法都是 PySpark API,底层一样------都是构造 Column 对象传给 Spark JVM 执行。纯选列名时用字符串最简洁;需要运算时用 col()(Python 风格)或 selectExpr()(SQL 风格),两者等价,选哪个看个人偏好。
select 是 Transformation,只是记录"需要哪些列",不触发计算。其性能价值在于:读取 Paimon 表时,Spark 会利用列裁剪只加载被 select 的列,减少 I/O。
3.2 filter:过滤不需要的行
任务是统计"活跃用户近一个月的消费",需要过滤用户表和订单表:
python
from pyspark.sql.functions import col, current_date, date_sub
# 过滤用户表:只要状态为 active 的用户
active_users = users.filter(col("status") == "active")
# 过滤订单表:只要近 30 天、状态为 completed 的有效订单
recent_valid_orders = orders.filter(
(col("order_time") >= date_sub(current_date(), 30))
& (col("order_status") == "completed")
)
filter 的条件用列表达式组合,支持以下运算:
| 运算 | 写法 | 说明 |
|---|---|---|
| 等于 | col("status") == "active" |
|
| 不等 | col("status") != "deleted" |
|
| 大于 / 小于 | col("amount") > 100 |
|
| 逻辑与 | (条件1) & (条件2) |
注意两边要加括号 |
| 逻辑或 | `(条件1) | (条件2)` |
| 取反 | ~(条件) |
|
| IN | col("city").isin("上海", "北京") |
|
| IS NULL | col("city").isNull() |
|
| IS NOT NULL | col("city").isNotNull() |
|
| LIKE | col("username").like("张%") |
|
| BETWEEN | col("age").between(18, 60) |
闭区间 |
注意 :多个条件用
&和|连接时,每个条件必须加括号。col("a") > 1 & col("b") < 2会因运算符优先级报错,正确写法是(col("a") > 1) & (col("b") < 2)。
filter 和 where 完全等价,API 两者都有,习惯用哪个都行。
上面代码中的 current_date()、date_sub() 是 pyspark.sql.functions 模块提供的内置函数。它们不是 Python 函数,而是创建 Column 表达式传给 Spark JVM 执行。常用的日期函数:
| 函数 | 作用 | 示例 |
|---|---|---|
current_date() |
当前日期 | current_date() → 2026-08-23 |
date_sub(col, n) |
减 n 天 | date_sub(current_date(), 30) → 30 天前 |
date_add(col, n) |
加 n 天 | date_add(col("order_time"), 7) |
to_date(col) |
字符串转日期 | to_date(col("register_date")) |
datediff(end, start) |
日期差(天数) | datediff(current_date(), col("order_time")) |
year/month/day(col) |
提取年/月/日 | year(col("order_time")) |
除了日期函数,pyspark.sql.functions 还有几百个内置函数覆盖各类场景:数学(round / abs / ceil)、字符串(upper / concat / trim)、聚合(sum / count / avg)、条件(when / coalesce)等,后续章节会逐步用到。
例子进展:过滤后,active_users 约剩 450 万用户(冻结和删除的约 50 万被排除),recent_valid_orders 约剩 1200 万条(近 30 天已完成的订单)。这些仍是 Transformation,尚未计算。但 Catalyst 已在计划阶段将 filter 条件标记为谓词下推候选,执行时会在存储层跳过不匹配的数据。
四 聚合与排序:groupBy、agg、orderBy
4.1 单表聚合
先不关联用户表,直接在订单表上做一次初步统计------按 user_id 聚合,看每个用户的消费金额和下单次数:
python
from pyspark.sql.functions import sum as spark_sum, count as spark_count, round
user_spending = (
recent_valid_orders
.groupBy("user_id")
.agg(
spark_sum("amount").alias("total_amount"), # 消费总金额
spark_count("order_id").alias("order_count"), # 下单次数
)
.withColumn("avg_order_amount",
round(col("total_amount") / col("order_count"), 2) # 客单价
)
)
user_spending.show(5)
# +-------+-------------+-------------+-----------------+
# |user_id|total_amount |order_count |avg_order_amount |
# +-------+-------------+-------------+-----------------+
# |u10001 | 2345.60 | 8 | 293.20 |
# |u10002 | 890.00 | 3 | 296.67 |
# |...
几个关键点:
groupBy+agg是固定搭配 :groupBy指定分组键,agg内部指定聚合函数groupBy会触发 Shuffle :同一个 user_id 的订单可能分散在不同分区(不同文件)中,必须把它们汇到同一分区才能聚合------这就是第一篇讲的宽依赖。Catalyst 在这里标记 Shuffle 边界,之前是 Stage 0(读取 + 过滤),之后是 Stage 1(聚合)。本例中后续还有一次groupBy("city"),所以全程共两次 Shufflealias给结果列命名 :不设的话列名会变成sum(amount)这样的长名withColumn添加派生列 :客单价 = 总金额 / 下单次数,用withColumn添加round控制精度:金额字段保留两位小数spark_sum/spark_count:pyspark.sql.functions中的sum、max、min、round等函数和 Python 内置同名,直接from pyspark.sql.functions import sum会覆盖内置函数。本文用as spark_sum起别名避免冲突。另一种更简洁的方式是模块前缀写法:
python
import pyspark.sql.functions as F
# 统一用 F. 前缀调用,天然不会和内置冲突
F.sum("amount")
F.count("order_id")
生产代码中 F. 前缀写法更常见,不需要给每个函数起别名。
常见的聚合函数一览:
| 聚合函数 | 作用 | 示例 |
|---|---|---|
sum("col") |
求和 | spark_sum("amount") |
count("col") |
计数(非空值) | spark_count("order_id") |
countDistinct("col") |
去重计数 | countDistinct("product_id") |
avg("col") / mean("col") |
平均值 | avg("amount") |
max("col") / min("col") |
最大 / 最小 | max("amount") |
stddev("col") |
标准差 | stddev("amount") |
first("col") / last("col") |
第一 / 最后值 | first("order_time") |
4.2 多列分组
groupBy 也支持传多个分组键,比如想同时按城市和性别统计:
python
# 按城市 + 性别分组,统计各组合的用户数
city_gender_stats = (
active_users
.groupBy("city", "gender")
.agg(
spark_count("user_id").alias("user_count"),
)
)
city_gender_stats.show(5)
# +------+------+------------+
# |city |gender|user_count |
# +------+------+------------+
# |上海 |男 | 1284567 |
# |上海 |女 | 1156432 |
# |北京 |男 | 1356789 |
# |北京 |女 | 1243210 |
# |...
等价的 SQL 写法:
sql
SELECT city, gender, COUNT(user_id) AS user_count
FROM active_users
GROUP BY city, gender
多列分组的结果中,分组键会作为普通列出现在结果 DataFrame 中。
4.3 排序
排序用 orderBy,也是 Transformation:
python
# 按消费总金额降序排列
user_spending_sorted = user_spending.orderBy(col("total_amount").desc())
# 先按消费金额降序,金额相同再按下单次数降序
user_spending_sorted = user_spending.orderBy(
col("total_amount").desc(),
col("order_count").desc()
)
orderBy 在 Spark 中也是一个 Shuffle 操作------需要把相同 key 的数据汇到一起,这与 groupBy 的 Shuffle 原理一致。
例子进展:此时已有每个用户的消费汇总(user_spending),但还没有城市信息------城市在用户表中。下一步需要把两张表关联起来。
五 多表关联:join
要把 user_spending(含 user_id 和消费金额)与 active_users(含 user_id 和 city)关联,按 user_id 做 Join:
python
city_spending = (
active_users
.join(user_spending, on="user_id", how="left")
.select("city", "total_amount", "order_count", "avg_order_amount")
)
参数说明:
| 参数 | 说明 |
|---|---|
on |
关联键,可以是字符串(单键)或列表(多键),也可以用列表达式指定不等值条件 |
how |
Join 类型,默认 inner |
四种常见 Join 类型:
| Join 类型 | 行为 | 适用场景 |
|---|---|---|
inner |
只保留两表都有的 key | 交集 |
left |
左表全保留,右表不匹配的填 null | 本场景:保留所有活跃用户,没下单的金额为 null |
right |
右表全保留,左表不匹配的填 null | 较少使用 |
outer / full |
两表全保留,不匹配的都填 null | 找差异、对账 |
本场景为什么用 left join:任务是统计"各城市活跃用户的消费",需要保留所有活跃用户------即使某用户近一个月没有下单,也应在结果中体现(消费金额为 0 或 null),这样运营才能看到哪些城市的用户活跃度低。
Join 后的数据规模:active_users 有 450 万行,user_spending 有约 380 万行(有消费记录的用户)。left join 后仍为 450 万行,其中约 70 万行的 total_amount 为 null。
性能提示:Join 是 Shuffle 操作,是性能成本最高的操作之一。两表 Join 时,如果某个 key 的数据量远超其他(如大 V 用户的订单量是普通用户的 1000 倍),会导致数据倾斜------某个 Task 处理的数据远超其他 Task,成为瓶颈。倾斜的识别与处理将在性能调优篇展开。
5.1 Join 后的空值处理
left join 会产生 null------没有消费记录的用户,其 total_amount、order_count 为 null。需要在聚合前处理:
python
from pyspark.sql.functions import coalesce, lit
city_spending_filled = (
active_users
.join(user_spending, on="user_id", how="left")
.select("city", "total_amount", "order_count", "avg_order_amount")
.fillna({
"total_amount": 0.0,
"order_count": 0,
"avg_order_amount": 0.0,
})
)
fillna 接受一个字典,按列指定填充值。也可以用 na.fill() 别名。
5.2 按城市聚合
有了带城市信息的用户消费数据,最终按城市聚合:
python
city_report = (
city_spending_filled
.groupBy("city")
.agg(
spark_sum("total_amount").alias("city_total_amount"),
spark_sum("order_count").alias("city_order_count"),
)
.withColumn("city_avg_order_amount",
round(col("city_total_amount") / col("city_order_count"), 2)
)
.orderBy(col("city_total_amount").desc())
)
city_report.show(10)
# +------+-------------------+------------------+----------------------+
# |city |city_total_amount |city_order_count |city_avg_order_amount |
# +------+-------------------+------------------+----------------------+
# |上海 |12345678.90 |45321 |272.55 |
# |北京 |11234567.80 |38765 |289.87 |
# |广州 |8765432.10 |32109 |273.05 |
# |深圳 |7654321.00 |29876 |256.24 |
# |成都 |5432109.80 |23456 |231.55 |
# |...
例子进展:从 8000 万条订单 + 500 万用户中,最终聚合为约 300 行(城市数),结果从 12GB 原始数据压缩到几百 KB。这一步的 groupBy 会触发 Shuffle,把同一城市的数据汇到同一分区进行聚合。
六 列操作与派生字段
上面的代码已经用到了 withColumn、alias、round 等列操作,这里做系统梳理。
6.1 列的引用方式
python
from pyspark.sql.functions import col, expr, lit
# 三种引用列的方式,效果相同
df.select(col("city")) # col() 函数
df.select("city") # 字符串
df.select(expr("city")) # expr() 表达式
6.2 常用列操作方法
| 方法 | 作用 | 示例 |
|---|---|---|
select("col1", "col2") |
选取列 | users.select("user_id", "city", "status") |
withColumn("name", expr) |
添加 / 替换列 | df.withColumn("discount", col("amount") * 0.9) |
withColumnRenamed("old", "new") |
重命名列 | df.withColumnRenamed("amount", "total_amount") |
drop("col1", "col2") |
删除列 | users.drop("username", "register_date") |
alias("name") |
列别名 | (col("amount") * 0.9).alias("discount") |
DataFrame 是不可变的 :上表所有方法都返回一个新的 DataFrame ,原来的 DataFrame 和底层 Paimon 表都不受影响。
drop()不会删除存储上的列,只是在执行计划里标记"这一列不要读"------执行时 Spark 从 Paimon 文件读取时就跳过它(列裁剪),数据根本不会加载到内存。只有显式调用write才会往存储写数据。
6.3 派生字段示例
给城市消费报表添加一列"消费等级":
python
from pyspark.sql.functions import when
city_report_final = (
city_report
.withColumn("tier",
when(col("city_total_amount") > 10000000, "S")
.when(col("city_total_amount") > 5000000, "A")
.when(col("city_total_amount") > 1000000, "B")
.otherwise("C")
)
)
when().when().otherwise() 等价于 SQL 的 CASE WHEN ... THEN ... ELSE ... END。
6.4 常用内置函数
除了聚合函数外,列操作中最常用的是以下几类函数:
python
from pyspark.sql.functions import (
# 数学运算
round, ceil, floor, abs,
# 字符串操作
upper, lower, trim, concat, substring, regexp_replace,
# 日期时间
current_date, current_timestamp, date_format, date_sub, date_add, datediff, months_between,
# 条件
when, coalesce, lit, # coalesce: 返回第一个非 null 值; lit: 创建字面量列
# 类型转换
cast,
)
类型转换示例:
python
# 将字符串类型的 amount 转为 double
df = df.withColumn("amount", col("amount").cast("double"))
# 将日期字符串格式化
df = df.withColumn("month", date_format(col("order_time"), "yyyy-MM"))
七 空值处理
7.1 空值的来源
DataFrame 中的 null 来自三种情况:
| 来源 | 说明 | 示例 |
|---|---|---|
| 原始数据缺失 | 数据采集时未填 | 用户表某行 city 字段为空 |
| Join 产生的 null | left/right/outer join 不匹配的一侧 | 无消费记录的用户,total_amount 为 null |
| 运算产生的 null | 非法运算结果 | 0 / 0 或对 null 做运算 |
7.2 空值处理方法
python
from pyspark.sql.functions import col
# 1. na.drop():删除含 null 的行(删行,不是删列)
users_clean = users.na.drop() # 任意列为 null 就删该行
users_clean = users.na.drop(subset=["city"]) # 只删 city 为 null 的行,其他列有没有 null 不管
# 2. na.fill():填充默认值
users_filled = users.fillna({"city": "未知", "age": 0}) # 按列指定
# 3. na.replace():替换特定值
users_replaced = users.na.replace("NULL", None, ["city"])
# 将 city 列中的字符串 "NULL" 替换为真正的 null
# 4. coalesce:取第一个非 null 值
from pyspark.sql.functions import coalesce
# city 有值就用原值,为 null 就填"未知",结果存在新列 city_safe 里
df = df.withColumn("city_safe", coalesce(col("city"), lit("未知")))
# col("city") → 引用 city 列
# lit("未知") → 字面量 "未知"(lit = literal)
# coalesce(...) → 取第一个非 null 值:city 不为 null 就用 city,为 null 就用 "未知"
在本例中,Join 后的空值已用 fillna 处理。如果原始用户表中 city 字段也有缺失,应在 Join 前处理:
python
# 在过滤 active_users 时一并处理 city 空值
active_users_clean = (
users
.filter(col("status") == "active")
.filter(col("city").isNotNull()) # 排除 city 为空的记录
)
八 去重与采样
8.1 去重
python
# 1. distinct():全列去重,所有列的值完全相同才去重
unique_orders = recent_valid_orders.distinct()
# 2. dropDuplicates():按指定列去重
unique_users = orders.dropDuplicates(["user_id"])
# 同一个 user_id 只保留一行(默认保留第一次出现的)
去重的典型场景:
| 场景 | 方法 | 说明 |
|---|---|---|
| 订单表可能有重复记录 | dropDuplicates(["order_id"]) |
按 order_id 去重 |
| 只关心用户是否出现过 | dropDuplicates(["user_id"]) |
每个 user_id 只留一行 |
| 完全相同的行 | distinct() |
所有列值相同才去重 |
注意 :
dropDuplicates需要 Shuffle(相同 key 要汇到同一分区才能比较),大数据量下性能开销显著。如果确定数据无重复,不要无意义调用。
8.2 采样
在完整数据上开发和调试效率低------8000 万条订单的每次 Action 都要等几分钟。先采样一小部分跑通逻辑,再全量执行。
python
# 采样 1% 的订单数据用于开发调试
sample_orders = recent_valid_orders.sample(fraction=0.01, seed=42)
print(sample_orders.count()) # 约 12 万条
# 采样 1000 行用于快速查看数据样式
limited_orders = recent_valid_orders.limit(1000)
| 方法 | 作用 | 说明 |
|---|---|---|
sample(fraction, seed) |
按比例随机采样 | 每行独立以 fraction 概率纳入,总量约等于 fraction;seed 保证可复现 |
limit(n) |
取前 n 行 | 不是随机采样,而是各分区取前若干行合并 |
sampleBy("col", {k1: f1, k2: f2}) |
分层采样 | 按指定列的值分层,各层独立采样;字典中未列出的组不会被采样 |
sample 是伪随机(每行独立抛硬币),1200 万行采 1% 不一定是精确 12 万行,有轻微波动。相同 seed 每次跑出相同结果,方便复现问题。
sampleBy 的 fractions 字典只对列出的 key 采样,未列出的组完全排除:
python
# 大城市采 1%,小城市多采些保证样本量
fractions = {"上海": 0.01, "北京": 0.01, "拉萨": 0.5, "银川": 0.5}
stratified_sample = recent_valid_orders.sampleBy("city", fractions, seed=42)
# 上海/北京各采 1%,拉萨/银川各采 50%,其他城市不纳入
如果只是所有城市统一采 1%,直接用 sample(fraction=0.01) 即可,不需要 sampleBy。
实践建议 :开发流程通常是
sample → 写逻辑 → show/count 验证 → 全量跑。在 1% 采样数据上验证逻辑通过后,再对全量数据执行最终计算,避免逻辑错误导致全量重算。
九 数据写出
9.1 写回 Paimon 表
python
(
city_report_final
.write
.format("paimon")
.mode("overwrite")
.saveAsTable("paimon.report.city_consumption")
)
写回 Paimon 表后,下游可以直接通过 spark.table("paimon.report.city_consumption") 查询,也可以用 BI 工具对接。
9.2 写出配置
| 配置 | 选项 | 说明 |
|---|---|---|
format |
paimon / parquet / csv / json / orc |
写回 Paimon 表用 format("paimon");写裸文件可省略,默认 parquet |
mode |
overwrite / append / ignore / error |
overwrite 覆盖;append 追加;ignore 已存在则跳过;error(默认)报错 |
partitionBy |
列名列表 | 按指定列分区写出,每列值生成一个目录 |
option |
格式相关选项 | 如 CSV 的 header、sep |
按消费等级分区写出示例:
python
(
city_report_final
.write
.format("paimon")
.mode("overwrite")
.partitionBy("tier")
.saveAsTable("paimon.report.city_consumption")
)
# 表的物理存储按 tier 分区,下游查询时可直接利用分区裁剪
write 是 Action,此处才真正触发整个 DAG 的执行------从读取、过滤、Join、聚合到写出,一气呵成。
十 完整代码
把前面的所有步骤串起来,就是完整的城市消费力报表任务:
python
from pyspark.sql import SparkSession
from pyspark.sql.functions import (
col, sum as spark_sum, count as spark_count, round,
when, coalesce, lit, current_date, date_sub
)
# 1. 创建 SparkSession(含 Paimon Catalog)
spark = (
SparkSession.builder
.appName("city-consumption-report")
.config("spark.sql.catalog.paimon", "org.apache.paimon.spark.PaimonCatalog")
.config("spark.sql.catalog.paimon.warehouse", "s3://warehouse/")
.config("spark.sql.shuffle.partitions", 200)
.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. 过滤:活跃用户 + 近 30 天有效订单
active_users = (
users
.filter(col("status") == "active")
.filter(col("city").isNotNull())
)
recent_valid_orders = (
orders
.filter(col("order_time") >= date_sub(current_date(), 30))
.filter(col("order_status") == "completed")
.dropDuplicates(["order_id"]) # 去重,防止重复订单
)
# 4. 按用户聚合消费
user_spending = (
recent_valid_orders
.groupBy("user_id")
.agg(
spark_sum("amount").alias("total_amount"),
spark_count("order_id").alias("order_count"),
)
.withColumn("avg_order_amount",
round(col("total_amount") / col("order_count"), 2)
)
)
# 5. Join 用户表,补充城市信息
city_spending = (
active_users
.join(user_spending, on="user_id", how="left")
.select("city", "total_amount", "order_count", "avg_order_amount")
.fillna({
"total_amount": 0.0,
"order_count": 0,
"avg_order_amount": 0.0,
})
)
# 6. 按城市聚合
city_report = (
city_spending
.groupBy("city")
.agg(
spark_sum("total_amount").alias("city_total_amount"),
spark_sum("order_count").alias("city_order_count"),
)
.withColumn("city_avg_order_amount",
round(col("city_total_amount") / col("city_order_count"), 2)
)
.orderBy(col("city_total_amount").desc())
)
# 7. 添加消费等级标签
city_report_final = (
city_report
.withColumn("tier",
when(col("city_total_amount") > 10000000, "S")
.when(col("city_total_amount") > 5000000, "A")
.when(col("city_total_amount") > 1000000, "B")
.otherwise("C")
)
)
# 8. 写回 Paimon 表(此处触发整个 DAG 执行)
(
city_report_final
.write
.format("paimon")
.mode("overwrite")
.partitionBy("tier")
.saveAsTable("paimon.report.city_consumption")
)
spark.stop()
从代码量看,20 行核心逻辑就完成了 8000 万条订单的聚合统计。但这 20 行背后经历了:读取 Paimon 表 → 过滤 → 去重 → 用户级聚合 → Join → 空值填充 → 城市级聚合 → 排序 → 分区写出,每个环节都对应一到两个 DataFrame API 调用。
十一 小结
本文用一张订单表(8000 万行)和一张用户表(500 万行),走完了 DataFrame API 的核心操作链路。回顾全文的 API 使用节奏:
| 步骤 | API | Transformation / Action |
|---|---|---|
| 创建入口 | SparkSession.builder(含 Paimon Catalog) |
--- |
| 读取数据 | spark.table("paimon.dw.xxx") |
--- |
| 选取列 | select |
Transformation |
| 过滤行 | filter / where |
Transformation |
| 聚合 | groupBy.agg |
Transformation(含 Shuffle) |
| 关联 | join |
Transformation(含 Shuffle) |
| 派生列 | withColumn |
Transformation |
| 空值处理 | fillna / na.drop |
Transformation |
| 去重 | distinct / dropDuplicates |
Transformation(含 Shuffle) |
| 采样 | sample / limit |
Transformation |
| 排序 | orderBy |
Transformation(含 Shuffle) |
| 写出 | write.format("paimon").saveAsTable |
Action |
| 探查 | show / count / printSchema |
show/count 为 Action;printSchema 为元数据读取 |
全文唯一的 Action 在 write()------在此之前,所有操作都在 Driver 端构建执行计划,直到 write 触发,Catalyst 优化器整合全链路后生成物理执行计划,Driver 将 Task 分发到 Executor 并行执行。
下一篇:DataFrame 进阶------多表 Join 策略(broadcast vs shuffle)、窗口函数(row_number / rank / 累计求和)、复杂数据类型(array / map / struct)的处理方法。