☰
SSM旅游平台协同过滤实战:UserCF与ItemCF落地及避坑指南
2026/10/1 3:34:43 网站建设 项目流程

简介:这是一套面向高校计算机专业毕业设计场景的Java Web完整项目源码,采用SSM框架实现前后端分离的在线通用旅游平台,适合正在准备毕设或需要实战练手的Java学习者参考。项目围绕旅游业务构建了景点推荐管理、精选路线管理、用户信息管理与系统管理四大模块,涵盖景点信息的增删改查、路线设置与查询、用户资料维护以及公告、简介、在线留言、站内新闻等后台功能,业务逻辑贴近真实旅游网站需求。压缩包共1288个文件,约52.7MB,包含java源码、jsp页面、js脚本、css样式、html页面、xml配置、properties配置、sql数据库脚本以及大量gif、png、jpg图片素材和jar依赖包,前端页面、后端逻辑与MySQL数据库脚本一应俱全,可直接导入运行。目前已有58人学习下载,适合希望快速获取完整毕设方案、理解SSM分层结构与协同过滤推荐思路的同学参考借鉴。

1. 从一份 SSM 旅游平台源码说起:协同过滤到底落在哪一层

很多同学拿到「基于协同过滤的在线通用旅游平台网站(SSM 前后端完整源码)」这类题目时,第一反应是去翻 Controller 和 JSP,结果翻完一圈发现:登录、下单、后台管理都看懂了,唯独「协同过滤」四个字找不到落点。这是最典型的翻车现场——把推荐算法当成了一个页面,而不是一条数据链路。它真正的位置在「用户行为采集 → 评分矩阵构建 → 相似度计算 → 推荐结果回写 → 前端展示」这条链上,SSM 只是承载它的骨架。这篇笔记就按这条链路拆:先讲清 SSM 三层里推荐模块该放哪、评分数据从哪来,再给可复现的 UserCF/ItemCF 实现、参数怎么调、冷启动和矩阵稀疏怎么救,最后落到一个能直接验证效果的技巧。适合正在做 Java 课程设计、毕业设计,或者想把协同过滤真正跑通而不是只贴个类名的同学。

2. SSM 分层里推荐模块该放哪:从表结构到调用链

2.1 为什么推荐逻辑不能塞进 Controller

SSM 的经典分层是 Controller → Service → Mapper,很多人图省事,把相似度计算直接写在 Controller 里,请求一次算一次。小数据量下看不出问题,一旦用户数和景点数上去,单次请求要遍历全量评分矩阵,响应时间直接爆炸。正确的做法是把推荐拆成两层:离线计算层负责生成「用户-推荐结果」和「景点-相似景点」两张结果表,在线层只做查询和排序。这样 Controller 只干一件事——根据当前用户 ID 查推荐表,Service 负责组装景点详情,Mapper 负责 SQL。

我一般会建三张核心表:user_behavior(行为流水)、user_sim(用户相似度)、recommend_result(推荐结果)。行为流水是原始数据,相似度和推荐结果是离线算出来的中间产物。这样设计的好处是,算法迭代时只重跑离线任务,不用动线上接口。

-- 用户行为流水表:评分、浏览、收藏统一记录 CREATE TABLE user_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 NULL COMMENT '评分1-5,非评分为空', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_scenic (scenic_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 推荐结果表:离线任务写入,线上直接查 CREATE TABLE recommend_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, predict_score DECIMAL(6,4) NOT NULL, reason VARCHAR(64) DEFAULT 'user_cf', update_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_scenic (user_id, scenic_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

behavior_type用整型而不是字符串,是为了后续做加权时能直接映射权重;score允许为空,因为浏览行为没有评分,只有评分行为才填。recommend_result上的唯一索引很关键,离线任务重跑时用INSERT ... ON DUPLICATE KEY UPDATE就能幂等写入,避免重复推荐。

2.2 行为权重怎么定:把隐式反馈变成可计算的分数

协同过滤的输入是评分矩阵,但旅游平台里真正显式的评分很少,大部分是浏览和收藏。常见做法是给不同行为赋权:浏览 1 分、收藏 3 分、评分按实际值、下单 5 分。这样把隐式反馈折算成 1-5 的等效评分,矩阵密度能提升一个量级。

// BehaviorWeight.java:行为到评分的映射 public class BehaviorWeight { public static double toScore(int behaviorType, Double rawScore) { switch (behaviorType) { case 1: return 1.0; // 浏览 case 2: return 3.0; // 收藏 case 3: return rawScore == null ? 3.0 : rawScore; // 评分 case 4: return 5.0; // 下单 default: return 0.0; } } }

参数说明:权重不是拍脑袋定的,浏览给 1 是因为它噪声最大,用户可能只是点错;下单给 5 是因为它代表真实消费意图。如果平台收藏行为特别多,可以把收藏降到 2.5,避免收藏党拉偏相似度。这个映射函数建议放在 Service 层,离线任务和实时补算共用同一份逻辑,否则两边口径不一致,推荐结果会对不上。

2.3 离线任务怎么触发:定时还是手动

离线计算不需要上重型调度框架,SSM 项目里用 Spring 的@Scheduled就够了。关键是控制频率:用户量小的时候每小时跑一次,用户量上万后改成每天凌晨跑一次全量,白天只对活跃用户做增量。触发入口我一般留一个手动接口,方便调试时立刻重算,不用等定时。

@Component public class RecommendJob { @Autowired private RecommendService recommendService; // 每天凌晨2点全量重算 @Scheduled(cron = "0 0 2 * * ?") public void fullCompute() { recommendService.computeUserSimilarity(); recommendService.generateRecommend(); } }

cron表达式0 0 2 * * ?表示每天 2:00 执行。注意全量计算要加分布式锁或数据库标记位,防止多实例部署时重复跑。手动接口建议加权限校验,别让普通用户能触发全量计算,否则一次误点就是几分钟的数据库压力。

3. 协同过滤核心实现:UserCF 与 ItemCF 的 Java 落地

3.1 评分矩阵构建:从流水表到 Map 结构

算法第一步是把数据库里的行为流水读成内存中的评分矩阵。旅游平台的数据量通常在几千用户、几百景点这个量级,用Map<Long, Map<Long, Double>>完全够用,不需要上 Spark。外层 key 是用户 ID,内层 key 是景点 ID,value 是折算后的评分。

// 构建用户-景点评分矩阵 public Map<Long, Map<Long, Double>> buildMatrix() { List<UserBehavior> list = behaviorMapper.selectAll(); Map<Long, Map<Long, Double>> matrix = new HashMap<>(); for (UserBehavior b : list) { double score = BehaviorWeight.toScore(b.getBehaviorType(), b.getScore()); matrix.computeIfAbsent(b.getUserId(), k -> new HashMap<>()) .merge(b.getScenicId(), score, Double::sum); // 同一对多次行为累加 } return matrix; }

这里用merge而不是put,是因为同一用户对同一景点可能既浏览又收藏,累加能体现更强的偏好。但要注意封顶,累加后超过 5 分的按 5 分处理,否则重度用户会把矩阵值拉得过大,影响余弦相似度的可比性。selectAll在数据量大时要改成分页或按时间窗口查,别一次性把全表拉进内存。

3.2 UserCF:找相似的人,推他喜欢的景点

UserCF 的逻辑是「和你口味相似的人喜欢的景点,你也可能喜欢」。核心是两步:算用户两两之间的余弦相似度,然后取 TopN 相似用户的景点做加权推荐。

// 余弦相似度计算 public double cosine(Map<Long, Double> u1, Map<Long, Double> u2) { double dot = 0, norm1 = 0, norm2 = 0; for (Map.Entry<Long, Double> e : u1.entrySet()) { norm1 += e.getValue() * e.getValue(); Double v2 = u2.get(e.getKey()); if (v2 != null) dot += e.getValue() * v2; } for (double v : u2.values()) norm2 += v * v; if (norm1 == 0 || norm2 == 0) return 0; return dot / (Math.sqrt(norm1) * Math.sqrt(norm2)); }

参数说明:余弦相似度对评分绝对值不敏感,只看方向,适合旅游这种评分尺度不统一的场景。如果想让高分用户影响更大,可以换成皮尔逊相关系数,但要处理方差为零的情况。计算两两相似度是 O(n²),用户上千后要加倒排索引优化——只计算有共同景点评分的用户对,没有交集的直接跳过。

// 基于相似用户的推荐 public List<RecommendResult> recommendByUser(long userId, int topN) { Map<Long, Double> target = matrix.get(userId); if (target == null) return Collections.emptyList(); // 取相似度最高的 topN 个用户 List<Map.Entry<Long, Double>> sims = userSim.entrySet().stream() .filter(e -> !e.getKey().equals(userId)) .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(topN) .collect(Collectors.toList()); Map<Long, Double> scores = new HashMap<>(); Map<Long, Double> simSum = new HashMap<>(); for (Map.Entry<Long, Double> sim : sims) { for (Map.Entry<Long, Double> item : matrix.get(sim.getKey()).entrySet()) { if (target.containsKey(item.getKey())) continue; // 已看过的不推 scores.merge(item.getKey(), sim.getValue() * item.getValue(), Double::sum); simSum.merge(item.getKey(), sim.getValue(), Double::sum); } } // 加权平均,归一化 return scores.entrySet().stream() .map(e -> new RecommendResult(userId, e.getKey(), e.getValue() / simSum.get(e.getKey()))) .sorted((a, b) -> Double.compare(b.getPredictScore(), a.getPredictScore())) .limit(10) .collect(Collectors.toList()); }

topN一般取 10-20,太小推荐多样性差,太大引入不相关用户。simSum做归一化是为了消除相似用户数量不同带来的偏差。过滤掉target.containsKey的景点,避免推荐用户已经看过的内容,这一步不做的话推荐列表会全是老面孔。

3.3 ItemCF:找相似的景点,从历史偏好推

ItemCF 的逻辑是「你喜欢的景点,和它相似的景点你也可能喜欢」。旅游场景里 ItemCF 往往比 UserCF 更稳,因为景点之间的相似关系比用户之间的相似关系更稳定,不会因为一个用户的行为突变而剧烈波动。

// 景点相似度:基于共同被哪些用户评分 public void computeItemSimilarity() { Map<Long, Map<Long, Double>> itemUsers = new HashMap<>(); for (Map.Entry<Long, Map<Long, Double>> u : matrix.entrySet()) { for (Map.Entry<Long, Double> i : u.getValue().entrySet()) { itemUsers.computeIfAbsent(i.getKey(), k -> new HashMap<>()) .put(u.getKey(), i.getValue()); } } for (Long i1 : itemUsers.keySet()) { for (Long i2 : itemUsers.keySet()) { if (i1 >= i2) continue; // 只算一次 double sim = cosine(itemUsers.get(i1), itemUsers.get(i2)); if (sim > 0.1) { // 阈值过滤,减少噪声 itemSim.computeIfAbsent(i1, k -> new HashMap<>()).put(i2, sim); itemSim.computeIfAbsent(i2, k -> new HashMap<>()).put(i1, sim); } } } }

sim > 0.1这个阈值是血泪经验:不设阈值的话,相似度矩阵会非常稠密,大量弱相关景点混进推荐结果,用户看到的就是一堆莫名其妙的推荐。阈值具体取多少要看数据,一般 0.1-0.2 之间试。i1 >= i2的判断保证每对只算一次,相似度矩阵是对称的,存两份浪费内存。

3.4 推荐结果回写与线上查询

离线算完的结果要落库,线上接口只查recommend_result表。回写用批量插入,别一条条 insert。

// 批量回写推荐结果 public void saveRecommend(List<RecommendResult> list) { if (list.isEmpty()) return; recommendMapper.batchUpsert(list); }

对应的 Mapper XML 用ON DUPLICATE KEY UPDATE,保证重跑幂等。线上查询接口按user_id查,按predict_score倒序取前 10,再关联景点表补详情。如果推荐表里没有该用户的记录(新用户),走热门景点兜底,别返回空列表。

4. 避坑与排查:协同过滤在 SSM 项目里最容易翻车的 5 个点

4.1 现象:推荐结果全是热门景点,个性化为零

原因:评分矩阵太稀疏,大部分用户只有一两条行为,相似度算出来全是 0 或极低,最后只有热门景点能凑够推荐数。这是冷启动和稀疏性的叠加问题。

解决:一是引入行为权重把隐式反馈用起来,把矩阵密度提上去;二是对相似度为 0 的用户走「热门 + 地域」兜底,按用户注册地推当地景点;三是设置最小行为数阈值,行为少于 3 条的用户不参与相似度计算,避免噪声用户污染矩阵。

4.2 现象:离线任务跑完,线上推荐没变化

原因:最常见的是缓存没清。很多项目在 Service 层加了@Cacheable,推荐结果更新后缓存还是旧的。其次是事务没提交,离线任务里用了@Transactional但异常被吞了。

解决:推荐结果回写后主动清缓存,或者给缓存设短过期时间。检查离线任务的日志,确认batchUpsert实际影响行数。我一般会在回写后打一行log.info("recommend updated, rows={}", rows),排查时一眼就能看出有没有写进去。

4.3 现象:相似度计算时 CPU 飙满,接口超时

原因:两两计算是 O(n²),用户上千后单线程跑不动,而且和线上请求抢资源。

解决:离线任务加线程池分批计算,或者限制在低峰期跑。更彻底的做法是用倒排索引,只计算有共同景点的用户对。另外给离线任务设超时,跑不完就中断,别让它拖垮整个应用。

4.4 现象:同一用户刷新几次,推荐结果跳来跳去

原因:相似度排序时用了HashMap的遍历顺序,相同相似度的用户顺序不稳定,导致推荐结果抖动。

解决:排序时加次级排序键,比如相似度相同时按用户 ID 排序。sorted的比较器要写成全序,别只比相似度。这个坑很隐蔽,因为结果不是错的,只是不稳定,但用户体验上会觉得系统「不靠谱」。

4.5 现象:新用户进来直接 500

原因:matrix.get(userId)返回 null,后面直接.entrySet()空指针。

解决:所有从矩阵取数据的地方都做 null 判断,新用户走兜底逻辑。这个是最基础的防御性编程,但赶工期时最容易漏。建议在 Service 入口统一处理:拿不到推荐就走热门,拿得到再走个性化。

5. 让推荐效果可验证:离线指标 + 线上 A/B 的最小做法

算法写完不算完,得能证明它比「随机推」或「全推热门」好。最省事的验证方式是留出法:把用户行为按时间排序,最后 20% 作为测试集,前面 80% 训练,看推荐列表命中测试集的比例,也就是命中率(Hit Rate)。

// 离线命中率评估 public double hitRate(int testRatio) { // 按时间切分,前80%训练,后20%测试 List<UserBehavior> all = behaviorMapper.selectAllOrderByTime(); int split = (int) (all.size() * (1 - testRatio / 100.0)); List<UserBehavior> train = all.subList(0, split); List<UserBehavior> test = all.subList(split, all.size()); // 用训练集重建矩阵和相似度(略) Map<Long, Set<Long>> groundTruth = new HashMap<>(); for (UserBehavior b : test) { groundTruth.computeIfAbsent(b.getUserId(), k -> new HashSet<>()) .add(b.getScenicId()); } int hit = 0, total = 0; for (Map.Entry<Long, Set<Long>> e : groundTruth.entrySet()) { List<RecommendResult> recs = recommendByUser(e.getKey(), 20); Set<Long> recItems = recs.stream() .map(RecommendResult::getScenicId).collect(Collectors.toSet()); for (Long truth : e.getValue()) { total++; if (recItems.contains(truth)) hit++; } } return total == 0 ? 0 : (double) hit / total; }

参数说明:testRatio取 20 是常见做法,测试集太小评估不稳,太大训练数据不够。recommendByUser的 topN 要和线上一致,否则评估结果没有参考意义。这个指标跑出来,UserCF 和 ItemCF 各算一遍,谁高用谁,别凭感觉选。

线上验证更简单:给推荐位加个来源标记,一半用户走协同过滤,一半走热门,统计点击率。不用上专业 A/B 平台,在recommend_result表里加个group字段,查询时按用户 ID 哈希分流就行。跑一周看数据,点击率提升超过 10% 就说明算法有效,没提升就回去调相似度阈值和权重。

我自己的习惯是:任何推荐算法上线前,先跑一遍离线命中率,低于热门兜底的就别上,省得线上丢人。调参时一次只动一个变量,相似度阈值、topN、行为权重分开调,不然出了问题根本不知道是哪个参数导致的。这套流程在课程设计和毕业设计里足够撑起「算法有验证」这一环,答辩时也能拿出数据说话。希望帮到你。

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

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

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

立即咨询