尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring + Redis + MongoDB构建推荐系统:从数据链路到缓存实战

Spring + Redis + MongoDB构建推荐系统:从数据链路到缓存实战 简介这是一套基于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前提是集群里有 Connectorval 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 RedisTemplateString, String redisTemplate; Override public void run(ApplicationArguments args) { Query query new Query(); query.addCriteria(Criteria.where(rating_count).gt(100)); ListMovie 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 的序列化器换成StringRedisSerializervalue 换成Jackson2JsonRedisSerializer是常见做法。3.2 MongoRepository findAll 的条件查询细节项目里的MovieRepository直接继承了MongoRepository写查询方法时有两个常见问题。第一findAll()不带条件会一次性把所有电影捞到内存如果数据量上万应用内存瞬间涨上去接口响应也慢。第二用findAll(Example)做条件查询时如果Movie对象里有 null 字段ExampleMatcher默认会忽略 null。比如查「类型为动作片、上映年份大于 2018」的电影public interface MovieRepository extends MongoRepositoryMovie, String { Query({ genres: ?0, year: { $gt: ?1 } }) ListMovie findByGenresAndYearAfter(String genre, int year); }Query注解直接写 MongoDB 查询语法不需要拼 JSON?0和?1是方法参数的占位符。字段名映射时要注意如果 POJO 里是驼峰命名avgRating而 MongoDB 文档字段是下划线avg_ratingSpring Data 不会自动做这个映射需要在实体类加Field(avg_rating)注解。3.3 推荐接口的缓存优先策略推荐接口的逻辑顺序是先查 Redis再查 MongoDB。项目里RecommendService的实现很像一个二级缓存结构Redis 命中直接返回未命中回源数据库再写回缓存伪代码逻辑如下public ListMovie recommend(String userId, int limit) { String cacheKey user:recommend: userId; ListMovie cached getFromCache(cacheKey); if (cached ! null) { return cached; } // 从MongoDB查用户最近行为再查相似电影 ListString recentMovieIds getRecentMovieIds(userId); ListMovie 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())); ListMovie 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(indexuser_id, columnsmovie_id, valuesscore).fillna(0) sim cosine_similarity(pivot.T) sim_df pd.DataFrame(sim, indexpivot.columns, columnspivot.columns) sim_df.to_csv(movie_similarity.csv, indexTrue)这段脚本的思路是先把用户评分矩阵转成「用户×电影」的透视表空值补 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 里的实现方式有很多项目级别最简单的做法是使用RedisTemplateSETNXpublic ListMovie 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 { ListMovie 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排除指定列表ListMovie findByIdInAndIdNotIn(ListString similarityIds, ListString excludedIds);excludedIds就是当前用户近 30 天有行为记录的电影 ID 列表这个方法在 MongoDB 层面提前过滤掉代码逻辑更清晰也方便接口层直接返回结果不再做二次处理。5.4 用压测脚本确认缓存策略生效推荐接口写完以后验证缓存是否真正生效可以写一个简单的压测脚本对比 Redis 前后请求的响应时间。不引入 JMeter 的情况下Shell 里用ab命令就够了ab -n 1000 -c 50 http://localhost:8080/api/v1/recommend?userId1024第一次压测时 Redis 里没有user:recommend:1024这个 key响应时间可能在 80~120ms预热之后再次执行ab单次请求降到 10ms 以内同时 MongoDB 的连接数保持不变。如果两次响应时间没差别说明缓存可能没被命中检查RedisTemplate的 key 拼写是不是和写入时一致。使用 curl 命令先访问一次接口预热确认 Redis 里有 key再跑压测才是有效数据。本文还有配套的精品资源点击获取
返回列表