简介:这份资源是面向高校计算机专业学生的毕业设计与课程设计参考项目,主题为基于Spark的外卖大数据平台分析系统,适合已具备一定大数据基础、需要完成实战项目或课程作业的学习者。压缩包共40个文件,约645KB,以Scala源码为主体,辅以Markdown说明文档、HSQL与SQL脚本、JSON与CSV样例数据、XML配置及Python脚本等,覆盖从数据采集、清洗到分析建模的完整链路。项目围绕Spark核心知识点展开,包括RDD转换与行动操作、DataFrame与Dataset的SQL查询、Spark SQL多数据源交互、Spark Streaming实时流处理以及MLlib机器学习算法应用,并涉及Driver、Executor与Worker的架构理解与性能调优思路。目前已有168人学习下载,可作为毕业设计选题落地的完整参考,帮助读者掌握大数据平台的设计流程与工程实现方法。
1. 外卖订单数据堆到 2000 万行之后,单机 pandas 为什么开始集体翻车
去年帮一个做计算机课程毕设的学弟看进度,他那个「基于 Spark 的外卖大数据平台分析系统」卡在了最尴尬的位置:数据量刚过 2000 万行订单,单机 pandas 跑一次全量聚合要四十多分钟,中途内存直接爆掉,Jupyter 内核重启了三次。他一开始以为是代码写得烂,把groupby改成了pivot_table,又换成numpy手写循环,结果只是把崩溃时间从第 12 分钟推迟到第 18 分钟。问题根本不在写法,而在单机内存模型——pandas 默认要把整列读进内存再算,2000 万行订单加上用户、商家、配送三张维表做 join,峰值内存轻松突破 16GB,普通笔记本根本扛不住。
这个标题讲的就是把这类外卖业务数据从「单机跑不动」搬到 Spark 上做分布式分析:订单量、客单价、时段分布、区域热力、商家排名、配送时长这些指标,用 Spark SQL 和 DataFrame 重新实现一遍,再配一个能看结果的可视化层。它适合两类人:一类是正在做大数据方向毕设、需要一套能跑通、能答辩、能写进论文的系统;另一类是刚接触 Spark、想找一个真实业务场景练手的工程师。核心不是把 Spark 当玩具跑个 WordCount,而是让它真正处理一份有业务含义、有脏数据、有倾斜问题的订单数据集。
2. 先把数据流和分层想清楚:ODS、DWD、DWS 三层怎么切
2.1 为什么外卖场景必须做分层,不能一张宽表算到底
外卖订单的原始数据通常来自两个方向:一是业务库导出的订单明细,字段包括订单 ID、用户 ID、商家 ID、下单时间、支付时间、配送时间、订单金额、配送费、优惠金额、订单状态;二是维表,用户表、商家表、区域表、骑手表。如果直接把订单明细和所有维表 join 成一张大宽表再算指标,会踩两个坑:第一,维表更新后历史宽表要重算,成本极高;第二,不同指标需要的粒度不一样,客单价按订单算,区域热力按区域算,骑手效率按骑手算,一张宽表满足不了所有粒度。
常见做法是分三层。ODS 层保留原始数据,不做任何清洗,只做分区落地,按天分区。DWD 层做清洗和标准化:过滤掉取消订单、异常金额订单,把时间字段统一成标准格式,把用户、商家、区域维表打平进来,形成订单粒度的事实表。DWS 层按主题聚合:按天、按区域、按商家、按小时分别汇总,每个主题一张表。这样上层做报表或者可视化时,直接查 DWS 层,不用每次扫全量明细。
我一般会建议毕设项目至少把 DWD 和 DWS 两层做出来,ODS 层可以用一份模拟数据文件代替,不必真的接 Kafka。因为答辩老师更关心你有没有分层意识、有没有处理数据倾斜、指标口径是否一致,而不是你有没有搭一套完整的采集链路。
2.2 用 Spark SQL 建 DWD 订单事实表的最小代码
下面这段代码是在本地模式跑通的 DWD 层构建逻辑,读的是 CSV 格式的模拟订单数据,输出 Parquet 分区表。字段名按外卖业务常见口径命名,实际替换成你自己的列名即可。
from pyspark.sql import SparkSession from pyspark.sql.functions import col, to_timestamp, when, datediff, unix_timestamp # 本地模式启动,driver 内存给到 4G,避免小数据集也 OOM spark = SparkSession.builder \ .appName("waimai_dwd_build") \ .master("local[4]") \ .config("spark.driver.memory", "4g") \ .config("spark.sql.shuffle.partitions", "8") \ .getOrCreate() # 读原始订单 CSV,第一行是表头,自动推断类型 raw_order = spark.read \ .option("header", "true") \ .option("inferSchema", "true") \ .csv("data/ods/order_raw.csv") # 读商家维表和区域维表 dim_shop = spark.read.option("header", "true").csv("data/ods/shop_dim.csv") dim_area = spark.read.option("header", "true").csv("data/ods/area_dim.csv") # 清洗:过滤取消订单、金额为负、下单时间为空的记录 clean_order = raw_order.filter( (col("order_status") != "cancelled") & (col("order_amount") > 0) & (col("order_time").isNotNull()) ) # 时间标准化:把字符串转成 timestamp,并派生日期分区字段 clean_order = clean_order.withColumn( "order_ts", to_timestamp(col("order_time"), "yyyy-MM-dd HH:mm:ss") ).withColumn("dt", col("dt").cast("string")) # 打平维表:商家所属区域、区域名称 dwd_order = clean_order \ .join(dim_shop, on="shop_id", how="left") \ .join(dim_area, on="area_id", how="left") \ .select( "order_id", "user_id", "shop_id", "area_id", "area_name", "order_ts", "dt", "order_amount", "delivery_fee", "discount_amount", "pay_time", "deliver_time", "order_status" ) # 按天分区写入 Parquet,分区字段放最后 dwd_order.write \ .mode("overwrite") \ .partitionBy("dt") \ .parquet("data/dwd/dwd_order")这段代码的关键点有三个。第一,spark.sql.shuffle.partitions在本地模式下调成 8,默认 200 会让小数据集产生大量空 task,反而拖慢速度。第二,清洗条件里order_amount > 0和order_status != "cancelled"是最基本的业务过滤,实际项目里还要处理重复订单、测试订单、金额异常大的刷单订单。第三,维表 join 用 left join 而不是 inner join,因为有些订单的商家可能已经下线,维表里查不到,inner join 会直接丢掉这些订单,导致统计口径偏小。
2.3 DWS 层聚合:客单价、时段分布、区域热力三张主题表
DWD 建好之后,DWS 层就是按不同维度做 group by。下面这段代码一次性产出三张主题表,分别对应客单价、时段分布、区域热力。
from pyspark.sql.functions import sum as _sum, count, avg, hour, round as _round dwd = spark.read.parquet("data/dwd/dwd_order") # 主题一:按天、按区域的客单价和订单量 dws_area_daily = dwd.groupBy("dt", "area_id", "area_name").agg( count("order_id").alias("order_cnt"), _round(_sum("order_amount") / count("order_id"), 2).alias("avg_order_amount"), _round(_sum("delivery_fee"), 2).alias("total_delivery_fee") ) # 主题二:按小时的下单量分布,用于看午高峰和晚高峰 dws_hourly = dwd.withColumn("order_hour", hour("order_ts")) \ .groupBy("dt", "order_hour") \ .agg(count("order_id").alias("order_cnt")) # 主题三:按商家的订单量和销售额排名基础表 dws_shop_daily = dwd.groupBy("dt", "shop_id").agg( count("order_id").alias("order_cnt"), _round(_sum("order_amount"), 2).alias("total_amount") ) dws_area_daily.write.mode("overwrite").parquet("data/dws/dws_area_daily") dws_hourly.write.mode("overwrite").parquet("data/dws/dws_hourly") dws_shop_daily.write.mode("overwrite").parquet("data/dws/dws_shop_daily")客单价用sum(order_amount) / count(order_id)而不是avg(order_amount),是因为订单金额字段里可能包含配送费和优惠,直接 avg 会把配送费也算进客单价,口径不对。时段分布用hour()函数从 timestamp 里取小时,注意这里取的是下单时间不是支付时间,两者在业务上含义不同。商家排名表只存基础聚合结果,具体排名在应用层用窗口函数或者排序取 TopN,不在这里写死。
3. 把 Spark 分析结果接到可视化层:三种落地方式怎么选
3.1 毕设场景下最稳的方案:Spark 产出结果表,Web 层直读
毕设项目最容易翻车的地方不是 Spark 算不出来,而是算完之后不知道怎么展示。常见做法有三种:第一种是 Spark 把结果写到 MySQL 或者 Hive,然后 Java 或 Python Web 后端查库返回 JSON,前端用 ECharts 画图;第二种是 Spark 直接输出 CSV,前端用静态文件加载;第三种是接 BI 工具。
我一般会推荐第一种,因为答辩时老师会问「你的系统架构是什么」,你说 Spark 算完写 MySQL,Web 层查库展示,这是一个完整的数据流,听起来也合理。第二种太简陋,第三种对毕设来说太重。具体做法是在 DWS 层算完之后,用 Spark 的 JDBC 写入 MySQL。
# 把 DWS 结果写入 MySQL,供 Web 后端查询 dws_area_daily.write \ .format("jdbc") \ .option("url", "jdbc:mysql://localhost:3306/waimai_analysis") \ .option("dbtable", "dws_area_daily") \ .option("user", "root") \ .option("password", "your_password") \ .option("driver", "com.mysql.cj.jdbc.Driver") \ .mode("overwrite") \ .save()这里要注意 MySQL 表的字符集要设成 utf8mb4,否则区域名称里的中文可能乱码。另外mode("overwrite")会先 drop 再建表,如果表结构已经手动建好并且有索引,改成append更安全。写入前最好在 MySQL 里先建好表,字段类型和 Spark 的 schema 对齐,避免自动建表时类型映射出错。
3.2 可视化层选 ECharts 还是 Superset,看你的时间预算
如果毕设时间充裕,想做得好看一点,可以用 Superset 或者 Metabase 直接连 MySQL,拖拽生成图表,省去写前端的时间。但这两个工具对新手来说部署成本不低,Docker 拉起来之后还要配数据源、配 dashboard,遇到端口冲突或者权限问题容易卡住。如果时间紧,直接用 ECharts 写几个静态页面,后端用 Flask 或 Spring Boot 提供三个接口:区域热力、时段分布、商家 TopN,前端 ajax 请求后渲染,两天就能搞定。
我见过一个比较聪明的做法是:Spark 算完的结果表导出成 JSON 文件,前端直接读本地 JSON 渲染,完全跳过 Web 后端。这样架构上少一层,答辩时如果老师问「为什么不用数据库」,你可以说「结果表数据量小,静态化可以降低系统复杂度」,也算一个合理的解释。但缺点是数据更新要重新导出,不适合实时场景。
3.3 指标口径对齐:三个最容易和答辩老师扯皮的地方
第一个是「订单量」到底算不算取消订单。如果你的 DWD 层已经过滤了取消订单,那 DWS 层的订单量就是有效订单量,答辩时要主动说明。第二个是「客单价」是否包含配送费。外卖行业通常客单价指商品金额,不含配送费,但有些平台会把配送费算进去。第三个是「区域」是按用户收货地址还是按商家地址。这两个口径算出来的区域热力完全不同,一个反映消费需求分布,一个反映供给分布。建议在论文里单独用一小节说明指标定义,避免答辩时被追问。
4. 避坑与排查:Spark 跑外卖数据最常见的五个翻车现场
4.1 数据倾斜:某个热门商家把 task 拖到超时
现象是 Spark 任务跑到 99% 卡住不动,打开 Spark UI 看 stage 详情,发现某个 task 处理的数据量是其他 task 的几十倍。原因是 group by shop_id 时,某个头部商家的订单量远超其他商家,所有这个商家的记录都分到同一个 partition。解决办法有两个:一是加盐,给 shop_id 拼接随机后缀打散,聚合两次;二是如果只是 TopN 排名,先用sortWithinPartitions加limit做局部 TopN,再全局合并。毕设数据量不大时,直接把spark.sql.shuffle.partitions调大也能缓解,但治标不治本。
4.2 时间字段解析失败导致大量 null
现象是 DWD 层建完之后,按小时聚合的结果全是 null。原因是原始 CSV 里的时间格式不统一,有的是2024-01-01 12:00:00,有的是2024/01/01 12:00,to_timestamp用固定格式解析时对不上的直接返回 null。解决办法是先用date_format或者正则把时间字符串标准化,再解析。或者用to_timestamp的多种格式尝试,失败时记录到脏数据表里单独排查。
4.3 小文件过多拖慢后续查询
现象是 DWS 层写完之后,用spark.read.parquet读回来发现分区目录下有几万个几百字节的小文件,查询时启动 task 的时间比计算时间还长。原因是 shuffle 分区数设得太大,每个分区写一个文件。解决办法是在写入前用coalesce或repartition控制文件数,一般每个分区目录下 1 到 3 个文件比较合适。本地模式跑毕设数据,coalesce(1)就够了。
4.4 JDBC 写入 MySQL 时中文乱码
现象是 Spark 写入 MySQL 后,区域名称字段显示成问号或者乱码。原因是 JDBC URL 没指定字符集,或者 MySQL 表默认字符集是 latin1。解决办法是在 URL 后面加?useUnicode=true&characterEncoding=utf8,并且建表时指定DEFAULT CHARSET=utf8mb4。如果已经写入乱码,需要先 truncate 表再重新写,直接改字符集不会修复已有数据。
4.5 本地模式内存不够,driver OOM
现象是任务刚启动就报java.lang.OutOfMemoryError: Java heap space。原因是spark.driver.memory默认只有 1G,读大 CSV 或者做 join 时不够用。解决办法是在SparkSession.builder里显式设置config("spark.driver.memory", "4g")或更高。注意这个参数在代码里设置和在spark-submit命令行设置效果一样,但本地模式跑 Jupyter 时只能在代码里设。如果调到 8G 还 OOM,说明数据量真的超过了单机处理能力,需要考虑换集群或者采样。
5. 从能跑到能答辩:三个让系统看起来更专业的进阶技巧
5.1 用窗口函数做商家排名,而不是先全量排序再取 TopN
很多人写商家排名时习惯orderBy(desc("total_amount")).limit(10),这在数据量大时会把所有数据 shuffle 到一个节点排序,效率极低。更好的做法是用窗口函数在每个区域内先排名,再过滤。
from pyspark.sql.window import Window from pyspark.sql.functions import row_number, desc # 按区域分区,按销售额降序排名,取每个区域前 10 window_spec = Window.partitionBy("area_id").orderBy(desc("total_amount")) shop_rank = dws_shop_daily \ .withColumn("rank", row_number().over(window_spec)) \ .filter(col("rank") <= 10)这样每个区域并行排序,不会把所有数据压到一个节点。窗口函数的partitionBy决定了并行度,orderBy决定了排名规则。如果要做全局 TopN,可以把partitionBy去掉,但那样又回到单点排序的问题,所以全局 TopN 更适合用sortWithinPartitions加limit的两阶段做法。
5.2 加一层结果校验,证明你的数字不是拍脑袋出来的
答辩时老师经常会问「你这个订单量总数对不对」。如果你能现场跑一个校验脚本,对比 DWD 层订单数和 ODS 层过滤后的订单数是否一致,会显得很专业。校验逻辑很简单:ODS 层总行数减去取消订单数减去异常金额订单数,应该等于 DWD 层行数。如果不等,说明清洗条件写错了或者 join 丢了数据。
ods_cnt = raw_order.count() cancel_cnt = raw_order.filter(col("order_status") == "cancelled").count() abnormal_cnt = raw_order.filter(col("order_amount") <= 0).count() dwd_cnt = dwd_order.count() expected = ods_cnt - cancel_cnt - abnormal_cnt assert dwd_cnt == expected, f"数据校验失败: DWD={dwd_cnt}, 预期={expected}"这个断言跑通之后,你可以在论文里写「系统包含数据质量校验环节,确保分层之间数据一致性」,比空泛地说「数据清洗」有说服力得多。
5.3 把 Spark UI 的截图放进论文,比文字描述更有力
Spark 任务跑起来之后,4040 端口有 Web UI,能看到 stage、task、shuffle 读写、执行计划。答辩前把 DAG 图、stage 详情、SQL 执行计划各截一张图放进论文的「系统实现」章节,配上一句「通过 Spark UI 观察任务执行情况,确认无数据倾斜」,比写一大段文字描述架构有效得多。如果本地模式跑不出来 UI,可以在spark-submit时加--conf spark.ui.port=4040显式指定端口。
我自己做这类项目最大的教训是:不要一上来就追求「全量数据 + 完整链路」,先用 10 万行采样数据把 ODS 到 DWS 到 MySQL 到前端的链路跑通,再逐步加数据量。很多毕设卡住不是因为技术难,而是因为一开始就把目标定得太大,结果每一步都在调环境、调内存、调依赖,最后时间全花在排错上,核心逻辑反而没写多少。先把最小闭环跑通,再往上加指标、加维度、加可视化,这才是能按时交付的做法。希望帮到你。
本文还有配套的精品资源,点击获取