简介:这是一套基于Spring、Redis与MongoDB构建的电影推荐系统完整项目,涵盖源码、项目说明与实验报告,适合计算机相关专业学生用于毕业设计、课程设计或初期项目立项演示。压缩包共844个文件,大小16.47MB,其中包含340个JavaScript文件、151个CSS样式、72个Python脚本、34个HTML页面等,覆盖前端交互、后端逻辑、样式布局与项目配置,另有PDF文档和实验报告供参考。目前已有58人学习,代码经过测试可正常运行,可借鉴学习也可直接改造扩展。项目内附的Parquet数据文件、配置文件等有助于快速理解系统数据流转,结合说明文档能掌握Spring整合Redis与MongoDB的开发思路。整体适合小白进阶和中高级开发者参考。
1. 为什么推荐系统课设选 Spring + Redis + MongoDB 这套组合
推荐系统是课程设计和毕业设计里最容易被做砸的方向,因为大部分人把它理解成了「写一个算法跑出结果」,实际上在工程环境里,算法只占一小部分,剩下的全是在处理数据流和并发。这个项目是拿 Spring 做接口编排,MongoDB 存全量行为日志和电影元数据,Redis 做热门榜和用户在线特征的缓存,整套链路是「离线统计 + 在线召回」的经典套路。推荐给这类项目的人,不管是做毕设还是中期演示,重点不是算法有多花哨,而是每一层数据怎么流转、缓存和数据库怎么对账、接口挂了从哪查起。本篇会沿着数据怎么进、怎么存、怎么取、怎么缓存失效这条线拆开讲,最后收在缓存击穿和序列化这类最容易扣分的细节上。
2. 先拆数据链路:MongoDB 文档模型、Redis Key 设计与 Parquet 数据导入
2.1 推荐系统里 MongoDB 和 Redis 的职责划分
这个项目的数据来源于一个按天分区的 Parquet 文件,从 Hive 或 Spark 离线任务导出,记录的是用户对电影的行为流水——谁在什么时间看了哪部电影、打了多少分。MongoDB 在这个位置承担的是行为流水库和电影特征库,因为文档模型比关系型更适合存这些字段不固定的数据,比如一部电影有导演、演员、评分人数、地区、语言,另一部电影可能没有评分,这种异构结构用 BSON 文档来存不需要改表结构。
Redis 做的是在线链路的事情。推荐接口被用户反复请求时,如果每次都去 MongoDB 里跑聚合,MongoDB 的压力会非常大,因为$group这类聚合管道是 CPU 密集型的操作。这个项目把热门电影榜、用户最近打分记录、以及后面要讲的相似度矩阵都放到了 Redis 里,靠 key 过期来控制数据的新鲜度,而不是每次请求都实时计算。
2.2 集合结构设计与日志表归档
新建一个名为movie_recommend的数据库,设计三个核心集合。这里需要注意,如果你之前用过 MySQL,会习惯把「用户最近打分」设计成一张表定期刷,但在 MongoDB 里可以直接做成内嵌数组,如:
db.user_behavior.insertOne({ user_id: 1024, movie_id: 78321, score: 4.5, behavior_type: "rating", timestamp: ISODate("2025-06-18T12:30:00Z"), context: { device: "android", channel: "recommend_detail" } })user_id和movie_id要建联合索引,否则按用户拉行为流水时会全表扫描。timestamp 单独建索引,因为做时间范围聚合时,比如统计最近 7 天行为,不走索引会慢很多。实际生产习惯是行为表按月归档,项目里如果不做冷热分离,推荐在 spring boot 的启动类里写一个ApplicationRunner,定期把三个月前的数据renameCollection成user_behavior_202503这样的归档集合。
MongoDB 核心集合一览
| 集合名 | 存放内容 | 索引建议 | 写入来源 |
|---|---|---|---|
| movie | 电影元数据:片名、类型、导演、上映年份 | title_embed、genres 联合索引 | 初始化时导入 |
| user_behavior | 用户打分、收藏、点击流水 | {user_id:1, timestamp:-1} 联合索引 | 用户操作实时写入 |
| similarity_cache | 物品间相似度,如协同过滤矩阵 | source_movie_id 单字段索引 | 离线脚本定期刷新 |
2.3 Parquet 格式导入 MongoDB 的两种方式
项目自带的part-r-00000-f84d648d-b491-4392-903a-805ae88196b4.gz.parquet是从 Hive 导出的压缩列式存储格式,不能直接用mongoimport导入,mongoimport只认 JSON、CSV 或 TSV。常见做法是用 Spark 读 Parquet 后再写入 MongoDB,前提是集群里有 Connector:
val df = spark.read.parquet("/data/movie_behavior/20250618/*.parquet") df.write.format("mongo") .mode(SaveMode.Append) .option("uri", "mongodb://127.0.0.1:27017/movie_recommend.user_behavior") .option("database", "movie_recommend") .option("collection", "user_behavior") .save()如果本机没有 Spark 环境,我一般会把spark.read.parquet换成直接读单文件的方案:Spark 导出单分区结果时part-r-00000-xxx.gz.parquet往往就是一个可独立读取的完整文件,用 pandas 配合pyarrow读成 DataFrame 再 to_json,然后交给mongoimport。注意压缩格式是 gzip,文件后缀里虽然带着.gz.parquet,但 Spark 默认用的是 snappy 压缩,遇到报错Failed to load snappy native library时先在 pom 里加org.xerial.snappy:snappy-java的依赖。
2.4 Redis 的 Key 设计与数据类型选择
这个项目的 Redis 缓存设计直接对应四种数据类型:
# 热门电影榜,key 用 ZSet,分数是加权评分 ZADD hot:movies:ranking 8.7 78321 8.2 99114 7.9 50872 # 用户最近 20 条行为,用 List 只保留最新记录 LPUSH user:recent:1024 movie:78321 movie:99114 LTRIM user:recent:1024 0 19 # 物品相似度矩阵,用 Hash 存目标电影的近邻列表 HSET sim:matrix:78321 neighbor:99114 0.85 neighbor:50872 0.73记住一个原则:key 的命名空间用冒号分层,hot:movies表示是热门电影相关的缓存,user:recent:{userId}表示某个用户的最近行为列表。ZSet 适合做排行榜这类需要按分数排序的场景,这也是项目里热门榜不用 List 而用 ZSet 的原因。行为列表用 List 加LTRIM截断,是为了防止一个高频用户的列表无限膨胀。
3. Spring 服务层实现:从 MongoRepository 查询到 Redis 缓存更新
3.1 初始化加载与数据预热
项目启动后需要做一次数据预热,把 MongoDB 里评分人数大于某个阈值的电影加载到 Redis 里。这里有一个新手容易掉进去的坑:直接在@PostConstruct里跑全量加载,如果 MongoDB 数据量大,启动要等一分多钟,而且在 Spring Bean 还没完全初始化完成前访问 Redis 连接池,可能出现连接未就绪的异常。推荐把预热放进ApplicationRunner,因为ApplicationRunner的执行时机是所有 Bean 创建完成之后:
@Component public class CachePreheatRunner implements ApplicationRunner { private final MongoTemplate mongoTemplate; private final RedisTemplate<String, String> redisTemplate; @Override public void run(ApplicationArguments args) { Query query = new Query(); query.addCriteria(Criteria.where("rating_count").gt(100)); List<Movie> movies = mongoTemplate.find(query, Movie.class); for (Movie movie : movies) { double score = movie.getAvgRating() * Math.log10(movie.getRatingCount()); redisTemplate.opsForZSet().add("hot:movies:ranking", movie.getId(), score); } } }这段代码用Math.log10(ratingCount)对高分但冷门的电影做了降权,避免一部只有一个人打了 10 分的电影冲上热榜。这里要注意opsForZSet()操作的前提是序列化器配置正确,否则写入 Redis 后 key 会带\xac\xed...前缀乱码,原因是默认用了 JDK 序列化器。在RedisConfig里把 key 的序列化器换成StringRedisSerializer,value 换成Jackson2JsonRedisSerializer是常见做法。
3.2 MongoRepository findAll 的条件查询细节
项目里的MovieRepository直接继承了MongoRepository,写查询方法时有两个常见问题。第一,findAll()不带条件会一次性把所有电影捞到内存,如果数据量上万,应用内存瞬间涨上去,接口响应也慢。第二,用findAll(Example)做条件查询时,如果Movie对象里有 null 字段,ExampleMatcher默认会忽略 null。比如查「类型为动作片、上映年份大于 2018」的电影:
public interface MovieRepository extends MongoRepository<Movie, String> { @Query("{ 'genres': ?0, 'year': { $gt: ?1 } }") List<Movie> findByGenresAndYearAfter(String genre, int year); }@Query注解直接写 MongoDB 查询语法,不需要拼 JSON,?0和?1是方法参数的占位符。字段名映射时要注意,如果 POJO 里是驼峰命名avgRating,而 MongoDB 文档字段是下划线avg_rating,Spring Data 不会自动做这个映射,需要在实体类加@Field("avg_rating")注解。
3.3 推荐接口的缓存优先策略
推荐接口的逻辑顺序是:先查 Redis,再查 MongoDB。项目里RecommendService的实现很像一个二级缓存结构,Redis 命中直接返回,未命中回源数据库再写回缓存,伪代码逻辑如下:
public List<Movie> recommend(String userId, int limit) { String cacheKey = "user:recommend:" + userId; List<Movie> cached = getFromCache(cacheKey); if (cached != null) { return cached; } // 从MongoDB查用户最近行为,再查相似电影 List<String> recentMovieIds = getRecentMovieIds(userId); List<Movie> recommendations = computeBySimilarity(recentMovieIds, limit); redisTemplate.opsForValue().set(cacheKey, recommendations, 30, TimeUnit.MINUTES); return recommendations; }这里设置 30 分钟过期时间是有讲究的,太短用户刷几次就回源数据库,失去了缓存意义;太长用户已经看完某部电影,推荐列表还是不更新,体验会很差。30 分钟这个窗口对电影推荐场景来说用户基本无感知。
3.4 写入行为日志时的双写一致性
用户打分场景下需要同时更新 MongoDB 和 Redis。代码流程是按事务拆开的,MongoDB 负责落库,Redis 负责更新热榜分数和该用户的最近行为列表:
@Transactional public void rateMovie(String userId, String movieId, double score) { mongoTemplate.save(new Rating(userId, movieId, score, System.currentTimeMillis())); List<Movie> movies = mongoTemplate.find( Query.query(Criteria.where("_id").is(movieId)), Movie.class); Movie movie = movies.get(0); double newAvg = (movie.getAvgRating() * movie.getRatingCount() + score) / (movie.getRatingCount() + 1); mongoTemplate.updateFirst( Query.query(Criteria.where("_id").is(movieId)), Update.update("avg_rating", newAvg).inc("rating_count", 1), Movie.class); }@Transactional在 MongoDB 事务里不是免费的,如果 MongoDB 版本低于 4.0,它不支持多文档事务,@Transactional会静默失效。这是很多课程设计里「打分接口偶发数据不一致」的根源。项目资源的实验报告里如果没写 MongoDB 版本,建议本地至少升到 4.2,副本集模式下事务才能正常工作,单机 standalone 模式不支持事务。
这段代码更新平均分是「读-改-写」三步操作,在并发情况下两个用户同时打分,后写覆盖先写的分数。但课设阶段这个并发量很低,属于可接受的边界,真正压测时应该用 MongoDB 的$inc和$avg聚合操作来避免。
4. 实时推荐与离线任务:相似度计算落库和排序兜底
4.1 离线计算流程脚本化
项目的推荐策略不是直接在接口里跑复杂算法,而是通过离线任务把相似度矩阵提前算好放进 MongoDB 的similarity_cache集合,接口只做查表。这样做的好处是用户请求时延迟可控,算法复杂度再高也不影响在线接口。离线脚本可以用 Python 写,定时任务用 Linux crontab 或 Spring 的@Scheduled触发。计算逻辑用的是「基于物品的协同过滤」,核心公式是余弦相似度:
import pandas as pd from sklearn.metrics.pairwise import cosine_similarity ratings = pd.read_csv("user_ratings.csv") pivot = ratings.pivot_table(index="user_id", columns="movie_id", values="score").fillna(0) sim = cosine_similarity(pivot.T) sim_df = pd.DataFrame(sim, index=pivot.columns, columns=pivot.columns) sim_df.to_csv("movie_similarity.csv", index=True)这段脚本的思路是先把用户评分矩阵转成「用户×电影」的透视表,空值补 0,然后转置矩阵,让行变成电影,再算电影之间的余弦相似度。输出结果是一张电影到电影的相似度方阵,每行是一个电影和其余所有电影的相似度分数,之后导入 MongoDB 的similarity_cache集合。
离线任务是重计算任务,数据量大时跑完可能要几分钟,接口里不要每次同步等待这个结果。@Scheduled(cron = "0 0 2 * * ?")表示每天凌晨两点执行一次,这样用户早上打开应用拿到的推荐列表就是基于前一天所有行为数据计算出来的。
4.2 在线召回:从 Redis 读最近行为再查相似度
在线接口的召回逻辑是先拿到用户最近看过的电影,再查出这些电影各自的相似电影,做一个聚合排序。推荐结果要过滤掉用户已经看过的片子,避免推荐列表里有用户刚打过分的那一部,这个过滤条件不能漏,否则上线后被用户发现列表里永远有自己刚看完的那部电影。
聚合排序的分数要综合两个维度:相似度权重和电影本身的热度分。纯看相似度的结果是几部冷门电影因为和用户看过的某部片相似度极高而被排到第一位,观感很差。调权时我一般用线性加权,final_score = 0.7 * similar_score + 0.3 * hot_score,这两个系数放在配置文件里方便调参,不要写死在代码里。
4.3 冷启动兜底:Redis 热门榜直接当推荐结果
新用户没有行为数据,无法计算协同过滤,这时直接返回 Redis 里的热门榜就是最合理的选择。项目里把这部分处理为「兜底策略」而不是报错:user:recommend:{userId}查不到缓存,且该用户没有历史行为,就拿hot:movies:ranking这个 ZSet 的前 20 个影集。
新增电影的推荐问题也是常见考点。没有任何用户对它产生过行为,协同过滤矩阵里没有它的特征,这个项目里的做法是每天凌晨把新增电影按导演、演员、类型去匹配老电影的特征向量,找到最相似的几部老片,然后把这个新电影挂到老电影的相似列表后面。这个思路在业内叫「内容特征匹配」,不用等用户行为积累也能参与推荐。
5. 接口压测与缓存异常处理的进阶技巧:分布式锁与序列化排错
5.1 缓存击穿场景下的分布式锁
热门电影的推荐详情在 Redis 里 key 过期的一瞬间,大量用户同时请求这个 key,请求全部打到 MongoDB,数据库连接数瞬间被占满,这是缓存击穿。只给 key 加过期时间不能解决问题,还要在缓存重建时加锁。Redis 分布式锁在 Spring 里的实现方式有很多,项目级别最简单的做法是使用RedisTemplate+SETNX:
public List<Movie> recommendWithLock(String userId, int limit) { String lockKey = "lock:user:recommend:" + userId; String requestId = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (locked != null && locked) { try { List<Movie> recommendations = computeFromDatabase(userId, limit); redisTemplate.opsForValue().set( "user:recommend:" + userId, recommendations, 30, TimeUnit.MINUTES); return recommendations; } finally { String currentLock = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(currentLock)) { redisTemplate.delete(lockKey); } } } else { // 拿不到锁的请求先睡50毫秒再试一次 Thread.sleep(50); return recommendWithLock(userId, limit); } }这段代码有四个关键点。第一,setIfAbsent同时设置了过期时间,保证原子性,避免先setnx再expire两步操作在中间崩溃导致死锁。第二,锁的 value 存了requestId,释放时先比较再删除,防止线程 A 超时后线程 B 拿到锁,A 却把 B 的锁删掉。第三,拿不到锁的请求用Thread.sleep(50)自旋重试,重试次数要控制,避免无限递归栈溢出,实际做法是加一个计数参数,最多重试 5 次。第四,锁的粒度是用户维度而不是全局维度,保证不同用户的推荐互不影响。
这个「锁误删」的问题在很多课程设计的答辩里被问到过,能答出requestId的作用基本就能加分。真实的项目里一般用 Redisson 的getLock()方法,内部处理了看门狗续期,但课设项目里手写SETNX更能展示对原理的理解。
5.2 Redis 客户端连接的验证与排错
拿到项目源码后第一件要做的事不是启动 Spring Boot,而是先确认 Redis 和 MongoDB 服务是通的。使用RedisDesktopManager连接 Redis 时,如果看到 key 显示为\xac\xed\x00\x05t\x00\x08hot这样的乱码,说明配置的序列化器不是 String。还有一个高频报错是Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException,这是 Redis 未启动或端口错误,本地默认端口是 6379。MongoDB 安装后启动失败报The installer has encountered an unexpected error时,先检查服务窗口里的 MongoDB 服务是否设置为自动并已在运行,Windows 服务里找到 MongoDB Server 手动启动,然后用mongosh执行db.runCommand({ ping: 1 })验证连通性。
5.3 推荐结果正确性验证:同用户重复推荐的防重校验
推荐结果里出现同一部电影的多个变体(比如同一个系列的不同条目)不算 bug,但完全重复就会出现观感问题。为了避免推荐结果重复,聚合排序后要做一个去重操作:以movie_id为粒度,保留相似度最高的那条记录,同时过滤掉用户有时间戳行为记录的电影。这个过滤逻辑最好放在 SQL 层或 MongoDB 查询层实现,而不是在内存里循环去重。如果项目里用的是MongoRepository,可以自定义一个查询方法,用$nin排除指定列表:
List<Movie> findByIdInAndIdNotIn(List<String> similarityIds, List<String> excludedIds);excludedIds就是当前用户近 30 天有行为记录的电影 ID 列表,这个方法在 MongoDB 层面提前过滤掉,代码逻辑更清晰,也方便接口层直接返回结果不再做二次处理。
5.4 用压测脚本确认缓存策略生效
推荐接口写完以后,验证缓存是否真正生效,可以写一个简单的压测脚本对比 Redis 前后请求的响应时间。不引入 JMeter 的情况下,Shell 里用ab命令就够了:
ab -n 1000 -c 50 http://localhost:8080/api/v1/recommend?userId=1024第一次压测时 Redis 里没有user:recommend:1024这个 key,响应时间可能在 80~120ms;预热之后再次执行ab,单次请求降到 10ms 以内,同时 MongoDB 的连接数保持不变。如果两次响应时间没差别,说明缓存可能没被命中,检查RedisTemplate的 key 拼写是不是和写入时一致。使用 curl 命令先访问一次接口预热,确认 Redis 里有 key,再跑压测才是有效数据。
本文还有配套的精品资源,点击获取