1. 系统全景:这套商品推荐系统到底做了什么
先说结论:这是一个非常典型的电商场景实战项目,用 SpringBoot 3 + Vue3 + MyBatis 搭了一套前后端分离的商品推荐系统,核心亮点是内置了协同过滤推荐算法,能够根据用户的历史行为(浏览、收藏、加购、下单、评分)生成个性化的商品推荐列表。说白了,就是你在淘宝、京东上看到的"猜你喜欢"那一块。
适合谁来参考?我建议这么定位:如果你正在做毕业设计、想找一个能写在简历上的 Java 全栈实战项目,或者你是刚入门的后端开发想搞清楚"推荐系统到底是怎么在我的业务里跑起来的",这套东西非常对口。它不是那种只停留在理论层面的 demo,而是从 MySQL 表设计、后端接口、算法引擎到前端页面全部打通的可运行项目。
我当时拿到这套代码的第一反应是:终于见到一个不是"只写了登录注册"的项目了。它把一个完整的业务闭环给你做出来了——用户能浏览商品、产生行为数据、行为数据进入算法层、算法产出推荐结果、推荐结果通过接口吐给前端、前端再渲染成"为你推荐"的列表。这个闭环本身就是推荐系统里最核心的东西,很多商业项目往里砸钱优化,换的也无非是推荐效果的精度和实时性。
再说说技术选型。SpringBoot 负责提供 RESTful 接口,Vue3 负责渲染页面和交互,MyBatis 负责操作 MySQL 数据,前后端分离部署,这组合本身没啥新鲜的,但胜在扎实。真正让这套系统区别于普通 CRUD 项目的,是它把协同过滤算法真正落到了业务代码里,而不是嘴上说说。
从这个角度看,这个项目的参考价值不只是"代码能跑",而是让你完整走一遍"算法模型 + 工程落地 + 产品呈现"的全流程。这个经验比单纯刷几道算法题值钱得多。
2. 协同过滤推荐引擎:算法不只是调库,关键是思路
2.1 基于用户的协同过滤(UserCF)是怎么工作的
协同过滤的基本假设,一句话:相似的人会喜欢相似的东西。所以在实现上,它天然分成两条技术路线:基于用户的 UserCF 和基于物品的 ItemCF。
先讲 UserCF。它的核心步骤是这样的:
- 根据用户的历史行为数据,构建"用户-物品"评分矩阵。纵向是用户,横向是商品,交叉点是这个用户对商品的行为强度(比如浏览记1分、加入购物车记2分、购买记3分、评分用真实评分)。
- 计算用户之间的相似度,找出和目标用户最像的 K 个邻居。
- 把这些邻居有行为、但目标用户没有行为的商品,按评分预测值从高到低排序,取 Top-N 推荐给用户。
这里最关键的环节就是相似度计算。常用的方案有三种:余弦相似度、皮尔逊相关系数、杰卡德相似系数。电商场景里,我给的建议是优先用余弦相似度,因为实际评分数据往往是稀疏的(大量用户只有少量行为),而余弦相似度对稀疏数据的容忍度相对好一些。
余弦相似度的计算逻辑是:把两个用户各自的评分向量做内积,再除以两个向量模长的乘积。公式写出来是这样:
sim(u, v) = (u · v) / (|u| × |v|)举个具体例子。用户 A 对商品 1、2、3 分别评了 5、3、4 分,用户 B 对商品 1、2、4 分别评了 4、2、5 分。那 A 的向量就是 [5, 3, 4, 0],B 的向量是 [4, 2, 0, 5],两个向量的点积是 5×4 + 3×2 + 4×0 + 0×5 = 26,A 的模长是 sqrt(5²+3²+4²) ≈ 7.07,B 的模长是 sqrt(4²+2²+0²+5²) ≈ 6.71,余弦相似度就是 26 / (7.07 × 6.71) ≈ 0.55。相似度越接近 1,说明两个人品味越接近。
然后你用这个相似度作为权重,预测目标用户对某个未见商品的评分,权重和评分加权求平均,就得到一个预测得分。把所有候选商品按得分排序,取前面几个输出,这就是完整的 UserCF 流程。
2.2 基于物品的协同过滤(ItemCF)为什么更适合电商
ItemCF 的逻辑反过来:喜欢商品 A 的用户,往往也会喜欢和 A 相似的商品。它先通过所有用户的行为构建商品之间的相似度矩阵,比如买了手机的人通常也买了充电器,那"手机"和"充电器"之间的相似度就很高。
电商场景里我强烈建议用 ItemCF,原因很现实:
- 用户数量通常远大于商品数量,用户相似度矩阵存储和计算开销巨大,商品数量相对可控。
- 用户兴趣变化快,UserCF 推荐结果对用户实时行为敏感度不够;ItemCF 只要有用户最新的行为,就能立刻推荐相关的商品。
- 电商推荐更讲究"看了又看""买了又买"的关联推荐,ItemCF 的结果天然符合这个逻辑。
当然,有一种情况你得用 UserCF,比如做资讯推荐、社区内容推荐,用户群体小且兴趣标签相对稳定,UserCF 能挖掘出跨品类的潜在兴趣。
2.3 评分数据的标准化处理
协同过滤算法能不能跑出效果,数据是关键。原始日志里你拿到的往往不是分数,而是行为类型,所以第一步要定义行为权重表。这是我项目里用的一套常见映射:
| 行为类型 | 权重值 | 说明 |
|---|---|---|
| 浏览 | 1 | 弱意向,权重最低 |
| 收藏 | 2 | 表达了兴趣 |
| 加入购物车 | 3 | 有明显购买意向 |
| 提交订单 | 4 | 接近成交 |
| 完成支付 | 5 | 强意向,权重最高 |
这样处理的原因很朴素:加购的人比只看不买的人目标更明确,支付成功的人欲望最强烈。用这种梯度权重去构建评分矩阵,推荐出来的结果才不会推荐文章里那些"只逛不买"的噪音数据。
2.4 冷启动问题怎么破
冷启动是推荐系统绕不过去的坎。新用户进来没有任何行为数据,算法算不出任何东西;新商品上架,没有任何用户碰过它,它也不会出现在任何推荐列表里。
我的做法是加一层兜底策略:新用户直接给他推热门商品榜,这个榜单就是全站商品的评分总数降序排列,简单粗暴但有效;新商品则在推荐结果里按时间衰减因子给一定的曝光权重,让它有机会被看到,攒出第一批行为数据。这套项目里我建议直接在代码里预留一个"热门推荐"接口,冷启动用户走后端默认分流。用户产生 5 条以上行为后再切到协同过滤推荐,效果会好很多。
3. 系统架构与核心模块实现细节
3.1 前后端分离的结构到底怎么拆
这套系统的项目结构,我拆开来看,大概是这样的:
后端 SpringBoot 工程:
backend ├── controller -- REST 接口层 ├── service -- 业务逻辑层 ├── mapper -- MyBatis 数据访问层 ├── model -- 实体类与 DTO ├── recommend -- 协同过滤算法核心包 └── config -- 跨域、全局异常等配置前端 Vue3 工程:
frontend ├── api -- axios 接口封装 ├── views -- 页面组件 ├── router -- 路由配置 ├── store -- 状态管理(Pinia) └── components -- 通用UI组件按照这个结构组织,前端每个页面只通过 axios 调后端接口拿数据,后端只负责业务逻辑返回值,谁都不越界。这也意味着你如果想把前端换成小程序、把后端接口接给 App,都是无缝切换的,这正是前后端分离的核心价值。
3.2 数据库表设计:推荐系统的基础设施
MySQL 里到底要建哪些表?我按实际需求给你一张表清单,参考这套设计是完全可以跑的:
| 表名 | 核心字段 | 作用 |
|---|---|---|
| user | id, username, password, nickname | 用户基础信息 |
| product | id, name, category, price, cover, description | 商品基础信息 |
| user_behavior | id, user_id, product_id, behavior_type, create_time | 核心行为日志表,推荐算法原材料 |
| user_rating | id, user_id, product_id, rating, create_time | 用户显式评分表 |
| recommend_result | id, user_id, product_id, score, create_time | 推荐结果缓存表 |
| order_info | id, user_id, order_no, total_amount | 订单主表 |
| order_item | id, order_id, product_id, quantity, price | 订单明细表 |
行为表是绝对核心,算法全靠它喂数据。所以设计上有一个很重要的原则:行为数据一旦落库就不能改、不能删,要当成日志表来维护。商品表和用户表有更新,但行为表只追加不更新。我把 user_behavior 表设置了按月分区的策略,避免单表数据膨胀后查询越来越慢。
recommend_result 表也很关键,它就是推荐结果的缓存层。协同过滤算法是离线批量算的,总不能用户每次刷新页面都现算一遍,那数据库和 CPU 都扛不住。正确的做法是:定时任务每天凌晨跑一次算法,把每个用户的推荐结果写入 recommend_result 表,用户在线访问时直接查这张表返回,性能极好。如果用户产生新行为,又想实时看到推荐变化,可以规避 rec_result 表,改为在接口层动态融合实时计算出的候选集,不过这是进阶玩法了。
3.3 相似度计算的 Java 实现:从公式到代码
这里我直接给出可落地的核心代码片段。注意,实际项目里你可以在 Java 里实现相似度计算,也可以借助 Redis 的 sort set 加速,但理解原始实现永远是第一步。
public class CosineSimilarity { // 计算两个用户向量的余弦相似度 public static double calculate(Map<Integer, Double> userVectorA, Map<Integer, Double> userVectorB) { double dotProduct = 0.0; double normA = 0.0; double normB = 0.0; for (Double value : userVectorA.values()) { normA += Math.pow(value, 2); } for (Double value : userVectorB.values()) { normB += Math.pow(value, 2); } if (normA == 0.0 || normB == 0.0) { return 0.0; } for (Map.Entry<Integer, Double> entry : userVectorA.entrySet()) { Integer productId = entry.getKey(); if (userVectorB.containsKey(productId)) { dotProduct += entry.getValue() * userVectorB.get(productId); } } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); } }这里有个隐藏的细节很多人会踩坑:用 Map 存向量时要补 0。如果一个用户对商品 X 没评过分,你就不塞这个 key,那算出来的点积其实是忽略了缺失维度的。有人觉得这是偷懒,但其实对余弦相似度来说,缺失维度的处理本身就是一种稀疏容忍策略,效果可接受。真正的坑是你把缺失值当 0 塞进去,两个都有大量缺失的用户会因为大量 0 值而拉高相似度,属于纯噪音。
3.4 推荐结果的生成与排序
有了相似度和用户评分矩阵,下一步就是预测目标用户对候选商品的评分并排序。核心伪代码逻辑如下:
// 基于用户相似度预测用户对物品的评分 public double predictRating(int userId, int productId, List<Integer> nearestNeighbors, Map<Integer, Map<Integer, Double>> userRatingMatrix) { double weightedSum = 0.0; double similaritySum = 0.0; for (Integer neighborId : nearestNeighbors) { Double rating = userRatingMatrix.get(neighborId).get(productId); if (rating == null) { continue; } double sim = CosineSimilarity.calculate( userRatingMatrix.get(userId), userRatingMatrix.get(neighborId) ); weightedSum += sim * rating; similaritySum += Math.abs(sim); } return similaritySum > 0 ? weightedSum / similaritySum : 0.0; }这段代码的意图很清晰:相似用户的意见权重更高,不相似的人对推荐结果的影响趋近于零。然后再把用户没有行为过的所有商品都算一遍,按预测分排序,取 Top-N。
但这里有一个性能隐患:如果商品总量有几万条,每个用户每次刷新都要全量算一遍,系统就跑死了。所以项目中一般有两种策略,一是离线把候选集缩小,只计算用户行为商品对应的关联商品;二是先查出相似度高于某个阈值(比如 0.3)的邻居用户,再基于他们的行为集合生成候选集。实测下来,Filter First、Calculate Later 的策略能把计算量降到全量的 1/10 到 1/100,效果几乎不损失。
4. 业务链路与接口实现:推荐是怎么融进商品系统的
4.1 用户行为采集接口
采集接口是整个推荐系统的入口,前端每个关键操作都要往这个接口打数据。这里实现了两个接口:记录用户行为和查询推荐结果。行为采集接口设计如下:
@RestController @RequestMapping("/api/behavior") public class BehaviorController { @PostMapping("/record") public Result<Void> record(@RequestBody BehaviorDTO behavior) { // behavior 包含 userId, productId, behaviorType // 1. 写入 user_behavior 表 // 2. 同步更新 redis 中该用户的行为时间戳 // 3. 返回记录状态 } }前端埋点的触发时机包括:进入商品详情页时触发浏览事件、点击收藏按钮时触发收藏事件、点击加入购物车时触发加购事件、支付成功后触发支付事件。每一个事件带的时间和用户 ID、商品 ID 就是算法的原材料。
因为行为事件频率高,日志表写入量会越来越大。我的一个实测经验是:数据库连接池耗尽问题在这个接口上出现过不止一次。解决方法是写入行为时用异步线程池 + 批量刷盘策略,把多条行为先攒在内存队列里,积攒到一定量或一定时间再批量落库。即便中途服务重启,顶多丢几条行为日志,不影响推荐效果,却能把接口性能提升一个档次。你要是跑这套源码,强烈建议把异步写入这个优化加上。
4.2 推荐查询接口与前端对接
查询接口的逻辑也很简单,优先从缓存表里读结果,如果没有再算实时结果:
@GetMapping("/{userId}/recommend") public Result<List<ProductVO>> getRecommendList(@PathVariable Integer userId) { // 1. 查推荐结果缓存表 // 2. 如果缓存命中,按商品信息 join 后返回 // 3. 如果缓存未命中,属于冷启动用户,走热门榜单兜底 }前端在商品列表页底部放一个"猜你喜欢"模块,挂载时调这个接口,拿到商品数据后渲染卡片。核心的一段 Vue3 逻辑长这样:
const recommendList = ref([]) async function loadRecommend() { const res = await getRecommendList(currentUser.id) recommendList.value = res.data.map(item => ({ ...item, priceText: `¥${item.price.toFixed(2)}` })) } onMounted(loadRecommend)这里有个体验细节值得说:前端展示推荐结果时,一定要把"推荐理由"也展示出来,比如"因为你看过手机,推荐你这款充电器"。单纯推商品不给理由,用户感知不到"推荐"的价值,只能觉得很奇怪为什么平白无故出现这些商品。加了推荐理由后,点击率和信任感都会明显提升。
5. 工程落地避坑指南
5.1 MyBatis 关于动态 SQL 和多表联查的经验
推荐系统场景里 SQL 的特点是多表关联复杂、条件动态化。比如推荐结果要 join 商品信息、同时过滤掉已下单商品、还要按评分排序,这个 SQL 如果写在注解里会非常痛苦。我建议所有复杂查询都用 XML 写。
核心表 user_behavior 必须建索引,而且要建联合索引(user_id, product_id),这是推荐算法最频繁的查询条件:查某用户的所有行为、查某商品被哪些用户行为过。索引的重要性在数据量 50 万条行为记录后体现得尤其明显——没索引时一个相似度计算的查询能跑到秒级,加了索引直接降到 50 毫秒以内。
5.2 前后端分离的跨域与联调问题
前后端分离项目最常见的第一个坑就是跨域。前端跑在 5173 端口,后端跑在 8080 端口,浏览器会拦截跨域请求。解决办法有两种:后端加全局 CORS 配置,或者前端 devServer 配置代理转发。我推荐用前端代理的方式,因为生产环境前后端通常会部署在同一个域名下,代理方式更接近真实部署形态。
Vue3 + Vite 下的代理配置:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样配合的话,前端代码里写接口地址就直接写/api/product/list,不用写完整的 localhost 地址,一套代码走到生产环境也不用改前端代码,只需要在 Nginx 里配好 /api 反向代理就能切换。
5.3 算法层的一些优化点
这套项目如果只是按基础逻辑跑起来,做一个毕业设计是够的。但如果想让推荐效果更好,可以做这几件小事:
第一,计算 Top-N 推荐时,把"用户已购买商品"从候选集里剔除,不然会出现反复推荐已经买过的东西。你千万别觉得这是小事,很多早期项目就栽在这上面,用户都下单了还天天推荐同款,体验非常差。
第二,加入时间衰减因子。用户上周的行为和三个月前的行为,价值不应该一样。给每条行为乘一个 exp(-Δd) 的衰减权重,最近的兴趣权重更高,推荐结果会显得聪明很多。这套逻辑代码量不大,效果却非常直观。
第三,数据量到了一定规模之后,把计算从 Java 代码里搬出来。比如用 Redis 管理用户与用户的相似度矩阵,用 xxl-job 定时把离线算好的结果推给 MySQL 的 recommend_result 表。这样既不让算法计算阻塞在线请求,又能在用户请求推荐时保持毫秒级响应。选 Redis 的原因是它的排序数据结构天然适合存 Top-K 相似度和 Top-N 推荐列表。
6. 常见问题与排查经验速查
我把自己在这套系统调试中踩过的坑整理成一个速查表,方便你直接对应排查。
| 现象 | 可能原因 | 排查思路与解法 |
|---|---|---|
| 系统启动后无法登录 | 数据库初始化未完成 | 检查 SQL 脚本是否执行过 user 表初始数据 |
| 前端页面请求都是 401 | Token 校验或跨域问题 | 看后端控制台是否有 CORS 相关异常,确认代理是否生效 |
| 推荐接口返回空列表 | 目标用户行为数据不足 | 先去 user_behavior 表查该用户是否有记录,如果少于 5 条会走冷启动兜底 |
| 相似度计算结果全为 0 | 评分矩阵构建时 key 没对齐 | 检查 user_rating 表是否能正常关联到 product_id |
| 接口很慢 | 行为表没建索引 | 给 user_behavior 表补联合索引,实测效果极明显 |
| 推荐结果里有已购商品 | 过滤逻辑缺失 | 在 SQL 里 join 订单明细表,用 not exists 排除 |
还有一个常见的理解性误区要说明:协同过滤算出来的相似度,不代表真实语义上的相似。比如啤酒和尿布在很多年轻爸爸的购物行为里同时出现,算法判断它们相似度极高,但这不代表工业设计上它们长得像。这是行为模式的相似,不是内容属性的相似。你要是拿算法输出去做商品文案推荐,就会闹笑话。理解这层区别,才能正确使用协同过滤的结果。
7. 一点实操体会
这个项目跑通一遍之后,最大的感受是:推荐系统本质上不是一个算法问题,而是一个数据工程问题。算法本身在经典教材里讲得很透,论文也就那几篇,但真正让你头疼的是数据埋点准不准、行为权重合不合理、存储查询扛不扛得住。我之前见过很多"推荐系统"项目,算法代码写得光鲜亮丽,结果一看埋点,前端两个页面压根没接行为上报,所有数据全靠 SQL 脚本造,那这个系统本质上就是个 demo。
所以我在实际做这套系统的时候,反而把更多精力放在了行为采集链路的完整度上。先保证用户的每一次浏览、加购、下单都能真实地落到库里,再去谈推荐效果。你把这个观念带到一个工作环境里,会少走非常多弯路。
最后再分享一个小技巧:你在测试推荐效果的时候,不要只用管理员账号测。管理员账号一般只会浏览几个固定商品,数据量太少压根看不出效果。建议你先注册三五个普通账号,每个账号刻意制造不同的行为偏好,比如一个只看手机数码、一个只看家居生活,然后看这几个账号收到的推荐列表差异大不大。如果差异大,说明算法生效了;如果推荐结果趋同,多半是相似度计算或评分构建环节有问题。这个验证方法我每次做完推荐系统必用,效率极高。