做推荐系统这件事很多人第一反应是算法很难。但当我真正把一个 Java SpringBoot Vue3 MyBatis 的协同过滤电影推荐系统从前端页面到后端算法完整跑通之后最大的体会是算法本身就是整个项目里相对好啃的部分真正折磨人的是数据怎么设计、接口怎么拆、推荐结果怎么才能让用户觉得有点东西。这篇文章就围绕我最近完成的一个前后端分离的电影推荐系统源码项目把整套系统的设计思路、协同过滤落地细节、MySQL 表结构、MyBatis 持久层写法、Vue3 前端联调过程以及调试阶段踩过的坑全部梳理一遍。这个东西适合谁如果你正在学 SpringBoot想找一个真实完整的项目练手或者手头有一个课程设计、个人作品集项目想用推荐系统作为亮点再或者你已经在写传统 CRUD 项目想看看带算法的业务系统到底怎么组织代码——这篇文章都值得从头读到尾。我不讲那种只能在 PPT 里跑通的伪推荐只讲能真实部署、能出推荐结果、能接住用户请求的工程实现。1. 项目定位与技术选型为什么是这套组合而不是 Python1.1 这套系统到底要解决什么问题我接到的需求很明确做一个电影推荐网站用户注册登录后可以浏览电影、给看过的电影打分、收藏喜欢的电影系统根据用户的历史行为在首页生成猜你喜欢推荐列表。核心指标就两个——推荐内容的相关性和接口响应速度。功能清单拆下来大概是这些用户模块注册、登录、Token 鉴权、个人信息电影模块电影列表分页、类型筛选、关键字搜索、电影详情交互模块用户评分、收藏、取消收藏推荐模块基于协同过滤生成个性化推荐列表、热门电影榜单管理简单数据管理员可以录入电影信息我初期直接用 SQL 脚本灌数据管理后台非重点这个需求看起来不复杂但个性化推荐四个字决定了它不是普通的增删改查。推荐模块需要读取全量用户-电影评分数据计算相似度生成推荐结果而且不能每次请求都现场算一遍否则数据库扛不住。1.2 技术选型对比Java 后端在这类项目里的优势很多做推荐系统的教程一上来就是 Python用 Pandas 加 scikit-learn 算相似度看起来确实方便。但我的目标是做一个能长期维护、能对接真实前端的完整系统不是一份 Jupyter Notebook。Python 的问题不在算法而在工程化。Flask 或 FastAPI 写接口很快但用户体系、权限拦截、事务管理、部署运维这些环节生态比较散团队协作时也容易写出每个人风格都不一样的代码。Java 这边 SpringBoot 把 Web 容器、依赖注入、事务、拦截器、配置管理全部标准化了项目结构清晰招人好招部署资料多。所以我定了这套组合层级选型选它的理由后端框架Spring Boot 2.7.x生态成熟内置 Tomcat快速构建 REST API持久层MyBatis配合 MyBatis-Plus 辅助SQL 可控性强适合写复杂查询和批量操作数据库MySQL 8.x稳定、通用InnoDB 支持事务和外键前端框架Vue3 Vite Element Plus组合式 API 写起来清爽组件库开箱即用状态管理PiniaVue3 官方推荐比 Vuex 更轻量缓存Caffeine本地缓存推荐结果避免重复计算这里多说一句 MyBatis 和 MyBatis-Plus 的选择。我用 MyBatis 作为主 ORM但同时引入了 MyBatis-Plus 的 BaseMapper好处是单表 CRUD 不用写 SQL复杂的推荐统计查询再手写 XML。两者共存完全不冲突别迷信二选一。1.3 前后端分离的模块划分前后端分离的意思是前端工程和后端工程完全独立通过 HTTP 接口通信。我本地开发时前端跑在http://localhost:5173后端跑在http://localhost:8080联调时靠 Vite 代理把/api转发到后端。后端工程按 maven 标准结构分包com.example.movie ├── controller # 接口层只做参数接收和结果封装 ├── service # 业务层推荐算法核心在这里 ├── mapper # MyBatis 的 Mapper 接口 ├── entity # 数据库实体 ├── dto # 前端交互对象 ├── config # 跨域、拦截器、缓存配置 └── common # 统一返回结果、异常处理前端工程按 Vue 惯例分包views放页面components放组件api里统一封装 axios 请求router配路由守卫store放 Pinia。这样拆的好处是职责单一。推荐算法改动只影响 service 层页面调整只动 views不会出现改一个按钮结果数据库连接串崩了的尴尬情况。2. 协同过滤算法实现从评分矩阵到推荐列表的完整推导2.1 两种协同过滤路线的选择协同过滤分为基于用户User-Based和基于物品Item-Based两条路线。基于用户的思路是物以类聚、人以群分找到和我口味最像的一群用户看他们看了什么我没看过的电影推荐给我。基于物品的思路是喜欢 A 的人往往也喜欢 B我评分过《盗梦空间》系统找到和《盗梦空间》最相似的几部电影推荐给我。我在项目里主力用的是 Item-Based 协同过滤原因是工程上更好落地。电影数量相对用户数量少一个数量级物品相似度矩阵的计算和更新成本更低而且新电影出现后只要积累了一定评分就能进入推荐池推荐的实时性可控。User-Based 我只用在首页的好友在看这种辅助推荐位。2.2 相似度计算的公式与代码实现Item-Based 的核心是计算电影两两之间的相似度。最常用的是余弦相似度Cosine Similarity。把每个电影的评分向量看成一个多维空间中的向量用户维度就是空间维度向量的夹角越小说明两个电影被同一批用户用相近的分数评价过也就是越相似。公式是sim(A, B) Σ(ruA * ruB) / (sqrt(Σ(ruA²)) * sqrt(Σ(ruB²)))其中 ruA 是用户 u 给电影 A 的评分。我直接用 Java 实现没有依赖 Python 的 sklearnpublic double cosineSimilarity(MapLong, Double itemAScores, MapLong, Double itemBScores) { double dotProduct 0.0; double normA 0.0; double normB 0.0; for (Map.EntryLong, Double entry : itemAScores.entrySet()) { normA entry.getValue() * entry.getValue(); Double scoreB itemBScores.get(entry.getKey()); if (scoreB ! null) { dotProduct entry.getValue() * scoreB; } } for (Double score : itemBScores.values()) { normB score * score; } if (normA 0.0 || normB 0.0) { return 0.0; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }实际工程里我用了调整余弦相似度Adjusted Cosine Similarity。为什么要多加一步因为不同用户的打分习惯完全不同有些人手松给什么片都是 8 分以上有些人手紧 6 分算高分。如果不做去均值处理相似度会被手松的人主导。调整方法是对每个评分减去该用户的平均分再套余弦公式。double adjustedScore score - userAvg.get(userId);这一行改动推荐结果的变化非常明显。我去掉均值前后对比过去均值前推荐列表里总出现评分人数最多的大片个性化程度低去均值后小众文艺片开始冒出来了因为它们的评分结构更像目标用户。2.3 Top-N 推荐生成从相似度到推荐列表的完整链路相似度算完后生成推荐列表分四步走取出用户已评分过的电影列表作为种子物品。对每个种子电影找出相似度 Top K 的候选电影我取 K 10。对候选电影做加权评分预测预测分 Σ(用户对种子电影评分 * 种子电影与候选电影相似度) / Σ(种子电影与候选电影相似度)。过滤掉用户已经看过的电影按预测分降序排列取 Top N。第三步的公式很直观用户打高分且与候选电影高度相似的种子电影对候选电影预测分的贡献就大。这相当于让用户自己的历史品味来投票。要注意的是性能。假设有 2000 部电影每部电影算一遍两两相似度就是约 200 万次计算每次计算还要遍历评分集合。这个量级如果每次请求都实时算接口必然慢。我的做法是把相似度矩阵和用户的 Top-N 推荐结果都缓存起来只在有新的评分产生时增量更新。具体缓存策略在第四章细说。3. MySQL 表结构与 MyBatis 持久层的实战设计3.1 核心表设计五张表撑起整个系统数据库设计是这类项目最容易被低估的部分。推荐系统对数据的要求不只是能存更要好取。我的 MySQL 库建了五张核心表-- 用户表 CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, nickname VARCHAR(50) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 电影表 CREATE TABLE movie ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(200) NOT NULL COMMENT 片名, genres VARCHAR(100) COMMENT 类型逗号分隔, director VARCHAR(100), release_year INT, rating DECIMAL(3,1) COMMENT 豆瓣风格平均分, poster_url VARCHAR(500), description TEXT, PRIMARY KEY (id), KEY idx_genres (genres), KEY idx_release_year (release_year) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT电影表; -- 评分表 CREATE TABLE rating ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, movie_id BIGINT NOT NULL, score TINYINT NOT NULL COMMENT 1-10分, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_movie (user_id, movie_id), KEY idx_movie (movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户评分表; -- 收藏表 CREATE TABLE favorite ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, movie_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_movie (user_id, movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收藏表;另外一张recommend_log表记录每次推荐结果方便运营分析推荐覆盖率也为后续做推荐质量评估留数据。这里有三个设计细节是踩坑后加的一是评分表必须加(user_id, movie_id)唯一索引否则前端多点两下就会出现同一个用户对同一部电影的多条评分推荐算法直接被脏数据搞乱二是电影类型字段用逗号分隔存储查询时用FIND_IN_SET或LIKE处理够用且简单不需要拆关联表三是所有时间字段默认值都用CURRENT_TIMESTAMP省得 Java 代码里手动塞时间还能避免服务器时区不一致导致的脏数据。3.2 MyBatis 动态 SQL推荐场景里的三个关键写法MyBatis 的 XML 映射文件里动态 SQL 是这个项目的核心武器。我总结三个高频场景的写法。第一个是电影列表的多条件筛选。前端首页有类型、年份、排序三个筛选维度必须拼 SQLselect idselectMovieList resultTypecom.example.movie.entity.Movie SELECT * FROM movie where if testgenres ! null and genres ! AND FIND_IN_SET(#{genres}, genres) /if if testyear ! null AND release_year #{year} /if /where ORDER BY choose when testsort ratingrating DESC/when when testsort yearrelease_year DESC/when otherwiseid DESC/otherwise /choose LIMIT #{offset}, #{pageSize} /selectwhere标签会自动处理多余的 ANDchoose做排序字段的白名单控制避免用户传值直接拼接进 ORDER BY 造成 SQL 注入。第二个是协同过滤的数据源查询。推荐算法需要拿到每个用户对所有电影的评分这个查询最怕 N1 问题。我的做法是一次性查出全量评分select idselectAllRatings resultTypecom.example.movie.dto.RatingDTO SELECT user_id, movie_id, score FROM rating /select然后直接在 Java 内存里组装成MapLong userId, MapLong movieId, Double score的稀疏矩阵。2000 部电影、1 万个用户、20 万条评分数据内存占用也就几十 MB完全没问题。千万别写循环里查数据库的代码200 万次查询会直接把数据库打挂。第三个是批量插入。用户一次导入评分数据或管理员批量录入电影时逐条 INSERT 性能太差。MyBatis 支持 foreach 批量insert idbatchInsertRatings INSERT INTO rating (user_id, movie_id, score) VALUES foreach collectionlist itemitem separator, (#{item.userId}, #{item.movieId}, #{item.score}) /foreach /insert配合allowMultiQueriestrue或者在 MySQL JDBC 连接串上加rewriteBatchedStatementstrue插入效率能提升一个量级。3.3 冷启动数据填充没有评分时的推荐怎么做系统刚上线时没有用户评分数据协同过滤算出来全是 0首页推荐会空白。这也是推荐系统最经典的冷启动问题。我的处理分三层第一层预置模拟用户行为数据。我在 SQL 脚本里插入了 300 个模拟用户、2000 部电影、5 万条模拟评分。评分的生成逻辑是先给每部电影一个基础分正态分布在 6.5 分附近再让模拟用户按不同口味偏好打分比如偏爱科幻片的用户给科幻类目附加 1.5 分。这样数据天生就带人群分化协同过滤算法一上来就能算出有意义的相似度。第二层新用户注册后默认推荐热门榜。热门榜用 SQL 直接算SELECT movie_id, COUNT(*) cnt, AVG(score) avg_score FROM rating GROUP BY movie_id ORDER BY cnt DESC, avg_score DESC LIMIT 10。这是兜底推荐保证页面永远不为空。第三层新用户第一次评分后立即触发增量推荐。用户只要打了分就基于这一条种子评分跑一遍推荐虽然样本少但至少有看过科幻片就推科幻片的基础效果。等评分积累多了推荐质量自然上升。4. SpringBoot 后端服务接口设计、缓存与并发处理4.1 REST 接口与统一返回结构后端接口设计我遵循 REST 风格统一返回结构定义在common包里public class ResultT { private Integer code; // 200 成功401 未登录500 异常 private String message; private T data; }核心接口清单如下方法路径功能POST/api/user/login登录返回 JWTPOST/api/user/register注册GET/api/movie/list电影分页列表GET/api/movie/{id}电影详情POST/api/rating/submit提交评分GET/api/recommend/{userId}获取个性化推荐GET/api/movie/hot热门榜单Controller 层只做三件事拿参数、调 service、包 Result。业务逻辑全部下沉到 service这样接口层永远瘦瘦的改动时风险小。4.2 推荐服务的缓存策略Caffeine 如何帮我省掉 90% 计算推荐计算是高开销操作。我的做法是引入 Caffeine 本地缓存设置两级缓存第一级缓存相似度矩阵。因为电影之间的相似度变化很慢只有新评分入库才需要更新。我设置了CacheLong, MapLong, Doublekey 是电影 IDvalue 是它与其他电影的相似度集合失效时间 30 分钟。第二级缓存每个用户的推荐结果。CacheLong, ListMovieVOkey 是用户 IDvalue 是算好的 Top-N 电影列表失效时间 10 分钟。用户提交新评分时主动把该用户 ID 对应的推荐缓存清掉。这样用户评分后刷新页面能立刻看到新推荐体验上非常流畅。实测下来加了缓存后推荐接口的 P99 响应时间从 2 秒多降到 200 毫秒以内。这里有个小坑Caffeine 的expireAfterWrite是惰性淘汰数据过期后不是立刻删除而是下次访问时判断。如果数据一致性要求高必须配合主动失效不能只依赖过期时间。4.3 评分提交的并发处理与幂等设计评分接口是高并发入口用户可能连续提交多次也可能同一时间多个设备操作。我在POST /api/rating/submit里做了三层保护第一层幂等。由于表结构里有(user_id, movie_id)唯一索引重复提交直接走ON DUPLICATE KEY UPDATEMyBatis 对应的 SQL 是INSERT INTO rating (user_id, movie_id, score) VALUES (#{userId}, #{movieId}, #{score}) ON DUPLICATE KEY UPDATE score VALUES(score)这样无论前端怎么重复提交最终只有一条有效评分。第二层事务。更新评分和清除推荐缓存虽然跨了不同组件但数据库部分必须在一个事务里完成。我在 service 层加Transactional确保评分写入失败时不会留下半截状态。第三层异步化。推荐缓存清除操作不走主链路用 Spring 的Async注解丢到线程池执行接口主流程只做数据库写入。这样极端并发下也不会因为缓存清理阻塞评分提交。5. Vue3 前端实现与前后端联调的关键细节5.1 页面结构与路由设计前端我用 Vue3 组合式 API 加 Element Plus 搭建页面结构分为四个主路由/home首页包含轮播 Banner、热门榜、个性化推荐区/movie电影列表页支持类型筛选、分页/movie/:id电影详情页展示信息、评分组件、收藏按钮/user个人中心展示我的评分、我的收藏路由守卫是必须做的。我在router/index.js里给需要登录的页面加了meta.requiresAuth: true每次跳转前检查 Pinia 里有没有 token没有就重定向到登录页。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })这里有个坑刷新页面后 Pinia 状态会被清空token 虽然还在 localStorage但用户信息丢了。我的解决方案是在 App.vue 的onMounted里调用/api/user/info重新拉取用户信息并用 Pinia 里的state持久化插件自动同步 localStorage。5.2 联调中的跨域与 Token 处理前后端分离开发中最常见的就是跨域问题。前端跑 5173 端口后端跑 8080 端口浏览器会拦截非同源的请求。我踩过两种处理方式最终用的是生产可靠的方案后端 CORS 配置加前端代理双保险。后端方面在 SpringBoot 里配置全局 CORSConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }前端开发环境则在 Vite 配置代理请求路径以/api开头的全部转发到 8080server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }Token 鉴权我用的 JWT。前端在 axios 请求拦截器里统一加上Authorization: Bearer token后端写一个拦截器对/api/**请求校验 token校验失败返回 401。这里特别提醒拦截器要把登录、注册接口放行不然还没登录就被拦住了另外 token 过期后返回的 401前端 axios 响应拦截器里要统一处理比如跳转登录页并清掉本地 token。5.3 推荐结果展示与用户反馈闭环推荐列表的展示看似简单实际上有个关键点要给用户为什么推荐这个的上下文。我在电影卡片上不直接说猜你喜欢而是显示一行小字因为你看过《星际穿越》这个信息来自推荐服务返回的reason字段。用户看到推荐来源点击率和好感度明显比无解释推荐高。评分组件我用了 Element Plus 的el-rate支持 hover 预览。用户评分后立即调用submitRating接口成功后 toast 提示同时前端本地更新该电影的平均分显示。这个反馈闭环对推荐系统特别重要用户每一次评分都是对算法的一次投喂评分行为越真实推荐越准。前端还有一个细节推荐接口返回的数据里要包含isFavorited字段标识当前用户是否已收藏这样电影卡片的收藏按钮初始状态才正确。这个字段不能靠前端另发请求拼接否则每个卡片多一次 API 调用页面会明显变慢。6. 实测效果、踩坑记录与后续扩展6.1 调试阶段最让我抓狂的三个问题第一个是 MyBatis 的驼峰映射问题。数据库字段create_time默认不会自动映射到 Java 属性createTime结果查出来的对象里 createTime 永远是 null。排查了半天才发现是mapUnderscoreToCamelCase配置没开。在application.yml里加上mybatis: configuration: map-underscore-to-camel-case: true这个问题初学 MyBatis 的人几乎必踩建议一上来就写好这个配置。第二个是推荐结果空列表问题。项目刚联调完时我拿一个新注册的测试账号看首页推荐区居然是空的。原因是新用户没有任何推荐种子缓存里也没有数据热门兜底逻辑只写在首页的热门榜没写在推荐区。修复方案就是第三章说的三层冷启动策略把热门榜和推荐区打通。第三个是前端跨域请求带 cookie 的问题。因为用了allowCredentials(true)前端 axios 也开了withCredentials结果 CORS 配置里allowedOrigins不能用*必须写具体域名否则浏览器直接拦截。生产环境上线后我又把allowedOrigins改成了配置项方便不同环境切换。6.2 推荐质量怎么评估不能只说看起来还行推荐系统的效果评估不能只靠肉眼。我做了两层评估离线层面把已有的评分数据按 8:2 拆成训练集和测试集。用训练集跑推荐对测试集里用户真实评分过的电影检查推荐列表里有没有出现计算精确率和召回率。我跑了 10 组实验Top-10 推荐的精确率稳定在 22% 到 28% 之间。这个数字不算惊艳但对一个纯协同过滤、没有引入电影内容特征的项目来说属于正常水平。在线层面我看两个指标推荐区的点击率和评分转化率。用户在首页看到推荐电影后会不会点进详情点进来后会不会顺手评分。调试过程中我发现推荐解释文案对点击率影响很大加了因为你喜欢 XX这个因子后点击率提升了大约 15%。6.3 我踩过之后想分享的三个心得第一相似度矩阵不要实时算。哪怕数据量只有几千部电影实时计算也会拖垮接口缓存和预计算是推荐系统工程的必修课。第二评分数据比算法重要。算法再花哨没有真实评分数据都是白搭。与其把时间花在折腾更复杂的模型上不如先做好评分引导和数据处理。第三冷启动问题是绕不过去的。任何推荐系统上线初期都要面对没有数据的尴尬热门榜兜底、模拟数据预填充、让用户注册后先选偏好标签这三板斧够用了。6.4 后面可以继续做的方向这个项目目前是纯离线协同过滤扩展空间还很大。我给自己列了几个下一步方向引入电影的内容特征导演、类型、主演做混合推荐解决纯协同过滤对新电影的冷启动问题用 Redis 替换 Caffeine 做分布式缓存让推荐结果在多实例部署时可以共享把推荐计算的定时任务改成基于消息队列的异步流程新评分进来后实时增量更新相似度矩阵。另外一个值得做的方向是给推荐结果加多样性控制。纯协同过滤容易推荐出一堆同质化的电影用户看多了会腻。可以用 MM最大边际相关性算法在相关性和多样性之间做平衡把推荐列表里相似度太高的电影替换掉这个优化对用户体验的提升非常明显。如果你准备照着这个思路自己搭一套我最后的建议是先别急着写代码把表结构和接口清单列清楚把推荐算法的数据流图画明白再动手。项目里的坑大多数不是算法问题而是数据模型没想清楚导致的返工。