
简介基于Spark的餐饮平台菜品智能分析推荐系统是一份可直接运行的毕业设计项目资源面向计算机、通信、人工智能、自动化等专业的学生、老师或从业者适用于课程设计、大作业及毕业设计等场景。项目整体为个人高分毕设答辩评审达到98分代码经调试测试基础能力较强的使用者可直接修改调整以实现不同功能。资源包为zip格式共49个文件大小2.05MB其中包含17个Java文件承担核心业务逻辑8个XML与3个JSP用于配置和页面展现6个CSS与5个JS负责前端样式与交互另附SQL数据库脚本及菜品评分相关的CSV、JSON数据方便还原完整运行环境。目前已有254人学习浏览适合希望快速上手Spark推荐系统或借鉴完整项目结构的开发者深入学习与二次开发。1. 基于Spark的餐饮菜品智能推荐系统拆解一份98分毕设的完整链路做餐饮平台的菜品推荐最怕的不是算法不够高级而是数据链路断在中间。这份基于Spark的餐饮平台菜品智能分析推荐系统把用户点餐评分数据从JSON、CSV统一收进Spark SQL再做清洗、统计和协同过滤推荐最终输出每个用户最可能下单的Top-N菜品。它本质上是一条完整的Spark数据处理链路文件解析、ETL清洗、ALS模型训练、推荐结果生成全在一个Java工程里跑通压缩包里带了user_meal_rating.json、spark.sql和可直接导入的Maven项目。我拆完这个项目后的判断是它适合正在做课设或毕设、需要一份能运行能答辩能演示的Spark项目的学生也适合刚接触Spark SQL和推荐算法的开发者用来对照学习一份真实项目的文件组织方式和代码写法。项目核心价值在链路完整、能复现而不是算法炫技。2. 项目结构与数据流转三个核心文件如何串起Spark分析任务2.1 文件清单解析JSON、CSV、spark.sql各负责什么解压之后目录里最核心的是code-main和三个数据相关文件。很多第一次拿到项目的人会盯着src目录看半天其实先看懂数据文件比看代码重要得多因为推荐结果的正确性一大半由数据格式决定。文件格式作用user_meal_rating.jsonJSON数组原始用户点餐评分数据Spark读入的主数据源user_meal_rating.csvCSV文本与JSON同数据源的副本便于用Excel核对和手工排查spark.sqlSQL脚本建表语句与常用统计查询用于理解字段关系和验证聚合逻辑README.mdMarkdown项目说明、运行步骤和环境要求src/mainJava源码Maven工程主体包含Spark任务入口与推荐算法实现JSON和CSV并存是这个项目很实用的设计。CSV可以直接用Excel打开拿它和JSON数据逐条对照能快速发现哪条记录的字段缺失、哪条评分为空比对着日志猜省事得多。spark.sql则是给评审老师看的数据建模证明里面有建表语句和几条典型统计查询我在复现时直接拿这些SQL去验证清洗后的结果比自己现写查询快。2.2 数据模型设计用户、菜品、评分三要素的字段粒度推荐系统建模的核心是用户-物品-评分三元组。这个项目的评分数据围绕三个主体展开用户、菜品、评价值。参照常见做法user_meal_rating.json中每条记录的基本字段设计如下{ user_id: u_1024, dish_id: d_308, dish_name: 宫保鸡丁, category: 川菜, rating: 4.5, rating_time: 2024-06-18 12:35:00 }字段说明user_id是用户唯一标识dish_id是菜品唯一标识dish_name和category是冗余存储的菜品信息rating是用户对菜品的评分取值1.0到5.0rating_time是点餐时间。dish_name和category冗余存进评分表是为了下游统计时少做一次join这种以查询效率优先的反范式设计在个人项目中很常见也对答辩评分的“理解数据冗余取舍”考察点有直接帮助。注意字段粒度rating保留到一位小数避免Float浮点误差在Spark聚合时造成偏差。user_id和dish_id都用带前缀的字符串而不是自增数字便于在JSON和CSV两份文件中保持可读性。如果自己扩展数据尽量不要改动这两个字段的类型否则后面ALS模型读取时会因为数值列缺失直接报错。2.3 Spark SQL数据预处理读入、去重、过滤、统计的完整流程拿到JSON第一步是让Spark正确读入并完成去重。我在自己机器上复现时先按下面这段Java代码把数据加载进来SparkSession spark SparkSession.builder() .appName(DishRecommendation) .master(local[*]) .getOrCreate(); // 读取JSON文件Spark会根据字段自动推断schema DatasetRow raw spark.read().json(data/user_meal_rating.json); // 用户可能在同一天对同一菜品多次评分按user_id和dish_id去重 DatasetRow deduped raw.dropDuplicates(user_id, dish_id); // 过滤掉评分为空或小于等于0的脏数据 DatasetRow cleaned deduped.filter(rating IS NOT NULL AND rating 0); // 注册为临时视图便于后续SQL查询 cleaned.createOrReplaceTempView(user_meal_rating);这段代码里dropDuplicates后面跟的两个列名是去重键只要user_id和dish_id相同就只保留一行。这里有个细节如果同一用户对同一道菜在不同时间打了不同分去重会保留第一次出现的那条而不是最新一条所以如果发现某些评分看起来偏旧就要在去重前按rating_time做降序排序。我一般会先跑一条count查询看一眼原数据的行数再对比去重后的行数如果两个数字差距超过5%多半是数据里有重复点餐记录。filter条件里的“rating 0”容易被忽略但它很关键。ALS训练时评分列如果有0或负值虽然不会直接报错但会拉低整个隐因子模型的均值让推荐结果偏向低分菜品。接着用spark.sql里的查询语句做一轮统计验证-- 统计每个用户的平均评分和点餐次数 SELECT user_id, COUNT(*) AS order_cnt, ROUND(AVG(rating), 2) AS avg_rating FROM user_meal_rating GROUP BY user_id ORDER BY order_cnt DESC;这条SQL里的COUNT和AVG是验证数据质量最直观的指标。如果点餐次数分布集中在少数几个用户身上说明数据倾斜严重后面训练ALS时要考虑按用户分组采样。如果AVG(rating)整体超过4.6分说明评分普遍虚高模型对菜品的区分度会不足这样即使推荐结果全对也看不出算法好坏。3. 推荐算法落地ALS协同过滤与Top-N菜品推荐的核心参数3.1 算法选型为什么餐饮菜品场景优先选择ALS菜品推荐本质上是给用户从几百道菜里挑出没吃过但可能喜欢的几道。可选算法有三种方向基于内容推荐、基于物品的协同过滤、矩阵分解。基于内容推荐需要菜品标签体系做得很细一个毕设项目很难把口味、食材、烹饪方式全量标签化基于物品的协同过滤实现简单但评分矩阵稀疏时菜品对之间的共现次数少相似度算不准。ALS交替最小二乘是Spark MLlib原生支持的矩阵分解算法把用户-菜品评分矩阵分解成用户隐因子矩阵和菜品隐因子矩阵再通过两个矩阵相乘预测缺失评分。我选ALS还有一个现实理由它最贴合这份资源的数据形态。数据是显式评分JSON里rating字段明确数据量在万级以内本地Spark跑得起MLlib直接提供recommendForAllUsers接口不需要自己手写相似度计算。缺点是ALS把用户和菜品都映射到低维向量模型解释性差属于典型的“黑匣子”答辩时需要用准确率指标来证明效果而不是靠口头解释原理。3.2 核心实现Java调用ALS训练推荐模型的完整代码ALS模型训练的核心代码在项目Main类里。下面这段是根据源码结构整理的典型实现import org.apache.spark.ml.evaluation.RegressionEvaluator; import org.apache.spark.ml.recommendation.ALS; import org.apache.spark.ml.recommendation.ALSModel; // 按时间排序后划分训练集和测试集避免随机切分造成数据泄露 DatasetRow[] splits cleaned.randomSplit(new double[]{0.8, 0.2}, 42L); DatasetRow training splits[0]; DatasetRow test splits[1]; ALS als new ALS() .setMaxIter(10) // 最大迭代次数 .setRank(12) // 隐因子个数 .setRegParam(0.05) // 正则化系数 .setUserCol(user_id_idx) // 用户ID列需为数值型 .setItemCol(dish_id_idx) // 菜品ID列需为数值型 .setRatingCol(rating) // 评分列 .setColdStartStrategy(drop); // 测试集中未见过的用户/菜品直接丢弃 ALSModel model als.fit(training); // 为每个用户生成Top-5推荐 DatasetRow recommendations model.recommendForAllUsers(5); recommendations.show(false);注意ALS要求user_id和dish_id列是数值类型而源数据的user_id是“u_1024”这种字符串直接传入会报“Column user_id must be of type numeric but was actually of type string”。解决办法是在读入后给字符串ID添加数值映射列// 用StringIndexer把字符串ID转成数值索引 import org.apache.spark.ml.feature.StringIndexer; StringIndexer userIndexer new StringIndexer() .setInputCol(user_id).setOutputCol(user_id_idx); StringIndexer dishIndexer new StringIndexer() .setInputCol(dish_id).setOutputCol(dish_id_idx);如果项目源码里已经处理了这个转换就直接用源码的映射列名如果自己从零搭建一定要把这一步加在ALS之前。我在原项目源码里看到它是在数据加载阶段就完成了ID映射所以训练阶段不需要再改。coldStartStrategy参数同样值得注意不设drop时测试集中的新用户会在预测阶段产生NaN评分后续计算RMSE时结果全是NaN排查起来很费劲。3.3 参数调优rank、regParam、迭代次数的实际调试经验ALS的四个核心参数各有各的坑。rank决定隐因子个数决定模型表达能力的上限取值范围通常在10到20之间。rank太小模型学不到用户偏好rank太大不仅训练变慢还容易过拟合推荐出的菜品偏向热门爆款失去个性化。我在调试时会固定maxIter为10依次用8、12、16、20试rank比较验证集上的RMSE选RMSE最低且训练时间可接受的那个。regParam是正则化系数默认0.1。这个值偏大时模型会偏向保守推荐结果方差小但个性化不足。我的做法是按0.01、0.05、0.1三档做小范围网格搜索。maxIter不是越大越好ALS每轮迭代都要做两次矩阵运算数据量大时10轮和20轮的训练时间差异明显而且超过15轮后RMSE基本不再下降属于典型的边际递减本质上就是拿训练时间换那零点几分的收益不划算。还有一个容易被忽视的参数是randomSplit的种子。用固定种子而不是不传种子能保证每次运行划分出的训练集和测试集完全一致这对答辩演示至关重要。评审老师如果要求现场重新跑一遍你希望得到和之前完全相同的推荐结果。我自己调试时种子一直用42L跑出来的推荐列表和答辩PPT上的截图完全一致省去了“怎么这次结果不一样”的尴尬解释。4. 环境搭建与本地运行JDK版本匹配和源码跑通的完整步骤4.1 版本匹配JDK、Spark、Scala三者必须对得上这个项目用Java写Spark任务第一个坎就是版本匹配。Spark是运行在JVM上的框架不同版本的Spark对应不同的Scala版本而Java代码的编译级别又受Spark版本限制三者必须同时满足才能在本地跑起来。我复现时采用的版本组合是组件推荐版本原因JDK1.88u202以上Spark 2.x和早期3.x对JDK11兼容性不佳Spark3.1.2已适配Java 8API稳定Scala2.12与Spark 3.1.x配套Maven3.6管理Spark依赖和打包如果机器上已经装了JDK 17直接用Spark 3.1.2会遇到反射访问报错因为Spark内部用了不少Java反射APIJDK 9以后的模块系统对这些调用限制严格。常见做法是本地单独装一个JDK 8在IDE的项目配置里指定JDK 8作为项目SDK而不是改系统默认的JAVA_HOME。我一般用Maven的compiler插件直接把source和target设为1.8这样即便IDE误用了高版本JDK编译也会按Java 8语法处理报错概率更低。4.2 本地运行步骤导入源码、配置数据路径、启动Spark任务拿到源码后先不要急着点运行。用下面这套顺序能少踩一半的坑第一步把zip解压用IDE以Maven项目方式导入code-main目录等待依赖下载完毕。这一步要确认pom.xml中存在spark-sql和spark-mllib两个核心依赖只引入spark-core跑不了DataFrame和ALS。第二步检查数据文件路径。源码里读取JSON用的是相对路径“data/user_meal_rating.json”而user_meal_rating.json实际放在项目根目录。最简单的方法是在运行配置的Working directory里指定项目根目录或者把数据文件统一放到项目根目录下的data文件夹中让相对路径和代码对上。第三步运行主类。主类名一般是RecommendationSystem或类似的入口运行前在JVM参数里加上“-Dspark.masterlocal[]”确保以本地模式启动。也可以直接在SparkSession.builder()里通过.master(local[])指定。# 如果项目提供了打包后的Jar包命令行运行方式如下 spark-submit \ --class com.example.DishRecommendation \ --master local[4] \ target/dish-recommendation-1.0-SNAPSHOT.jar \ data/user_meal_rating.json这段命令的四个参数含义分别是--class指定包含main方法的全限定类名--master local[4]表示用本机4个线程跑模拟分布式执行环境jar包路径是Maven打包后的产物最后一个参数是传给程序的数据文件路径程序内部会读取。本地调试时我更喜欢在IDE里直接跑spark-submit留给打包验证时用。4.3 运行提速DataFrame缓存、shuffle分区和广播变量数据量和计算量都不大时本地模式跑完整条链路通常不到一分钟。但如果你往JSON里塞了几万条评分训练时间会明显拉长这时候三个优化手段最实用。第一是DataFrame缓存。数据从JSON读入完成清洗后调用cleaned.cache()把中间结果缓存到内存。后续训练ALS、统计查询都要反复扫描这份数据缓存后能减少重复的磁盘IO和解析开销。缓存对小数据量效果不明显数据量上到几万行后能真切感受到差异。第二是调整shuffle分区数。Spark SQL聚合和ALS训练都会触发shuffle默认分区数是200而这个项目的数据量几千行撑死几十个分区导致每个分区里只有零星几条数据调度开销反而大于计算开销。在SparkSession配置里设置spark.sql.shuffle.partitions为20到50训练和聚合速度立刻提上来。第三是广播变量。如果菜品维表很小且需要和评分表做join在Spark SQL里自然写法是两张表直接join。小表几KB、大表几百MB时Spark默认会用SortMergeJoin进行全量排序合并白白浪费交换数据的时间。此时把小表广播出去改成BroadcastHashJoin对小数据量场景是立竿见影的优化import static org.apache.spark.sql.functions.broadcast; DatasetRow joined cleaned.join(broadcast(dishInfo), cleaned.col(dish_id).equalTo(dishInfo.col(dish_id)), left);广播join的原理是把小表复制到每个ExecutorMap阶段直接查内存字典省掉Reduce阶段的排序合并。适合广播的表大小阈值默认是10MB超过这个值的表需要显式调大spark.sql.autoBroadcastJoinThreshold参数但调太高会增加Executor内存压力得不偿失。5. 避坑指南Spark菜品推荐项目最容易翻车的五个位置这一章写的是复现和调试过程中实际遇到的坑每条按现象、原因、解决的顺序排排查时照着做就能对上号。5.1 故障一JSON文件读取后全是null或字段缺失现象spark.read().json()跑完打印schema发现user_id、rating等字段全是null或只解析出少数字段。原因JSON文件不是标准JSON数组而是每行一个JSON对象的“JSON Lines”格式。Spark的json数据源默认按整份文件解析对跨多行的对象数组支持不完整。另一种情况是文件编码带BOM头第一行字段名被污染。解决先打开文件确认格式。若是JSON Lines直接读没问题若是标准JSON数组或花括号跨多行改用option(multiLine, true)DatasetRow raw spark.read() .option(multiLine, true) .json(data/user_meal_rating.json);5.2 故障二ALS训练报“Column user_id must be of type numeric”现象跑到als.fit(training)时抛出IllegalArgumentException提示user_id列不是数值类型。原因前面提到过ALS只认数值型ID而源数据里user_id是带前缀的字符串。解决在ALS前用StringIndexer把字符串ID映射为数值索引并保证user_idx列名和setUserCol一致。映射后的索引从0开始不会影响模型效果。这里最容易踩的坑是忘记把StringIndexer输出列重命名导致setUserCol填错列名运行时才暴露问题。5.3 故障三本地运行报SparkContext端口冲突或Web UI启动失败现象第二次运行时报“Failed to bind Spark UI”或端口号占用异常任务卡在初始化阶段。原因Spark Web UI默认端口是4040IDE里反复运行任务时前一个进程没有完全终止端口被占用。解决在SparkSession配置里显式指定不同端口SparkSession spark SparkSession.builder() .appName(DishRecommendation) .master(local[*]) .config(spark.ui.port, 4050) .getOrCreate();调试期把spark.ui.port改成4050避免和默认的4040冲突。如果任务卡住优先检查IDE控制台是不是有多个Java进程在跑杀掉残留进程再运行。5.4 故障四换一台电脑运行就找不到数据文件现象代码没改换电脑后运行报FileNotFoundException路径里data/user_meal_rating.json找不到。原因项目用的是相对路径而相对路径的基准是运行时的工作目录不是项目根目录。IDE里不同机器的工作目录配置不一致命令行运行时更是取决于你在哪个目录下执行spark-submit。解决在代码里用绝对路径或从配置文件读取最稳妥的做法是通过启动参数传入数据路径这样源码不用改路径和机器解耦。5.5 故障五recommendForAllUsers输出为空推荐结果一个都没有现象模型训练正常跑到recommendForAllUsers(5)后结果集为空或部分用户没有推荐。原因冷启动问题。全量数据中存在新用户这些用户在训练集中没有出现过ALS无法生成对应的隐因子向量。另一个原因是设置了coldStartStrategy为drop把新用户直接丢弃了。解决确认输入的每条评分数据在训练集中都有对应的user和dish记录。对于全新用户采用兜底策略——用全站热销菜品Top-N作为冷启动推荐。这一步在答辩演示时往往是加分项能体现你理解了推荐系统的冷启动痛点。6. 结果验证与服务化改造从离线批处理走向可复用推荐模块6.1 推荐质量怎么验证RMSE加人工抽查缺一不可模型训练完不能只看日志里没有报错。常规做法是把测试集里的真实评分和模型预测评分做对比计算RMSE。RMSE低于0.5说明预测评分和真实值的平均误差在半分以内对菜品推荐这种众口难调的场合已经够用。但RMSE再低也不代表推荐列表合理因为用户真正在意的是Top-5里有没有想吃的菜。我还会随机抽三个用户打印出他们的历史点餐记录和推荐列表人工看推荐里是否存在同一个菜系扎堆的情况。如果某个用户明明不吃辣推荐列表里全是川菜说明模型把热门菜系的权重学偏了。更严谨的做法是用交叉验证把数据切5份轮流拿4份训练、1份验证最终5组RMSE取平均这一步直接复用MLlib的CrossValidator就行。6.2 二次开发把训练好的模型封装成轻量推荐服务如果要把项目从“跑通即可”升级成“可演示的产品”最值得改的是把模型训练和推荐查询拆开。一次训练多次查询避免每次请求都重新训练一遍。我的做法是将ALSModel缓存到内存再开一个简单的REST接口对外输出推荐结果// GET /recommend/{userId} 返回该用户的Top-5菜品 GetMapping(/recommend/{userId}) public ListString recommend(PathVariable String userId) { DatasetRow result model.recommendForAllUsers(5); // 按userId过滤并解析JSON字段返回dish_id列表 return parseRecommendation(result, userId); }这个接口的核心意义是把Spark从“一次性批处理任务”变成“可复用模块”。首次加载模型需要几秒之后每次查询都在毫秒级演示时体验完全不一样。分享一个我自己的习惯从那以后每次要跑Spark推荐项目我都会强制自己先执行一遍spark.sql里的分布统计确认总行数、用户数、菜品数三个数字和预期一致再进ALS训练。两分钟的前置检查换掉的是训练完才发现数据不对、只能回头重洗的深夜。希望帮到你。本文还有配套的精品资源点击获取