【PySpark 学习笔记 三】DataFrame API 入门

全文只回答一个问题:如何用 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 风格),两者等价,选哪个看个人偏好。

selectTransformation,只是记录"需要哪些列",不触发计算。其性能价值在于:读取 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)

filterwhere 完全等价,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"),所以全程共两次 Shuffle
  • alias 给结果列命名 :不设的话列名会变成 sum(amount) 这样的长名
  • withColumn 添加派生列 :客单价 = 总金额 / 下单次数,用 withColumn 添加
  • round 控制精度:金额字段保留两位小数
  • spark_sum / spark_countpyspark.sql.functions 中的 summaxminround 等函数和 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,把同一城市的数据汇到同一分区进行聚合。


六 列操作与派生字段

上面的代码已经用到了 withColumnaliasround 等列操作,这里做系统梳理。

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 的 headersep

按消费等级分区写出示例:

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)的处理方法。

相关推荐
AC赳赳老秦1 小时前
企业标签体系搭建:基于 OpenClaw 采集的多维度公开数据,构建企业画像标签库
java·运维·服务器·python·信息可视化·deepseek·openclaw
m0_547486661 小时前
《数据库原理及应用教程MySQL 8.0》全套PPT课件2026
数据库·mysql
程序员夏洛1 小时前
MySQL 中的事务隔离级别有哪些?
数据库·mysql
智嵌研习社1 小时前
Ollama 本地大模型完全配置指南:Modelfile 参数与系统环境变量深度解析
java·开发语言
冰暮流星1 小时前
mysql之字符串函数
数据库·mysql
YSoup1 小时前
2026 安卓面试助手APP(安卓八股、题库)
android·面试·职场和发展
xcyxiner1 小时前
flutter 运行到模拟器上
android·前端·flutter
m0_547486661 小时前
《Oracle数据库从入门到实战》全套PPT课件2026
数据库·oracle
Crawl1 小时前
5.登录与分页功能分析
java·后端