news 2026/10/28 1:02:18

基于Spark的外卖数据分析系统:从ETL到可视化大屏全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Spark的外卖数据分析系统:从ETL到可视化大屏全解析

简介:面向大数据毕设与课程设计场景,这组基于Spark的外卖平台分析项目源码,为计算机专业学生演示了如何将Spark内存计算、弹性分布式数据集、结构化查询、实时流处理及机器学习库融入真实业务分析流程。从外卖订单数据的生成采集、清洗预处理、指标聚合,到用户画像与销量预测建模,完整覆盖大数据项目的典型开发链路,可直接用于论文支撑或答辩演示。

压缩包共40个文件,核心代码以14个Scala源文件为主,辅以SQL/HSQL查询脚本、JSON/CSV/TSV业务数据、XML配置及Markdown文档,另有JPG结构示意图帮助理解,整体仅645KB,内容紧凑。目前已有168人学习下载。

通过源码目录可快速定位Maven构建配置、主程序入口与数据资源,学习工程级项目组织方式,还可沿README梳理多模块设计思路,在有限代码量中掌握Spark核心API的实战用法。

1. 计算机毕设选Spark做外卖数据分析:这套系统到底解决什么问题

很多计算机专业的毕设题目都会挂一个“大数据”前缀,比如“基于Spark的外卖大数据平台分析系统”。听起来很高大上,实际上它的核心不是推荐算法,也不是实时流处理,而是把外卖订单这类多维度数据清洗、聚合、统计,最后用直观的数字回答几个业务问题:一天卖了多少单、GMV是多少、哪个品类最赚钱、哪些商家是头部、用户复购情况怎么样。这套系统真正要解决的是“数据从哪来、怎么处理、怎么展示”的完整链路,而不是某个高深的模型。适合两种人:一是准备走大数据方向、需要一份能讲清楚架构的毕设;二是想在企业里快速搭一套离线数据看板demo的工程师。我见过太多人把精力花在调ECharts样式上,结果Spark作业一跑就OOM,被答辩老师一问“为什么用Spark不用MySQL”就卡壳。所以这篇笔记会把原理、代码和常见坑一起讲透,让你照着做就能出结果。

2. 外卖数据平台的架构设计:从模拟数据到分析结果要拆成哪几层

2.1 为什么是Spark而不是纯Hive或Pandas

做外卖数据分析,可选方案不少。如果数据量只有几万条,用Pandas处理最快,代码短,画图也方便。但如果订单数据到了几十万甚至上亿条,Pandas就撑不住了,内存直接爆炸。Hive能做离线分析,但底层跑MapReduce,同样的groupBy在Spark上可能快好几倍,尤其是迭代计算和SQL分析这种场景。Spark的优势在于内存计算、统一的DataFrame/SQL接口,以及完善的生态,适合作为毕设的技术选型。我一般会从三个角度问自己:数据量级会不会超过单机内存,答案是有可能;是否需要跑批任务,答案是是;要不要给别人讲清楚分布式计算原理,答案是这本来就是毕设考核点。所以选Spark是一个稳妥的决定。

同时要明确一点:Spark不是数据库,它不存数据,只负责计算。数据可以放在本地文件系统、HDFS或对象存储上。外卖分析系统通常把原始日志放在HDFS,但毕设环境里直接读本地JSON也能跑通,效果上没有本质区别。

工具选型对比可以从下表看:

方案处理速度数据量上限学习成本适用场景
Pandas单机内存级小于机器内存低数据探索、小样本分析
Hive批处理,MapReduce慢海量中离线数仓、复杂SQL
Spark内存计算快海量中高离线批处理、机器学习、流处理
Flink流处理低延迟海量高实时计算、事件驱动

从这个对比能看出,Flink虽然实时能力强,但外卖离线分析平台的重点是“跑批”而不是“实时”。如果你把题目改成“实时外卖分析系统”,那Flink才是更合适的选择。Spark SQL的语法与Hive高度兼容,所以从Hive迁移过来几乎没有额外成本。这也是为什么很多数仓工程师把Spark当作Hive的加速引擎来用。

2.2 目录结构与数据模型设计

一个清晰的项目目录能帮你少踩很多坑。我习惯把代码和测试数据分开,结构大概是下面这样:

spark-takeout-analysis/ ├── data/ │ ├── raw/ # 原始模拟数据 │ └── dw/ # 清洗后的parquet数据 ├── scripts/ │ ├── gen_data.py # 数据生成脚本 │ └── etl.py # Spark ETL脚本 ├── sql/ │ └── analysis.sql # 分析SQL(演示用) └── output/ └── result/ # 分析结果JSON/CSV

这个结构的好处是:原始数据、中间结果、最终输出互不干扰,调试的时候可以快速定位问题。实际做毕设时,你可能还需要一个README.md记录如何启动,以及保存Spark UI的截图,答辩时要展示。

数据模型方面,外卖平台至少需要三张核心表:订单事实表、用户维表、商家维表。订单表包含订单ID、用户ID、商家ID、商品分类、订单金额、配送距离、下单时间、支付时间等字段。用户表包含用户ID、注册时间、城市、性别。商家表包含商家ID、名称、品类、评分、月售。做毕设时不需要真的建三张表,但必须在模拟数据里体现这些字段,否则后面分析时没有维度可拆。

订单表字段设计可以参考下面这张表:

字段名类型说明
order_idString订单唯一ID
user_idString用户ID
merchant_idString商家ID
categoryString商品品类
total_amountDouble订单总金额
shipping_feeDouble配送费
discountDouble优惠金额
statusString订单状态,如completed/cancelled
order_timeString下单时间,格式为字符串
pay_timeString支付时间,格式为字符串

为什么时间用String而不是Timestamp?因为原始日志往往是字符串,Spark读取后统一转换更方便,而且如果原始数据有脏格式,String类型在清洗阶段更容易发现。注意,订单表中的金额字段建议使用Double,但生产环境为了保证精度,会用Decimal(10,2)。这里为了示例简洁,先用Double。

用户表和商家表可以简化:用户表只需user_id、city、register_time;商家表只需merchant_id、category、rating。这三个表通过user_id和merchant_id与订单表关联。

2.3 环境准备:本地Spark单机版与集群模式的选择

毕设阶段最怕的就是折腾集群。Spark支持local模式,一个进程就能跑完所有计算,这对开发调试非常友好。我建议先用local模式把业务逻辑跑通,再根据机器条件决定要不要搭Spark Standalone集群。如果你有两台以上Linux虚拟机,可以按官方文档搭一个简单的Standalone集群,Master加两个Worker,至少能证明你理解分布式部署。如果只有一台笔记本,local模式配合足够的内存也完全能支撑几十万条数据的分析,答辩时可以说“生产环境可扩展为集群,单机模式用于功能验证”。

环境检查一般做三步:确认Java版本、确认Spark版本、确认PySpark可用。在shell里执行:

java -version spark-shell --version python3 -c "import pyspark; print(pyspark.__version__)"

如果你看到Java是17或更高,建议换成Java 8或11,因为Spark 3.x对Java 17的支持版本有限,运行时容易碰到Unsupported class file major version。这个错误在Spark UI的日志里非常常见,实际就是JDK版本过高,和代码逻辑无关。

启动Spark应用时,最关键的是内存参数。在提交脚本时常见做法是:

spark-submit \ --master local[4] \ --driver-memory 2g \ --executor-memory 2g \ scripts/etl.py

这里的local[4]表示本地使用4个线程,driver-memory给驱动进程分配2GB内存,executor-memory是执行器内存。实际写代码时,还可以在SparkSession里设置spark.sql.shuffle.partitions,默认是200,对小数据集反而浪费资源,我一般会调成10到20。还有一个参数spark.executor.cores决定每个执行器占用的CPU核数,在local模式下不需要管,集群里才有意义。

要记住:local[4]的线程数并不等于CPU核数,它只是模拟并行度。如果机器只有双核四线程,设local[4]没问题,但别设成local[32],线程切换开销会让性能更差。这是一个经常被忽略的“玄学”点,实际测试下来,并行度超过物理核数后提升有限,反而增加调度压力。

3. 数据生成与ETL:用Spark读取外卖订单JSON的完整流程

3.1 模拟数据的字段设计

外卖订单数据至少包含:订单ID、用户ID、商家ID、品类、金额、配送费、优惠金额、下单时间、支付时间、订单状态。为了后面做分析,还需要用户城市、商家评分等维度。但如果所有数据都放在一个JSON里,既不好看也不现实。我一般会生成三类文件:orders.json、users.json、merchants.json。这样模拟了数仓的分层思想,后面做join也更自然。

订单数据一条记录示例:

{"order_id": 100001, "user_id": 2001, "merchant_id": 3001, "category": "快餐", "total_amount": 25.5, "shipping_fee": 3.0, "discount": 2.0, "status": "completed", "order_time": "2024-01-15 12:31:45", "pay_time": "2024-01-15 12:32:10"}

字段类型需要注意:total_amount这种金额用double或decimal,时间用字符串存储,后续再转成timestamp。

用户表和商家表可以生成得更简单些。用户表:

{"user_id": 2001, "city": "北京", "register_time": "2023-06-01"}

商家表:

{"merchant_id": 3001, "category": "快餐", "rating": 4.5}

这样设计的好处是,分析“不同城市用户的消费习惯”或“高评分商家的订单量”时,有维度可以用。

3.2 用Python脚本生成十万级订单数据

用Python写一个生成脚本,使用随机数模拟订单。这里要注意一点:模拟数据也要符合真实规律,比如午高峰和晚高峰的订单量比其他时段多,快餐品类占比高,这样分析结果才有说服力。下面这个脚本生成10万条订单,并且把下单时间集中在11点到13点、17点到20点。

import json import random random.seed(42) category_list = ["快餐", "奶茶", "面条", "炸鸡", "沙拉", "火锅", "烧烤", "蛋糕"] status_list = ["completed", "cancelled", "refunding"] city_list = ["北京", "上海", "广州", "深圳", "杭州"] def random_time(): # 模拟两个高峰时段 hour = random.choice([11, 12, 13, 17, 18, 19, 20]) minute = random.randint(0, 59) second = random.randint(0, 59) date = "2024-01-15" return f"{date} {hour:02d}:{minute:02d}:{second:02d}" def gen_orders(n): with open("data/raw/orders.json", "w", encoding="utf-8") as f: for i in range(n): order = { "order_id": 100000 + i, "user_id": random.randint(2000, 3000), "merchant_id": random.randint(3000, 3200), "category": random.choice(category_list), "total_amount": round(random.uniform(10, 80), 2), "shipping_fee": round(random.uniform(1, 5), 2), "discount": round(random.uniform(0, 10), 2), "status": random.choices(status_list, weights=[0.9, 0.07, 0.03])[0], "order_time": random_time(), "pay_time": random_time(), } f.write(json.dumps(order, ensure_ascii=False) + "\n") def gen_users(m): with open("data/raw/users.json", "w", encoding="utf-8") as f: for i in range(m): user = { "user_id": 2000 + i, "city": random.choice(city_list), "register_time": f"2023-{random.randint(1,12):02d}-01", } f.write(json.dumps(user, ensure_ascii=False) + "\n") def gen_merchants(k): with open("data/raw/merchants.json", "w", encoding="utf-8") as f: for i in range(k): merchant = { "merchant_id": 3000 + i, "category": random.choice(category_list), "rating": round(random.uniform(3.5, 5.0), 1), } f.write(json.dumps(merchant, ensure_ascii=False) + "\n") if __name__ == "__main__": gen_orders(100000) gen_users(1000) gen_merchants(200)

生成原理是每行写一个JSON对象,这种格式叫JSON Lines。Spark读取时逐行解析,不需要等整个文件加载进内存,也比一个大JSON数组更稳。参数说明:random.seed(42)保证结果可复现,对你调试代码是福音;weights让订单状态分布符合“90%已完成”的常识,这样后续统计“有效订单”时才有筛选依据。注意,脚本里pay_time是随机生成的,可能早于order_time,这算是故意留下的脏数据,后面清洗正好用上。

3.3 Spark读取JSON并完成清洗

现在用Spark读取这份JSON文件,并做几件清洗工作:把字符串时间转为Timestamp、过滤掉支付时间早于下单时间的异常数据、去掉重复订单ID。这里使用Spark SQL的DataFrame API,比直接写RDD直观太多。

from pyspark.sql import SparkSession from pyspark.sql.functions import to_timestamp, col spark = SparkSession.builder \ .appName("TakeoutETL") \ .master("local[4]") \ .config("spark.sql.shuffle.partitions", "10") \ .getOrCreate() orders = spark.read.json("data/raw/orders.json") # 类型转换:将字符串时间更新为Timestamp orders = orders.withColumn("order_ts", to_timestamp(col("order_time"), "yyyy-MM-dd HH:mm:ss")) \ .withColumn("pay_ts", to_timestamp(col("pay_time"), "yyyy-MM-dd HH:mm:ss")) # 过滤异常:支付时间在订单时间之前的记录去掉 valid_orders = orders.filter(col("pay_ts") >= col("order_ts")) # 去重:按order_id去重,保留第一条 valid_orders = valid_orders.dropDuplicates(["order_id"]) # 过滤掉status为cancelled的订单,保留有效订单 valid_orders = valid_orders.filter(col("status") == "completed")

这段代码的关键是to_timestamp,它需要第二个参数指定格式,否则可能返回null。dropDuplicates(["order_id"])是全表去重,如果数据量很大,这个操作会触发shuffle,需要关注后续性能。很多人会在这一步犯错:先filter(status == "completed")再去重,如果同一个订单ID有两条记录,一条completed一条cancelled,先过滤再排序才能留住有效状态。这里为了示例简单,就直接对清洗后结果过滤了,真实项目中建议先按订单ID排序,再取每个订单的最新状态。

清洗完以后,检查一下到底过滤掉多少数据,以及时间字段有没有解析成功的。常见做法是:

print("原始条数:", orders.count()) print("清洗后条数:", valid_orders.count()) print("时间字段为空的数量:", valid_orders.filter(col("order_ts").isNull()).count())

如果order_ts还有空值,说明时间格式有部分记录不匹配,需要进一步查看原始值。可以用orders.select("order_time", "pay_time").show(5, truncate=False)来肉眼对格式。

参数说明:spark.sql.shuffle.partitions设成10,因为我们只有10万条数据,默认200会生成太多小文件,影响后续读取。如果你的数据量到了千万级,这个参数可以相应调大,经验值是总数据量 / 每个分区目标大小(128MB)。在ETL阶段,分区数太大还会造成每个executor压力不均,所以调小反而更稳。

3.4 写入Parquet列式存储提高后续查询效率

清洗后数据通常要落盘,方便下次分析。CSV格式虽然通用,但没有任何压缩和列剪裁能力。Parquet是列式存储格式,Spark读写Parquet的效率和压缩比都远好于CSV。保存时还能按日期分区,比如按order_date分区,让后续查询只扫描相关分区。

from pyspark.sql.functions import date_format # 增加一个订单日期列,用于分区 valid_orders = valid_orders.withColumn("order_date", date_format(col("order_ts"), "yyyyMMdd")) # 写入parquet,按order_date分区 valid_orders.write \ .mode("overwrite") \ .partitionBy("order_date") \ .parquet("data/dw/orders_clean")

写入后的目录结构会变成data/dw/orders_clean/order_date=20240115/*.parquet。这样设计的好处是:后续分析如果按天过滤,Spark会直接跳过无关分区,查询速度更快。写完后可以重新读回来验证:

df = spark.read.parquet("data/dw/orders_clean") df.groupBy("order_date").count().show()

这里有一个常见误区:partitionBy的列会从数据中复制一份到分区路径中,如果后面你还把它当成普通列用,没问题,但如果列基数很大(比如用户ID),会生成大量小目录,反而不如不分区。对外卖数据来说,按日期分区是合适的,因为数据是时间序列。

如果担心生成太多小Parquet文件,可以考虑写入前先repartition(4)或者用coalesce(4)控制最终文件数量。很多人在运行成功后会发现data/dw/orders_clean下面有上百个part文件,每个只有几KB,这种情况会在后续分析时拖慢读取速度。常见做法是:

valid_orders.repartition(4).write \ .mode("overwrite") \ .partitionBy("order_date") \ .parquet("data/dw/orders_clean")

这样每个分区目录下最多4个文件。还有一个参数spark.sql.files.maxPartitionBytes,默认128MB,决定读取时一个分区最多读多大。小文件场景下,这个值调大一点可以减少分区数。

4. 核心指标分析:Spark SQL离线统计的六个KPI

4.1 订单量、GMV与客单价:groupBy的基本写法

从业务角度看,外卖平台最关心的就是卖出多少单、收多少钱、平均每单多少钱。用Spark SQL写非常简单。先把清洗后的数据注册成临时视图,然后用SQL查询。我一般偏好SQL,因为可读性好,答辩时可以一行行解释业务含义。

# 继续使用前面的spark会话 df = spark.read.parquet("data/dw/orders_clean") df.createOrReplaceTempView("orders") result = spark.sql(""" SELECT order_date, COUNT(DISTINCT order_id) AS order_cnt, SUM(total_amount - discount) AS gmv, ROUND(SUM(total_amount - discount) / COUNT(DISTINCT order_id), 2) AS avg_order_amount FROM orders WHERE status = 'completed' GROUP BY order_date ORDER BY order_date """) result.show()

这里的total_amount - discount相当于用户实际实付金额,能体现真实GMV。客单价用实付金额除以订单数。COUNT(DISTINCT order_id)是为了防止前面清洗时漏掉的重复数据造成二次污染。注意DISTINCT在大数据量下成本高,如果确认数据已经去重,可以直接用COUNT(order_id)。

指标算完以后,通常要导出到文件。常见做法是:

result.write.mode("overwrite").csv("output/result/daily_summary")

如果你不想要目录而只要一个文件,就先coalesce(1)再写。但要注意这会把结果集中到单个分区,大数据量下不推荐。对于日报表这种小结果,完全没有问题。

4.2 热门品类与商家TOP N:row_number窗口函数

很多人在分析时只想到groupBy,但“每个分类下销量最高的商家”这类问题需要窗口函数。Spark SQL对窗口函数的支持很成熟,这也是区别于Hive的重要卖点。

top_merchants = spark.sql(""" SELECT category, merchant_id, order_cnt, gmv, ROW_NUMBER() OVER (PARTITION BY category ORDER BY gmv DESC) AS rank FROM ( SELECT category, merchant_id, COUNT(DISTINCT order_id) AS order_cnt, ROUND(SUM(total_amount - discount), 2) AS gmv FROM orders GROUP BY category, merchant_id ) t """).filter("rank <= 5") top_merchants.show(20)

里层查询先算每个商家在各品类下的订单量和GMV,外层用ROW_NUMBER()按品类分区、按GMV降序排名,最后过滤出每类前五。为什么用ROW_NUMBER()而不是RANK()?因为业务上只要Top N,不需要保留并列名次。如果要求并列,可以用DENSE_RANK()。

窗口函数的性能取决于分区数据量,如果category字段的某个品类数据特别多(比如快餐),会导致该分区处理慢,这就是数据倾斜的雏形。后面避坑章会细讲。

另外可以顺便算各品类GMV占比,用于饼图展示:

category_gmv = spark.sql(""" SELECT category, ROUND(SUM(total_amount - discount), 2) AS gmv, ROUND(SUM(total_amount - discount) / SUM(SUM(total_amount - discount)) OVER(), 4) AS gmv_ratio FROM orders GROUP BY category ORDER BY gmv DESC """)

这里的SUM(...) OVER()是无分区窗口,相当于全局求和,只要结果集不大,这样写是安全的。如果数据量大,可以改为先聚合再算占比,避免全量数据进入单个任务。

4.3 分时段订单趋势:时间函数与重分区

外卖订单有明显的时段分布,分析这个能让平台做运力调度。从order_ts中提取小时,再按小时聚合。

hourly = spark.sql(""" SELECT HOUR(order_ts) AS hour, COUNT(DISTINCT order_id) AS order_cnt FROM orders GROUP BY HOUR(order_ts) ORDER BY hour """) hourly.show(24)

这个查询在Spark 3.x中可以直接用HOUR函数,旧版本可能要用hour(order_ts)或date_format(order_ts, 'H')。另外要注意时区问题。Spark默认使用系统时区,如果你的数据里存的是UTC时间,而你在国内分析,直接用HOUR会差8个小时。为了避免这种玄学问题,我一般在读取JSON时就把时间转成本地时区,或者在生成模拟数据时就用本地时间。

输出结果可以用collect()转成Python列表,方便写进可视化接口:

hourly_list = [{"hour": row["hour"], "order_cnt": row["order_cnt"]} for row in hourly.collect()]

如果你还要看周末和工作日的差异,可以从日期中提取星期几。date_format可以生成周几的数字:

weekday = spark.sql(""" SELECT date_format(order_ts, 'u') AS week_day, COUNT(DISTINCT order_id) AS order_cnt FROM orders GROUP BY week_day ORDER BY week_day """)

这里的格式'u'是ISO标准的星期几,1表示周一、7表示周日。

4.4 用户复购行为:自连接与聚合去重

复购率是外卖平台的重要指标,常见定义是“在统计周期内下单次数≥2的用户占比”。要算这个,需要按用户聚合订单数,再统计不同订单数档位的用户数。

repurchase = spark.sql(""" SELECT CASE WHEN order_cnt = 1 THEN '1次' WHEN order_cnt = 2 THEN '2次' ELSE '3次以上' END AS order_cnt_group, COUNT(DISTINCT user_id) AS user_cnt FROM ( SELECT user_id, COUNT(DISTINCT order_id) AS order_cnt FROM orders GROUP BY user_id ) t GROUP BY CASE WHEN order_cnt = 1 THEN '1次' WHEN order_cnt = 2 THEN '2次' ELSE '3次以上' END """) repurchase.show()

这里要注意:子查询里COUNT(DISTINCT order_id)去重,外层COUNT(DISTINCT user_id)也要去重,否则自连接后用户会重复计数。很多人在这里翻车,得到的复购率超过100%。

如果数据量很大,COUNT(DISTINCT user_id)会产生巨大的精确去重负担。此时可以用approx_count_distinct函数替代,返回近似结果,误差在1%以内:

from pyspark.sql.functions import approx_count_distinct repurchase_approx = df.groupBy( when(col("order_cnt") == 1, "1次") .when(col("order_cnt") == 2, "2次") .otherwise("3次以上").alias("order_cnt_group") ).agg(approx_count_distinct("user_id").alias("user_cnt"))

这个函数基于HyperLogLog算法,适合上亿用户量的场景。对于10万条数据,直接用精确去重没有问题,但如果题目改成“海量用户日志分析”,能说出这个优化点会是加分项。

5. 常见的坑与排查:Spark外卖分析项目翻车记

5.1 现象:本地能跑,换个数据量就OOM

很多同学在local模式下用小数据测试没问题,把数据量加到几十万或者几百万就报java.lang.OutOfMemoryError。原因通常是spark.sql.shuffle.partitions没调,或者driver内存不够。另一个常见原因是代码里用了大量collect()把数据拉回本地,比如在10万条数据上collect()没问题,到了100万条直接打爆driver。遇到这个坑时,日志里通常会出现:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

解决方法是:尽量避免collect();如果只是看几条结果,用show()或take(10);统计结果要落到文件时,用write而不是collect后写。spark-submit时加--driver-memory 4g也是一个兜底方案,但根本上要改代码习惯。

还有一个很容易被忽视的点:cache()之后没有unpersist()。在Spark UI里,你可能会发现反复执行同一个动作时内存占用一直涨。数据使用完以后,一定记得df.unpersist()释放缓存。这是“内存管理”的基本功,面试时也会问。

另外,local模式下local[4]的4个线程共享driver内存,如果设置了--executor-memory实际不一定生效,别被这个参数骗了。在单机模式下,driver-memory才是最重要的。

5.2 现象:JSON里的时间字符串被解析成null

to_timestamp返回null,时间列全是null,group by后结果为空。原因最常见的是格式不匹配。比如原始数据是2024/01/15 12:31:45,而你写的格式是yyyy-MM-dd HH:mm:ss,自然解析失败。还有Spark 3.x对时间解析更严格,2024-1-15这种非补零格式可能解析不了。遇到这种情况,先从原始文件里抽一条:

orders.select("order_time", "pay_time").show(5, truncate=False)

如果发现格式确实不统一,可以用regexp_replace把分隔符统一替换成-和空格,再做转换。例如:

from pyspark.sql.functions import regexp_replace orders = orders.withColumn( "order_time_clean", regexp_replace(regexp_replace(col("order_time"), "/", "-"), "T", " ") )

如果原始时间格式很多,一个to_timestamp解决不了,可以写一个UDF,用Python的datetime.strptime轮询尝试,但性能会差一些。我建议在数据生成阶段就把格式统一,毕设里不值得花太多时间做多格式兼容。

5.3 现象:数据倾斜导致某个Stage卡死

现象是Spark UI里某个Task处理的数据量是其他Task的几十倍,运行时间异常长。在外卖数据里最常见的就是品类字段,比如“快餐”占了80%的订单,group by category时发生严重倾斜。日志上不一定有ERROR,但整个Job一直在跑某个Stage,进度条不动。

解决思路有几个:加盐(salting)打散热点key,对热点品类单独计算再union,或者把shuffle.partitions调大。对于毕设项目,最简单的方式是先做一次groupBy(category)算出每个品类数量,找出占比最大的品类,然后把它单独拎出来处理。代码示意:

# 看一下品类分布 df.groupBy("category").count().orderBy("count", ascending=False).show()

如果快餐占比超过一半,可以这样拆分:先算非快餐的部分,再算快餐的Top N,最后用union合并。这样能避免所有热点数据压在一个桶里。

更通用的加盐方案是:在category后面拼接一个随机数,把热点key拆成多个子key。但这样最后还需要去掉盐再做一次聚合,逻辑比较复杂。毕设阶段,你只需要在文档里写清楚“数据倾斜的解决方案”,并在代码里用到简单的倾斜缓解,就足够应对答辩。

5.4 现象:控制台一堆WARN,哪个才是真正要处理的错误

Spark运行时会打印大量WARN,比如WARN WindowExec: No Partition Defined for Window operation、WARN TaskSetManager: Lost task等等。很多新手一看到WARN就慌,其实大部分可以忽略。关键是要区分WARN和ERROR。

比如Lost task后面的错误如果伴随java.net.ConnectException,可能需要检查网络配置。No Partition Defined for Window operation只是提示窗口函数没有指定分区,会全局排序,如果数据量不大也没问题。

我的习惯是:先看最后有没有ERROR和Exception,没有就认为跑成功;然后看Spark UI中失败的任务和Executor日志。日志级别可以通过log4j.properties调成WARN,减少刷屏:

log4j.rootCategory=WARN, console log4j.logger.org.apache.spark=WARN log4j.logger.org.apache.hadoop=WARN

但有一个WARN要重视:Failed to create local dir,表示磁盘空间不足,会直接影响数据写入。这时如果有中间结果写到/tmp,很可能空间不够,需要设置spark.local.dir到剩余空间大的目录。

关于Spark UI的使用,我想多说一句:在local模式下,访问http://localhost:4040可以看到每个Stage的输入数据量、Shuffle读写、任务耗时。数据倾斜和内存问题在这里一目了然。不要只依赖控制台日志,学会看Spark UI是进阶的分水岭。

6. 把分析结果做成数据大屏:Spark结果直出JSON的快速技巧

6.1 用coalesce导出单文件JSON

当多个指标要展示时,最好把Spark计算结果统一导出为JSON文件,前端只需要请求一次。默认write.json会生成多个part文件,前端不好读。我一般会coalesce(1)把结果合并成一个文件再写。

result_df.coalesce(1).write.mode("overwrite").json("output/result/summary.json")

注意coalesce(1)会把所有数据拉到单个分区,如果结果很大反而变慢。外卖分析的结果通常只是几十行KPI,所以完全没问题。如果是给前端的大屏,可以继续用json.dumps把它转成嵌套结构。

6.2 前端展示的接口取舍:静态JSON还是实时接口

毕设和demo阶段,不建议引入Spring Boot实时接口。最简单的做法是让Spark任务定时跑,输出JSON后由前端静态引用,或者用python3 -m http.server 8000把它变成静态服务。这样既避免了前后端联调的成本,又能把Spark计算和展示解耦。如果你追求实时性,可以用Spark Streaming或Structured Streaming,但外卖平台离线分析系统的毕业设计重点在于离线链路,实时不是必须项。所以我建议把离线跑批做扎实,再在答辩时口头说明“可扩展为实时”。

可视化本身可以选ECharts,数据格式为:

{ "hourly_order": [{"hour": 11, "order_cnt": 1234}, ...], "category_gmv": [{"category": "快餐", "gmv": 12345}, ...] }

前端用fetch读取这个JSON,直接填充到折线图和饼图。注意把Spark导出的JSON里小数字段改为整数或字符串,避免科学计数法导致前端显示异常。

最后想补一个小技巧:把result.show()的结果复制到Excel,做一个简单的趋势图,也比“跑通了但没展示”要强。我还习惯把含分数的Double字段四舍五入到两位小数,不然前端显示一长串数字非常难看。在Spark里用round(expr, 2)即可。

做毕设时,不要为了炫技加太多组件,一条离线分析链路跑通,你能把每个参数讲明白,就已经胜过那些贴GitHub项目却不理解原理的同学。我自己当年就因为在答辩时把shuffle.partitions说成“随便设的”,被老师追问了好久。希望你做完这套系统后,能把每个数字背后的业务含义和每个Spark参数的影响都搞清楚。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 1:02:03

STM32C5与CubeMX2实战解析:嵌入式选型与工具链升级指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/28 0:51:31

共享单车检测数据集:VOC转YOLO与训练避坑指南

简介&#xff1a;这份资源是面向计算机视觉初学者与目标检测实践者的共享单车检测数据集&#xff0c;可直接用于YOLO系列算法的训练、验证与调参实验&#xff0c;帮助解决城市共享单车识别与计数等实际场景问题。压缩包共272个文件&#xff0c;包含136张jpg实拍图片与136个xml标…

作者头像 李华
网站建设 2026/10/28 0:28:51

30天Caveman重启实验:原始饮食与自重训练改善代谢健康

提到“caveman”&#xff0c;你脑海里大概会蹦出两个画面&#xff1a;一个是举着石斧、浑身毛茸茸的原始人&#xff0c;在荒野里追着猛犸象跑&#xff1b;另一个是某个聊天群里甩出来的表情包&#xff0c;头发乱糟糟的男人被女朋友吼“你能不能讲点道理”。说实话&#xff0c;我…

作者头像 李华
网站建设 2026/10/28 0:28:48

零下60℃超低温冷库施工关键技术与实操要点

1. 项目概述&#xff1a;为什么零下60℃不是“再低一点”那么简单零下60℃超低温冷库——这个词一出来&#xff0c;很多人第一反应是&#xff1a;“不就是比普通冷冻库更冷一点吗&#xff1f;加厚保温板、换台大冷机不就完了&#xff1f;”我刚入行那会儿也这么想。直到在某高校…

作者头像 李华
网站建设 2026/10/28 0:02:57

微信个人号API开发案例:图片/语音/视频消息怎么下载解析存到本地?

收到的多媒体消息回调里一般只有一个临时下载地址&#xff0c;不下载就过期。下载存本地这套流程要解决四个问题&#xff1a;下载时机、格式转换、存储组织、可检索索引。这篇把从回调到落库的完整管道讲清楚。 一、下载时机&#xff1a;回调到达即触发&#xff0c;别懒加载 …

作者头像 李华
网站建设 2026/10/27 23:59:38

从 Notebook 到 Python 项目:如何重构量化行情获取代码?

一句话结论&#xff1a; 从 Notebook 迁移到 Python 项目&#xff0c;关键不是把单元格里的代码复制到 .py 文件&#xff0c;而是将行情获取、数据校验、配置管理和策略逻辑分离&#xff0c;让同一套数据处理流程能够被研究脚本、回测程序和定时任务重复调用。 摘要 Notebook …

作者头像 李华