SSM旅游平台协同过滤推荐算法实战:从数据表设计到避坑指南
2026/9/24 19:00:07 网站建设 项目流程

简介:本资源为基于SSM框架与协同过滤算法的在线通用旅游平台网站毕业设计全套资料,面向计算机相关专业正在做毕设的学生及需要Java项目实战练习的学习者,也可用于课程设计与期末大作业。项目包含前端、后端与MySQL完整源码,并附数据库脚本、开发说明文档、LW、演示视频及代码注释,压缩包约106.17MB,整体已通过严格调试,可直接运行。系统功能覆盖景点推荐管理、精选路线管理、用户信息管理与系统管理四大模块,其中景点模块支持信息添加、修改、删除与查询,路线模块提供精选路线设置与查询,用户模块维护注册信息,系统管理则包含公告、简介、在线留言与站内新闻等后台维护功能。协同过滤的引入使景点与路线推荐更贴合游客偏好,适合作为推荐算法与SSM整合的学习范例。目前已有109人学习,读者可借此快速掌握项目结构、数据库设计与推荐逻辑,完成从环境搭建到功能复现的完整实践。

1. 从一份 SSM 旅游平台源码说起:协同过滤到底解决什么问题

很多同学拿到「基于 SSM 的协同过滤在线旅游平台」这个题目时,第一反应是去搜现成的 Java 毕业设计源码,把项目跑起来、把论文凑够字数就交差。但真正动手改过的人会发现,这个题目里最值钱、也最容易翻车的部分,恰恰是「协同过滤」这四个字。旅游平台本身只是一个 CRUD 外壳——用户、景点、订单、评论,这些用 SSM 三层架构堆出来并不难;难的是让系统在用户浏览景点时,能推出一批「你可能还想去」的线路,而不是把数据库里销量前十的景点原样甩出来。

协同过滤要解决的就是这个「千人千面」的问题。它的核心逻辑很朴素:如果两个用户过去对景点的评分或收藏行为高度相似,那么其中一个喜欢、另一个还没看过的景点,就有很大概率被另一个用户接受。放到旅游场景里,这意味着一个爱去古镇、爱住民宿的用户,和一个同样爱去古镇、爱住民宿的陌生用户,他们的足迹可以互相「借力」。这套思路不需要你懂深度学习,用 Java 加几张 MySQL 表就能落地,非常适合作为计算机毕业设计的技术亮点。

这篇文章面向的是正在做 SSM 毕业设计、想把这个题目做出差异化的同学。我会从数据表怎么设计、相似度怎么算、推荐结果怎么塞进页面,一路讲到调参和排错。你不需要事先精通推荐算法,但需要能看懂 Java 和基本的 SQL。读完你应该能自己把协同过滤模块接进任意一个 SSM 项目里,而不是只会复制粘贴一份看不懂的源码。

2. 协同过滤在旅游平台里的数据底座与相似度计算

2.1 用户-景点评分矩阵怎么建才不返工

协同过滤的第一块砖是评分矩阵。旅游平台和电影、电商最大的区别在于:用户对景点的「评分」往往非常稀疏。一个人可能浏览了 50 个景点,但只对其中 3 个打了分。如果你只依赖显式评分,矩阵会稀疏到算法几乎失效。所以我在实际项目里一般会做「显式 + 隐式」双通道:显式是用户主动打的分(1~5 分),隐式是收藏、下单、停留时长这类行为,折算成 0.5~3 分的虚拟评分。

表结构上,最少需要三张核心表。用户表t_user存基础信息;景点表t_scenic存景点名称、类型、城市、价格;行为表t_behavior是推荐系统的命脉,字段包括user_idscenic_idbehavior_type(1 浏览、2 收藏、3 下单、4 评分)、scorecreate_time。这里有个血泪经验:behavior_typescore一定要分开存,不要图省事只存一个分数,否则后期想调整权重时你连原始行为都还原不出来。

CREATE TABLE t_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, behavior_type TINYINT NOT NULL COMMENT '1浏览 2收藏 3下单 4评分', score DECIMAL(3,1) DEFAULT 0 COMMENT '显式评分1-5,隐式行为由程序折算', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_scenic (scenic_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

建完表后,用一条 SQL 把行为聚合成评分矩阵的原料。注意这里用MAX而不是SUM,因为同一个用户对同一个景点反复浏览不应该无限累加权重,取最强的那次行为更合理。

SELECT user_id, scenic_id, MAX(CASE behavior_type WHEN 4 THEN score WHEN 3 THEN 3.0 WHEN 2 THEN 2.0 WHEN 1 THEN 0.5 ELSE 0 END) AS final_score FROM t_behavior GROUP BY user_id, scenic_id;

这段 SQL 的逻辑是:对每个「用户-景点」组合,按行为类型映射成统一量纲的分数,评分行为直接用用户打的分,下单给 3 分,收藏给 2 分,浏览给 0.5 分。参数上你可以根据业务调整,比如旅游平台下单的决策成本很高,可以把下单权重提到 4 分。跑出来的结果就是后续相似度计算的输入矩阵。

2.2 皮尔逊相似度与余弦相似度的选型对比

有了矩阵,下一步是算用户之间的相似度。常见的有两种:余弦相似度和皮尔逊相关系数。很多教程直接甩公式,但不告诉你什么时候用哪个。我的经验是:如果你的评分尺度统一、用户打分习惯差异不大,用余弦相似度就够了;如果存在「有的用户永远打 5 分、有的用户永远打 3 分」这种尺度偏差,皮尔逊更稳,因为它会减去每个用户的平均分。

在旅游场景里,尺度偏差其实很常见——有人觉得 3 分就是「还行」,有人觉得 3 分是「很差」。所以我一般选皮尔逊。下面是对两个用户共同评分景点集合计算皮尔逊相似度的 Java 实现。

public double pearson(Map<Long, Double> userA, Map<Long, Double> userB) { // 找出两个用户共同评分的景点 Set<Long> common = new HashSet<>(userA.keySet()); common.retainAll(userB.keySet()); if (common.size() < 2) return 0; // 共同项太少,相似度不可信 double sumA = 0, sumB = 0; for (Long id : common) { sumA += userA.get(id); sumB += userB.get(id); } double avgA = sumA / common.size(); double avgB = sumB / common.size(); double numerator = 0, denomA = 0, denomB = 0; for (Long id : common) { double da = userA.get(id) - avgA; double db = userB.get(id) - avgB; numerator += da * db; denomA += da * da; denomB += db * db; } if (denomA == 0 || denomB == 0) return 0; return numerator / Math.sqrt(denomA * denomB); }

这段代码的关键点有三个。第一,common.size() < 2直接返回 0,这是防止「只有一个共同景点」时相似度虚高,这个坑我在早期项目里踩过,导致推荐结果全是噪声。第二,皮尔逊的分子是协方差、分母是标准差乘积,结果落在 -1 到 1 之间,负值表示负相关,实际推荐时一般只取正值。第三,denomA == 0说明该用户对所有共同景点打分完全一样,此时方差为 0,公式会除零,必须提前拦截。

参数上,共同评分景点数的阈值(这里设的 2)可以根据数据量调整。数据多的时候提到 3 或 5,推荐会更准但覆盖率下降;数据少的时候降到 1,但要做好结果过滤。这个权衡没有标准答案,得看你的数据集规模。

2.3 用 UserCF 生成 TopN 推荐并写回数据库

算出相似度后,就可以做 UserCF 的核心步骤:找 K 个最相似的用户,把他们喜欢、但目标用户没看过的景点加权汇总,取前 N 个作为推荐。下面这段代码把整个流程串起来。

public List<Long> recommend(Long targetUserId, int k, int n) { Map<Long, Double> target = loadUserRatings(targetUserId); // 1. 计算目标用户与其他所有用户的相似度 List<UserSim> sims = new ArrayList<>(); for (Long otherId : allUserIds()) { if (otherId.equals(targetUserId)) continue; double sim = pearson(target, loadUserRatings(otherId)); if (sim > 0) sims.add(new UserSim(otherId, sim)); } // 2. 取相似度最高的 K 个邻居 sims.sort((a, b) -> Double.compare(b.sim, a.sim)); List<UserSim> neighbors = sims.subList(0, Math.min(k, sims.size())); // 3. 加权汇总邻居喜欢、目标用户未接触的景点 Map<Long, Double> scores = new HashMap<>(); for (UserSim nb : neighbors) { Map<Long, Double> nbRatings = loadUserRatings(nb.userId); for (Map.Entry<Long, Double> e : nbRatings.entrySet()) { if (target.containsKey(e.getKey())) continue; // 已看过,跳过 scores.merge(e.getKey(), nb.sim * e.getValue(), Double::sum); } } // 4. 取分数最高的 N 个 return scores.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(n) .map(Map.Entry::getKey) .collect(Collectors.toList()); }

逻辑说明:第一步遍历所有用户算相似度,只保留正值;第二步按相似度降序取前 K 个邻居,K 一般取 10~30;第三步用「相似度 × 邻居评分」作为加权分累加,已经看过的景点直接跳过;第四步排序取前 N 个,N 通常取 5~10 用于首页推荐位。

参数 K 和 N 的调法:K 太小,推荐容易受个别邻居的极端偏好影响;K 太大,会引入不相关用户稀释结果。我一般从 K=20、N=8 起步,然后看推荐结果的多样性。如果推出来的全是同一类景点,说明 K 偏大或者数据太集中,需要调小 K 或加入类型打散逻辑。写回数据库时,建议单独建一张t_recommend表,存user_idscenic_idscoregenerate_time,每次离线计算后覆盖写入,前端查询时直接读这张表,避免每次请求都实时算相似度。

3. 把推荐模块接进 SSM 三层架构的完整落地路径

3.1 MyBatis 映射与 Service 层的推荐调用时机

推荐算法写成一个独立的RecommendService之后,接下来要解决的是「什么时候调用」。很多同学的做法是每次用户打开首页就实时算一遍,结果页面加载要等好几秒,体验直接翻车。正确的做法是离线计算 + 缓存读取:用一个定时任务(Spring 的@Scheduled或 Quartz)在凌晨低峰期跑全量推荐,把结果写进t_recommend表;前端请求时只做一次简单的查询。

MyBatis 的映射文件里,查询推荐结果的 SQL 要关联景点表,把推荐分数和景点详情一起返回,避免 N+1 查询。

<select id="selectRecommendByUser" resultMap="ScenicVoMap"> SELECT s.id, s.name, s.city, s.price, s.type, r.score AS recommend_score FROM t_recommend r JOIN t_scenic s ON r.scenic_id = s.id WHERE r.user_id = #{userId} ORDER BY r.score DESC LIMIT #{limit} </select>

这段映射的关键是ORDER BY r.score DESCLIMIT,让数据库帮你做排序和截断,而不是查出来再在 Java 里排。参数userIdlimit由 Service 层传入,limit一般和算法里的 N 保持一致。注意t_recommend表要建(user_id, score)的联合索引,否则用户量上来后这条查询会变慢。

Service 层的调用时机我一般分两种:首页推荐位用离线结果,景点详情页的「相似景点」用实时计算。详情页的实时计算只针对单个景点,数据量小,可以接受毫秒级延迟。这种冷热分离的设计能兼顾体验和新鲜度。

3.2 冷启动:新用户和新景点没有行为数据怎么办

协同过滤最大的软肋是冷启动。新注册用户没有任何行为,算不出相似度;新上架的景点没有任何人评分,永远推不出去。这两个问题不解决,系统上线第一天就会显得很傻。我的处理方案是分而治之。

对新用户,走「热门 + 多样性」兜底:按城市和景点类型分组,每组取销量最高的几个,混合后推荐。这样至少不会推空。同时在前端埋点,用户一旦产生浏览或收藏行为,就把他标记为「可计算」,下次定时任务时纳入协同过滤。

对新景点,走「内容相似」兜底:用景点自身的属性(城市、类型、价格区间)找最相似的已有景点,把新景点挂到那些景点的「相似推荐」里,借流量曝光。等积累到一定行为量,再交给协同过滤接管。

// 新用户兜底:按类型分组取热门 public List<Long> coldStartRecommend(int n) { List<Long> result = new ArrayList<>(); for (String type : Arrays.asList("古镇", "自然风光", "主题乐园", "博物馆")) { result.addAll(scenicMapper.selectHotByType(type, n / 4)); } return result.stream().distinct().limit(n).collect(Collectors.toList()); }

这段兜底逻辑按四种常见旅游类型各取 N/4 个热门景点,去重后截断。参数上,类型列表要和你的景点分类字典对齐,不能硬编码得和数据库对不上。这个方案不优雅,但能保证冷启动阶段页面不空,是性价比最高的做法。

3.3 用定时任务做离线推荐与增量更新

离线推荐的核心是「全量 + 增量」结合。全量任务每周跑一次,重算所有用户的相似度矩阵;增量任务每天跑一次,只处理过去 24 小时有新行为的用户。这样既保证结果新鲜,又不会每天把整个矩阵重算一遍。

@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点 public void dailyRecommend() { // 1. 找出过去24小时有行为的用户 List<Long> activeUsers = behaviorMapper.selectActiveUsers(1); for (Long userId : activeUsers) { List<Long> recs = recommendService.recommend(userId, 20, 8); recommendMapper.deleteByUser(userId); for (int i = 0; i < recs.size(); i++) { recommendMapper.insert(userId, recs.get(i), 8 - i); // 分数按排名递减 } } }

逻辑说明:selectActiveUsers(1)查最近 1 天有行为的用户;对每个活跃用户重算推荐;先删旧记录再插新记录,保证不重复。分数用8 - i按排名递减,这样前端排序时天然有序。参数上,cron 表达式要根据服务器负载调整,别和数据库备份任务撞车。增量任务只覆盖活跃用户,非活跃用户的推荐结果保持上周的全量结果即可,反正他们也不登录。

4. 协同过滤推荐模块的避坑与排查清单

4.1 推荐结果全是热门景点

现象:不管哪个用户登录,推荐列表前几名永远是那几个销量最高的景点,协同过滤形同虚设。

原因:热门景点的评分用户多,在加权汇总时天然占优,把长尾景点的分数压下去了。这是推荐系统里的经典「流行度偏置」。

解决:在加权分上除以景点流行度的对数做惩罚,公式改成sim * rating / Math.log(1 + scenicPopularity)。这样热门景点的基础分被压低,长尾景点有机会冒头。惩罚系数可以先设 1.0,观察结果多样性后再调。

4.2 相似度计算耗时过长导致任务超时

现象:定时任务跑几个小时跑不完,日志里全是相似度计算的耗时记录。

原因:用户两两计算相似度是 O(n²) 复杂度,用户上万后计算量爆炸。

解决:两个方向。一是先用「共同评分景点数 ≥ 阈值」做粗筛,只对可能相似的用户对做精算;二是把用户按活跃度分桶,只计算同桶内和相邻桶的相似度,牺牲一点精度换速度。我一般先做粗筛,把候选对从几百万降到几万,耗时能降两个数量级。

4.3 评分矩阵里出现大量 0 分污染

现象:推荐结果莫名其妙,推出来的景点和目标用户偏好完全不搭。

原因:把「未评分」当成了 0 分参与计算。协同过滤里,未评分和 0 分是两回事,前者是「不知道」,后者是「很差」。

解决:加载评分矩阵时,只把有行为的记录放进 Map,没有行为的景点根本不出现在 Map 里。皮尔逊计算时只遍历共同项,天然规避了这个问题。检查你的loadUserRatings方法,如果它给所有景点都填了默认值,那就是 bug 源头。

4.4 定时任务重复执行导致推荐表数据翻倍

现象:t_recommend表里同一个用户有多条重复记录,前端显示重复景点。

原因:定时任务没有做幂等,或者上一次任务没跑完下一次又启动了。

解决:插入前先deleteByUser,并且给定时任务加分布式锁或数据库锁,保证同一时间只有一个实例在跑。如果是单机部署,用synchronizedReentrantLock就够了;多机部署得上 Redis 锁。这个坑在答辩演示时特别致命,因为数据翻倍会让页面看起来很乱。

4.5 新用户推荐接口返回空列表

现象:新注册用户打开首页,推荐位一片空白。

原因:协同过滤算不出结果,又没有兜底逻辑。

解决:在 Service 层判断,如果协同过滤返回空,就走 3.2 节的冷启动兜底。判断逻辑要放在最外层,保证任何情况下都有结果返回。我一般还会在兜底结果里混入一两个高评分的新景点,给新内容曝光机会。

5. 让推荐结果可解释:从评分预测到前端展示的最后一公里

推荐系统做到最后,最容易被忽视、但在答辩时最加分的一环是「可解释性」。你推了一个景点给用户,用户凭什么信?如果只是干巴巴一个列表,说服力很弱。我的做法是在推荐结果里附带一句解释,比如「和你一样喜欢古镇的 12 位用户也收藏了这里」,或者「根据你最近浏览的 3 个自然风光景点推荐」。

实现上,在t_recommend表里加一个reason字段,离线计算时把推荐依据写进去。UserCF 的解释可以取贡献分数最高的那个邻居的行为,比如「相似用户 A 给了 5 分」。下面是一个生成解释的简单方法。

public String buildReason(Long targetUserId, Long scenicId, List<UserSim> neighbors) { for (UserSim nb : neighbors) { Double score = loadUserRatings(nb.userId).get(scenicId); if (score != null && score >= 4.0) { return "和你偏好相似的用户也给了高分"; } } return "根据你的浏览记录推荐"; }

这段逻辑遍历邻居,找到第一个给该景点打过高分的人,返回对应的解释文案。参数上,score >= 4.0的阈值可以调,旅游场景里 4 分以上算明确喜欢。解释文案要短,前端展示时一般截断到 15 个字以内。

验证推荐效果,别只看「能不能跑通」。我一般会做两个检查:一是覆盖率,看有多少比例的景点被推荐过,如果只有头部景点被推,说明多样性不够;二是人工抽查,随机抽 10 个用户,看推荐结果是否符合直觉。这两个检查花不了多少时间,但能帮你发现算法层面的问题,而不是等到答辩被老师问住。

最后说个我自己的习惯:每次调完参数,我都会把推荐结果导出成 CSV,用 Excel 做个简单的透视,看看不同用户类型的推荐差异。这个土办法比任何复杂指标都直观。做毕业设计,能把一个模块讲清楚、调明白,比堆一堆花哨技术更有价值。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询