ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SSM协同过滤电影推荐系统:从数据库设计到算法实现全解析

SSM协同过滤电影推荐系统:从数据库设计到算法实现全解析 这套SSM协同过滤电影推荐系统如果只看标题很多人会以为又是一套普通的图书管理、会议室预约之类的增删改查课设。但只要你真正把它拆开看一遍会发现它的工作量和含金量完全不在一个层级它不只要把SSM的增删改查跑通还要在业务层里真正落地一个“能猜用户口味”的推荐算法。也就是说它把Java Web开发、MySQL建模、数据挖掘里最经典的协同过滤揉成了一个完整可运行的项目。这篇文章就把我从零搭建这套系统的完整思路、数据库设计、算法实现、SSM整合配置以及调试过程中踩过的坑全部写成一份可以直接参考的实操笔记。这套项目适合两类人。一类是正在做毕业设计或课程设计的学生拿它当题目论文素材、算法解释、测试数据都够写一万字往上另一类是SSM框架已经会写增删改查、但对推荐系统只听过概念、不知道代码怎么落的开发者。看完这篇你至少能回答三个问题协同过滤在Java Web项目里到底怎么算SSM整合时哪些配置细节最坑拿到一套现成项目之后怎么最快跑起来并讲清楚原理。1. 项目整体设计与思路拆解1.1 需求定位推荐系统不只是“登录增删改查”先给这个系统做需求拆解。它面向两类用户普通用户和管理员。游客注册后成为普通用户能做的事包括浏览电影列表、按电影类型筛选、看电影详情、参与评分、在个人中心看到系统按“你的兴趣”生成的推荐片单。管理员则负责电影数据的维护新增电影、修改电影信息、删除下架电影、管理用户状态、查看整体评分数据。这套功能看着是标准的后台管理系统但真正的核心不在这堆操作上而在“评分”和“推荐”这两个点的联动上。为什么评分是核心因为协同过滤算法是一个靠“群体行为数据”驱动的算法。用户一旦给电影打了分这条记录就进入了算法的原始输入矩阵。系统再基于所有用户的评分记录计算用户之间或者电影之间的相似度最后从相似用户喜欢的电影里挑出目标用户没看过的电影形成推荐结果。没有评分数据推荐引擎就是一个空壳。所以做这个项目数据库里必须要有足够量的评分测试数据界面上也必须有明显的“我要评分”入口把数据的采集闭环打通。管理员端的价值同样重要。推荐系统依赖的电影信息、类型分类如果没有管理后台维护数据全是脏的算法再准也没意义。可以说普通用户端负责贡献行为数据管理员端负责维护内容数据推荐引擎在这两层数据之上运行三者缺一不可。1.2 技术选型SSM、MySQL与协同过滤的组合逻辑先看框架。SSM这个组合放到现在虽然不算新潮但作为学习项目、课设毕设项目它的地位依然很稳。Spring负责管理业务层的对象ServiceImpl、Mapper实现类等全部交给IOC容器管理事务用注解或XML统一控制SpringMVC负责前端的请求分发把浏览器发来的URL映射到Controller的方法上MyBatis负责数据库访问把Mapper接口和SQL语句绑定在一起。三层的边界很清晰Controller收参数、Service算业务、Mapper查数据。相比SpringBootSSM的配置文件全部手写虽然麻烦但能让你把各层之间的依赖关系真正看明白。再说算法。电影推荐系统的推荐算法有多个方向基于内容的推荐需要对电影的导演、演员、题材做文本分析和标签化基于深度学习的推荐需要模型训练环境部署成本高而协同过滤只需要用户行为数据不依赖电影本身的文本内容也不需要GPU纯Java就能算完。协同过滤是三个方向里最“性价比”的选择也是课设论文里最好讲清楚原理的算法。数据库选MySQL没有悬念。开源、免费、生态环境成熟JDBC驱动稳定MyBatis的XML映射对MySQL语法支持完整Navicat之类的可视化工具一装建库、导数据、查评分记录都很方便。整套技术栈里没有任何一个环节是需要额外付费或者高配置环境的。1.3 推荐引擎的完整工作流程把推荐引擎的运转拆开就是三个步骤采集数据、离线计算、在线推荐。采集数据这一步靠业务前端完成。用户注册时写入users表浏览电影时产生行为日志最关键的是用户评分时写入rating表形成一条“user_id movie_id score”的记录。这一步是数据源头也是很多人在做系统时容易忽略的点——光把评分功能做出来还不够还要保证每次评分的时间和分数都被正确记录因为评分时间可以用来做时间衰减分数是相似度计算的直接输入。离线计算是核心。当rating表里有足够多数据之后推荐程序会把它读出来构造成一个用户-电影评分矩阵再根据你选择的协同过滤方向计算用户相似度或者物品相似度。计算完成后直接为目标用户算出TopN候选电影列表把结果存到一张推荐结果表或者直接在内存中生成。为什么是离线计算因为协同过滤的相似度计算复杂度较高如果每次用户点开“推荐”页面时都实时全量算一遍Tomcat的线程池很可能会被拖垮响应时间也会让人无法接受。提前算好、按用户ID缓存是更工程化的做法。在线推荐就是展示层的活了。用户点击首页的“猜你喜欢”或者进入个人中心的推荐页后端从内存缓存或数据库里取出该用户的推荐列表再关联电影信息、封面、类型渲染到JSP页面上。整个流程串起来看协同过滤只是中间那一层计算逻辑但让算法真正跑起来的是前后两端的数据流。任何一个环节断裂推荐系统都会“看起来跑通实际不出结果”这个在第四章会专门讲。2. 数据库设计与核心算法落地2.1 数据库表结构设计与建表SQL我建库时核心就设计了四张表用户表、电影表、评分表、类型表。类型表和电影表做成一对多的关系不单独建关联表因为一部电影在当前需求下只挂一个主类型保持简单清晰。用户表的设计有一个细节值得注意密码字段长度要预留足够。不要用varchar(20)因为注册功能一般会对密码做MD5或BCrypt加密加密后的字符串长度远超过明文长度。我习惯用varchar(100)。CREATE TABLE tb_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;电影字段不需要做成了几十个字段的超大表核心字段控制在十个左右就够电影名、导演、主演、类型、地区、上映年份、豆瓣评分、封面图、简介。CREATE TABLE tb_movie ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, director VARCHAR(100), actors VARCHAR(500), type_id INT, region VARCHAR(50), year INT, rating DECIMAL(3,1), cover_url VARCHAR(255), description TEXT, INDEX idx_type (type_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;评分表是整个系统里最重要的一张表也是协同过滤算法的输入来源。CREATE TABLE tb_rating ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, movie_id INT NOT NULL, score DOUBLE NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_movie (user_id, movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里加唯一索引uk_user_movie是个很必要的操作它的作用是防止同一个用户对同一部电影重复评分。实际项目中用户可能因为误触连续点了两次“评分”如果没有这个约束评分表里就会出现两条同人同片的记录算法读数据时就会算出重复项影响相似度结果。建完表之后必须做一件事灌测试数据。我建议手动准备20到30个用户、80到120部电影、500条以上评分记录。只有数据量到了这个量级协同过滤的相似度计算才看得出来效果推荐结果才不会变成“全表随机”。纯靠几个人注册、手动评十几条记录推荐结果永远是空的或者乱的。2.2 协同过滤相似度计算原理与代码实现协同过滤分为两类基于用户的UserCF和基于物品的ItemCF。UserCF的核心逻辑是“找跟你口味相似的人把这些人喜欢而你没看过的电影推荐给你”ItemCF的核心逻辑是“你喜欢电影A而电影B和电影A被同一批用户喜欢那你大概率也会喜欢B”。那么“相似”这两个字到底怎么量化在代码里就是靠向量计算。每个用户对电影的评分可以看成一个高维向量向量的维度是所有电影用户看过的电影维度上有值没看过的为0。两个用户越相似他们向量的方向就越接近这时候用余弦相似度计算。余弦相似度的公式可以先用直觉理解两个向量之间的夹角越小相似度越接近1。如果两个人看过的电影完全一样、打分也完全一样相似度就是1。如果完全没有交集相似度就是0。在代码里计算余弦相似度要做的就是把两个用户共同评分过的电影找出来计算这些评分值的点积再除以两个向量各自的模长乘积。public double cosineSimilarity(MapInteger, Double userA, MapInteger, Double userB) { double dotProduct 0.0; double normA 0.0; double normB 0.0; for (Map.EntryInteger, Double entry : userA.entrySet()) { normA Math.pow(entry.getValue(), 2); if (userB.containsKey(entry.getKey())) { dotProduct entry.getValue() * userB.get(entry.getKey()); } } for (Double score : userB.values()) { normB Math.pow(score, 2); } if (normA 0.0 || normB 0.0) { return 0.0; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }项目里的实体类通常是这样组织的UserSimilarity类封装相似用户ID和相似度值RecommendService先从数据库读出所有评分记录在这里构建出MapInteger, MapInteger, Double这样一个双层Map结构外层键是用户ID内层键是电影ID值是该用户对这部电影的评分。构建矩阵的过程虽然简单但数据量一大内存占用就比较明显。这也是为什么说这个算法更适合中小规模的业务系统真到了千万级用户和千万级电影的规模必须上离线计算框架。ItemCF的编码思路也类似只是把“用户”和“电影”的维度互换。计算电影A和电影B的相似度看的是哪些用户同时给这两部电影评过份以及评分是否相近。做电影推荐时有一个实战判断标准当用户数量比电影数量多得多时优先选ItemCF因为物品之间相似度矩阵的规模远小于用户相似度矩阵计算开销低推荐结果也更稳定。课程设计里用户数通常只有几十上百两种算法都能跑但论文里如果能做一次两种算法的效果对比内容会显得充实很多。2.3 评分预测与Top-N推荐生成算出相似度不是目的目的是给用户找出他“可能喜欢但还没看过”的电影。这个过程分两步。第一步根据相似用户生成候选电影集合。假设用户A我们计算出与他最相似的K个用户把这K个用户评分过的电影全部找出来去掉A已经评分过的电影剩下的就是候选集。第二步预测A对候选中每部电影的评分。预测公式是加权平均用相似用户的相似度作为权重把他们对这部影片的评分做加权求和再除以权重总和。公式用代码表达更直观public double predict(Integer userId, Integer movieId, MapInteger, MapInteger, Double ratingMatrix, ListUserSimilarity similarUsers) { double weightSum 0.0; double scoreSum 0.0; for (UserSimilarity su : similarUsers) { Double score ratingMatrix.get(su.getUserId()).get(movieId); if (score ! null) { scoreSum su.getSimilarity() * score; weightSum su.getSimilarity(); } } return weightSum 0 ? scoreSum / weightSum : 0.0; }这里有一个非常容易踩的坑做相似度计算时不能把所有相似用户的电影都塞进相似用户列表。我在第一次实现时直接把所有用户两两算相似度然后全量排序取前50导致推荐结果里混进大量相似度只有0.05的弱关系用户预测评分完全被拉偏。正确做法是设置一个相似度阈值比如相似度低于0.2的用户直接过滤掉只保留强关系用户参与加权平均。这一步对推荐质量的提升非常明显。Top-N推荐生成相对简单就是对预测评分做排序取前N条。public ListMovie recommendMovies(Integer userId, int topN) { ListMap.EntryInteger, Double sortedList new ArrayList(predictMap.entrySet()); sortedList.sort((a, b) - Double.compare(b.getValue(), a.getValue())); ListInteger topIds new ArrayList(); for (int i 0; i Math.min(topN, sortedList.size()); i) { topIds.add(sortedList.get(i).getKey()); } return movieMapper.selectByIds(topIds); }实际推荐结果还需要处理一个边界情况新注册用户没有任何评分记录。此时算法计算出的相似用户列表为空预测评分无从谈起。所以代码里必须加一个兜底逻辑当用户评分记录数为0时直接返回全站评分最高的电影作为“热门推荐”这就是冷启动问题最经典的解法。3. SSM工程搭建与核心代码实现3.1 开发环境版本选择与工程骨架版本这一块最容易出问题的是JDK、Tomcat与Spring版本之间的兼容。我用的是JDK 1.8 Maven 3.6.3 Tomcat 8.5 MySQL 5.7Spring版本4.3.18注意不要用太新的5.x搭配Tomcat 8.5配置方式变动较大对课设项目没有必要的学习成本。这套组合经过大量项目验证互相之间配合是最稳的。工程结构上我推荐用标准的SSM三层分包加一个entity包src/main/java com.film.controller - 页面跳转与接口入口 com.film.service - 业务逻辑接口 com.film.service.impl - 业务逻辑实现 com.film.dao - MyBatis的Mapper接口 com.film.entity - 实体类User, Movie, Rating等 src/main/resources jdbc.properties - 数据库连接配置 applicationContext.xml - Spring核心配置 spring-mvc.xml - SpringMVC配置 mybatis-config.xml - MyBatis配置 mapper/ - 每个Mapper接口对应的XML src/main/webapp WEB-INF/web.xml - 入口配置 WEB-INF/jsp - 视图页面如果你的Mapper XML放在了java目录下maven的pom.xml里一定要加resources配置否则编译后mapper XML文件不会出现在classes目录下运行时会报Invalid bound statement (not found)异常。build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource resource directorysrc/main/resources/directory /resource /resources /build3.2 三个核心配置文件逐个拆解先看web.xml。这是一个Web项目的入口在web.xml里配置Spring的ContextLoaderListener和SpringMVC的DispatcherServlet。有一处很容易写错contextConfigLocation填写的classpath路径。我见过很多次classpath写错导致的“启动不报错一访问404”因为Spring容器根本没加载到Service。web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1 context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namespringmvc/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namespringmvc/servlet-name url-pattern//url-pattern /servlet-mapping /web-app然后是applicationContext.xml。这个文件负责Spring容器的配置。有三个关键配置组件扫描只扫描service和dao不扫描controller、数据源和事务管理、MyBatis的Mapper扫描。context:component-scan base-packagecom.film context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nametypeAliasesPackage valuecom.film.entity/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.film.dao/ /bean tx:annotation-driven transaction-managerdataSourceTransactionManager/ bean iddataSourceTransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean当时我在这段配置上踩过一个真实的坑MapperScannerConfigurer扫描com.film.dao但dao包里的接口方法和XML里没有形成一一对应启动时不报错一调用Mapper方法就报Invalid bound statement。排查了半天发现是我把XML文件路径错放到了resources根目录而不是resources/mapper目录下。所以我想强调一点SSM项目的配置是一环扣一环的任何一处路径与扫描范围不匹配问题都是跑到运行时才暴露。web项目里经常出现的静态资源404问题可以这样解决在spring-mvc.xml里配置mvc:default-servlet-handler/让Tomcat默认的DefaultServlet处理CSS、JS、图片这些静态资源。如果漏掉这一条页面的Bootstrap样式就会全部失效只剩光秃秃的HTML。3.3 推荐引擎在业务层的调用流程推荐模块的业务调用流程我是这样处理的。当用户点击个人中心的“为我推荐”时请求走到RecommendController。Controller先判断用户是否登录然后调RecommendService的recommend(userId, topN)方法。Service的方法体里先查一下rating表里该用户有没有评分记录没有的话走热门榜单兜底逻辑返回全站电影平均评分最高的前N部有评分记录就从recmd表缓存取没缓存则现场构建矩阵、算相似度、算预测分、生成推荐结果同时把结果写入缓存。Service public class RecmdServiceImpl implements RecmdService { Autowired private RatingMapper ratingMapper; Autowired private MovieMapper movieMapper; private final MapInteger, ListMovie cache new ConcurrentHashMap(); Override public ListMovie recommend(Integer userId, Integer topN) { if (cache.containsKey(userId)) { return cache.get(userId); } ListRating myRatings ratingMapper.selectByUserId(userId); if (myRatings null || myRatings.isEmpty()) { ListMovie hotMovies movieMapper.selectTopByAverageRating(topN); cache.put(userId, hotMovies); return hotMovies; } ListRating allRatings ratingMapper.selectAll(); MapInteger, MapInteger, Double ratingMatrix MatrixBuilder.build(allRatings); ListUserSimilarity similarUsers recmdAlgorithm.findSimilarUsers(ratingMatrix, userId, topK); MapInteger, Double predictScores recmdAlgorithm.predictAll(userId, ratingMatrix, similarUsers); ListMovie result buildResult(predictScores, topN); cache.put(userId, result); return result; } }业务层有一个很实用的经验把算法的计算过程单独拆成一个RecommendAlg工具类跟业务Service解耦。这样代码的可读性大幅提高论文里写“本系统采用模块化设计算法与业务分离”也更有说服力。另外缓存用的是ConcurrentHashMap而不是数据库表省掉频繁查询和写入的IO开销在几十个用户的课设场景下完全够用答辩时也可以说这是为了提升推荐响应速度做的轻量缓存设计。4. 常见问题、排查技巧与项目交付4.1 环境搭建阶段的典型报错第一类是数据库连接失败。最常见的报错是The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这是MySQL 5.7以上驱动和本地时区不匹配的问题。解决办法是在JDBC的URL里加参数jdbc:mysql://localhost:3306/recommend_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai第二类是Tomcat启动冲突。如果启动时提示Port 8080 was already in use说明8080端口被其他进程占用了。可以用netstat -ano | findstr 8080查占用进程也可以直接在IDEA里把Tomcat端口改掉。这些是新手经常遇到的环境问题花半小时排查一次之后就不算事了。第三类是依赖包冲突。这个我和朋友联调时遇到过很典型的情况pom里的Spring版本和SpringMVC版本不一致或者引入了不同版本的mybatis-spring包导致启动时出现BeanCreationException或者NoSuchBeanDefinitionException。依赖管理这个环节课设项目一般不需要特别精确的版本控制只要你把所有Spring相关的依赖统一成一个版本号不要凭感觉乱写版本就能避开大部分冲突。4.2 推荐结果异常的系统性排查推荐功能做完不等于推荐结果正确。如果你发现预测分算出来全是0或者推荐列表永远只有几部片可以从下面几个方向排查。先看数据输入层。登录MySQL执行一条查询确认rating表里真的存在多条评分记录并且user_id和movie_id都能在对应的表里关联到数据。常见情况是评分表里存了一些脏数据比如user_id是999但用户表里根本没有这个ID。算法拿到这种数据联表查询时会把这条记录直接过滤掉或者产生内连接为空。再看算法核心层。如果你发现相似度全都很低问题多半出在向量的构建逻辑上。比如只统计了共同评分的电影数量没有把评分值本身算进向量或者评分矩阵构建时没有把未评分的电影补0导致向量长度不一致。推荐系统里有一个“维度匹配”的概念两个用户的向量必须基于同一套电影维度构建否则点积和余弦计算都是错位的。最后看结果排序层。排序如果直接用HashMap做输出顺序完全不可控。一定要把entrySet放到ArrayList或者用stream().sorted()显式排序否则前端看到的就是乱序的推荐列表。这里给一个排查思路在算法里加日志打印。我写推荐模块时会在计算相似度那一步把每个相似用户的ID、相似度、共同评分数量打印出来。推荐结果不对时看日志里的数字基本能一眼定位问题是在数据层还是计算层。4.3 论文写作与交付物整理的思路最后聊一聊交付和论文。这类项目拿到的交付物通常包括源码工程、数据库SQL脚本、开发环境说明、调试部署步骤以及一篇1万字以上的论文文档。论文结构一般按照毕业论文或课程报告的通用套路走第一章绪论写研究背景和意义重点是描述“信息过载”问题第二章相关技术介绍分别写SSM框架原理、MySQL数据库、协同过滤算法这是凑篇幅的重头戏但不要只贴官方的概念最好结合项目实际写出选择了什么技术、为什么选择。第三章需求分析要画用例图、流程图把系统的两类角色、功能边界说清楚。第四章系统设计数据库表结构、E-R图、架构图、核心类设计都在这一章。第五章系统实现对应着贴界面截图和核心代码包括登录、电影管理、评分流程和推荐结果页。第六章算法实验用测试数据做一次UserCF与ItemCF的推荐效果对比这里如果能把相似度K值调参的过程写进去我前面提到的K值选择、阈值过滤就是非常有说服力的实验内容了。第七章总结与展望。论文里容易犯的错是“介绍性内容太多、实测性内容太少”。一万字的篇幅如果只堆概念答辩时一问就露馅。更实在的做法是把你实际调试过程中整理出的数据放进论文有多少用户、多少电影、评分记录总数、推荐算法的响应时间、不同K值下的推荐准确率。这些真实数据比任何华丽辞藻都更能撑起论文质量。源码交付这一块也有一个提醒不要把数据库密码、Tomcat启动脚本等环境细节写死了。拿到项目的人很可能用的是不同的MySQL密码、不同的JDK版本部署说明里应该明确写出“需要修改jdbc.properties和Tomcat安装路径”。代码注释也值得多写一点特别是算法核心类。我在这类项目里见过最有效的注释方式是每个方法前加一段“输入是什么、输出是什么、计算逻辑是什么”的注释这对调试和答辩都有直接的加分效果。收尾一点真心话这套系统从零开始做到能跑、能演示、能拿出去答辩前前后后我整理过不止一次。第一次做完推荐结果惨不忍睹后来发现问题出在评分数据太少和相似度阈值没设第二次改进算法把冷启动、加权平均、K值这些都补上效果才像样。如果你也在做这类带算法的Java Web项目我的建议是别急着写代码先把数据流理清楚谁产生数据、算法怎么消费数据、结果怎么展示回前端。把这条链路想明白代码只是翻译这个思路的过程而已。最后再分享一个小技巧给推荐系统做演示的时候别用管理员账号登录也别用新注册的空账号。提前用几个测试账号分别评上十几部电影评分风格差异大一点比如一个账号全评科幻片一个账号全评爱情片演示时切换登录就能直观看到两种完全不同的推荐结果。这个细节看着小但能让评委和同学一眼看懂“协同过滤到底干了什么”比讲十页算法公式都管用。
返回列表