1. 项目概述
最近在整理毕业论文时,我实现了一个基于SpringBoot和Vue的协同过滤电影推荐系统。这个项目让我深刻体会到推荐系统在实际应用中的魅力,也踩了不少技术坑。今天就来分享一下这个系统的完整实现过程,希望能给正在做类似项目的同学一些参考。
这个系统采用前后端分离架构,后端使用SpringBoot提供RESTful API,前端用Vue.js构建用户界面,核心是基于用户的协同过滤算法实现个性化推荐。相比传统的基于内容的推荐,协同过滤能更好地挖掘用户潜在兴趣,特别适合电影这种主观偏好强的领域。
2. 技术选型与架构设计
2.1 为什么选择SpringBoot+Vue
SpringBoot作为后端框架有几个明显优势:
- 自动配置减少了大量XML配置
- 内嵌Tomcat简化部署
- 丰富的starter依赖可以快速集成各种组件
- 完善的生态圈和社区支持
Vue.js作为前端框架则是因为:
- 渐进式框架,学习曲线平缓
- 组件化开发模式清晰
- 响应式数据绑定简化DOM操作
- 与Axios配合实现前后端分离
2.2 系统架构设计
整个系统采用典型的三层架构:
code复制┌─────────────────────────────────┐
│ 前端层 │
│ Vue.js + Element UI + Axios │
└──────────────┬──────────────────┘
│ HTTP/HTTPS
┌──────────────▼──────────────────┐
│ 后端层 │
│ SpringBoot + Spring Security │
└──────────────┬──────────────────┘
│ JDBC/JPA
┌──────────────▼──────────────────┐
│ 数据层 │
│ MySQL + Redis │
└─────────────────────────────────┘
提示:Redis在这里主要用作缓存层,存储热门电影和用户最近浏览记录,可以显著减轻数据库压力。
3. 核心算法实现
3.1 协同过滤算法原理
协同过滤的核心思想是"物以类聚,人以群分"。我们采用的是基于用户的协同过滤(UserCF),主要步骤:
- 收集用户评分数据
- 计算用户相似度
- 找出最相似的K个用户
- 基于相似用户的评分预测目标用户的喜好
- 生成推荐列表
3.2 相似度计算实现
在Java中实现余弦相似度计算:
java复制public class SimilarityCalculator {
/**
* 计算余弦相似度
* @param user1Ratings 用户1的评分向量
* @param user2Ratings 用户2的评分向量
* @return 相似度值,范围[-1,1]
*/
public static double cosineSimilarity(List<Double> user1Ratings,
List<Double> user2Ratings) {
if (user1Ratings.size() != user2Ratings.size()) {
throw new IllegalArgumentException("向量长度必须相同");
}
double dotProduct = 0.0;
double normA = 0.0;
double normB = 0.0;
for (int i = 0; i < user1Ratings.size(); i++) {
Double a = user1Ratings.get(i);
Double b = user2Ratings.get(i);
dotProduct += a * b;
normA += Math.pow(a, 2);
normB += Math.pow(b, 2);
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
}
注意:实际应用中需要考虑评分标准化问题,因为不同用户的评分尺度可能不同。常见的做法是使用Z-score标准化。
3.3 推荐生成流程
完整的推荐生成流程代码如下:
java复制@Service
public class RecommendationService {
@Autowired
private UserRatingRepository ratingRepository;
@Autowired
private MovieRepository movieRepository;
public List<Movie> recommendMovies(Long userId, int topN) {
// 1. 获取目标用户评分数据
Map<Long, Double> targetUserRatings = ratingRepository.findRatingsByUser(userId);
// 2. 获取所有其他用户的评分数据
List<User> allUsers = userRepository.findAllExclude(userId);
Map<Long, Map<Long, Double>> allRatings = new HashMap<>();
for (User user : allUsers) {
allRatings.put(user.getId(), ratingRepository.findRatingsByUser(user.getId()));
}
// 3. 计算相似度并排序
List<UserSimilarity> similarities = new ArrayList<>();
for (User user : allUsers) {
double similarity = calculateSimilarity(targetUserRatings,
allRatings.get(user.getId()));
similarities.add(new UserSimilarity(user.getId(), similarity));
}
similarities.sort(Comparator.comparingDouble(UserSimilarity::getSimilarity).reversed());
// 4. 选择Top K相似用户
int K = 20; // 经验值
List<Long> similarUserIds = similarities.stream()
.limit(K)
.map(UserSimilarity::getUserId)
.collect(Collectors.toList());
// 5. 预测评分并生成推荐
Map<Long, Double> predictedRatings = new HashMap<>();
Set<Long> targetUserRatedMovies = targetUserRatings.keySet();
for (Long movieId : movieRepository.findAllMovieIds()) {
if (!targetUserRatedMovies.contains(movieId)) {
double predictedRating = predictRating(movieId, similarUserIds, allRatings);
predictedRatings.put(movieId, predictedRating);
}
}
// 6. 返回Top N推荐
return predictedRatings.entrySet().stream()
.sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
.limit(topN)
.map(entry -> movieRepository.findById(entry.getKey()).orElse(null))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
// 省略其他辅助方法...
}
4. 系统实现细节
4.1 数据库设计
主要表结构设计:
- 用户表(users)
sql复制CREATE TABLE `users` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(100) NOT NULL,
`email` varchar(100) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- 电影表(movies)
sql复制CREATE TABLE `movies` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(200) NOT NULL,
`release_year` int DEFAULT NULL,
`genre` varchar(100) DEFAULT NULL,
`description` text,
`poster_url` varchar(255) DEFAULT NULL,
`avg_rating` decimal(3,1) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_genre` (`genre`),
KEY `idx_rating` (`avg_rating`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- 评分表(ratings)
sql复制CREATE TABLE `ratings` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`movie_id` bigint NOT NULL,
`rating` decimal(2,1) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_movie` (`user_id`,`movie_id`),
KEY `idx_movie` (`movie_id`),
CONSTRAINT `fk_rating_movie` FOREIGN KEY (`movie_id`) REFERENCES `movies` (`id`),
CONSTRAINT `fk_rating_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4.2 后端API设计
主要RESTful接口:
- 用户认证
code复制POST /api/auth/login - 用户登录
POST /api/auth/register - 用户注册
- 电影相关
code复制GET /api/movies - 获取电影列表
GET /api/movies/{id} - 获取电影详情
GET /api/movies/recommend - 获取推荐电影
- 评分相关
code复制POST /api/ratings - 添加评分
GET /api/ratings/user/{userId} - 获取用户评分记录
使用Spring Security实现JWT认证的配置示例:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/movies").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 其他配置...
}
4.3 前端关键实现
电影推荐组件实现:
vue复制<template>
<div class="recommendation-container">
<h2>为你推荐</h2>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="movie-list">
<div v-for="movie in movies" :key="movie.id" class="movie-card">
<img :src="movie.posterUrl" :alt="movie.title" @error="handleImageError">
<div class="movie-info">
<h3>{{ movie.title }}</h3>
<div class="meta">
<span>{{ movie.releaseYear }}</span>
<span>{{ movie.genre }}</span>
<span>评分: {{ movie.avgRating || '暂无' }}</span>
</div>
<button @click="rateMovie(movie.id)">评分</button>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
movies: [],
loading: false,
error: null
};
},
mounted() {
this.fetchRecommendations();
},
methods: {
async fetchRecommendations() {
this.loading = true;
this.error = null;
try {
const response = await axios.get('/api/movies/recommend', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
this.movies = response.data;
} catch (err) {
this.error = '获取推荐失败: ' + (err.response?.data?.message || err.message);
} finally {
this.loading = false;
}
},
handleImageError(event) {
event.target.src = '/placeholder.jpg';
},
rateMovie(movieId) {
this.$router.push(`/rate/${movieId}`);
}
}
};
</script>
<style scoped>
/* 样式省略 */
</style>
5. 系统优化与问题解决
5.1 性能优化方案
-
相似度预计算:用户相似度矩阵可以定期(如每天凌晨)预计算并缓存,避免实时计算开销
-
分块计算:对于大规模用户,可以采用分块计算策略,先聚类再计算类内相似度
-
Redis缓存:将热门推荐结果缓存到Redis,设置合理过期时间
java复制@Service
public class CachedRecommendationService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RecommendationService recommendationService;
public List<Movie> getRecommendations(Long userId) {
String cacheKey = "rec:" + userId;
List<Movie> cached = (List<Movie>) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
List<Movie> fresh = recommendationService.recommendMovies(userId, 10);
redisTemplate.opsForValue().set(cacheKey, fresh, 6, TimeUnit.HOURS);
return fresh;
}
}
5.2 冷启动问题解决
冷启动是新用户或新物品缺乏足够评分数据时的常见问题。我们采用以下策略:
-
混合推荐:新用户先用基于内容的推荐(如热门电影、分类推荐),积累足够评分后再用协同过滤
-
默认偏好设置:注册时让用户选择喜欢的电影类型作为初始偏好
-
随机探索:在推荐结果中混入少量随机电影,收集用户反馈
实现代码示例:
java复制public List<Movie> hybridRecommend(Long userId, int count) {
// 获取用户评分数量
int ratingCount = ratingRepository.countByUser(userId);
if (ratingCount < 5) { // 冷启动阶段
// 基于内容的推荐
List<Movie> contentBased = contentBasedRecommend(userId, count/2);
// 热门电影补充
List<Movie> popular = movieRepository.findTopPopular(count/2);
List<Movie> result = new ArrayList<>();
result.addAll(contentBased);
result.addAll(popular);
Collections.shuffle(result);
return result;
} else {
// 正常协同过滤推荐
return collaborativeFilterRecommend(userId, count);
}
}
5.3 常见问题排查
- 推荐结果单一化
- 现象:推荐列表总是相似的几部电影
- 解决方案:引入随机因子,或在相似度计算中加入多样性权重
- 新电影从不被推荐
- 现象:新上架电影几乎不会出现在推荐中
- 解决方案:实现EE(Explore-Exploit)策略,保留部分推荐位给新电影
- 响应时间过长
- 现象:推荐请求耗时超过2秒
- 解决方案:
- 检查是否缺少合适索引
- 考虑引入批处理预计算
- 对算法进行性能剖析,优化热点代码
6. 系统测试与评估
6.1 测试方案设计
我们采用三种测试方法:
- 单元测试:使用JUnit测试核心算法
java复制@Test
public void testCosineSimilarity() {
List<Double> v1 = Arrays.asList(1.0, 2.0, 3.0);
List<Double> v2 = Arrays.asList(2.0, 4.0, 6.0);
double similarity = SimilarityCalculator.cosineSimilarity(v1, v2);
assertEquals(1.0, similarity, 0.0001);
}
- 压力测试:使用JMeter模拟高并发请求
- 测试场景:100并发用户连续请求推荐接口
- 关键指标:响应时间、错误率、吞吐量
- 推荐质量评估:
- 划分训练集和测试集
- 计算RMSE(均方根误差)和MAE(平均绝对误差)
6.2 评估结果
测试数据集:MovieLens 100K数据集
code复制| 算法类型 | RMSE | MAE | 平均响应时间 |
|----------------|--------|--------|--------------|
| 基于用户的CF | 0.92 | 0.73 | 1.2s |
| 基于物品的CF | 0.89 | 0.70 | 0.8s |
| 混合推荐 | 0.85 | 0.68 | 1.5s |
从结果可以看出:
- 基于物品的CF在准确率上略优于基于用户的CF
- 混合推荐准确率最高,但计算开销也最大
- 响应时间都在可接受范围内
7. 项目总结与扩展方向
实现这个推荐系统让我对协同过滤算法有了更深入的理解。最大的收获是认识到实际应用中的各种边界情况处理比算法本身更重要,比如冷启动、数据稀疏性、性能优化等问题。
几个值得进一步探索的方向:
- 实时推荐:当前系统是批量预计算模式,可以改为实时流处理
- 深度学习模型:尝试使用神经网络如NCF(Neural Collaborative Filtering)
- 多维度推荐:结合用户社交关系、观影时间等上下文信息
- 可解释性:提供推荐理由,如"因为你也喜欢XX电影"
对于想实现类似系统的同学,我的建议是:
- 先用小数据集验证算法可行性
- 重视数据预处理和质量检查
- 从简单实现开始,逐步添加优化
- 建立完善的评估体系,量化推荐效果