Spring Boot协同过滤商品推荐:从评分矩阵到ItemCF实现

发布时间:2026/9/15 15:43:25
Spring Boot协同过滤商品推荐:从评分矩阵到ItemCF实现
简介面向Java毕业设计/课程设计的基于协同过滤算法商品推荐系统项目采用Spring Boot框架与Vue前端实现包含完整可运行的源码、说明文档与演示视频适合计算机相关专业学生参考、复用或二次开发。压缩包内共811个文件源码以Java后端、Vue组件、JavaScript、CSS、HTML等为主另有SQL数据库脚本、3份Word说明文档、MP4操作演示视频以及大量GIF界面截图整体大小58.36MB。项目支持JDK1.8、MySQL5.7、Tomcat7与Maven3.3.9提供一键安装、运行批处理脚本导入Eclipse/IDEA即可启动环境搭建门槛较低。目前已有242人学习浏览。通过完整源码与说明文档既能帮助理解协同过滤推荐算法的工程化实现、Spring Boot分层架构与前后端联调方式又能借助视频和SQL脚本快速完成部署从商品展示、用户行为采集到推荐结果输出形成完整闭环适合作为毕业设计或课程设计的基础模板。1. 基于协同过滤的商品推荐项目Spring Boot 源码结构与实际价值如果你在找 Java 毕业设计源码大概率见过“商品推荐系统”这个题目但压缩包里同时出现index.html.bak、update-password.vue.bak、1-install.bat、2-run.bat、3-build.bat时第一反应往往是“是不是发错附件了”。这个项目没有发错Spring Boot 负责后端接口Vue 文件是管理端页面备份三个 bat 脚本分别解决依赖安装、后端启动和前端打包。真正推荐引擎采用的是协同过滤算法用户的行为数据会落进 MySQL 5.7再由后端实时算出推荐列表。做毕设、做课程设计、想快速把推荐链路跑通的人都能从这套结构里找到能抄的部分。2. 协同过滤算法选型UserCF、ItemCF 与评分矩阵设计协同过滤不是一个固定的算法而是一族基于“用户历史行为”的推荐方法。真正把协同过滤落到商品推荐系统里时第一步要确定的不是相似度公式而是评分矩阵长什么样。评分矩阵的数据形态直接决定后续所有推荐逻辑的复杂度和可维护性。2.1 评分矩阵的数据形态与 MySQL 表结构所谓“评分”不一定必须有用户打星的界面。在商品推荐系统里更常见的是把行为映射成分数浏览记 1 分加购记 3 分下单记 5 分。这样既能统一入库结构又不会受制于前端有没有“打分”交互。对应到 MySQL 5.7表结构可以这样定义CREATE TABLE user_item_rating ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT 用户ID, item_id BIGINT NOT NULL COMMENT 商品ID, score TINYINT NOT NULL DEFAULT 1 COMMENT 行为映射得分 1/3/5, behavior_type VARCHAR(16) NOT NULL DEFAULT click, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_item (user_id, item_id), KEY idx_item_id (item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个表决定了协同过滤算法的数据底座。uk_user_item唯一键保证一个用户对一个商品只保留一条得分记录重复点击时通过ON UPDATE CURRENT_TIMESTAMP刷新时间而不是插入新行。idx_item_id是给基于商品的协同过滤准备的计算商品相似度时要按商品分组收集用户向量没有这个索引MySQL 5.7 会执行全表扫描。写入层把行为转换成得分的逻辑建议放在 Service 里不要在 SQL 里反复写CASE WHEN否则时间一长会有人往表里塞进奇怪的中文行为值。另一条常用的查询是按时间窗口取数只保留近 90 天内的行为避免一年前的购买记录持续参与推荐SELECT user_id, item_id, MAX(score) AS score FROM user_item_rating WHERE create_time DATE_SUB(NOW(), INTERVAL 90 DAY) GROUP BY user_id, item_id;使用MAX(score)而不是AVG(score)是我踩过坑之后改过来的。同一用户对同一商品既有点击又有下单时平均分会把 5 分拉低到 3 分左右推荐引擎误以为“兴趣一般”。保留最大分更能表达用户对商品的实际意图。2.2 UserCF 与 ItemCF 的取舍商品推荐领域里有两个最常用的分支基于用户的协同过滤和基于物品的协同过滤。两者解决的问题不同工程开销也完全不同。拿这套 Spring Boot 商品推荐系统的数据规模来看ItemCF 往往是更合适的落点。一张表说明差异维度UserCFItemCF寻找对象找到与你行为相似的用户找到与你买过的商品相似的商品相似度矩阵规模用户数 x 用户数增长快商品数 x 商品数更稳定在线计算用户行为变化后矩阵可能失效商品相似度可离线算好在线查表冷启动表现新用户没有邻居输出为空新用户有 1 次点击即可推荐毕业设计演示友好度用户量小时结果直观数据稀疏时不易返回空列表选 ItemCF 有三个理由。第一商品表通常只有几百到几千行item 相似度矩阵完全能常驻 JVM 内存第二商品之间的相似关系不会因为用户随机点击而剧烈变化适合每天用定时任务离线算一遍第三演示时最怕新注册账号没有历史行为ItemCF 至少能拿用户的一次点击去拉相似商品。如果选 UserCF答辩时很容易被问住“你的用户数量超过 100 万时用户相似度矩阵怎么存”2.3 余弦相似度与数据归一化的边界ItemCF 要计算两个商品向量的相似度把商品 A 的“用户-得分”映射看成向量商品 B 同理两者夹角的余弦值就是相似度cos(A, B) (A·B) / (|A| x |B|)。代码里不需要构建一个长达用户数的稠密向量只取“同时评过 A、B 的用户”部分累加分子分母的模长先按商品离线算好。相比皮尔逊相关系数余弦相似度不需要对每个用户做均值中心化逻辑更简单答辩时也更好讲。行为映射出的 1/3/5 分数本身是离散值做中心化收益很小。这里隐藏一个边界问题要不要先对 score 做归一化我一般不做。如果推荐结果出现严重的“爆款集中”现象再引入 log 缩放或用户行为次数倒数来处理。预处理放在读取评分数据之后、相似度计算之前改动只影响一个纯函数不会波及整个推荐链路。3. Spring Boot 工程配置与三脚本部署流程拿到源码的第一件事不是改代码而是核对环境。项目要求 JDK 1.8、MySQL 5.7、Maven 3.3.9这组版本组合在 Spring Boot 生态里相对保守能规避大量“springboot 版本太高”导致的问题。3.1 JDK 1.8 与 Spring Boot 版本线的匹配关系Spring Boot 3.x 要求 JDK 17 起步如果继续用 JDK 1.8只能走 Spring Boot 2.7 及以前的版本线。项目用 JDK 1.8因此引入的 Spring Boot 版本不能超过 2.7.x。很多人在 IDEA 创建 Spring Boot 项目时默认选了最新版本结果启动时报UnsupportedClassVersionError或NoClassDefFoundError本质都是字节码版本不兼容。Maven 3.3.9 是这一组合的基准它能解析 2021 年之前发布的多数依赖但如果你把依赖升级到新版本可能会出现依赖解析失败或传递依赖冲突。MySQL 5.7 与 JDBC 驱动的搭配同样有讲究。驱动尽量选 5.1.49不要选 8.x。8.x 驱动默认开启 SSL 和时区检查稍微配置不到位启动就报The server time zone value错误。3.2 pom.xml 依赖清单与自动装配说明一个能跑起来的 Spring Boot 后端pom.xml 里最少需要这些依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.3.12.RELEASE/version relativePath/ /parent properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.1.4/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies核心是把父版本控制在2.3.12.RELEASE。它对应 Spring Boot 2.3.x内嵌 Tomcat 9与 JDK 1.8 完全兼容。MyBatis 2.1.4 依赖了其中的spring-boot-autoconfigure的自动装配机制项目启动时会自动读取DataSource配置并注册SqlSessionFactory你不需要自己写Configuration类来创建 SqlSessionFactoryBean。Lombok 标记为optional是为了避免打进最终产物它在编译期生成 getter/setter运行期不需要这样部署 jar 时不会引入多余注解处理器。3.3 三个 bat 脚本的功能与执行顺序压缩包里的1-install.bat、2-run.bat、3-build.bat解决的问题不同执行顺序已经在文件名里标出来了。我用一个表格来说明各自边界脚本作用执行时机1-install.bat拉取 Maven 依赖并打包首次使用或依赖变更后2-run.bat启动 Spring Boot 后端每次开发调试3-build.bat编译 Vue 前端并复制静态资源前端文件改动后REM 1-install.bat mvn clean install -Dmaven.test.skiptrue REM 2-run.bat mvn spring-boot:run -Dfile.encodingUTF-8 REM 3-build.bat cd frontend call npm install call npm run build xcopy dist ..\src\main\resources\static\ /E /Y-Dmaven.test.skiptrue不只是“跳过测试”这么简单。项目的测试用例大多需要连接数据库而首次mvn install通常发生在还没配置好 MySQL 5.7 的环境里保留测试执行往往直接失败。spring-boot:run -Dfile.encodingUTF-8用来固定 JVM 文件编码避免 Windows 下中文商品名在 JSON 响应里乱码。xcopy把 Vue 打包后的 dist 复制到后端 static 目录最终 Jar 自带页面不需要额外部署 Nginx。如果前端源码是带.bak后缀的备份文件执行3-build.bat之前要确认src/views下存在真实的.vue文件。备份文件是给人看的不是给 npm 看的。我会先对比.bak与当前文件的时间戳如果.bak更新就把它重命名为.vue再替换当前文件。4. ItemCF 推荐引擎的 Java 实现与相似度计算把推荐引擎落地到 Spring Boot我习惯拆成三块实体与 Mapper、离线相似度计算、在线推荐接口。每一块都能独立编译测试跑出问题时定位也快。4.1 实体类与 Mapper 层设计先用一个商品相似度表保存 ItemCF 的计算结果这样在线推荐时不需要实时做全量计算。表结构定义如下CREATE TABLE item_similarity ( id BIGINT AUTO_INCREMENT PRIMARY KEY, item_id_a BIGINT NOT NULL, item_id_b BIGINT NOT NULL, similarity DOUBLE NOT NULL, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_pair (item_id_a, item_id_b) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;similarity用 DOUBLE 存余弦相似度实际值域通常在 [0,1]。唯一键uk_pair必须保证 (item_id_a, item_id_b) 顺序统一写入时把较小 id 放前面。如果顺序不固定同一对商品会出现两条记录在线推荐累加得分时会把相似度重复计算。Mapper 接口我只保留三个方法其他花哨查询一律不要Mapper public interface ItemSimilarityMapper { ListItemSimilarityDO selectByItemId(Long itemId); Double selectSimilarity(Long itemIdA, Long itemIdB); int batchInsert(ListItemSimilarityDO list); }selectSimilarity在一次推荐流程里会被高频调用所以 SQL 必须走uk_pair唯一索引。我实际用的是selectByItemId先取候选集再用内存 Map 做后续查询而不是在 for 循环里调selectSimilarity避免 MySQL 5.7 被你打成连接数瓶颈。4.2 在线推荐接口的实现逻辑这是整个项目里最重要的方法输入是用户 ID输出是推荐商品 ID 列表Service public class RecommendServiceImpl implements RecommendService { private final RatingMapper ratingMapper; private final ItemSimilarityMapper similarityMapper; public RecommendServiceImpl(RatingMapper ratingMapper, ItemSimilarityMapper similarityMapper) { this.ratingMapper ratingMapper; this.similarityMapper similarityMapper; } Override public ListLong recommend(Long userId, int topN) { ListRatingDO myRatings ratingMapper.selectRecentByUserId(userId, 90); // 用户没有行为时用热门商品兜底避免返回空列表 if (myRatings.isEmpty()) { return ratingMapper.selectHotItemIds(topN); } ListLong candidateIds new ArrayList(); for (RatingDO my : myRatings) { ListItemSimilarityDO sims similarityMapper.selectByItemId(my.getItemId()); for (ItemSimilarityDO sim : sims) { Long candidate sim.getItemIdA().equals(my.getItemId()) ? sim.getItemIdB() : sim.getItemIdA(); candidateIds.add(candidate); } } // 去掉用户已买过的商品 SetLong bought myRatings.stream() .map(RatingDO::getItemId) .collect(Collectors.toSet()); MapLong, Double scoreMap new HashMap(); for (Long cand : candidateIds) { if (bought.contains(cand)) continue; double total 0.0; for (RatingDO my : myRatings) { Double sim similarityMapper.selectSimilarity(cand, my.getItemId()); if (sim ! null) { total sim * my.getScore(); } } scoreMap.merge(cand, total, Double::sum); } return scoreMap.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }这段代码把推荐过程拆成四步取用户最近 90 天的评分记录对这些商品查相似表得到候选集合从候选中剔除用户已经买过的商品用sim * my.getScore()加权累计每个候选商品的得分排序后取前 topN。scoreMap.merge(cand, total, Double::sum)是处理重复候选的关键同一个候选商品可能会从多个相似源被加进列表不合并会低估它的最终得分。topN 参数不从代码里写死放配置recommend: top-n: 20在 Controller 里用Value(${recommend.top-n})注入答辩时可以直接改配置文件调整推荐条数不需要重新编译。用户向量中包含的历史评分越久远推荐结果越偏向“你过去喜欢过什么”所以时间窗口参数同样建议配置化方便在不同数据量下切换。4.3 离线任务计算商品相似度在线推荐依赖item_similarity表这张表怎么填用 Spring Boot 自带的Scheduled注解就能做定时任务Component public class ItemSimilarityJob { private final RatingMapper ratingMapper; private final ItemSimilarityMapper similarityMapper; Scheduled(cron 0 0 2 * * ?) public void computeSimilarity() { ListRatingDO allRatings ratingMapper.selectAll(); MapLong, ListRatingDO byItem allRatings.stream() .collect(Collectors.groupingBy(RatingDO::getItemId)); ListItemSimilarityDO result new ArrayList(); ListLong itemIds new ArrayList(byItem.keySet()); for (int i 0; i itemIds.size(); i) { for (int j i 1; j itemIds.size(); j) { double sim cosine(byItem.get(itemIds.get(i)), byItem.get(itemIds.get(j))); if (sim 0.01) { result.add(new ItemSimilarityDO(itemIds.get(i), itemIds.get(j), sim)); } } } similarityMapper.batchInsert(result); } private double cosine(ListRatingDO a, ListRatingDO b) { MapLong, Integer scoreA new HashMap(); MapLong, Integer scoreB new HashMap(); for (RatingDO r : a) scoreA.put(r.getUserId(), r.getScore()); for (RatingDO r : b) scoreB.put(r.getUserId(), r.getScore()); double dot 0.0, normA 0.0, normB 0.0; for (Map.EntryLong, Integer e : scoreA.entrySet()) { normA e.getValue() * e.getValue(); Integer s scoreB.get(e.getKey()); if (s ! null) dot e.getValue() * s; } for (Integer v : scoreB.values()) normB v * v; return normA 0 || normB 0 ? 0.0 : dot / (Math.sqrt(normA) * Math.sqrt(normB)); } }sim 0.01是阈值参数相似度低于它的商品对推荐贡献几乎为零塞进表里只会增加在线查询负担。阈值设太高又会损失长尾商品的推荐能力不同数据规模下的建议值评分记录量级建议阈值原因小于 50000.005数据稀疏低阈值保留更多候选5000 到 500000.01折中噪声与召回大于 500000.05数据稠密需要提高阈值过滤干扰batchInsert不要用循环单条插入否则 2 万对相似商品能查出几分钟的延迟。用 MyBatis 的foreach批量插入JDBC URL 加上rewriteBatchedStatementstrue插入速度能提升一个数量级。4.4 把多次查询改成批量查询的细节上面的在线推荐代码在候选商品数量大时selectSimilarity会被调用几百次。一个只做演示的毕设项目可能无所谓但你可以在答辩时主动提一个优化点把循环查询改成一次性批量查询。先调整 MapperListItemSimilarityDO selectByItemIds(Param(itemIds) ListLong itemIds);对应 SQLSELECT item_id_a, item_id_b, similarity FROM item_similarity WHERE item_id_a IN foreach collectionitemIds itemid open( separator, close)#{id}/foreach OR item_id_b IN foreach collectionitemIds itemid open( separator, close)#{id}/foreach拿到批量结果后在内存里按基准商品 ID 分组再双重循环做加权求和。注意 MySQL 5.7 的IN列表长度建议保持在 1000 以内超过就拆成多段查询再合并结果。这个优化看似很小却是能写进项目答辩稿里的“性能改进点”比讲一堆空洞的推荐理论更容易得高分。5. 推荐系统启动排错与热门商品降权技巧最后这部分讲两个实用技巧启动阶段怎么排查问题以及推荐结果被热门爆款“污染”时怎么处理。5.1 启动阶段最常见的三个错误执行2-run.bat时有三个错误几乎每台机器都会遇到。第一Access denied for user rootlocalhost。这不是代码问题是application.yml里的 MySQL 账号密码和本地数据库不一致改配置文件即可。第二The server time zone value报错URL 增加serverTimezoneAsia/Shanghai。第三Port 8080 was already in use直接改server.port并加一句注释避免下次再撞spring: datasource: url: jdbc:mysql://localhost:3306/recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.jdbc.Driver server: port: 8081driver-class-name写成com.mysql.jdbc.Driver对应 mysql-connector-java 5.1.x如果换成 8.x 驱动类名要改为com.mysql.cj.jdbc.Driver。这是很多人升级驱动后启动失败的直接原因。5.2 热门商品降权与推荐多样性检查ItemCF 最容易被质疑的一点推荐结果全是热门商品。这不是 bug而是相似度计算的“聚合效应”。某商品被大量用户评分它与很多商品都有相似度得分会被推高。解决办法是对候选商品原始得分加一个流行度惩罚项double penalty Math.pow(1 itemPopularity.getOrDefault(candId, 0), 0.3); scoreMap.merge(candId, total / penalty, Double::sum);指数取 0.3 是逐步调出来的太大会把长尾商品抬到前面影响准确率太小起不到惩罚效果。itemPopularity可以复用评分表的数据用一条 SQL 按商品统计次数放进 Map 再传入推荐服务。验证多样性我用一个简单习惯把推荐接口返回的 top20 和真实热门排行用 SQL 对比SELECT item_id, COUNT(user_id) AS uv FROM user_item_rating GROUP BY item_id ORDER BY uv DESC LIMIT 20;如果推荐结果和这条 SQL 高度重合说明惩罚力度不够把指数上调到 0.5如果完全不重合且点击率很低优先检查时间窗口内是否混入了大量无意义的浏览记录。本文还有配套的精品资源点击获取