DD 数据开发学习站DATA ENGINEERING
模块 04 · 离线计算

PySpark 实战

SparkSession、DataFrame API、Spark SQL,完成清洗与 GMV 对账。

难度 · 进阶 约 180 分钟 必修 练习 8 项 计划内
建议先学:Spark 基础
本页目录 · 27 节

本章目标:能用 PySpark 读订单 CSV、定义 schema、清洗订单、算出月度 GMV 和品类 TopN,并用 DuckDB 对账。 前置知识:读完 Spark 基础;会写 GROUP BY、JOIN、窗口函数。 预计用时:3 小时。

1. 为什么需要它

生产里大多数 Spark 任务是 SQL,但复杂清洗、调用 Python 库、把多个数据源拼成流水线时,DataFrame API 更顺手。它和 SQL 最终走同一个 Catalyst 优化器,不存在“谁更高级”。

场景 优先选择
日常报表、聚合、join Spark SQL
复杂清洗、循环处理多个表、调用 Python 函数 DataFrame API
非结构化数据、精细控制分区 RDD(少用)

本章用 data/sample/ 下的两份小 CSV 走一遍真实流程:读数据、定 schema、清洗、算指标、对账、写分区表。数据虽然只有 12 行,代码结构和生产任务一致。验收数字如下:

指标 目标值
原始订单 / 明细 12 / 12
有效订单(排除 canceled / unavailable) 9
有效明细 9
GMV(含运费) 1,509.00
GMV(不含运费) 1,250.00
客单价 AOV 167.67

注意:完全数据集和真实脏数据的完整处理在独立的项目线里做。本章只保留能讲清 PySpark 用法的字段,避免把工具课变成项目复述。

2. 核心概念

2.1 SparkSession:一个程序一个入口

SparkSession 是 Spark 3.x 的统一入口,封装了 SparkContext、SQL 和 catalog:

from pyspark.sql import SparkSession

spark = (SparkSession.builder
         .appName("pyspark_gmv")
         .master("local[*]")
         .config("spark.sql.shuffle.partitions", "8")
         .getOrCreate())

appName 在 Spark UI 和 YARN 上可见;local[*] 用本机所有核,学习用;getOrCreate() 已有会话就复用;结束前 spark.stop()。本地小数据把 shuffle 分区设成 8,避免默认 200 产生大量空任务;这不是生产参数。

2.2 读取 CSV 与 schema

最省事的读法是 option("header", True).option("inferSchema", True).csv(path)。inferSchema 会多扫一遍数据推断类型,方便探索,但生产里更推荐显式 schema。下面用 DDL 字符串定义,写起来比 StructField 列表短:

from pyspark.sql.types import StructType

orders_schema = StructType.fromDDL(
    "order_id STRING, status STRING, city STRING, "
    "amount DOUBLE, order_date STRING")
items_schema = StructType.fromDDL(
    "order_id STRING, product_id STRING, category STRING, "
    "price DOUBLE, freight DOUBLE")

orders = (spark.read.option("header", True)
          .schema(orders_schema)
          .csv("data/sample/orders_demo.csv"))
items = (spark.read.option("header", True)
         .schema(items_schema)
         .csv("data/sample/order_items_demo.csv"))

示例数据的 order_date 在 CSV 里是字符串。ODS 先保持字符串,DWD 再用 to_date / to_timestamp 转换,这样原始值可追溯。显式 schema 类型稳定,不会因为某批数据有异常字符串就推断成 string,还能少扫一遍、明确字段含义。常用选项有 header、sep、encoding、nullValue、mode(PERMISSIVE / DROPMALFORMED / FAILFAST)和 schema。

注意:订单表的 amount 是订单级金额,明细表的 price + freight 才是商品级金额。两者只能选一个口径算 GMV,不能相加,否则金额翻倍。本章统一用明细口径。

2.3 DataFrame 常用 API

API 对应 SQL 说明
select SELECT 选列、改名、算表达式
filter / where WHERE 条件过滤,二者等价
withColumn SELECT ..., expr AS col 新增或覆盖一列
groupBy(...).agg(...) GROUP BY + 聚合 分组统计
join(other, on, how) JOIN inner、left、right、outer
orderBy / limit ORDER BY / LIMIT 排序、取前 n 行
dropDuplicates DISTINCT 去重,可指定子集
fillna / dropna COALESCE / WHERE NOT NULL 空值处理
from pyspark.sql import functions as F

df.select("order_id", F.col("price").alias("单价"))
df.filter((F.col("status") != "canceled") & F.col("price").isNotNull())
df.withColumn("amount", F.col("price") + F.col("freight"))
df.groupBy("category").agg(F.count("*").alias("cnt"),
                           F.round(F.sum("amount"), 2).alias("gmv"))
a.join(b, on="order_id", how="left")
df.orderBy(F.col("gmv").desc()).limit(10)

注意:join 前先想清楚两边的粒度。订单表一行一单,明细表一行一商品;直接 join 后行数会按明细膨胀。评价和支付也是一单多行,必须先聚合到订单粒度再 join,否则 GMV 和评分都会被放大。

2.4 Spark SQL 临时视图

detail.createOrReplaceTempView("dwd_detail")
spark.sql("""
    SELECT date_format(order_date, 'yyyy-MM') AS month,
           ROUND(SUM(item_amount_with_freight), 2) AS gmv
    FROM dwd_detail
    GROUP BY 1 ORDER BY 1
""").show(20, truncate=False)

临时视图只在当前 SparkSession 有效,程序结束就消失,不写元数据、不落表。createGlobalTempView 可跨会话,访问要加 global_temp.;需要持久化则用 saveAsTable 写入 Metastore。

注意:Spark SQL 和 Hive SQL 不完全一样。Spark 日期格式化用 date_format(col, 'yyyy-MM'),DuckDB 用 strftime(col, '%Y-%m');Spark 里常用 coalesce 替代 nvl。跨引擎写 SQL 时,日期函数和隐式类型转换是两大坑。

2.5 清洗与口径

示例数据能覆盖的清洗点不多,但每一条都对应生产里的一类问题:

问题 示例处理 注意
取消/不可用订单 排除 与明细 join 时自然过滤掉对应明细
品类为空 补 unknown 空品类不等于删商品
订单无明细 保留订单 不进 AOV 分母
评价/支付一单多行 先按订单聚合 直接 join 会 fan-out
时间字符串 ODS 保留,DWD 转 DATE 原始值要可追溯
订单金额与明细金额并存 只用明细金额算 GMV 两套口径不能相加

清洗顺序建议:订单先排除 canceled 和 unavailable;明细 join 有效订单;评价先聚合再 join;商品缺品类补 unknown;时间字符串统一转 DATE。不要为了“看起来干净”而删掉业务空值,比如未签收订单的签收时间。

2.6 分区、repartition 与 coalesce

分区数决定并行度:太少机器闲着、单任务过大易 OOM;太多调度开销大、输出小文件多。

算子 是否 shuffle 能否增加分区 适用场景
repartition(n) 是 能 打散数据、提高并行度、按列重分区
coalesce(n) 通常否 不能,只能减少 写文件前合并小分区

写 Parquet 分区表:

spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
out = detail.withColumn("dt", F.date_format("order_date", "yyyy-MM"))
(out.repartition(8, "dt").write.mode("overwrite")
     .partitionBy("dt").parquet("data/warehouse/dwd_order_detail"))

partitionBy("dt") 生成 dt=2024-01/ 这样的目录,查询带 dt 条件时只读命中目录,这叫分区裁剪;读回后看 explain 里的 PartitionFilters 可验证。dynamic 覆盖只重写本次涉及的分区;默认静态模式会覆盖整张表,补数时特别危险。

注意:Windows 本地写 Parquet 可能报 winutils.exe 相关错误,这是缺 Hadoop 本地库,不是代码错误。只读练习可继续;要写文件用 WSL2、Docker,或参考 project/hive/README.md。不要为一个本地写文件折腾一下午。

3. 动手示例

3.1 完整脚本

先确认 java -version 和 python -c "import pyspark; print(pyspark.__version__)" 都正常,再在项目根目录新建 scripts/spark_gmv.py,写入下面的完整脚本。

# -*- coding: utf-8 -*-
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
from pyspark.sql.types import StructType

spark = (SparkSession.builder
         .appName("sales_gmv")
         .master("local[*]")
         .config("spark.sql.shuffle.partitions", "8")
         .getOrCreate())
spark.sparkContext.setLogLevel("ERROR")

RAW = "data/sample/"
orders_schema = StructType.fromDDL(
    "order_id STRING, status STRING, city STRING, "
    "amount DOUBLE, order_date STRING")
items_schema = StructType.fromDDL(
    "order_id STRING, product_id STRING, category STRING, "
    "price DOUBLE, freight DOUBLE")

def read_csv(path, schema):
    return spark.read.option("header", True).schema(schema).csv(RAW + path)

orders = read_csv("orders_demo.csv", orders_schema)
items = read_csv("order_items_demo.csv", items_schema)
orders.printSchema()

valid_orders = (orders
                .filter(~F.col("status").isin("canceled", "unavailable"))
                .withColumn("order_date", F.to_date("order_date")))
detail = (items
          .join(valid_orders.select("order_id", "order_date",
                                    "city", "status"),
                "order_id", "inner")
          .withColumn("item_amount", F.col("price"))
          .withColumn("item_amount_with_freight",
                      F.col("price") + F.col("freight"))
          .withColumn("category",
                      F.coalesce("category", F.lit("unknown"))))

monthly = (detail
           .withColumn("month", F.date_format("order_date", "yyyy-MM"))
           .groupBy("month")
           .agg(F.round(F.sum("item_amount_with_freight"), 2).alias("gmv"))
           .orderBy("month"))
monthly.show(20, truncate=False)

overview = detail.agg(
    F.countDistinct("order_id").alias("order_cnt"),
    F.round(F.sum("item_amount_with_freight"), 2).alias("gmv"),
    F.round(F.sum("item_amount"), 2).alias("goods_gmv"),
    F.round(F.sum("item_amount_with_freight")
            / F.countDistinct("order_id"), 2).alias("aov"),
)
overview.show()

category_gmv = (detail
                .groupBy("category")
                .agg(F.round(F.sum("item_amount_with_freight"), 2).alias("gmv"),
                     F.countDistinct("order_id").alias("order_cnt"))
                .orderBy(F.col("gmv").desc()))
category_gmv.show(10, truncate=False)
spark.stop()

运行 python scripts/spark_gmv.py。printSchema 应看到 order_date 最初是 string,DWD 里才转成 date。

3.2 月度 GMV 和总览

示例数据全部落在 2024-01:

month gmv
2024-01 1509.00

overview 输出:

order_cnt gmv goods_gmv aov
9 1509.00 1250.00 167.67

订单数是 9,不是 12。原始订单里有 2 个取消单和 1 个不可用单,排除后剩 9 个;每个有效订单都有明细,所以订单粒度和明细粒度在这一份数据里刚好相同。换成完整数据后,两者可能差几单,写报告要说明用的是哪个口径。

3.3 品类 TopN

category_gmv 使用含运费 GMV:

品类 GMV(含运费)
数码 885.00
家居 375.00
服饰 249.00

三个品类加起来正好是 1,509.00。品类 TopN 用 orderBy(...).limit(N);每个品类内取 TopN 商品要用窗口函数,见练习 5。

3.4 与 DuckDB 对账

在项目根目录执行下面的脚本,用 DuckDB 按同一口径重算:

python - <<'PY'
import duckdb

con = duckdb.connect()
con.execute("""
CREATE VIEW orders AS
SELECT * FROM read_csv_auto('data/sample/orders_demo.csv', header=true);
CREATE VIEW items AS
SELECT * FROM read_csv_auto('data/sample/order_items_demo.csv', header=true);
""")
print(con.sql("""
    SELECT COUNT(DISTINCT o.order_id) AS order_cnt,
           ROUND(SUM(i.price + i.freight), 2) AS gmv,
           ROUND(SUM(i.price), 2) AS gmv_nofreight,
           ROUND(SUM(i.price + i.freight)
                 / COUNT(DISTINCT o.order_id), 2) AS aov
    FROM items i
    JOIN orders o USING (order_id)
    WHERE o.status NOT IN ('canceled', 'unavailable')
""").df())
PY

对账结果:

指标 PySpark DuckDB
order_cnt 9 9
GMV(含运费) 1,509.00 1,509.00
GMV(不含运费) 1,250.00 1,250.00
AOV 167.67 167.67

面试高频:两个引擎结果对不上,先查口径,不要先怀疑引擎。依次核对:订单状态是否都排除 canceled/unavailable?评价和支付是否先聚合再 join?GMV 是否都含运费?时间窗口是否一致?join 前后行数是否一致?日期类型和时区是否一致?这六步能解决大部分差异。

4. 常见坑

坑 表现 原因与处理
用 inferSchema 读生产数据 某天字段类型突然变 string 显式 schema,固定类型
评价/支付直接 join 明细 GMV 和评分被放大 先聚合到订单粒度再 join
只过滤明细不复核订单 取消单金额仍被算入 有效订单和明细缺一不可
把订单金额和明细金额相加 GMV 翻倍 选一个口径,不要混用
把业务空值当脏数据 误删大量未签收订单 区分业务空值和错误空值
count() 当“看一眼” 每次都在跑全量 先 cache() 或写临时表
toPandas() 拉全量 Driver OOM 先 limit/聚合,结果小才转 pandas
静态覆盖写分区表 补一个月把整表删了 设 partitionOverwriteMode=dynamic

5. 练习

练习 1:商品品类统计

读 items,按 category 统计商品数和平均价格,按平均价格降序打印。

参考答案
items = read_csv("order_items_demo.csv", items_schema)
(items.groupBy("category")
      .agg(F.countDistinct("product_id").alias("product_cnt"),
           F.round(F.avg("price"), 2).alias("avg_price"))
      .orderBy(F.col("avg_price").desc()).show())

countDistinct("product_id") 统计不同商品数,avg("price") 算平均单价。生产里给商品表显式 schema,避免价格被推断成 string。

练习 2:join 前为什么要确认粒度

detail 一行是一个订单商品,评价表一行是一条评价。两者按 order_id join 后,行数会变吗?什么情况下 GMV 会被算大?

参考答案

会变。评价表的 order_id 不唯一,一单可能多条评价,直接 join 会让明细行翻倍,GMV 被重复计算。应先按 order_id 聚合 AVG(review_score),再 join 到明细。任何“一单多行”的表都要先聚合到订单粒度。

练习 3:统计示例数据质量

分别统计重复 order_id、取消/不可用订单、缺品类明细、有效明细行数,并说明怎么处理。

参考答案
print("重复 order_id:",
      orders.count() - orders.select("order_id").distinct().count())  # 0
print("取消/不可用订单:",
      orders.filter(F.col("status").isin("canceled", "unavailable")).count())  # 3
print("缺品类明细:",
      items.filter(F.col("category").isNull()).count())  # 0
print("有效明细:",
      items.join(orders.filter(~F.col("status").isin("canceled", "unavailable")),
                 "order_id").count())  # 9

处理:取消/不可用订单在 DWD 排除;缺品类补 unknown;重复主键先确认是采集重复还是业务重复,不要直接删原始数据。

练习 4:用 Spark SQL 算 2024-01 的 GMV

参考答案
detail.createOrReplaceTempView("dwd_detail")
spark.sql("""
    SELECT ROUND(SUM(item_amount_with_freight), 2) AS gmv,
           COUNT(DISTINCT order_id) AS order_cnt
    FROM dwd_detail
    WHERE order_date >= DATE '2024-01-01'
      AND order_date <  DATE '2024-02-01'
""").show()

GMV 应为 1509.0,订单 9。数据量大时坚持用日期范围条件,不要用 date_format(...) = '2024-01',范围条件更容易命中分区裁剪。

练习 5:每个品类 GMV Top3 商品

参考答案
from pyspark.sql.window import Window
product_gmv = (detail
               .groupBy("category", "product_id")
               .agg(F.round(F.sum("item_amount_with_freight"), 2).alias("gmv")))
w = Window.partitionBy("category").orderBy(F.col("gmv").desc())
(product_gmv.withColumn("rn", F.row_number().over(w))
 .filter(F.col("rn") <= 3).orderBy("category", "rn")
 .show(30, truncate=False))

partitionBy("category") 在每个品类内排名。允许并列时用 RANK() 或 DENSE_RANK();ROW_NUMBER() 遇到并列会任意取一个。示例数据每个品类商品很少,换成完整数据后才能看出 TopN 的筛选效果。

练习 6:写分区表并验证裁剪

把 detail 按 dt 写成 Parquet,读回后只查 2024-01,输出行数并确认分区裁剪。

参考答案
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
out = detail.withColumn("dt", F.date_format("order_date", "yyyy-MM"))
(out.repartition(8, "dt").write.mode("overwrite")
     .partitionBy("dt").parquet("data/warehouse/dwd_order_detail"))
back = spark.read.parquet("data/warehouse/dwd_order_detail")
print(back.filter(F.col("dt") == "2024-01").count())   # 9
back.filter(F.col("dt") == "2024-01").explain(True)

repartition(8, "dt") 会 shuffle,把数据按月份重分布;coalesce(8) 只合并分区,不会按月份组织。explain 里出现 PartitionFilters 就说明裁剪生效。

6. 面试延伸

面试题:DataFrame 和 RDD 怎么选?

是什么:RDD 是无 schema 的分布式对象集合;DataFrame 是带 schema 的分布式表,能走 Catalyst 优化和 Tungsten 执行。

解决什么问题:结构化数据用 DataFrame 更省事、更快,优化器能做列裁剪、谓词下推、join 重排;RDD 适合非结构化数据和需要精细控制分区的场景。

我的实操边界:我在本地用 DataFrame 做过清洗和聚合,也用 RDD 写过 WordCount;没有做过 RDD 级别的内存和分区调优。

面试题:临时视图和 Hive 表有什么区别?

是什么:临时视图是当前 SparkSession 内的命名查询,只存在内存里;Hive 表有 Metastore 元数据和存储上的数据,跨会话、跨引擎可见。

解决什么问题:临时视图适合一个作业内部复用 DataFrame;Hive 表适合多个作业共享、需要权限和血缘管理的稳定资产。

我的实操边界:我在本地用 createOrReplaceTempView 串过多步 SQL,也看过 Hive 风格建表和分区;没有在生产 Metastore 上建过表,也没处理过权限和元数据同步。

面试题:Spark 和 DuckDB 算同一个指标,结果对不上,怎么排查?

是什么:两个引擎语法和默认行为不同,但统一口径后应该得到相同结果。对不上通常是口径、类型或 join 粒度出了问题。

解决什么问题:对账是数据开发的核心动作。先统一过滤条件、去重规则、金额公式和日期边界,再逐层比对行数和金额,才能定位差异。

我的实操边界:我在示例数据和项目线上做过 Spark 与 DuckDB 的 GMV、AOV 对账,能按“订单状态、评价/支付聚合、运费口径、时间窗口、join 行数、日期类型”六步排查;没有处理过生产级跨引擎数据漂移。

7. 本章验收

  • 能创建和关闭 SparkSession,并解释 local[*] 的含义
  • 能显式定义订单 CSV schema,并说出它比 inferSchema 稳在哪里
  • 能写出 select、filter、withColumn、groupBy、agg、join、orderBy、limit 的实际用法
  • 能注册临时视图,并用 Spark SQL 算出月度 GMV
  • 能说出至少 4 类清洗点和处理方式,不会把业务空值当脏数据
  • 能跑出 GMV 1,509.00、AOV 167.67,并与 DuckDB 结果对齐
  • 能解释 repartition 和 coalesce 的区别,能写按 dt 分区的 Parquet 并验证分区裁剪
  • 完成 6 道练习,至少 4 道能独立写出答案

下一章

Hive 基础:把 SQL 跑在 HDFS 上,理解表、元数据与分区。

本章任务0 / 8
本页目录