SpringBoot+Vue3+MyBatis电影推荐系统:协同过滤算法实战解析 做电影推荐系统听起来好像核心在算法实际上把SpringBootVue3MyBatis这套组合跑通、把协同过滤算法真正嵌进业务才是最锻炼人的地方。这套前后端分离的电影推荐系统源码业务链路很清晰用户注册登录、浏览电影、给电影打分系统再根据所有用户的评分数据用协同过滤算出“口味相近的人”或“口味相近的电影”最终在个人推荐页给出一份专属片单。数据落在MySQL后端用Java实现算法前端Vue3做交互展示很适合正在学Java后端、同时对推荐系统好奇的开发者拿来练手。本文直接讲这套系统从架构到细节的完整实现思路包括我实际踩过的坑。1. 项目整体架构与设计思路1.1 技术组合选型这套方案解决什么问题SpringBoot负责对外提供REST接口Vue3负责页面交互MyBatis负责SQL操作MySQL负责持久化这个组合几乎是中小型全栈项目的标准答案。它有几点很直接的优势第一SpringBoot的启动成本和维护成本都低不需要一堆XML配置内嵌Tomcat打一个jar包就能跑。电影推荐系统的服务端接口虽然不算多但用户、电影、评分、推荐四条主链路加起来也有二十来个接口用SpringBoot组织起来非常清晰。第二Vue3的Composition API在处理“页面状态多、组件之间共享数据”的场景下比Vue2舒服很多。推荐系统前端要同时处理电影列表、详情、评分、推荐结果、用户登录态这些状态在组件间频繁流转Vue3的响应式机制和逻辑组合能力让代码更干净。第三MyBatis是半自动ORMSQL由自己掌控。推荐系统里存在大量自定义查询比如“把用户评过分的电影的相似电影找出来并按预测分排序”这种SQL用MyBatis写起来非常顺手不像全自动ORM那样要绕来绕去。而且它的resultMap和别名映射足够灵活可以相对轻松地把数据库字段映射到实体类。有人会问为什么不用更重量级的推荐中间件答案很简单这套系统的定位是单体架构、学习型项目、数据量在几万条级别根本用不上分布式计算。引入Spark或者复杂向量数据库反而会把成本推到不可控的方向。做技术选型不看“哪个技术听着高级”而是看“哪种组合在当下规模和团队能力内最稳”。这个原则我在这套源码里看得特别清楚。1.2 数据在前后端之间是怎么流动的先画一条完整的数据链路你就知道这套系统为什么这样设计了。用户在前端页面点“给电影打4分”Vue3组件把请求发给axiosaxios带着用户token向SpringBoot的/api/rating接口发起POST请求。Controller接住参数先做基础校验然后调用Service层Service层把评分写入MySQL的评分表。到这里只是完成了第一步推荐模块并没有实时重算所有相似度而是等定时任务或下一次预计算执行时把新的评分数据纳入算法计算。推荐链路则是另一个方向用户在推荐页请求/api/recommend/{userId}后端先判断这个用户有没有足够的评分记录。如果评分太少就走“热门榜兜底”逻辑如果评分足够后端从相似度表里拉取候选电影计算加权分数返回一个按预测评分排序的电影列表前端拿到数据渲染成卡片。这个设计里最关键的一点是“相似度预计算”。协同过滤最耗时的部分就是两两计算用户之间或物品之间的相似度。如果在请求到达时才临时算用户一多会卡住。所以我把它拆成离线任务定时计算相似度结果存进MySQL的表里在线推荐接口只负责“查相似度加权排序”速度能快不少。1.3 功能模块拆解从注册到推荐这套系统可以拆成四个核心模块前端页面基本围绕它们展开。用户模块负责注册、登录和登录态管理。登录成功后后端返回一个token前端保存起来后续请求带上它后端再用来识别当前用户。很典型的单点登录雏形。电影模块负责电影的列表展示和详情展示。列表要做分页详情要展示标题、类型、导演、简介、评分这些基础信息。这个模块本身不复杂但它的数据质量直接影响推荐效果——如果电影表里全是脏数据推荐算法再强也救不回来。评分模块是推荐系统最重要的数据入口。用户看了一部电影之后打分这个动作直接决定后续推荐结果准不准。因此评分接口的设计要考虑重复评分、分数范围、用户是否真实存在这些细节后面第3章我会专门说。推荐模块是系统的核心价值所在。它读取所有用户的评分记录构建“用户-电影”评分矩阵用协同过滤算法做相似度计算最后生成推荐集合。推荐模块在我看来是整套代码里最值得花时间读的部分因为它是“规则型业务代码”和“算法型代码”的交界处。2. 协同过滤算法原理怎么猜中你想看什么2.1 评分矩阵一切推荐的数据基础所有协同过滤算法第一步都是要把评分数据组织成一个矩阵。矩阵的行是用户列是电影交叉点的值是某个用户给某部电影的评分。假设系统里有3个用户、4部电影评分矩阵可能长这样用户电影A电影B电影C电影D用户145空2用户2534空用户3空254空表示该用户没看过或没给这部电影评分。这个矩阵在表结构里不会真的以二维表存在而是拆成评分表里的多条记录算法计算时再按“行转列”的方式聚合出来。不要小看这一步它决定了你后面所有计算的数据格式。我在项目里是写了一个RatingMatrixBuilder的组件专门负责把t_rating表的数据转换成MapLong, MapLong, Double外层key是userId内层key是movieId值是对应的评分。实际数据比这个例子稀疏得多。几十部电影一个用户往往只评过五六部矩阵稀疏度能到90%以上。这不是坏事因为协同过滤的核心假设是“通过共同的评分来计算相似度”只要不是完全空的数据就还有得算。但这也意味着如果某个用户只打了一两次分算法根本找不到足够多的共同评分项这时候就必须靠后面说的冷启动策略兜底。2.2 基于用户的协同过滤从相似用户找口味基于用户的协同过滤核心思想一句话人以群分。用户1和用户2都对电影A和电影B给过高分说明这两个人的口味接近那么用户2看过的电影C用户1没看过系统就把电影C推荐给用户1。这里的引擎就是相似度计算。最经典的是余弦相似度公式看起来有点吓人其实本质上就是“两个向量的夹角有多小”。把用户的历史评分当向量向量夹角越小说明两个用户的评分趋势越一致也就是口味越接近。拿上面表格里的用户1和用户2举例。两人共同评分的电影只有A和B那么向量分别是用户1 [4, 5]用户2 [5, 3]。余弦公式拆开算点积 4×5 5×3 35用户1模长 sqrt(4² 5²) ≈ 6.4用户2模长 sqrt(5² 3²) ≈ 5.83相似度 35 / (6.4 × 5.83) ≈ 0.94相似度接近1说明这两个用户的观影口味高度相似。算出所有两两用户之间的相似度之后推荐时先找到目标用户最相似的TopK个邻居再用这些邻居对某部电影的评分做加权平均得到目标用户可能给这部电影的评分。预测公式是pred(用户u, 电影i) 对目标用户的所有邻居v求和[相似度sim(u, v) × 邻居评分score(v, i)] / 对同组邻居求和[相似度sim(u, v)]这个逻辑很直观但有一个工程上的问题用户数量通常比电影数量增长得更快、更不稳定新用户不断加入用户间两两相似度需要频繁重算。所以在小规模系统里可以做用户规模上去之后就会变得吃力。2.3 基于物品的协同过滤工程落地我更推荐它基于物品的协同过滤核心思想换成了四个字物以类聚。如果很多用户在给电影X高分的同时也给电影Y高分就认为电影X和电影Y是相似的。然后反过来做推荐用户喜欢过电影X就把和X相似的电影Y推荐给他。这套逻辑其实更适合电影这种物品变化频率不高的场景。一部电影的“用户评分画像”一旦建立起来短期内不会发生剧烈变化所以物品相似度矩阵可以离线算好、长期复用。我在实际项目里就是用Item-Based算法作为推荐主逻辑的。物品相似度也可以用余弦公式计算只是把“用户评分向量”换成“每个物品被所有用户的评分向量”。电影X的向量是所有用户给它的评分电影Y同理。那些评分人数太少或者没有共同评分人的电影计算出的相似度基本是0可以忽略。算好之后物品相似度落库。在线推荐时对目标用户u要预测电影i的评分公式如下pred(用户u, 电影i) 对用户u评过分的所有电影j求和[相似度sim(i, j) × 评分score(u, j)] / 对同组电影j求和[相似度sim(i, j)]意思是用户u看过一部和电影i很像的电影并且给了高分那电影i的预测评分也不会低。权重是相似度相似度越高贡献越大。分母做归一化避免只看评分绝对值导致高分片霸榜。我在项目里用Java写了一个简单的物品相似度预计算组件核心思路就是遍历电影对聚合共同评分用户逐步累加点积和模长。代码我做了简化版重点是计算骨架public ListMovieSimilarity calc(MapLong, MapLong, Double userRatings, ListMovie movies) { ListMovieSimilarity result new ArrayList(); int n movies.size(); for (int i 0; i n; i) { Long movieI movies.get(i).getId(); for (int j i 1; j n; j) { Long movieJ movies.get(j).getId(); double sim cosineSimilarity(movieI, movieJ, userRatings); // 相似度低于阈值的不落库减少存储和计算 if (sim 0.3) { result.add(new MovieSimilarity(movieI, movieJ, sim)); } } } return result; } private double cosineSimilarity(Long movieI, Long movieJ, MapLong, MapLong, Double userRatings) { double dot 0.0, normI 0.0, normJ 0.0; int commonCount 0; for (MapLong, Double ratings : userRatings.values()) { Double scoreI ratings.get(movieI); Double scoreJ ratings.get(movieJ); if (scoreI null || scoreJ null) { continue; } dot scoreI * scoreJ; normI scoreI * scoreI; normJ scoreJ * scoreJ; commonCount; } // 共同评分人数太少时相似度没意义直接返回0 if (commonCount 2 || normI 0 || normJ 0) { return 0.0; } return dot / (Math.sqrt(normI) * Math.sqrt(normJ)); }注意两点一是commonCount 2这个条件只有一两个用户共同评过分的情况下计算出的相似度非常偶然不具备统计意义干脆忽略二是相似度阈值0.3不是所有电影对都要存进相似度表存太多只会拖慢查询而且很多低相似度对根本不会进入TopN推荐候选。这个阈值可以根据数据规模调节。2.4 冷启动问题评分太少时的兜底策略冷启动是所有推荐系统绕不开的问题电影推荐系统尤其典型。新用户刚注册一条评分都没有协同过滤算法完全失效因为“没有共同评分”就无从计算相似度新电影刚上架没有任何用户评分它也不会被推荐出去。我的做法是分级处理。第一层新用户没有评分时推荐热门电影。这个热门不是简单按豆瓣评分排而是按“系统内用户平均评分人数×评分均值”的综合热度来排。一部电影被许多人评分且平均分不低才进入热门榜。代码里就是一个简单的排序SQL加上LIMIT 10。第二层新用户有少量评分但不到N条时直接用Item-Based算法基于这一两条评分扩展推荐。因为只要有评分就能找到与之相似的电影。第三层新电影冷启动我暂时没有做太复杂的处理而是给所有新电影一个“新片曝光位”在详情页和列表页给予展示让它们有机会获得第一批评分。这套策略不完美但对小型项目来说足够稳定。它真正解决的是“推荐页不能空白”这个产品底线。3. 后端落地SpringBoot与MyBatis的关键实现3.1 项目骨架与数据库连接配置SpringBoot项目用Maven管理依赖核心依赖其实只有几个spring-boot-starter-web提供Web能力mybatis-spring-boot-starter负责MyBatis整合mysql-connector-java负责MySQL驱动。如果用token做登录态还要加上一个JWT库。我习惯把JWT依赖选为jjwt体积小、API清晰适合这种规模的项目。项目分包结构我会这样组织com.example.movie ├── controller // 接口层 ├── service // 业务层 ├── mapper // MyBatis Mapper接口 ├── entity // 实体类 ├── dto // 接口出入参对象 ├── common // 统一返回包装、异常处理 └── recommend // 算法与预计算组件recommend单独分包是因为推荐计算的代码和普通CRUD逻辑差异很大后续想替换算法实现时只动这个包就够了不影响业务层。application.yml里的数据库连接配置是这套系统里最容易出问题的点我直接给出一个经过验证的配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/movie_recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:/mapper/*.xml type-aliases-package: com.example.movie.entity configuration: map-underscore-to-camel-case: true连接串里的characterEncodingutf8和serverTimezoneAsia/Shanghai这两个参数一个保中文不乱码一个保时间不错乱没有它们后面会有各种莫名其妙的问题。map-underscore-to-camel-case是让数据库字段create_time自动映射到属性createTime少写一堆自定义resultMap。3.2 四张核心表设计与初始化数据这套系统最核心的数据表有四张我给你看实际使用的DDL精简版CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_movie ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, genres VARCHAR(255), director VARCHAR(64), release_year INT, rating DECIMAL(3, 1) DEFAULT 0, poster_url VARCHAR(255), description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_rating ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, movie_id BIGINT NOT NULL, score INT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_movie (user_id, movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_movie_sim ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, similar_movie_id BIGINT NOT NULL, similarity DOUBLE NOT NULL, KEY idx_movie_id (movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;t_rating表上的联合唯一索引uk_user_movie非常关键它在数据库层面挡住了同一用户对同一部电影的重复评分比业务代码里先查再插更可靠。初始化数据这一步容易被忽略。一份好的模拟数据要覆盖各种情况用户至少要20个电影至少50部评分记录我在项目里生成了三百多条。关键是评分不能只打高分要有意引入一些口味差异一部分用户喜欢热门商业片一部分用户偏爱冷门文艺片还有一部分用户打分特别严格普遍给低分。这样算法才能体现出“相似用户”的区分度。一个非常实用的建议生成模拟评分时把用户的评分行为限定在一定电影集合内人为制造“共同评分”关系。否则所有评分完全是随机分散用户之间的共同时几乎为零算法跑出来等于没有。3.3 评分接口写入前必须做好的三件事评分接口是推荐系统的数据入口出问题影响极大。实操下来这个接口除了基本的接收参数至少还要处理好三件事。第一件是参数校验。前端传来的userId、movieId、score三个参数都可能是非法值。score必须限制在1到5之间不能允许0、6这些越界数据进来。我用的是SpringBoot自带的Validated注解加上自定义校验逻辑简单省事。第二件是防止重复评分。虽然数据库有唯一索引兜底但业务层还是应该先查一次给用户返回友好提示。否则直接抛数据库异常前端拿到的错误信息会非常难以理解。实际实现是先查t_rating表里有没有匹配记录有就提示“已经评分过了”。如果要做“改评分”功能就改为更新逻辑。第三件是校验用户存在性。很多人容易漏掉这一步因为外键约束可以拦着非法userId但数据库中如果存在悬空数据查询阶段就可能查出空数据直接影响推荐结果。所以我要求评分请求进来先查用户查不到直接返回“用户不存在”。接口代码大致长这样PostMapping(/api/rating) public ResultVoid addRating(RequestBody Valid RatingCreateDTO dto) { User user userService.getById(dto.getUserId()); if (user null) { return Result.error(用户不存在); } if (dto.getScore() 1 || dto.getScore() 5) { return Result.error(评分必须在1-5之间); } boolean exists ratingMapper.existsByUserIdAndMovieId(dto.getUserId(), dto.getMovieId()); if (exists) { return Result.error(这部电影你已经评过分了); } ratingMapper.insert(dto.getUserId(), dto.getMovieId(), dto.getScore()); return Result.success(); }这里我故意没有直接在注解上写Min/Max因为业务校验和框架校验混在一起容易让异常处理变复杂所以统一在方法体里手动校验。等接口跑通之后再考虑用框架校验重构。3.4 推荐接口与相似度预计算任务推荐接口的设计要点是“重计算轻请求”。所有消耗大量CPU的相似度计算全部放到预计算阶段。我用SpringBoot自带的定时任务实现通过Scheduled注解每天凌晨两点全量重算一次物品相似度。为什么不实时更新因为遇到一个用户打了一分就重算全量相似度N个电影的两两计算是平方级复杂度用户量一大定时任务还没跑完新的评分又进来了。对这套系统来说每天重算一次已经足够实时性换不回来多少推荐质量的提升。核心相似度表落库后推荐接口就变得很简洁GetMapping(/api/recommend/{userId}) public ResultListMovieVO recommend(PathVariable Long userId) { ListLong ratedMovieIds ratingMapper.findMovieIdsByUserId(userId); if (ratedMovieIds.size() 3) { ListMovie hotMovies movieMapper.findHotMovies(10); return Result.success(MovieVO.convert(hotMovies)); } ListMovieVO recommendations recommendService.recommendByItemCF(userId, ratedMovieIds, 10); return Result.success(recommendations); }ratedMovieIds.size() 3是我自己定的阈值。评分少于3条协同过滤的可信度太低直接走热门榜。这个阈值不是固定的如果后续评分数据更稠密可以降低到2条更稀疏就提高到5条。recommendByItemCF的核心SQL是一个带聚合的MyBatis查询直接把“候选电影预测评分”算好再取前N条。MyBatis XML里大概长这样select idfindRecommendByItemCf resultTypecom.example.movie.dto.RecommendDTO SELECT ms.similar_movie_id AS movieId, SUM(ms.similarity * r.score) / SUM(ms.similarity) AS predictScore FROM t_movie_sim ms JOIN t_rating r ON r.movie_id ms.movie_id AND r.user_id #{userId} WHERE ms.movie_id IN foreach collectionratedMovieIds itemmid open( separator, close) #{mid} /foreach AND ms.similar_movie_id NOT IN foreach collectionratedMovieIds itemmid open( separator, close) #{mid} /foreach GROUP BY ms.similar_movie_id ORDER BY predictScore DESC LIMIT #{limit} /select这个SQL一举完成了三件事找到用户评分电影的相似电影、排除已经看过的电影、按加权预测评分排序。用MyBatis的foreach处理动态的IN集合非常方便。如果你用全自动ORM这个查询反而要绕好几层。4. 前端Vue3从登录到推荐的完整链路4.1 页面结构与路由规划前端页面我拆成了四个核心视图登录页、电影列表页、电影详情页、推荐页。路由配置用vue-router配合路由守卫做登录拦截。电影列表页放在/movies展示分页卡片列表每张卡片上能直接看到海报、标题和综合评分。详情页放在/movies/:id展示完整信息最重要的交互是底部的评分组件用户拖一下或点一下星星就能提交评分。推荐页放在/recommend登录后才有权限看专门展示个性化片单。路由守卫用Vue3的beforeEach实现这个比Vue2直观太多直接在回调里判断metarouter.beforeEach((to) { if (to.meta.requiresAuth !userStore.isLogin) { return { path: /login }; } });recommend页面挂上meta: { requiresAuth: true }不清楚意思的第一次看也能看懂未登录用户一律先进登录页登录成功后原路返回。4.2 axios封装与请求拦截前后端分离后所有请求走axios重复代码很多。我习惯在一开始就把axios封装成一个统一模块全局只处理两件事请求时带上token响应时统一处理业务错误和登录失效。const request axios.create({ baseURL: /api, timeout: 10000, }); request.interceptors.request.use((config) { if (userStore.token) { config.headers.Authorization Bearer ${userStore.token}; } return config; }); request.interceptors.response.use( (res) res.data.data, (err) { if (err.response?.status 401) { userStore.logout(); router.push(/login); ElMessage.error(登录已失效请重新登录); } else { ElMessage.error(err.response?.data?.message || 请求失败); } return Promise.reject(err); } );这里有个小坑后端如果返回401同时带了一段JSONerr.response.data.message就能取到但如果后端返回的不是标准结构这里的兜底请求失败就能避免前端弹出一堆看不懂的文字。开发阶段的跨域问题我直接在前面用Vite的proxy配置解决给/api开一个代理到后端8080// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, } } }这样前端代码里的请求路径只要写/api/...浏览器看到的是同源请求不存在跨域。这个方案在开发环境最省事等上线部署时再用Nginx做同样的反向代理。4.3 推荐页面的评分交互与展示推荐页是整个系统最有“产品感觉”的页面。后端返回的推荐结果是一组电影卡片图片、片名、预测评分都有。前端拿到后渲染成响应式网格窗口缩小时自动单列。卡片上除了展示信息我还加了两个交互一个是“看详情”按钮跳详情页另一个是快捷评分组件直接在推荐卡片上打分。这个设计很有必要因为推荐页本身就是一个“用户探索”场景看到推荐想打个分如果还必须跳到详情页再操作交互链就断了。空状态必须要做。用户刚注册、评分记录少于阈值时后端返回的是热门榜电影但万一数据初始化没跑好后端可能返回空列表。这时候前端要显示“暂无推荐先去给几部电影评个分吧”这样的引导文案而不是一块空白区域。还有一个细节推荐页的请求要带loading状态因为预计算虽然很快但第一次请求时后端可能需要加载缓存网络慢一点用户就会觉得卡。加一个骨架屏或者loading转圈体感会好很多。5. 排错实录常见问题与排查方法5.1 MyBatis映射失败与SQL语法坑我自己在这个项目里遇到过好几个MyBatis的“经典问题”。第一个是Mapper方法参数多个时没有加Param导致XML里的#{userId}报错或者拿到null。MyBatis在多参数时必须显式指定参数名这是最容易被新手踩的。第二个是XML里SQL关键字转义问题。写查询时如果用到了小于号会被XML解析器当成标签开始符直接报编译错误。这时候必须写成lt;或者用![CDATA[ ... ]]把整段比较逻辑包起来。比如删除比较老的评分记录时select ... ![CDATA[ SELECT * FROM t_rating WHERE create_time 2025-01-01 00:00:00 ]] /select第三个是SQL查询列和实体属性对不上。虽然开启了map-underscore-to-camel-case但如果SQL里的列名用了别名比如ms.similar_movie_id AS movieId实体里的属性又是movieId这里没有下划线也能映射上但如果实体属性叫movie_id就又对不上了。排查这类问题最快的方式是先在数据库客户端里跑一遍SQL确定列名再回来看映射。5.2 跨域请求被拦截前后端分离开发时前端在Vite开发服务器上默认是5173端口后端跑在8080两个端口不同所有请求都算跨域。表现就是浏览器控制台报Access-Control-Allow-Origin相关错误请求在Network面板里显示为红色或者被CORS拦截。解决方案有两类但不要同时使用。一类是后端加全局跨域配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173) .allowedMethods(*) .allowCredentials(true); } }另一类是前面提到的Vite proxy方案。我个人更推荐开发环境用Vite proxy因为它连后端CORS都不用开所有请求从浏览器角度都是同源更接近真实的生产部署结构。如果两个方案都开了某些浏览器版本下反而会出现“preflight请求返回了多个CORS头”的问题。5.3 中文乱码与MySQL时区报错中文乱码在电影推荐系统里特别容易暴露因为电影标题、简介、类型全是中文。乱码的原因通常不是前端也不是后端而是数据库连接串里没有指定字符集。只要确保三个地方一致基本不会乱数据库表用utf8mb4、JDBC连接串带characterEncodingutf8、后端代码文件本身是UTF-8编码。MySQL 8连接时还有一个很典型的报错The server timezone value ??? unknown。这是数据库时区没匹配上导致的解决办法就是连接串加serverTimezoneAsia/Shanghai。如果数据库是MySQL 8.x还可能需要额外的allowPublicKeyRetrievaltrue参数否则可能出现公钥检索报错。5.4 推荐结果有问题全是高分片或总是那几部推荐系统跑完之后最容易出现的现象是推荐结果全是全局高分片。原因很直接预测评分的加权计算里热门高分电影经常出现在很多相似电影集合中累积下来分数偏高。这在Item-Based CF里尤其明显。解法是给相似度计算加阈值和评分归一化。我项目里做了两步一是把用户原始评分减去该用户的平均分把“绝对分数”变成“偏好偏移”避免一个给分严格的用户拉高所有相似电影的预测值二是在每部电影的推荐候选里适当排除掉和用户已看过电影相似度极高、但本身就是热门大片的电影给小众片留一点推荐空间。还有一个情况是用户评分很稀疏只有一两部评分这时候协同过滤出来的结果往往等于“和这部片最像的同类电影”看起来像重复推荐。这不能完全怪算法评分数据不够是根本原因。所以推荐位的展示上我会把“猜你喜欢”和“热门榜”分开评分稀疏时先推荐热门引导用户多评分数据变多之后再切协同过滤。6. 项目做完之后的几点体会整套系统做下来我对“推荐算法落地”这件事多了不少实感。算法本身并不复杂余弦相似度、加权评分任何一个计算机专业的人都能看懂难的是数据链路能不能闭环、评分数据是不是真实可靠、冷启动用户看到推荐页时会不会觉得“这东西有点懂我”。协同过滤就像一个小型搜索引擎前面任何一步数据的缺失都会影响最后那一段的推荐效果。如果你也想复刻一套这样的系统我的建议是先不要急着扩大范围。把用户、电影、评分、推荐四条链路跑通加上热门榜兜底就已经是一个功能完整的项目了。后续再往上加Redis缓存相似度结果、引入隐式反馈比如浏览时长、点击行为、把定时任务换成消息队列增量更新都是在同一套架构上做扩展。最后分享一个小经验我每次改完推荐算法相关代码都不会只盯着推荐页看结果。我会先用测试账号制造几组口味非常极端的评分数据比如一个人只打分和它同类的电影然后对比推荐结果的变化。这是验证算法有没有真的起作用的最快方式比你写一堆单元测试来得更直接。