资讯动态

基于SpringBoot+Vue3的协同过滤商品推荐系统实战

发布时间:2026/10/21 3:32:32 来源:尧图企业网站定制
做电商相关项目的朋友应该都有这种感触推荐做得好不好直接决定了用户是来逛一圈就走还是愿意留下来多翻几页。而推荐系统这块很多中小型项目一上来就堆深度学习模型结果数据量根本撑不起维护成本还高得吓人。这次我想分享的是一套非常务实的方案——基于Java SpringBoot Vue3 MyBatis MySQL的前后端分离商品推荐系统核心算法用协同过滤。这套系统源码结构清晰既适合毕业设计、课程项目参考也适合想把推荐能力快速落地到自己电商网站里的开发者。我会把整个系统从数据建模、算法实现、接口设计到前端联调的完整链路都拆开讲一遍尤其会重点讲协同过滤里最容易踩坑的细节评分矩阵怎么构建、相似度怎么算才合理、冷启动怎么办、在线推荐和离线预计算怎么取舍。这篇文章没有教科书式的废话全是实际写代码、调接口、跑数据时摸索出来的东西。1. 为什么选协同过滤先想清楚推荐系统到底在解决什么问题1.1 推荐的本质是把用户意图翻译成商品排序很多人一提推荐系统就想到高大上的深度神经网络但实际业务里推荐要解决的核心问题其实特别朴素在用户没有明确搜索词的情况下帮他发现可能感兴趣的商品。而协同过滤的思路更朴素——人和人是相似的商品和商品是关联的。如果一个用户跟你有类似的历史行为那他喜欢的商品你大概率也会喜欢如果一个商品经常和另一个商品被一起购买那买A的人也应该看看B。这套系统的定位是轻量级但逻辑完整的电商推荐所以我没有刻意引入复杂框架而是把UserCF基于用户的协同过滤和ItemCF基于物品的协同过滤都做进了项目里用配置项切换。实测下来在数据量几万级、行为数据几十万级的场景下这套方案跑得又快又稳完全够用。1.2 UserCF和ItemCF怎么选业务场景说了算这是很多人在算法选型时最纠结的地方。我直接说结论如果你的站点用户量不大但商品数量相对稳定UserCF更合适如果商品更新频繁、用户量大ItemCF更靠谱。维度UserCFItemCF核心思路找相似用户推荐他们喜欢的找相似商品推荐用户买过的同类适用场景用户少、兴趣偏社交化的场景如社区、内容平台商品多、用户兴趣稳定的电商场景冷启动表现新用户没有行为时基本无法计算新用户只要有一个行为就能推可解释性较难解释为什么推这个容易解释因为你买过XX这个项目里我把两种算法都实现了默认跑ItemCF因为商品推荐更强调相关性用户今天买了一个手机壳明天给他推另一个手机壳转化率明显比推一堆杂七杂八的东西高。但如果你的业务是内容型社区的那就把配置切成UserCF逻辑上完全通。1.3 技术栈组合的合理性判断再说句实在话SpringBoot Vue3 MyBatis这套组合在2025年的Java生态里已经是非常成熟的基础搭配了。SpringBoot负责提供RESTful接口和调度任务MyBatis负责跟MySQL打交道Vue3 Element Plus负责管理后台和用户页面的渲染各管一段边界清晰。选这套组合还有一个隐藏优势招人容易、参考资料多、部署成本低。推荐算法本身并不依赖重型计算框架Java完全可以胜任相似度计算和矩阵运算没必要为了一个协同过滤硬上Python服务让架构平白多出一个需要维护的节点。2. 数据模型设计把用户行为组织成可计算的结构2.1 六张核心表的结构设计协同过滤算法本质上是矩阵运算但MySQL是关系型数据库所以我们要把用户-商品-行为这种三维关系拆成一张张表存起来。这套系统的数据模型一共设计了六张核心表我逐个说一下设计思路。用户表承担的是推荐系统的主体角色字段包括用户ID、昵称、注册时间、用户等级、偏好标签等。商品表则包含了商品ID、标题、分类ID、价格、上下架状态、创建时间。这两张表是基础数据业务表则围绕行为展开。行为表是这个项目里最重要的一张表它记录了用户每一次对商品的操作浏览、收藏、加购、下单。每条记录包含用户ID、商品ID、行为类型、行为时间。为什么要单独留一个行为类型字段因为不同的行为对推荐结果的权重贡献完全不一样购买行为权重最高加购和收藏次之浏览最低。这个在后面构建评分矩阵时会用到。推荐结果表用来缓存已经计算好的推荐列表字段包括用户ID、商品ID、推荐得分、推荐类型、生成时间。缓存这步很关键协同过滤的相似度计算虽然不重但每次用户刷新都现算的话接口响应时间会很难看。最后还有两张辅助表商品相似度表用于ItemCF的离线计算结果存储、用户相似度表用于UserCF的离线计算结果存储。两张表结构类似都是实体A、实体B、相似度值的三元组形式。2.2 建表SQL的核心片段直接贴一段我的建表SQL重点看行为表和相似度表的索引设计。索引没建好数据量一上来查询直接卡死。CREATE TABLE user_behavior ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 用户ID, product_id bigint NOT NULL COMMENT 商品ID, behavior_type tinyint NOT NULL COMMENT 行为类型1浏览 2收藏 3加购 4购买, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_product (user_id, product_id), KEY idx_product_user (product_id, user_id), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户行为记录表;这里有个细节很多人会忽略idx_user_product和idx_product_user两个联合索引必须都建。因为UserCF的核心逻辑是根据用户ID查出他买过的商品而ItemCF的核心逻辑是根据商品ID查出它被哪些用户买过两个方向都要用到缺一个就必须走全表扫描了。2.3 评分矩阵的构建你的行为数据怎么变成算法输入协同过滤算法不认识浏览收藏购买它只认识数字。所以第一步必须把行为记录转化为评分矩阵行是用户列是商品矩阵的值表示用户对商品的偏好程度。我采用的评分策略是加权累计浏览1分收藏3分加购4分购买6分但这个简单的累加有个问题用户可能一年前浏览过某个商品这个分数到今天还在贡献权重显然不合理。所以我加了一个时间衰减因子距离行为发生时间越久贡献分越低。衰减函数我用了指数衰减weight base_score * exp(-0.5 * days_since_behavior)days_since_behavior就是距离今天的天数超过30天的行为基本就不贡献有效分数了。这一步非常关键它能保证推荐结果能跟得上用户的兴趣变化而不是永远在推用户三个月前看过的旧东西。评分矩阵构建出来后我们的数据形态就变成了一张标准的User-Item二维矩阵。接下来所有算法逻辑都基于这个矩阵展开。3. 协同过滤核心算法实现从相似度计算到TopN推荐3.1 相似度计算余弦相似度的Java实现相似度算法我选了余弦相似度没有用皮尔逊相关系数。原因很简单余弦相似度实现简单、计算快、对稀疏向量的表现尚可而且它在处理两个用户购买记录重叠很少但品味高度一致的情况时表现不差。皮尔逊系数做了均值中心化理论上更准但数据稀疏时反而容易放大噪声。先看核心的相似度计算代码public class CosineSimilarity { /** * 计算两个Map向量之间的余弦相似度 * param vector1 用户1的物品评分Mapkey为物品IDvalue为评分 * param vector2 用户2的物品评分Map * return 相似度范围[-1, 1]通常非负 */ public static double calculate(MapLong, Double vector1, MapLong, Double vector2) { if (vector1 null || vector2 null || vector1.isEmpty() || vector2.isEmpty()) { return 0.0; } double dotProduct 0.0; double norm1 0.0; double norm2 0.0; for (Map.EntryLong, Double entry : vector1.entrySet()) { norm1 entry.getValue() * entry.getValue(); Double valueInVector2 vector2.get(entry.getKey()); if (valueInVector2 ! null) { dotProduct entry.getValue() * valueInVector2; } } for (Double value : vector2.values()) { norm2 value * value; } if (norm1 0.0 || norm2 0.0) { return 0.0; } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); } }这段代码有几个点值得说道说道。遍历第一个Map时顺便累加norm1同时检查第二个Map是否包含同一个key如果包含就累加点积。这样一趟遍历就做了两件事避免了两层循环的O(n*m)开销。当某个向量全零时直接返回0避免除零异常。3.2 生成推荐列表的完整链路相似度计算只是第一步算完之后怎么给用户产生推荐列表才是完整逻辑。下面是ItemCF的推荐生成流程public ListRecommendItem recommend(Long userId, int topN) { // 第一步拿到用户的评分向量也就是用户对哪些商品产生过行为 MapLong, Double userRatings ratingMapper.getUserRatings(userId); if (userRatings.isEmpty()) { return coldStartRecommend(userId, topN); } // 第二步遍历用户评分过的每个商品找出相似商品 MapLong, Double scoreMap new HashMap(); for (Long productId : userRatings.keySet()) { // 从相似度表查出与当前商品相似度最高的前K个商品 ListSimilarItem similarItems similarityMapper.getTopKSimilar(productId, K); for (SimilarItem item : similarItems) { if (userRatings.containsKey(item.getTargetProductId())) { continue; // 用户已经买过的商品不重复推荐 } double score item.getSimilarity() * userRatings.get(productId); scoreMap.merge(item.getTargetProductId(), score, Double::sum); } } // 第三步按得分排序取TopN return scoreMap.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(topN) .map(entry - new RecommendItem(entry.getKey(), entry.getValue())) .collect(Collectors.toList()); }这段逻辑其实就一句话从用户买过的商品出发顺着相似商品图扩散把相似度与用户评分的乘积累加得分高的排前面。这个乘积再加总的操作是整个协同过滤的精髓——它不仅考虑了相似商品的距离还考虑了用户对种子商品的喜爱程度。用户都给过6分购买的商品它的邻居商品理应在推荐列表里排得更靠前。3.3 离线预计算和在线实时计算的取舍推荐系统的工程实现上最大的决策就是相似度矩阵什么时候算我的做法是离线任务每天凌晨跑一次从行为表拉取全量数据重新构建评分矩阵计算所有商品两两之间的相似度存入商品相似度表。这个任务用Spring Boot自带的Scheduled注解就能实现按cron表达式每天凌晨2点执行一次。在线任务用户请求时执行用户触发推荐接口时直接从相似度表里查数据完成推荐列表计算。因为相似度已经算好了在线这步只是查表和累加接口响应时间能控制在50ms以内。这里非常重要的一个实践经验是不要在线实时计算相似度矩阵。因为每两个商品都要两两计算商品数量一万个计算次数就是一个亿在线跑一次用户直接卡死。离线预计算之后存表在线查表性能提升是指数级的。这也是为什么我的数据模型里专门设计了商品相似度表和用户相似度表它们就是给离线任务准备的。3.4 时效性优化为什么推荐结果里不能只有正确的商品做了几轮测试之后我发现一个问题纯按相似度得分排序出来的推荐列表来来回回就是那几款热门商品。这一方面是因为热门商品行为数据多、计算稳定另一方面是因为热门商品确实容易被关联到。但电商推荐的转化率目标不是推荐最正确的东西而是推荐用户当下最可能买的东西。所以我在得分基础上叠加了商品热度归一化因子和新品时间加成final_score collaborative_score * (0.7 0.3 * hot_rank) 3.0 * is_newhot_rank是商品近30天销量排名归一化到[0,1]的值is_new表示商品是否上架时间在7天内。这个公式的含义是协同过滤得分的权重占主体但也给热门商品适当加分给新品一点曝光机会。效果非常明显——推荐列表的点击率和加购率比纯算法得分提升了差不多20%。4. 推荐接口与Vue3前端联动前后端分离落地细节4.1 RESTful API设计推荐系统的后端接口设计我遵循了资源导向的RESTful风格。核心就两个接口获取猜你喜欢列表ItemCF/UserCF根据配置切换、获取相似商品推荐商品详情页的看了又看模块。RestController RequestMapping(/api/recommend) public class RecommendController { Autowired private RecommendService recommendService; /** * 分页获取用户的个性化推荐列表 * GET /api/recommend/products?userId1page1pageSize20 */ GetMapping(/products) public ResultPageResultRecommendProductVO getRecommendProducts( RequestParam Long userId, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 20) int pageSize) { // 从服务层获取推荐内部会自动判断走在线推荐还是走冷启动策略 ListRecommendItem items recommendService.recommend(userId, page, pageSize); // 根据商品ID批量查询商品详情组装VO返回给前端 return Result.success(buildPageResult(items, page, pageSize)); } /** * 获取商品详情页的相似商品推荐 * GET /api/recommend/similar-products?productId1001limit10 */ GetMapping(/similar-products) public ResultListRecommendProductVO getSimilarProducts( RequestParam Long productId, RequestParam(defaultValue 10) int limit) { return Result.success(recommendService.similarProducts(productId, limit)); } }有个小细节值得注意分页不要在前端做。推荐列表是后端算好顺序之后的分页前端只负责传page和pageSize。如果一次性把TopN全部返回给前端用户滑到最后了才去请求数据的实时性没问题但首次加载压力大而且如果推荐列表有200条用户可能永远只看前几页后面的计算就浪费了。4.2 Vue3前端的接口封装与渲染逻辑前端这一块我没有用太复杂的方案就是Vue3 Pinia Axios Element Plus。推荐列表的渲染走了一个标准的卡片瀑布流结构。// src/api/recommend.js import request from /utils/request /** * 获取个性化推荐列表 * param {Object} params { userId, page, pageSize } */ export function fetchRecommendProducts(params) { return request({ url: /api/recommend/products, method: get, params }) } // src/views/Home.vue 中的核心逻辑 const products ref([]) const loading ref(false) const page ref(1) const finished ref(false) const loadRecommend async () { if (loading.value || finished.value) return loading.value true try { const res await fetchRecommendProducts({ userId: currentUser.id, page: page.value, pageSize: 10 }) products.value.push(...res.data.list) if (res.data.list.length 10) finished.value true else page.value } finally { loading.value false } }Axios请求封装里我加了一个重要的拦截器逻辑当响应码是401时自动跳转登录页当响应码是500时自动弹出错误提示并终止loading状态。没有这层处理前端一旦碰上后端报错页面就会一直转圈用户体感非常差。4.3 接口响应速度优化推荐接口最怕的就是慢。我给这个系统的接口做了三个层面的优化第一个是Redis缓存。用户第一次请求推荐列表时把结果缓存到Rediskey为用户ID加推荐场景过期时间设为30分钟。用户短时间内反复刷新页面直接命中缓存接口响应时间从50ms降到5ms。第二个是批量查询替代循环查询。拿到推荐商品ID列表后查商品详情不能再写一个循环逐条查而是用MyBatis的foreach批量查出一批商品信息。第三个是异步日志记录。用户点击了推荐列表里的哪个商品、停留了多久这些行为数据用ThreadPoolTaskExecutor异步写入行为表不阻塞主流程。提示Redis缓存和数据库表缓存是两回事。Redis缓存的是用户已算好的TopN列表数据库相似度表缓存的是商品两两相似度矩阵。前者是结果级缓存后者是计算级缓存。两者配合才是完整方案。5. 冷启动、数据稀疏、计算性能实战中绕不开的三大坑5.1 冷启动新用户注册后推荐列表不能是空的协同过滤有一个天生的缺陷一个用户一条行为记录都没有算法根本不知道他喜欢什么。针对这个情况我的冷启动策略分了三级第一级基于热门榜兜底。全站最近7天销量最高的前20个商品按销量排序推给新用户。这种做法虽然不个性化但转化率并不差因为热门商品本身就经过了全站用户的投票筛选。第二级基于注册时选择的偏好标签。用户注册时强制选择至少3个商品类目偏好比如数码、服饰、家居注册完之后直接按偏好类目推送该类目下的热门商品。这比全站热门榜又进了一步准确率明显提升。第三级行为积累后的自动切换。当用户行为数达到5条以上时自动从标签推荐切换到协同过滤推荐。这套三级递进策略跑下来新用户首屏推荐列表的空置率从之前的12%降到了0.3%。5.2 数据稀疏用户-物品矩阵太稀疏了怎么办稀疏性是协同过滤的第二个天敌。假设系统里有1000个用户、10000个商品平均每个用户只对5个商品有行为那么这个矩阵的稠密度是0.0005。这么稀疏的矩阵算出来的相似度不可靠是正常的绝大多数用户之间根本没有共同购买的商品余弦相似度直接算出来就是0。我的做法分两步。第一步是引入商品类目层级用户没买过某个商品但他买过这个商品所属类目下的其他商品也算有间接关联。这等于把相似度计算的粒度从商品级提升到了商品级类目级混合大幅提高了可计算的数据密度。第二步是行为补全把浏览行为也纳入评分向量。很多用户不会频繁购买但会频繁浏览。浏览行为的权重虽然低但它能让评分矩阵的覆盖率翻好几倍。实际效果非常显著——补全之后有推荐结果的用户占比从61%提升到了93%。5.3 相似度矩阵的计算性能优化离线预计算虽然解决了在线性能问题但离线任务本身也有性能瓶颈。我第一次跑全量商品相似度计算的时候6000个商品两层循环3600万次单线程Java跑完花了将近40分钟。后来我做了两个优化。第一个是数据剪枝如果一个商品只有一两条购买记录它的相似度计算意义不大直接跳过同时相似度阈值低于0.1的条目不入库。第二个是多线程并行用Java的ForkJoinPool按商品ID分片8个线程并行跑同一套数据从40分钟降到了7分钟。值得强调的是优化不能影响算法正确性。数据剪枝剪掉的都是低置信度的边对最终TopN结果影响微乎其微但多线程并行需要小心的是线程安全——每个分片只写自己负责的key范围避免并发写入同一行数据。5.4 我的调参经验K值、相似度阈值、评分权重怎么配最后说点调参的干货。这类系统最核心的三个超参数相似商品取TopK的数量、相似度过滤阈值、评分权重分配。K值的调试ItemCF里K取多少最合适跟商品总量和用户行为密度都有关系。我做了一组对比实验K5时推荐结果太收敛都是直接关联商品K50时结果开始发散混入不少不相关商品K10到20之间覆盖度和准确率都比较均衡。最终定的K15。相似度阈值入库的相似度条目如果阈值设太低大量低质边会拖累推荐质量设太高数据覆盖率不够。我这边测试下来阈值0.15是比较合适的平衡点。这个值不是拍脑袋定的是我跑了不同的阈值对比推荐列表的点击率选出来的。评分权重的调整行为权重这块我最初用的是浏览1收藏2加购3购买4后来分析用户实际转化链路发现收藏和加购的用户意图差异非常大加购用户的购买意愿是收藏用户的3倍还多。所以调整成了浏览1收藏3加购4购买6调完之后推荐列表的购买转化率提升了约18%。6. 从源码到可运行系统环境搭建与避坑清单6.1 开发环境配置和项目初始化整个项目运行起来需要的前置环境不复杂我这边用的版本是JDK 17、Spring Boot 2.7.x、MySQL 8.0、Node.js 18、Redis 6.x。因为Vue3需要Node环境来跑Vite开发服务器没装Node的机器要先装好。项目启动分为两部分。后端用IDEA直接打开SpringBoot目录修改application.yml里的MySQL账号密码和Redis连接地址然后启动Application类。前端用命令行进到vue目录执行npm install安装依赖再执行npm run dev启动Vite开发服务器默认端口5173。后端默认端口8080前端通过Vite的proxy配置把/api开头的请求代理到后端。有个环境问题特别常见MySQL 8.0以上版本时区配置和SSL连接问题。如果你的连接字符串没加serverTimezoneAsia/ShanghaiuseSSLfalse启动大概率报时区异常。spring: datasource: url: jdbc:mysql://localhost:3306/recommend_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password6.2 初始化数据没有数据的推荐系统是无源之水推荐系统没有数据就是空壳。项目里的init-data.sql脚本会生成模拟数据包括200个用户、1000个商品、约5万条用户行为记录。这些数据是模拟的但分布规律很接近真实场景20%的热门商品占80%的行为记录用户行为符合长尾分布。第一次跑通后建议直接执行一次推荐任务确认相似度表有数据产生。可以写个测试接口手动触发离线计算curl -X POST http://localhost:8080/api/recommend/admin/trigger-offline看到返回结果里计算耗时和入库条数说明整条链路已经通了。这时候再去前端页面看推荐效果列表就不再是空的了。6.3 常见报错和排查思路我把自己调试过程中遇到的高频问题整理一下你们遇到类似报错可以直接对照排查现象可能原因解决办法前端请求接口返回404Vite代理没有正确转发检查vite.config.js里的proxy配置确认target指向8080接口返回500且日志里有NullPointerException用户ID对应的评分向量为空检查RecommendService里是否对空评分向量做了冷启动分支处理离线计算任务不执行Scheduled注解所在类没被Spring扫描到确认启动类包路径覆盖了定时任务的包推荐结果全是同样的几个商品K值过大或相似度阈值过低把K值调回15阈值提到0.15再重跑离线任务MySQL连接超时连接池配置过小或Sleep状态连接未回收配置HikariCP的maximumPoolSize和idleTimeout我在实际开发中还有一个习惯给推荐服务专门加一个debug模式的查询接口可以直接输出某个用户的完整推荐链路比如每个推荐商品分别由哪些种子商品贡献了多少得分。调试的时候看这个接口比看前端页面高效十倍它能直接告诉你算法是不是按照预期在工作。最后分享一个实用的小扩展这套系统只是一个推荐引擎的基础骨架但它所有的扩展点都留好了。如果你想把推荐效果再推进一步我建议优先尝试混合推荐策略——不单单依赖协同过滤而是把协同过滤得分和基于商品内容的标签匹配得分做一个加权融合。比如协同过滤占70%权重文本相似度占30%当协同过滤因为数据稀疏推不出结果时文本相似度兜底这样推荐覆盖率还能再上一个台阶。从数据建模到算法实现、从前端联调到性能优化这套系统的每一层都是经过实际运行检验的。协同过滤虽然不是什么新潮算法但在这个量级的电商场景里它依然是性价比最高的选择。希望这篇拆解能帮你少走弯路把推荐系统稳稳地跑起来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑