资讯动态

Java+SpringBoot+Vue实现基于物品的协同过滤推荐系统实战

发布时间:2026/9/28 6:04:39 来源:尧图企业网站定制
做推荐系统别被“人工智能”四个字吓住。体育商品推荐这种业务在绝大多数中小型项目里用到协同过滤就足够了根本不需要上深度学习。这篇实战指南我从零开始讲清楚一件事怎么用 Java SpringBoot Vue MySQL 这套最常规的技术栈把一个“猜你喜欢”的功能从数据库设计到算法落地再到前端展示完整地做出来。我会把这套系统的算法选型理由、数据库表结构、后端核心代码、前端页面串联再到部署时常踩的坑全部摊开来讲。尤其是推荐算法那块很多人一看到“相似度计算”就发怵其实拆开就是一个余弦公式加两层循环小学生都能看懂的逻辑。适合谁看适合正在做毕业设计或者公司内部小项目的人也适合准备 Java 后端面试时被问到“推荐系统怎么做”时至少能说出个一二三的求职者。内容会偏实战理论只讲够用的部分不搞花架子。1. 推荐逻辑怎么选基于物品的协同过滤为何更适合体育商品先理清业务场景。体育商品的品类比较特殊球鞋、球拍、护具、运动服、瑜伽垫SKU 数量通常在几千到几万这个量级。用户群体倒是几万甚至几十万。这个“商品少、用户多”的结构直接决定了算法的选型方向。1.1 三种推荐算法的对比与取舍市面上能落地的推荐方案大致有三种基于内容的推荐给商品打标签根据用户历史兴趣匹配标签。逻辑简单但要求商品标签质量非常高运动鞋和篮球鞋这种“看起来很像”的品类很容易被标签体系带偏。基于用户的协同过滤找到“和你行为相似的人”把他们买过的商品推荐给你。这在用户量小的时候非常精准但一旦用户超过几万实时计算用户之间的相似度矩阵数据库和内存都扛不住。基于物品的协同过滤找到“和你喜欢的商品相似的其他商品”推荐给你。因为商品数量远小于用户数量商品相似度矩阵可以提前离线算好并缓存在线推荐时只需要查表加排序响应速度极快。这个项目我选择基于物品的协同过滤Item-Based Collaborative Filtering核心原因就是可维护性强。商品相似度矩阵每天早上定时刷新一次白天所有推荐请求都只做内存计算不碰数据库的大表扫描。对 MySQL 的压力非常小而且代码量也不大。1.2 相似度计算余弦相似度的直观理解协同过滤的核心是算相似度。商品 A 和商品 B 的相似度不是看它们的品牌、颜色、分类而是看“同一批用户对它们的打分是否趋同”。如果看了篮球的人大概率也看了篮球鞋那篮球和篮球鞋就是相似商品。最常用的就是余弦相似度公式长这样similarity(A, B) Σ(ui·vi) / (√Σui² · √Σvi²)看着吓人拆开解释把用户对商品 A 的评分向量设为 u对商品 B 的评分向量设为 v最终结果越接近 1说明两个商品越相似越接近 0说明毫无关系。比如有三个用户对两双球鞋的评分用户商品A评分商品B评分小明54小红32小刚01分子是 5×4 3×2 0×1 26分母是 √(2590) × √(1641) ≈ 5.83 × 4.58 ≈ 26.7相似度约 0.97非常相近。如果有十个商品每个商品都这么算一遍两两得出一个相似度数值就得到了相似度矩阵。这个矩阵不需要实时更新体育商品更新频率低一天刷新一次完全足够。2. 数据库设计评分表结构是推荐系统的地基推荐算法再漂亮数据表设计不合理也白搭。我的数据库设计遵循一个原则行为数据只增不改统计数据定时汇总。核心就五张表用户表、商品表、用户行为表、商品相似度表、推荐结果表。2.1 五张核心表的字段拆解用户表不特殊常规的 id、username、create_time 就够了。商品表除了基本的名称、价格、分类我额外加了三个字段click_count浏览次数和purchase_count购买次数用于冷启动时的热榜兜底cover_url商品图片地址后面接 MinIO 存储时会用到最核心的是用户行为表。它的设计决定了推荐算法能拿到什么数据CREATE TABLE user_behavior ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, product_id BIGINT NOT NULL, behavior_type TINYINT NOT NULL COMMENT 1-浏览 2-收藏 3-加购 4-购买, score DECIMAL(3,1) NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_product (product_id), INDEX idx_user_product (user_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;score字段是我刻意设计的。不同行为的权重不同浏览记 1 分收藏记 3 分加入购物车记 5 分购买记 10 分。这个权重不写死在代码里而是存入每条行为记录中这样后续调权重只需要改历史数据不需要发版。2.2 为什么需要行为权重而不是直接用购买记录如果用“是否购买”作为唯一打分依据数据会非常稀疏。一个用户一个月可能只买两三件商品但可能浏览过几十件。那些“看了没买”的浏览行为其实也包含了大量兴趣信号——他看了说明他关注他没买可能只是价格不合适。把浏览、收藏、加购、购买全部纳入评分矩阵数据密度会提升好几倍协同过滤算出来的相似度也更稳定。我第一次做这个项目时只用购买数据结果相似度矩阵稀疏得几乎全是 0推荐结果完全不可用。加入行为权重后效果立竿见影。2.3 商品相似度表与推荐结果表商品相似度表我在生产环境里存的是对称矩阵的上三角避免重复存储CREATE TABLE product_similarity ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_a BIGINT NOT NULL, product_b BIGINT NOT NULL, similarity DECIMAL(8,6) NOT NULL, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_pair (product_a, product_b) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;推荐结果表则是给前端用的。定时任务算好每个用户最可能感兴趣的 20 个商品直接写入这张表。在线时前端只需要一条带分页的查询毫秒级返回完全不依赖算法模块的实时计算。3. SpringBoot 后端实现推荐接口的三层代码逻辑工程结构上我按常规的 Controller / Service / Mapper 分层。推荐模块的核心代码全部集中在RecommendService里分为离线计算和在线推荐两块。3.1 离线计算Spring Task 定时刷新相似度矩阵用Scheduled注解每天凌晨 4 点跑一次全量计算避开晚高峰Component public class RecommendTask { Resource private UserBehaviorMapper behaviorMapper; Resource private ProductSimilarityMapper similarityMapper; Scheduled(cron 0 0 4 * * ?) public void calculateSimilarity() { // 1. 查询近30天的有效行为记录 ListUserBehavior behaviors behaviorMapper.selectLast30Days(); // 2. 构建 用户-商品 评分矩阵 MapLong, MapLong, Double userProductScore buildUserProductMatrix(behaviors); // 3. 取出所有商品ID ListLong productIds new ArrayList(collectAllProductIds(behaviors)); // 4. 双重循环计算两两余弦相似度只保留 0.3 的 for (int i 0; i productIds.size(); i) { for (int j i 1; j productIds.size(); j) { double sim cosineSimilarity(productIds.get(i), productIds.get(j), userProductScore); if (sim 0.3) { similarityMapper.insert(productIds.get(i), productIds.get(j), sim); } } } } }这里有两个工程细节非常关键第一只保留相似度大于 0.3 的关联。如果不设阈值几千个商品两两组合会生成几百万条记录大部分都是接近 0 的垃圾数据白占存储。第二用一次双重循环加对称写入。循环只走j i 1只算上三角。生产环境商品有 3000 个时全量计算大约耗时 2 分钟在凌晨定时任务里完全可以接受。3.2 在线推荐从相似商品池里捞取 Top N在线推荐的核心逻辑是根据用户最近的行为商品从相似度表里捞相似商品再按相似度加权求和排序public ListProductVO recommend(Long userId, int size) { // 1. 查询用户最近的浏览和购买记录 ListUserBehavior recentBehaviors behaviorMapper.selectRecentByUser(userId, 10); // 2. 收集候选商品及其相似商品 MapLong, Double candidateScore new HashMap(); for (UserBehavior behavior : recentBehaviors) { ListProductSimilarity simList similarityMapper.selectByProductId(behavior.getProductId()); for (ProductSimilarity sim : simList) { Long targetId sim.getTargetProductId(); double score behavior.getScore() * sim.getSimilarity(); candidateScore.merge(targetId, score, Double::sum); } } // 3. 过滤掉用户已经买过的商品 ListLong purchasedIds behaviorMapper.selectPurchasedProductIds(userId); candidateScore.keySet().removeAll(purchasedIds); // 4. 按得分排序取前 N 个 return candidateScore.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(size) .map(productMapper::selectById) .collect(Collectors.toList()); }细心的读者会发现这里没有做分页。因为推荐接口每次只取 20 个结果全内存排序完全够用。真正的商品浏览列表才需要分页那个用 MyBatis-Plus 的Page插件就可以解决后面章节会单独说。3.3 冷启动策略新用户和新商品怎么处理协同过滤有个天生的毛病新用户没有任何行为数据新商品没有任何用户行为算不出相似度。我的处理方案很朴素——降级到热榜推荐。凌晨定时任务里同时汇总最近 7 天的商品浏览量、收藏量与购买量按浏览×0.3 收藏×3 购买×10算综合热度分生成一份热榜商品列表。在线推荐时如果查不到用户行为数据直接返回这份热榜查到但候选商品不足 10 个用热榜补位。这个策略虽然朴素但在实际业务里新用户落地页的点击率反而比协同过滤还高因为热门商品本身就自带说服力。3.4 商品图片上传SpringBoot 集成 MinIO 的完整配置商品表里那个cover_url字段生产环境我用的 MinIO 做对象存储。每次新增商品时前端把图片上传到 SpringBoot再由后端转存到 MinIO返回一个可公开访问的 URL。集成步骤很简单先在pom.xml引入依赖然后在配置类里注册 MinIO 客户端minio: endpoint: http://192.168.1.100:9000 access-key: your-access-key secret-key: your-secret-key bucket: sports-goodsConfiguration public class MinioConfig { Value(${minio.endpoint}) private String endpoint; // 省略其他字段... Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }上传代码的注意点在于商品图片必须在写入商品记录的同一个事务里完成。如果先传图片再写商品图片传完了商品插入失败会产生孤儿图片反过来则会出现商品没有图片的脏数据。我这里的做法是写一个ProductService.createWithCover()先保存商品主记录拿到自增 ID用这个 ID 作为文件名的一部分再上传图片最后更新cover_url全程一个事务。4. Vue 前端页面串联从接口封装到推荐结果渲染前端我用 Vue 3 Vite Element Plus 这套组合。比起 Vue 2 的 Webpack 全家桶Vite 的开发服务器启动速度快了不是一点半点代理配置也直白得多。4.1 Axios 封装与接口设计所有后端请求走同一个 axios 实例把 baseURL、超时时间、请求拦截器、响应拦截器统一管起来import axios from axios; import { ElMessage } from element-plus; const request axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器携带 token request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截器统一处理业务错误 request.interceptors.response.use( response { const res response.data; if (res.code ! 200) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res.data; }, error { ElMessage.error(error.message || 网络异常); return Promise.reject(error); } ); export default request;两个容易踩的坑提前说baseURL写/api后开发环境必须在 Vite 配置里开 proxy 指向后端服务否则跨域问题会折腾一晚上生产环境则让 Nginx 做反向代理把/api前缀转发到 SpringBoot 的 8080 端口。4.2 首页推荐列表与路由参数的传递前端首页的推荐区域我用了两个组件一个横向滚动的热榜轮播一个纵向排列的“为你推荐”瀑布流。瀑布流就是调用GET /recommend/{userId}拿到商品列表再用 Element Plus 的el-row和el-col做栅格布局。点击商品跳详情页时需要把商品 ID 传给目标页面用 Vue Router 的 query 传参最省事router.push({ path: /product/detail, query: { id: productId } });在详情页里读取参数const route useRoute(); const productId route.query.id;这里有一个非常隐蔽的 bug同一个详情页组件连续点击不同商品时路由参数变化但组件不会重新加载。因为 Vue Router 默认会复用组件实例。解决办法很简单在这个路由对象上添加watch监听route.query.id的变化变化时重新请求详情接口。这是很多新手做商城类项目最容易忽略的问题。4.3 用户行为上报让推荐系统活起来的关键埋点前端不能只展示推荐结果更重要的是把用户的每次行为回传给后端。我在商品详情页挂了三个埋点页面加载时上报浏览行为点击收藏按钮时上报收藏行为点击“加入购物车”时上报加购行为。上报接口放在onMounted和按钮点击事件里const reportBehavior (behaviorType) { request.post(/behavior/report, { productId: Number(route.query.id), behaviorType: behaviorType }); }; onMounted(() reportBehavior(1)); // 1-浏览这块我要多说一句。很多人做推荐系统把全部精力放在算法代码上忽略了行为数据上报。我见过不止一次推荐系统上线后跑了两周查数据库发现用户行为记录寥寥无几——因为前端埋点漏了。没有行为数据协同过滤就是无米之炊。埋点应该在整个项目里最先做完而不是最后补。4.4 Vite 环境配置与代理转发顺便把 Vite 的代理配置贴出来这在接后端接口时是绕不过去的一步// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } });注意rewrite的作用前端请求/api/recommend/1经过代理转发到后端时变成/recommend/1。如果你的后端接口本来就有/api前缀这段 rewrite 就删掉。很多前后端联调问题都出在这个小配置上。5. 推荐接口的性能优化与数据库连接池推荐系统上线后流量稍微一上来第一个瓶颈往往不在算法而在数据库连接。我最初用 SpringBoot 默认的 HikariCP没改任何配置压测时 QPS 到 200 就开始频繁报连接超时。5.1 HikariCP 连接池参数调优HikariCP 是 SpringBoot 的默认连接池性能已经非常优秀但默认配置是按低并发场景设计的。我调整后的核心参数spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000maximum-pool-size不是越大越好。MySQL 默认最大连接数是 151你把连接池设成 200池子里能建立但数据库会拒绝多余连接。20 到 30 是单机 SpringBoot 应用的合理区间配合前面推荐的 Redis 缓存方案完全够支撑几千的 QPS。5.2 推荐接口的响应式缓存策略在线推荐接口有个特点同一用户在短时间内反复请求推荐结果不应该频繁变化。我给推荐结果加了本地缓存用 Caffeine过期时间 10 分钟Cacheable(cacheNames recommendCache, key #userId, unless #result null || #result.isEmpty()) public ListProductVO recommendWithCache(Long userId, int size) { return recommend(userId, size); }缓存命中率在实际运行中能到 80% 以上。想想这个场景用户逛了 10 分钟可能只刷新了 2 次首页但每次刷新都会重新加载推荐列表。如果接口查库10 分钟一个用户就产生几十次数据库查询有了缓存10 分钟内同一个用户只查一次。5.3 埋点数据的高频写入优化行为上报接口是高频小写入每个用户每次浏览商品都会调一次。如果每次请求都实时写 MySQL高峰期会造成写入锁竞争。我把行为上报改为异步批量写入先用Async注解把上报请求丢给线程池攒 500 条或 10 秒批量插入一次。这样设计后MySQL 的写入压力降了一个量级用户感知的上报耗时也从 50ms 降到了 2ms。注意异步写入必须接受一个前提极端情况下比如进程崩溃会丢最近几秒的行为数据。对于推荐系统来说这种程度的数据丢失完全不影响业务效果推荐本来就是概率问题差几秒的反馈无人在意。6. 部署上线与常见问题排查实录最后说说部署和上线阶段踩过的坑。这套系统生产环境的部署架构是前端 Nginx 托管静态资源后端 SpringBoot 打包成 jar 跑在 8080 端口MySQL 和 MinIO 各自独立服务器。6.1 线上部署的基础配置后端打包含外部配置的方式我推荐用--spring.config.additional-location把数据库密码、MinIO 密钥这类敏感配置放在单独的application-prod.yml里不跟随 jar 打包java -Xms512m -Xmx1024m -jar sports-recommend.jar \ --spring.config.additional-location/etc/sports-recommend/application-prod.yml-Xms512m -Xmx1024m这组堆内存参数我建议新手直接抄。设置太小比如 256m数据量大时容易频繁 Full GC设置太大比如 8g而机器本身只有 4g 内存直接就 OOM 宕机。1g 堆内存跑这套系统完全够用还留了一半给操作系统和 MySQL。6.2 前端部署与 Nginx 配置前端构建产物是一个dist目录里面全是静态文件。Nginx 配置里有两个关键点一是监听 80 端口并指定root指向dist二是把/api开头的请求反向代理给后端server { listen 80; server_name your-domain.com; root /var/www/sports-recommend; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }我这个配置坑特别多逐个说第一proxy_pass http://127.0.0.1:8080/;末尾的斜杠绝对不能丢丢了会导致请求路径变成/api/api/xxx。第二location /里的try_files必须带上/index.html否则用户刷新详情页时Nginx 会直接返回 404因为前端路由的/product/detail?idxx在服务器上对应的文件根本不存在。第三Vue 前端生产环境的路由模式必须是createWebHistory配合这个try_files否则刷新页面必挂。6.3 连接问题排查从 MySQL 到 MinIO上线初期会碰到的两个经典报错我直接给排查链路。报错一Cant connect to local MySQL server through socket这类报错的核心是连接方式不对。如果你在application.yml里配了url: jdbc:mysql://localhost:3306/sports_db而localhost会被 MySQL 客户端解析成 socket 连接而不是 TCP 连接。如果 MySQL 没有配置socket文件路径或者权限不对就会报上面这个错。解决方法是把localhost显式改成127.0.0.1强制走 TCPurl: jdbc:mysql://127.0.0.1:3306/sports_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8报错二MinIO 图片能上传但访问返回 403这不是代码问题是 MinIO 的访问权限问题。默认情况下 Bucket 的访问策略是private前端直接用cover_url打开图片会被拒绝。有两种解法开发环境图省事把 Bucket 策略改成public这样图片链接可以直接访问生产环境要求安全就不要改策略而是用 MinIO 的预签名 URL 功能由后端生成带过期时间的访问链接返回给前端。我建议不管哪个环境都养成用预签名 URL 的习惯后面接 CDN 时更顺。6.4 慢查询排查手动模拟推荐接口的执行计划上线后如果发现推荐接口响应慢不要急着优化代码先看 SQL 执行计划。我实践中最有效的办法是拿 MyBatis 日志打印出来的 SQL 语句到 MySQL 的EXPLAIN里跑一遍重点看三个字段type是不是ALL全表扫描、rows预估扫描行数是多少、key是否命中了索引。有一次排查推荐接口慢定位到原因是user_behavior表的user_id索引因为数据量大了之后失效MySQL 优化器宁愿走全表扫描也不走索引。解决办法把普通索引改成(user_id, behavior_type, create_time)联合索引。改完之后同一接口响应时间从 420ms 降到了 35ms。6.5 jar 包反编译排查问题的备用手段这里补一个应急技巧。生产环境出问题时如果你手头没有完整的源码仓库只有服务器上部署的 jar 包又想确认某个类里的具体逻辑可以用javap -c来反编译查看字节码或者直接把 jar 包用 IDEA 的反编译器打开看源码。我遇到过两次生产环境的问题都是靠反编译确认“部署的版本确实包含某段修复代码”而定位到是环境配置问题而不是代码问题。这个小手段关键时刻能救命但要记住这只是排查手段不是写代码的依赖路径源码管理还是得靠 Git 分支规范。7. 推荐效果的验证与后续迭代方向系统上线不代表事情结束了。推荐系统最关键的指标是点击率CTR和转化率CVR。我上线后做的最重要的一件事是给推荐位和热榜位两组流量做 A/B 对比实验。7.1 最简单的 A/B 方案用户 ID 奇偶分流实现成本最低的分流方式是按用户 ID 奇偶ID 为偶数的用户看推荐结果ID 为奇数的用户看热榜。前端从接口返回里多取一个experimentGroup字段埋点上报时带上这个字段两周后对比两个分组的商品点击率和下单转化率。如果推荐组的 CTR 明显高于热榜组说明协同过滤确实在起作用如果差距不明显就要检查行为权重设置是否合理、相似度阈值是否过高。我当时做出来的结果是推荐组点击率比热榜组高 20% 左右但转化率差距只有 7%。这个现象说明协同过滤更擅长“让人感兴趣”但真实购买的决策还受价格、库存、品牌偏好影响。所以后续迭代的方向是在推荐得分里加入价格距离惩罚项把用户历史购买的平均价格作为参照推荐商品的价位偏离太多时降权。7.2 行为权重的调整策略之前说了行为权重是存数据库的不是写在代码里的。调整权重时我会先跑离线验证挑出 1000 个有完整行为记录的用户用前 20 天的数据训练推荐模型用后 3 天的真实行为做验证看推荐结果中商品的被点击率。这个过程可以用一个简单的 JUnit 测试类定时跑不需要复杂的机器学习框架。7.3 算法升级的可选路径如果后续业务量涨了这套基于物品的协同过滤还不够用可以考虑两个升级方向引入 ALS 矩阵分解用 Spark MLlib 里的 ALS 算法替代手写的协同过滤能更好地处理隐式反馈数据但需要引入 Spark 集群运维成本陡增。混合推荐策略协同过滤结果加规则过滤比如新品加权、清仓商品降权再加一层排序模型比如 LightGBM 排序这是工业界最主流的做法。但我的个人体会是对于体育商品这种 SKU 有限、用户兴趣相对稳定的场景基于物品的协同过滤 热榜兜底已经能获得非常可用的效果。不要一开始就追求复杂算法先让简单的方案跑起来拿到真实数据再逐步演进这条路反而走得最快。最后再分享一个上线时特别容易忽略的小技巧推荐热点数据预热。定时任务每天凌晨算相似度矩阵但第一次部署后数据库里没有任何相似度数据用户第一次访问推荐接口时必然查到空列表。解决办法很简单部署完成后手动触发一次定时任务或者写个ApplicationRunner在项目启动时自动跑一遍。别小看这一步很多团队上线第一天的推荐接口都是空的原因就是少做了这个预热。

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

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

免费获取报价 →
↑