做毕设选题目的时候,很多同学会跑过来问我,什么样的题目不踩坑、答辩能讲出东西,又不会把自己困在死胡同里。我给的答案一直是同一个方向:垂直场景 + 大数据技术 + 算法落地。说直白点,就是类似“基于Spark的旅游推荐系统”这种题。它不是一个简单的XX管理系统,也不是纯算法论文,而是前后端业务、数据清洗、分布式计算、推荐引擎全部串起来的东西。Spring Boot负责对外接口,Spark负责离线计算和模型训练,前端做一些可视化展示,整套做完,技术栈完整、业务闭环、算法真实,评分自然不会差。这篇文章我会把这个项目的完整设计思路、数据与算法落地细节、常见翻车点和答辩准备都梳理出来,重点回答几个大家最关心的问题:为什么这套技术栈搭在一起是合理的、推荐结果是怎么真正算出来的、以及Spark和Spring Boot之间应该怎么配合而不是硬耦合。
1. 先从技术选型说起:Spark为什么值得放进毕设里
1.1 管理系统和推荐系统,评委想看的东西完全不同
你去看历年的毕设题,最扎堆的就是学生信息管理、图书馆管理系统、停车场管理系统这类。这类系统不是不能做,但问题在于逻辑太“平”:增删改查四个字能概括全部,数据库设计再规范,也体现不出太多区分度。答辩时老师问一句“有什么技术难点”,你很难接住。
推荐系统不一样。它天然自带三个讨论点:一是用户行为数据怎么收集和建模,二是推荐算法如何选型和评价,三是大数据框架在什么环节真正发挥作用。这三个问题每一个都可以展开讲、有得聊,而且都能用“我做了实验对比”“我做了数据评估”来支撑。评委感兴趣的点一旦被勾起,答辩节奏就会被你带跑。
再加上“旅游”这个场景,它比电商、电影推荐更贴近日常生活,评委对需求理解没有门槛,数据也容易拿到。用户想找景点、想规划路线、想避开人群,这些都是真实需求,比虚拟的业务场景更有说服力。
1.2 Spark在整个项目里到底负责什么
我见过不少同学把Spark硬塞进项目里,比如用Spark RDD排序一个几百条的数据,然后说“我用到了大数据技术”。这个思路很容易在答辩时被打穿,因为Spark的优势在于分布式并行计算,如果你处理的数据量连单机内存都放得下,那Spark就是多余的。
所以在设计之初就要想清楚,Spark承担的必须是“单机处理起来吃力”或者“需要周期性重算”的任务。在我们这个旅游推荐系统里,Spark主要做了三件事:
- 用户行为日志的离线清洗和特征统计。用户浏览景点、收藏地点、搜索景区、下单旅行产品的日志经过预处理后,按用户维度、景点维度聚合,生成评分矩阵和用户画像标签。
- 基于协同过滤的推荐模型训练。Spark MLlib里的ALS矩阵分解算法,在分布式环境下可以处理远大于单机运行的用户-物品矩阵,这也是整个推荐系统的核心。
- 周期性的批量任务调度。离线建模并不是只跑一次,随着用户行为不断累计,模型需要定时更新(比如每天凌晨跑一次全量任务),这种周期计算场景正是Spark的强项。
要特别说明的是,Spark和Hadoop中的MapReduce/compute engine不同,它更擅长内存迭代计算,正好匹配ALS这种需要反复迭代的矩阵分解算法。这也是不选MapReduce而选Spark的原因。如果你答辩时把这段常规思路讲出来,老师的第一个追问基本上就被你提前预判了。
1.3 Spring Boot和Spark为什么不是二选一
有的同学把Spring Boot理解为后端框架,把Spark理解为计算引擎,然后困惑“那我到底用哪个”。其实这两者是上下游关系,而不是并列关系。
Spring Boot跑在在线服务层,负责接收前端请求、查到推荐结果、返回JSON数据。它不负责“算出”推荐结果。Spark在离线计算层,负责大规模数据的处理和模型训练,产出结果后写回到数据库。用户在网页上的操作,只是去查数据库里已经算好的结果。
我把它们比喻成“中央厨房”和“前台出餐”:Spark是中央厨房,提前把菜批量做出来放保温柜里;Spring Boot是前台出餐员,客人点单时直接从保温柜里拿。食堂不可能等客人下单了才开始炒菜,在线服务也不能让用户去等Spark现算。这套思路在答辩时叫“在线离线分离架构”,是完全站得住脚的。
2. 系统整体架构设计:一条完整的数据流转链路
2.1 分层结构
做毕设时,我最推荐把系统拆成五层,每层各管各的,方便写文档,也方便和老师解释:
| 层级 | 组件/技术 | 职责 |
|---|---|---|
| 数据采集层 | 网页埋点、后端日志、爬虫采集 | 获取用户行为、景点信息、评价数据 |
| 数据存储层 | MySQL、HDFS、Redis | 业务数据、原始日志、缓存推荐结果 |
| 计算引擎层 | Spark Standalone + MLlib | 数据清洗、评分矩阵构建、ALS模型训练 |
| 业务服务层 | Spring Boot | 用户管理、景点展示、推荐接口、路线定制 |
| 展示层 | Vue + ECharts | 前端页面、推荐结果可视化、后台管理面板 |
这五层不用都做得特别重,每个层能讲清楚主要模块和一份核心逻辑就可以。很多同学会把大量时间花在后台管理页面上,在我看来不划算。管理页面能做基础的分页查看就够了,真正要花心思的是推荐链路的数据流。
2.2 数据是怎么从原始行为变成推荐结果的
我给你画一条主线,你照着这条主线安排开发顺序就不会乱:
- 用户在网页上浏览景点、评分、收藏,这些操作被记录到日志表。
- 定时任务将日志同步到HDFS,同时把结构化数据导入MySQL。
- 每天凌晨,Spark on Standalone提交一个离线任务,从MySQL/HDFS读取一段时间内的用户行为。
- Spark对数据进行清洗、去重、时间窗口过滤,构建出用户-景点评分矩阵。
- 用ALS算法训练模型,对每个用户生成TopN景点推荐候选。
- 把推荐结果写回MySQL表,或者写入Redis缓存。
- 用户打开首页,Spring Boot根据用户ID读取推荐列表,返回给Vue前端展示。
- 如果用户是新用户(没有推荐记录),走规则策略:热门榜、当季景点榜、默认城市榜。
注意,第5步和第6步中间的推荐结果并不一定直接就是最终内容。后续还可以做补全过滤:去掉用户已经去过的景点、去掉不营业的景区、按距离远近加重排序。这一步在项目里叫“后处理策略”,虽然不复杂,但在演示的时候效果很直观。
2.3 本地开发和部署的环境清单
很多同学一听到“Spark集群”就慌,担心要搞几台服务器。毕设完全不必。你的本机如果内存有16GB,直接装Spark Standalone单机模式就够了,和集群模式相比代码上没有任何区别,只是运行环境少了多节点。以下是我实际使用的环境组合,供你复制:
- JDK 1.8
- Maven 3.6+
- Scala 2.12
- Spark 3.1.x(自带Scala运行时,本地提交任务时版本要对)
- Hadoop 3.x(可选,只在读HDFS时需要,建议直接读MySQL或本地文件省很多事)
- Spring Boot 2.5.x
- MySQL 5.7
- Redis 6.x
- Vue 2.x + Element UI + ECharts
我特别提示一下,Spark与Scala版本必须严格对应。Spark 3.1默认用Scala 2.12,如果你引入的第三方包编译版本是2.11,提交任务时会直接抛版本冲突。这个坑我用了一个下午才排查出来,先放到这里提醒你。
3. 推荐算法核心:不是拿个别人算法来抄,是理解拿来做组合
3.1 两类协同过滤,到底用哪种
推荐系统里最基础的算法就是协同过滤,分为基于用户的UserCF和基于物品的ItemCF。
UserCF核心是“找和你口味相似的人,把你没去过的、他们去过且评价高的地方推荐给你”。它在新闻、社交类场景用得比较多,因为用户兴趣变化快,邻居用户的实时行为更能捕捉热点。
ItemCF核心是“找到和你喜欢的景点相似的景点”。比如你收藏了故宫,系统就会推荐颐和园、天坛这类同类景点。它在电商、长视频场景用得更多,因为物品相对稳定,相似关系可以离线算好。
那毕设怎么做选择呢?我做了一个折中方案:模型主算法用ALS矩阵分解,同时在代码里实现了ItemCF作为对照实验。这样有两个好处:一是可以在实验章节里比较两种算法的离线评价指标,丰富论文内容;二是ItemCF算出来的“景点相似表”还可以用于路线定制模块,比如你选择了“西安”,系统把相似度最高的几个景点按距离聚类,生成一条路线。
如果你时间不够,只做ALS也完全没问题,但至少要在原理上讲清楚ALS和协同过滤的关系——ALS是协同过滤中矩阵分解这一分支的代表算法,不是凭空冒出来的。
3.2 ALS矩阵分解的理解方式和参数调优
ALS对初学者最友好的一套解释是这样的:假设有3个用户和4个景点,用户-景点评分矩阵是3行4列的表格,表格里有不少空格(因为用户没去过那么多地方)。你想填满空格,但直接填不可靠,于是把矩阵拆成“用户特征矩阵”和“景点特征矩阵”两个小一点的矩阵相乘,尽可能还原已知评分,同时预测未知评分。
在Spark MLlib里调用ALS其实非常清爽,我贴一段核心训练代码:
import org.apache.spark.ml.recommendation.ALS val als = new ALS() .setMaxIter(10) .setRank(20) .setRegParam(0.1) .setUserCol("userId") .setItemCol("itemId") .setRatingCol("rating") .setColdStartStrategy("drop") val model = als.fit(trainingDF) val userRecs = model.recommendForAllUsers(10)这几个参数我建议你逐一想明白,答辩喜欢问:
- Rank:表示每个用户、每个物品被分解成几个隐向量特征。Rank越大表示特征表达越细腻,但计算量会涨,还会出现过拟合。我用20到50之间调试,最终取了20。
- MaxIter:迭代次数。太小收敛不到足够精度,太大会过度拟合且耗时成倍增加。10次已经能在一个可用数据集上跑出不错的结果。
- RegParam:正则化系数,防止过拟合。先取0.1,如果训练集RMSE和测试集RMSE差距过大,就调大一点。
训练完成后,你想要“相似景点”也简单。ALS模型里存了item的因子矩阵,你可以提取出来两两之间算余弦相似度。类似的代码实现只需要几十行,但是对于论文里的“多样性与精确性的平衡分析”非常有用。
3.3 冷启动问题:新用户什么都没有,推荐什么
这是答辩老师最爱的提问点之一,我必须单独拿出来讲。ALS这类算法对新人新物是失效的,因为评分为空、行为为空。如果你不处理,推荐模块就会返回空列表,演示一下就很尴尬。
我的策略是三层兜底:
- 第一层,用热门榜兜底。按最近30天访问量、好评率、搜索次数综合算一个热度分,新用户登录后首页推荐就是这份热度榜。
- 第二层,用兴趣标签加权。新用户注册时可以选城市、偏好的景点类型(自然风光、人文历史、亲子娱乐、美食打卡等)。热度分乘以用户兴趣向量里的标签权重,生成个性化程度高一些的粗推荐。
- 第三层,实时行为补全。用户进入系统后只要浏览或收藏了三五个景点,立刻触发一次增量计算,通过ItemCF的相似景点表把“浏览过A → 推荐相似度高的B/C/D”这个链路加进去,此时用户的推荐列表就已经开始有“个性化”的味道了。
只需要两三天数据积累,用户有了足够的评分记录,就可以切换到ALS正式推荐。这个渐进思路可以说是整篇论文里最高光的“业务亮点”,写文档时一定要展开。
3.4 路线定制推荐引擎:怎么从“单个景点”升级成“一条路线”
光推荐几个零散的景点,在旅游场景里显得不够落地。真正去旅游的人要的是一天或者两天的路线,于是我在推荐候选集上又加了一层路线生成逻辑,就是标题里说的“定制化旅行路线智能推荐引擎”。
路线生成算法我用了一种简单但有效的方式:
- 候选景点池先由ALS或ItemCF捞出来,大概20到30个。
- 通过经纬度坐标计算景点两两之间的时间距离(预估交通时间膨胀系数)。
- 按景点热度分、评分、用户兴趣权重做一个综合收益分。
- 用贪心思路从用户指定起点(比如酒店所在城市)出发,依次加入收益高且与上一景点距离可接受的下一个景点,形成一个序列。
- 校验路线总时长,比如一天游玩路线控制在10小时内,超了就裁剪尾部景点。
这里的核心不是追求全局最优路线规划,而是体现“推荐结果具备可消费的完整性”。你可以在演示时输入“2天1夜,喜欢自然风光,从市区出发”,前端直接渲染出一条带时间轴的地图路线,播放动画给老师看,展示效果会非常直观。
4. 数据从哪来、怎么清洗、格式怎么定
4.1 数据集来源与采集方案
做推荐系统最头疼的不是算法而是数据。我在项目里用了三部分数据拼出初始数据集:
- 某旅行社区公开的游记和景点评价(文本中包含用户ID、景点ID和评价内容)。
- 景区基础信息,包括名称、城市、经度纬度、门票参考价、主题标签和封面图。
- 自建平台的模拟用户行为日志,用脚本随机生成浏览、收藏、评分,保证系统演示时每个用户都能看到推荐内容。
三部分数据量大约到了2万用户、1.5万景点、30万条评分记录。这个规模在单机上跑Spark已经是比较像样了,模型输出的结果区分度明显好过几百条数据的玩具集。
这些数据在引入时要做脱敏和版权处理。不要用真实用户的隐私信息,用户ID可以重编号。论文附录里要写清楚数据来源和处理过程,这既是对学术规范的尊重,也是保护自己。
4.2 评分矩阵怎么构建
ALS需要的是用户ID、景点ID、评分三个字段。但原始的游记评价不是标准评分,需要转换。我当时把“用户对景点产生了正面评价”映射为 4分,“负面反馈”映射为 1分,“收藏行为”记 3分,“浏览未收藏”记 2分,“下单”记 5分。这样行为日志就能天然变成一个稀疏评分矩阵。
清洗逻辑我列几条,都是踩过坑之后总结的:
- 过滤掉只出现过1到2次行为的用户,这些用户没有足够的“口味特征”,训练后容易产出噪音。
- 过滤掉被标记为关闭或下架的景点。
- 对同一天内同一用户的重复浏览,只保留最高行为强度的那条记录。
- 时间窗口上,只用最近6个月的日志,太老的数据反映的是过期兴趣。
做完这些,模型训练的输入就已经是干净程度很高的数据了。
4.3 Spark任务和Spring Boot接口的具体配合方式
“Spark算出来的结果怎么变成网页上用户看到的内容”是学弟问我最多的问题之一。最常规的做法就是使用“离线写库,在线读库”,具体一点说:
第一步,单独写一个Scala的Object任务(SparkApplication)。任务里包含数据读取、ALS训练、结果生成三个步骤,最后用JdbcRDD或foreachPartition把结果写入MySQL的推荐结果表。
// 简化的Spring Boot 端接口示意 @GetMapping("/api/recommend/{userId}") public Result<List<Scenic>> recommend(@PathVariable Long userId) { List<Scenic> rec = redisCache.get("rec:user:" + userId); if (rec != null) { return Result.ok(rec); } List<Scenic> recFromDb = recommendMapper.findByUserIdAndType(userId, "ALS"); if (recFromDb.isEmpty()) { recFromDb = ruleRecommendService.hotList(); // 冷启动兜底 } redisCache.set("rec:user:" + userId, recFromDb, 30, TimeUnit.MINUTES); return Result.ok(recFromDb); }这段代码展示了Spark和Spring Boot之间最标准的解耦方式:Spark只负责算,Spring Boot只负责查。接口查不到再走兜底规则。Redis缓存可以把热点用户推荐结果压到毫秒级返回,答辩时也算一个性能优化点。
4.4 离线任务调度怎么安排
很多同学做毕设忽略调度,其实这个很好实现。我用的是最简单的Linux crontab,固定每天凌晨2点执行一次Spark提交脚本:
0 2 * * * /opt/spark/bin/spark-submit \ --class com.example.Job.TrainRecommendJob \ --master local[4] \ --driver-memory 2g \ /path/to/spark-travel-recommend-1.0.jar如果你想把工程化程度做得更高一点,可以自己写一个简单的管理接口,在Spring Boot后台点击按钮手动触发任务,但默认还是cron跑。毕业答辩时老师如果问“推荐结果多久更新一次”,你直接说“离线任务每天凌晨跑一次全量,线上CDN和Redis缓存30分钟更新一次”,这个问题就通过了。
5. 调试、评估和性能优化:这些地方最容易翻车
5.1 数据倾斜:Spark任务跑到最后一个stage就是卡住
我在一次训练中,JOB跑到最后一个Stage进度卡在99.9%,拖了40分钟没结束,最后直接OutOfMemory。原因很简单,某个热门城市(比如某些知名旅游城市)的景点评分数量是其他地区的几十倍,生成用户物品矩阵时,某些分区负载特别大。
缓解办法:训练前先用SQL按key做Aggregate,对评分数量超过阈值的热门景点单独挑出来走广播变量,或者在ALS训练前进行样本加权/降采样。毕设环境下最实用的还是这些做法:限制每个用户的最大行为数、每个景点的最大接收评分数量,让数据分布尽量均匀。
5.2 ALS的结果质量差,别只调模型参数
第一次跑出来的推荐结果我看了想哭:给用户推荐的景点全是同一个类,重复度极高,而且和用户历史兴趣没什么关联。后来定位到是数据预处理出了问题:我把用户浏览记录和评分放在一起,但没有区分行为权重,导致每个景点得分几乎只由访问量决定,用户本身的口味被稀释了。
正确做法按我前面说的行为映射规则,比如收藏权重是3、下单是5、浏览是2、负面反馈跳过不进入训练。再用Log变换处理一下累计值,效果会有非常明显的提升。如果调整完还是差,建议先去检查数据,而不是纠结Rank是20还是30。
5.3 推荐效果到底怎么评价
答辩老师必问:你说你的推荐效果好,怎么证明?所以离线评估必须做。
将清洗后的评分矩阵切分训练集和测试集,比例我用8:2(保持每个用户至少有一条测试数据)。训练后用测试数据去预测,计算RMSE(均方根误差)和MAE(平均绝对误差)。这两个指标越小说明预测评分越准。
除了评分误差,我还会统计Top10推荐命中率:在测试集中,用户真正访问过/评过分的景点是否出现在推荐列表中,如果出现就算命中。这个方法有一个不正式但很有效的通俗版本:每次演示前,挑一个有历史访问记录的用户账号,展示推荐列表里有多少个景点是他确实去过且打过分。肉眼可见的正确性比任何指标都有冲击力。
5.4 前端可视化怎么支撑演示
不要小看前端页面,推荐系统的门槛很容易被可视化拉高或者拉低。我强烈推荐你在首页放三个模块:
- 猜你喜欢:基于ALS的用户个性化Top10推荐。
- 热门景区榜:30天热度排序榜,用ECharts画一个柱状图加地图分布。
- 探索路线:输入出发点和时间后,生成“一日游路线”和“两日一夜路线”,在地图上画出连线。
地图展示我用的方案是引入第三方地图组件或者自己用坐标点填充到前端图表里。不要炫技,也不要停留在纯文字列表。老师看到地图上一条完整的路线被动态画出来,一般就开始打心里给高分了。
6. 常见问题速查表:把时间花在刀刃上
以下这些问题是我在做完整个项目后总结出来的最高频故障和答辩提问点,你可以做成速查表放在笔记本旁边:
| 症状 | 可能原因 | 排查方向 / 解决方案 |
|---|---|---|
| spark-submit后任务秒挂 | Scala版本和Spark不兼容 | 统一使用Spark官方默认Scala版本,检查依赖包 |
| ALS训练完推荐为空 | 冷启动策略没写 | 添加热心榜、标签加权和ItemCF增量逻辑 |
| 任务卡住不结束 | 数据倾斜严重 | 限制用户/景点行为上限,处理后重新提交 |
| 接口返回很慢 | 每次请求都在实时算推荐 | 改成离线写库、在线读库,加Redis缓存 |
| 前端图片不展示 | 图片来源跨域或字段为空 | 统一用本地或对象存储图片链接,检查数据完整性 |
| 论文里没有实验对比 | 只用了一个算法 | 至少补一个ItemCF对照,或调参对照实验 |
| 推荐结果全都是热门城市 | 用户特征弱,矩阵没有学到有效向量 | 增加行为权重区分,清洗垃圾用户,提高Rank观察 |
| 答辩问“为什么不用Hadoop” | 没有说清选型逻辑 | 强调Spark内存迭代优势、MLlib算法库、开发效率 |
| 答辩问“成本和扩展性” | 没有集群环境做测试 | 诚实回答Standalone模式验证,代码可以直接扩展到Yarn集群 |
| 答辩问“这个系统的难点在哪” | 难点没被识别 | 把冷启动、在线离线架构、路线拼接算法作为三大难点讲 |
除了排错,这里再给你两个心得。
心得一:先定数据再定算法。我曾经先写好了Spark训练代码,再去网上找数据,结果数据格式完全不匹配,评分字段乱七八糟,清洗花了一周。如果你做这个项目,第一天就把数据下载好、结构理清楚、脚本写好,后面会非常顺。
心得二:论文的代码量不决定分数,实验和思考才决定。我见过有人贴了2000行代码但老师问什么都答不出所以然,也见过只写300行核心算法但把每个参数都讲清楚的同学拿高分。推荐系统的重点在于你做了哪些实验、如何评估结果、如何解释现象,而不在于拼装了多少个页面。
我个人做下来最大的体感是:这种题目把自己的能力边界撑大了一圈。你既要懂Java,能写Spring Boot接口;又要懂Spark,能调分布式任务;还要懂一点前端和数据分析,能讲清楚数据如何变成商业价值。做完之后,简历上有东西可写,面试里有内容可聊。如果你当前正在这个题目上挣扎,我劝你别急着写代码,先按我这条数据链路把每一个模块的输入输出理清楚,再动手敲。你会发现后面每一步都顺很多。