协同过滤实战:Java+SpringBoot+SSM跳蚤市场推荐系统
说实话第一次看到“基于JavaSpringBootSSM的跳蚤市场商品推荐系统”这种标题我的第一反应是又是一个经典的课程设计/毕设选题。但你仔细拆一下会发现这个题目其实埋了不少值得讲的东西——它不是单纯做一个CRUD管理系统而是在传统SSM架构之上叠加了一个非常核心、也非常容易做砸的部分协同过滤推荐算法。这个题目里藏着的本质需求我理解为三件事第一用Java生态里最主流的SpringBoot技术栈把前后端业务跑通第二把推荐系统从“理论公式”落地成“可运行的Java代码”不是只在论文里画流程图第三把跳蚤市场这个场景特有的业务规则商品生命周期短、用户偏好不稳定、数据稀疏考虑进去让推荐结果真的能用。这篇文章我就按自己做这类项目的经验从技术选型思路、算法落地、数据库设计、联调实现到排坑实录把整个系统的关键环节完整走一遍。不管你是拿它做毕设、课程设计还是想把这套思路搬到你自己的小项目里希望能帮你省下不少试错的时间。1. 项目整体设计与技术选型背后的思路1.1 这个系统到底要做什么跳蚤市场系统的核心业务并不复杂用户注册登录、发布闲置商品、浏览搜索商品、收藏下单交易、买卖双方评价。这些功能单拎出来任何一个有一定Java基础的人都能用SpringBoot搭个差不多。但加上“推荐系统”这四个字整个项目的性质就变了。它要求系统不光是“被动等用户搜”而是要“主动推测用户想要什么”。在论文和答辩里这一块是你区别于“普通管理系统”的核心亮点在实际代码里这一块也是你最容易翻车、最容易被老师追问细节的地方。我需要先帮你在脑子里立一个整体架构图景不是画图是讲清楚各层关系前端用Thymeleaf模板引擎渲染或者用VueElementUI做前后端分离二选一。做毕设推荐前者省事、部署简单想多拿点分可选后者。应用层SpringBoot负责所有业务接口和页面跳转SpringMVC管请求路由MyBatis管数据库操作。推荐层基于用户行为日志浏览、收藏、下单生成用户-物品行为矩阵用协同过滤算法离线计算商品相似度或用户相似度在线为用户生成推荐列表。数据层MySQL存储用户、商品、订单等业务数据同时把推荐结果预计算后落到一张推荐表里避免每次请求都现场跑一遍算法。这套结构就是你论文里“系统总体架构图”对应的实际代码组织方式。没有更高深的东西但足够扎实。1.2 技术选型为什么是SpringBoot为什么还要提SSM现在2025年了如果你还去搜“SSM框架”搜出来的资料大部分是2018年前后的博客这是正常的SSM这个概念本身就属于SpringBoot还没全面普及时的主流组合。但你在课程设计/毕设题目里看到“JavaSpringBootSSM”这种并列写法其实对应的意思是项目核心基于SpringBoot搭建它是当前Java Web开发的事实标准业务层遵循SpringIOC/DI SpringMVCWeb层 MyBatis持久层这套经典的SSM分层思想。所以不要纠结“用了SpringBoot是不是就不算SSM”在实际落地中SpringBoot内置了Spring和SpringMVC再整合MyBatis本质上就是SSM架构的“现代化封装”。你在论文里完全可以写“本系统基于SpringBoot整合SSM框架”技术栈表述上没有任何硬伤。我给你的建议是项目骨架用SpringBoot 2.7.x MyBatis MySQL不要用最新版SpringBoot 3.x因为3.x是Jakarta EE规范很多老教程和老插件特别是PageHelper分页、druid连接池、一些基于javax包的依赖会有兼容性问题。做毕设求稳比求新重要。1.3 推荐算法选型协同过滤为什么适合跳蚤市场协同过滤Collaborative Filtering是推荐系统里最经典、也最适合作毕业设计落地的一类算法。它不需要商品的内容特征不需要做文本分词、图像识别不需要用户画像标签体系只需要“用户对物品的行为”这一份数据就能跑出结果。跳蚤市场这个场景有几个特点刚好和协同过滤的优势重叠商品是海量且非标的每个人的闲置物品都不一样很难用统一的属性表去建模。协同过滤不关心商品“是什么”只关心“哪些用户对它产生了行为”天然适配。用户没有明确偏好标签二手平台用户不会像电商平台那样勾选“我喜欢数码”“我喜欢穿搭”。协同过滤不需要这些。商品生命周期短一件商品卖出去之后就从货架消失了。这一点对基于用户的协同过滤UserCF很不友好但基于物品的协同过滤ItemCF可以通过行为日志持续适应新商品的出现。所以我在项目里最终采用的是基于用户的协同过滤 基于物品的协同过滤结合的策略以ItemCF为主UserCF作为冷启动的备选方案。后面第3章我会把这两种算法的代码实现细节、相似度计算公式、以及为什么这样混合做完整展开。2. 数据库设计与核心业务表2.1 建表思路从业务反推表结构推荐系统不是凭空算的它需要的数据全部来自业务表。很多人在这个项目上踩的第一个大坑就是业务表建得稀碎后面写算法时发现找不到“用户对商品的评分”。我在动手写代码前的习惯是先在纸上把“推荐算法需要哪些数据”列出来再反推业务表怎么设计。协同过滤算法的输入本质上是一个矩阵用户ID × 商品ID → 行为数值。这个行为数值可以来自三类数据显式评分跳蚤市场里很少有用户给商品打分不能作为主要数据源。隐式反馈浏览、收藏、加入购物车、下单这些行为是用户自然产生的是最可靠的数据源。交易评价成交后买方对卖方/商品的评价可作补充。基于这个前提我在表结构设计上定了以下几个核心表用户表、商品表、收藏表、订单表含明细、评价表、行为日志表、推荐结果表。2.2 核心表结构设计与字段说明先看用户表和商品表。这两个是最基础的表我不展开写全部字段只讲设计中容易忽略的点CREATE TABLE tb_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password CHAR(60) NOT NULL, -- BCrypt加密存储 nickname VARCHAR(30), phone VARCHAR(11), avatar VARCHAR(255), credit_score INT DEFAULT 100, -- 信用分交易纠纷扣分 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE tb_product ( id INT PRIMARY KEY AUTO_INCREMENT, seller_id INT NOT NULL, -- 卖家用户表外键 title VARCHAR(100) NOT NULL, description TEXT, category_id INT NOT NULL, price DECIMAL(10,2) NOT NULL, original_price DECIMAL(10,2), -- 原价用于显示折扣力度 cover_image VARCHAR(255), status TINYINT DEFAULT 0, -- 0上架 1下架 2已售出 browse_count INT DEFAULT 0, -- 浏览数热门可作冷启动推荐源 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_seller (seller_id), KEY idx_category (category_id), KEY idx_status (status) );商品表这个设计有两个字段是对推荐算法特别有用的你可能一眼没看出来我说一下updated_at二手商品是“活”的价格随时可变状态随时可变。推荐结果不能推荐一个已经下架或卖掉的商品。在定时刷新推荐结果时就是靠这个字段做增量更新判断。category_id虽然协同过滤不直接使用分类特征但在冷启动阶段用户还没有任何行为时可以基于分类做“热门分类下的热门商品推荐”。再看行为相关表。这里要和很多做这个选题的人说一个容易忽视的点协同过滤的数据源必须是“行为”不是“用户表里的某种属性”。我见过有人把“用户注册时选中的兴趣标签”当成协同过滤的输入那是基于内容的推荐算法选型就错了。CREATE TABLE tb_behavior ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, behavior_type TINYINT NOT NULL, -- 1浏览 2收藏 3加入购物车 4下单 5交易成功 behavior_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_product (product_id), KEY idx_time (behavior_time) ); CREATE TABLE tb_collect ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, collect_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_product (user_id, product_id) ); CREATE TABLE tb_recommend_result ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, score DECIMAL(8,4), algorithm_type TINYINT, -- 1基于用户 2基于物品 3热门兜底 recommend_time DATETIME DEFAULT CURRENT_TIMESTAMP, is_read TINYINT DEFAULT 0, KEY idx_user (user_id) );tb_recommend_result是推荐系统里的“落地存储层”。算法算出来的结果存到这里用户在首页直接查这张表拿推荐列表。这个设计看起来多了一张表但它是必要的——如果你的“推荐接口”每次请求都现场算一遍相似度矩阵系统并发一高必然卡死而且算法调整时要重新跑全量没法做“离线计算、在线服务”的经典架构。订单表、购物车表、评价表我就不全贴SQL了提一下它们和推荐的关系订单表里的成交记录是高质量正反馈评价表里的好评是“加分项”。我在行为日志里用behavior_type把下单和交易成功单独标出来就是为了在算分时对不同行为加权。2.3 数据流向从业务表到行为矩阵这段我说一下整个系统里数据是怎么流转的对后面理解算法实现很重要用户在浏览商品时页面Controller层会同时做一件“看不见的事”GetMapping(/product/{id}) public String detail(PathVariable Integer id, HttpSession session) { // 业务逻辑查询商品详情 Product product productService.getById(id); // 埋点记录用户行为 behaviorService.record(sessionUserId, id, BehaviorType.BROWSE); return product/detail; }用户在点击收藏、加入购物车、下单时同样埋点。这样tb_behavior表里慢慢积累了用户行为原始数据。推荐模块的定时任务每天早上跑一次从这张表里捞出近30天的行为记录构建用户-物品行为矩阵计算相似度更新tb_recommend_result。用户访问首页时推荐接口从这个结果表里直接查询如果结果表里还没有典型的新用户就走“热门商品兜底推荐”。这套数据流最大的好处是业务的添加和推荐的更新是解耦的互不影响。我后面在“常见问题”章节会说如果没有这个结果表直接在线算相似度会碰到什么灾难性问题。3. 协同过滤算法核心原理与Java实现细节3.1 基于用户的协同过滤UserCF的逻辑与代码算法思路用大白话讲就一句话找到和你兴趣相似的人把他们喜欢的、但你还没见过的商品推荐给你。步骤拆开构建“用户 → 商品行为集合”的映射行为可以来自收藏、浏览、下单加权。计算任意两个用户之间的相似度余弦Cosine和杰卡德Jaccard是两种常见方案。行为是离散的评价分数时用余弦合适行为是“收藏与否”这种二元值时杰卡德更直观。对一个目标用户找到Top K个相似用户汇总这些相似用户喜欢的商品按“相似度×行为权重”排序剔除用户已经收藏/购买过的商品得到推荐列表。我这里给出核心实现的伪代码级Java示意MyBatis查询部分略去重点是算法逻辑// 获取用户对商品的“行为集合”这里以收藏行为作为示例 public MapInteger, SetInteger getUserFavoriteMap() { ListCollectRecord records collectMapper.selectAll(); MapInteger, SetInteger map new HashMap(); for (CollectRecord r : records) { map.computeIfAbsent(r.getUserId(), k - new HashSet()).add(r.getProductId()); } return map; } // 计算用户u和用户v的兴趣相似度Jaccard public double jaccardSimilarity(SetInteger uFav, SetInteger vFav) { if (uFav.isEmpty() || vFav.isEmpty()) return 0.0; SetInteger union new HashSet(uFav); union.addAll(vFav); SetInteger intersection new HashSet(uFav); intersection.retainAll(vFav); return (double) intersection.size() / union.size(); }这个代码看着简单但有两个优化点实际项目中必须要做否则性能会炸一是不能全量两两计算用户相似度。一个系统哪怕只有几千个用户两两组合就是几百万对全部算一遍非常耗时。工程上的常见优化是倒排索引从“用户-商品”反转成“商品-用户”只对“有一共同行为的用户对”计算相似度。代码上就是先遍历商品把对同一商品产生过行为的用户两两组合累加对应相似度分子。二是活跃用户惩罚。如果一个用户收藏了几百个商品那他和任何人的交集都可能很大但他其实“谁的东西都喜欢”参考价值有限。工程上一般对用户行为集合大小取log或直接除以集合大小做归一化我项目里用的是“行为集合大小开根号”做分母效果比较稳。3.2 基于物品的协同过滤ItemCF的逻辑与代码基于物品的协同过滤思路是**推荐和你喜欢的商品相似的商品。**注意是“和你喜欢的商品相似”不是“和你看过的商品同分类”。关键区别在于相似度的定义来自“用户在行为上的共现”不是来自分类标签。比如一个二手单反相机和一个手绘板在分类上八竿子打不着但如果在很多用户的“收藏列表”里同时出现收藏了单反的人也同时收藏了手绘板算法会认为它们相似这通常是因为它们服务于同一类人比如二手数码创作者。ItemCF的核心计算过程构建“商品 → 对该商品产生行为的用户集合”的映射。对任意两个商品a和b计算它们被共同收藏/购买的用户数此为其相似度分子。用余弦公式做归一化消除热门商品的影响热门商品被很多人收藏会导致它和谁都“相似”。目标用户已收藏的物品列表为Seed将每个Seed物品的TopK相似物品汇总相似度加权求和。这里直接给一份我从项目里抽取的可运行核心代码用的是Stream Map方便你看懂全流程public ListRecommendItem recommendByItemCF(int userId, int topN) { // 1. 拿到用户收藏的商品作为种子 ListCollectRecord collectRecords collectMapper.selectByUserId(userId); if (collectRecords.isEmpty()) { return Collections.emptyList(); } // 2. 构建 商品 - 收藏用户集合 的映射只查需要的数据这里简化写法 MapInteger, SetInteger itemUserMap collectMapper.selectAll().stream() .collect(Collectors.groupingBy( CollectRecord::getProductId, Collectors.mapping(CollectRecord::getUserId, Collectors.toSet()) )); // 3. 计算种子商品两两相似度累加到“候选商品相似度分数” MapInteger, Double scoreMap new HashMap(); SetInteger seedItems collectRecords.stream() .map(CollectRecord::getProductId).collect(Collectors.toSet()); for (Integer seed : seedItems) { SetInteger users itemUserMap.get(seed); for (Integer other : itemUserMap.keySet()) { if (seed.equals(other) || seedItems.contains(other)) continue; // 排除自己与已收藏 SetInteger otherUsers itemUserMap.get(other); // 求交集大小即为“共现用户数” int intersectCount (int) users.stream().filter(otherUsers::contains).count(); // 余弦相似度共现数 / sqrt(两者用户集合大小乘积) double sim intersectCount / Math.sqrt(users.size() * (double) otherUsers.size()); scoreMap.merge(other, sim, Double::sum); } } // 4. 排序取TopN return scoreMap.entrySet().stream() .sorted(Map.Entry.Integer, DoublecomparingByValue().reversed()) .limit(topN) .map(e - new RecommendItem(e.getKey(), e.getValue())) .collect(Collectors.toList()); }这段代码是大二学生水平能看懂的实现用来跑通流程足够。但我在项目里最终不是这么写的原因还是性能——这个双重循环是O(n²)的商品数量过千时MySQL数据实际查出来还能抗住但Java内存里两层for循环几百万次操作在普通开发机上倒还好但如果做成在线实时接口就不行。所以我建议ItemCF相似度离线算好、存进一张“相似商品表”在线推荐时只查这张表。相似商品表结构大致是(product_id, similar_product_id, similarity_score)。这样把最消耗CPU的相似度计算放到定时任务里在线部分只需要把用户收藏的种子商品查出来联表查相似表再按分数汇总排序即可。这一步优化是在正常完成度之上加分的关键细节。3.3 混合策略用户冷启动与数据稀疏时的兜底方案任何推荐系统都躲不开冷启动问题。新用户在系统里没有任何行为数据协同过滤的公式根本没法算。我在这个项目里做了三层兜底策略按优先级排列第一层用户有收藏行为且收藏数≥1走ItemCF推荐第二层用户有浏览行为但无收藏根据浏览过的商品分类做“同分类热门商品”推荐第三层用户是全新用户无任何行为直接推“全站热门商品榜”。热门商品榜计算方式很简单就是近7天tb_behavior表中被浏览、收藏、下单行为总数最多的前N个商品按行为加权求和。对应到代码里public void generateHotRank() { ListMapString, Object hotList behaviorMapper.selectHotProducts(7); for (MapString, Object row : hotList) { int pid (Integer) row.get(product_id); long score (Long) row.get(total_score); recommendResultMapper.insert(0, pid, score, ALGO_HOT); } }这个就非常直白热门商品统一写到tb_recommend_result里user_id填0表示“所有用户通用”。新用户查询时不指定用户ID直接取user_id为0的结果。这里有一个细节**为什么不全量拷贝热榜给所有用户**因为量大冗余一个user_id0的通用热榜更省空间读的时候逻辑也简单。冷启动问题的另一面是数据稀疏。跳蚤市场项目如果用户量不大做毕设很可能只有几十个用户、几百个商品用户-商品矩阵非常稀疏相似度计算结果很可能全是0或空。一个不好看的推荐结果极可能被答辩老师追问。我的应对策略是如果ItemCF算出来的候选商品不足N个就用同分类热门商品补齐空缺名额。这个策略在代码里表现为ListRecommendItem cfResults recommendByItemCF(userId, topN); if (cfResults.size() topN) { ListProduct hotInCategory productMapper.selectHotInCategory(userId, categoryIds); // 去重后补齐 cfResults.addAll(hotInCategory.stream().filter(notInCfResults)...); }4. 推荐模块与SpringBoot业务层的联调实现4.1 自定义行为记录拦截器给推荐算法“喂数据”推荐系统是吃数据的数据质量直接决定推荐效果。我前面提到用户浏览商品、收藏、下单时都要埋点。代码上最好不要在每一个Controller方法里手动写一行behaviorService.record(...)那样太散、容易漏。我的做法是写了一个BehaviorInterceptor基于SpringMVC拦截器实现。拦截商品的详情页请求路径/product/*在Handler执行完成后统一记录浏览行为Component public class BehaviorInterceptor implements HandlerInterceptor { Autowired private BehaviorService behaviorService; Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { String uri request.getRequestURI(); if (uri.matches(/product/\\d)) { Integer userId (Integer) request.getSession().getAttribute(userId); Integer productId parseIdFromUri(uri); if (userId ! null productId ! null response.getStatus() 200) { behaviorService.record(userId, productId, BehaviorType.BROWSE); } } } }这个拦截器有三点需要说明response.getStatus() 200判断是为了避免用户访问了一个不存在的商品或没有权限的商品也记入行为污染数据。收藏和下单行为不需要拦截器直接在Service业务方法里记录即可因为它们是主动的、低频的、高权重的行为。注意要排除卖家自己浏览自己商品产生的行为。我项目里用一个简单的判断记录行为时检查userId.equals(product.getSellerId())如果是跳过。这个bug我一开始漏了导致系统给自己推荐自己的在售商品看着特别蠢。4.2 定时推荐计算任务把算法从“在线”搬到“离线”推荐计算的完整流程不该放在用户请求时触发而是定时批量算好。我用的是SpringBoot自带的Scheduled定时任务不需要引入Quartz轻量且够用。Component public class RecommendTask { Autowired private RecommendService recommendService; // 每天凌晨2点执行全量离线计算 Scheduled(cron 0 0 2 * * ?) public void generateDailyRecommend() { recommendService.generateAllUserRecommend(); log.info(每日推荐计算完成); } // 每30分钟执行一次“热门商品”刷新应对突然爆火的商品 Scheduled(fixedRate 1800000) public void refreshHotRank() { recommendService.refreshHotRank(); } }要启动这个功能还需要在启动类上加上EnableScheduling注解。这个小点也常在答辩时被问到——“你的推荐结果是多久更新一次的”答上来这个整体印象分会有明显提升。generateAllUserRecommend内部做的事情简单描述查出所有在过去30天内有行为的用户集合再逐用户调用ItemCF推荐逻辑把推荐结果topN写入tb_recommend_result。数据量大时逐用户处理会慢这里可以做一个小优化——每处理完一个用户就先提交一批不要攒到最后才统一session.flush()否则MyBatis批量insert大对象时容易OOM应用内存不够时。我自己在这就踩过一次一次性处理300多个用户×10条推荐批量insert写法有问题直接堆内存爆了。4.3 在线推荐接口实现从结果表快速取数据用户访问首页或“猜你喜欢”区域时调用的推荐接口非常简单。“简单”其实是刻意设计的结果——把复杂度都藏在了离线计算里。RestController public class RecommendController { Autowired private RecommendResultMapper recommendResultMapper; Autowired private ProductMapper productMapper; GetMapping(/api/recommend) public Result recommend(HttpSession session, RequestParam(defaultValue 10) int limit) { Integer userId (Integer) session.getAttribute(userId); if (userId null) { return Result.error(请先登录); } // 1. 查询离线结果 ListInteger productIds recommendResultMapper.selectRecommendedIds(userId, limit); // 2. 如果结果为空说明是新用户或数据不足走热门兜底 if (productIds.isEmpty()) { productIds recommendResultMapper.selectHotIds(limit); } // 3. 过滤掉已售出/已下架的商品 ListProduct products productMapper.selectByIds(productIds); products.removeIf(p - p.getStatus() ! ProductStatus.ON_SALE); return Result.success(products); } }这里有个细节值得你留意第2步的判断不是查不到就报错而是降级为热门。推荐系统里有个说法叫“优雅降级”意思是在数据不够、算法不可用的时候系统不能“哑掉”而是退回更简单的策略。这个思维不仅在代码里体现也是论文里“系统容错设计”这一小节的好素材。4.4 前端推荐场景展示把算法效果“可视化”给答辩老师看系统不管算法多好最终都要在页面上给用户看到。我在前端做了三个推荐区块分别对应不同的算法策略首页“猜你喜欢”这是ItemCF的主阵地展示离线计算出的个性化推荐列表。展示商品卡片时附带一行小字“因为您浏览过xxx”或“因为您收藏过xxx”这行解释性文案对用户信任度提升非常有帮助答辩时也很有说服力。商品详情页“相关推荐”基于当前商品的相似商品列表也是ItemCF的结果。用户看一个商品时侧边栏出现几个“相似好物”点击率通常比首页推荐高很多——因为它有实时上下文线索。“热门闲置”榜单冷启动和补充推荐位PC端首页侧栏或App端首屏第二屏的位置。这三个区块如果做成了整个系统看起来就像个小淘宝比单纯做管理后台有质感得多。5. 常见问题、性能瓶颈与排查技巧实录这一章是我最想批评一些开源项目和毕设代码的地方。很多参考代码是“能跑就交差”但实际你换一批数据一跑bug全冒出来。我在这个项目上踩过的坑、以及帮别人排查过的典型问题整理成以下四类。5.1 行为数据采集中遇到的脏数据问题问题表现推荐结果里频繁出现用户自己发布的商品、已售罄的商品、甚至被管理员下架的违规商品。排查过程最初我以为是SQL过滤条件漏了后来看日志发现是行为采集环节出了问题。浏览行为是在拦截器afterCompletion里记录的但拦截器不知道这个商品是否属于当前用户。我在拦截器里补了一个判断查一次商品表拿到seller_id与当前用户ID比较相等则跳过。这个查询比重不大但每次浏览商品都会多一次DBIO后来我用userId productId维度做了一层本地脱敏缓存降低相同商品重复浏览时的重复查询。另外还有一个坑已删除商品的行为记录。用户在商品下架前收藏了它商家随后下架或删除商品但tb_collect里还留着这条收藏。ItemCF算出来的相似商品集合中还带着这个已经消失的商品ID推荐结果表里就会生成指向不存在商品的外键。我的处理方式是在定时计算任务执行前先执行一条清理SQLDELETE FROM tb_collect WHERE product_id NOT IN (SELECT id FROM tb_product WHERE status ! -1);这个操作随手就做了但如果你没意识到推荐结果查询时会查出大量null数据前端一渲染就是一片空白卡片。5.2 推荐结果不准确——八成是评分策略出了问题我在联调时发现收藏行为权重和浏览行为权重如果一样推荐出来的东西偏向“热门大路货”个性化不强。原因是绝大多数用户的收藏行为太稀疏一两个收藏无法体现深度偏好而浏览行为会拉入大量噪音点错了、随便看看也记为浏览。我最终的权重方案是这样定出来的行为类型权重说明浏览0.2量最大但噪音也大权重压低收藏1.0意图信号强主信号加入购物车1.5比收藏更强说明在认真考虑下单3.0最高权重成交信号交易成功3.5最高质量正反馈说明确实值得买这套权重不是拍脑袋写的是通过离线实验“推荐列表命中测试集”的准确率对比确定的。我把历史数据按7:3切分训练集和测试集对几组不同权重参数跑了几轮最终选了使命中率最高的一组。你在论文里如果能写一句“推荐结果的评估采用留一法准确率达到xx%”会非常有含金量。这个评估代码建议就写一个简单的脚本类// 简化版评估逻辑对测试集中每个被收藏的商品 // 检查它是否出现在该用户基于训练集生成的Top10推荐中 public double evaluate(int topN, MapInteger, SetInteger trainData, MapInteger, SetInteger testData) { int hit 0, total 0; for (Map.EntryInteger, SetInteger entry : testData.entrySet()) { int userId entry.getKey(); ListRecommendItem rec recommendForUser(userId, topN, trainData); SetInteger recIds rec.stream().map(RecommendItem::getProductId).collect(Collectors.toSet()); for (Integer actual : entry.getValue()) { total; if (recIds.contains(actual)) hit; } } return total 0 ? 0 : (double) hit / total; }这里的recommendForUser就是纯内存版的ItemCF在评估脚本里绕开数据库直接调算法方法。这种评估方法虽然粗糙但在课程设计级别足够用。5.3 性能问题N1查询和内存溢出项目做完后我压测了一把发现“猜你喜欢”接口在模拟50个并发请求时就出现明显卡顿。用Arthas一查瓶颈在商品查询。原因是把商品ID列表传给productMapper.selectByIds时MyBatis如果配置的是简单循环逐条查询N150并发×10条推荐就是500条SQL数据库直呼顶不住。解决方式三条路第一条改用MyBatis的foreach构造WHERE id IN (...)一次查出所有商品SQL语句类似SELECT * FROM tb_product WHERE id IN foreach collectionids itemid open( separator, close)。第二条给productMapper.selectByIds配上Cacheable同用户短时间内重复访问时命中缓存。第三条在tb_recommend_result表设计时就冗余存一份商品主图URL和标题推荐接口直接查完结果表不再连表查商品表。这个方案查询最快但冗余会导致数据一致性维护工作变多。我在项目里的最终选择是前两种结合原因很简单为了不做复杂的冗余同步逻辑宁可多花一点查询时间。内存溢出问题出现在定时任务里。itemUserMap存储了所有商品和对应的所有收藏用户集合这个Map如果一次性全量加载到内存商品上万、收藏记录几十万时内存占满。优化方式就是分批处理按商品ID分段比如每1000个商品一批加载、算完一批后清理引用、让GC回收再处理下一批。同时在SQL层只查近30天的行为记录不要让全部历史垃圾行为占用内存。5.4 MyBatis常见隐性坑懒加载和外键关联查询失效这个项目里最容易栽的一个坑是你在推荐结果表里存的是商品ID列表但前端页面需要显示商品详情。如果直接查结果表后在循环里调productService.getById(id)查商品那就是典型的N1这点我在5.3已经说过了这里不再重复。另一个坑是MyBatis的collection关联查询懒加载。如果用户详情里带商品列表且配置了lazyLoadingEnabled在Service方法上又加了Transactional那么在事务提交后访问商品列表会报LazyInitializationException。这个问题的解决办法很简单要么关掉懒加载要么在事务内完成所有访问要么直接用selectByIds一次查完。我项目里所有关联查询都走了第二种方案一句话总结就是在MyBatis项目里除非你特别清楚懒加载的机制否则默认别开。5.5 推荐结果“只推荐旧货”——商品时效性处理这个坑百分之八十做过推荐系统的人都会遇到。算法本身没有“时间”概念——它会因为某个商品在数据里经常被收藏就一直高居推荐榜首。但跳蚤市场是强时效场景一个二手手机可能挂两周就卖出去了一个推荐榜上挂了两个月的“爆款”很可能早就没了。我的处理方式在推荐计算环节加上时间衰减。相似度计算时行为不是同等权重计数的越近的行为贡献越大。具体实现是一个指数衰减因子double timeDecay Math.exp(-0.05 * daysBetween(behaviorTime, today)); weight * timeDecay;这个公式的效果是一天前的行为几乎全额保留10天前的衰减到约60%30天前只剩约22%。同时在下发推荐结果时加一个硬性过滤条件只推近15天内上架的商品特殊情况如长期未售出的书、家具可以放宽到30天。从实际效果看这条过滤规则对最终用户体验的提升甚至比算法本身权重调参还明显。如果你要写到论文里这个策略有一个准确的名字叫**“物品时效性衰减”**Time Decay属于推荐系统里的常规改进手段不算高深但写出来是加分项。6. 总结与经验心得项目做到最后推荐系统的技术部分其实只占整个代码量的三分之一不到。大头都在业务CRUD、权限、页面交互和异常处理上。但真正让这个项目从“管理系统”升格成“像样的系统”的恰恰是推荐算法这个加分项以及围绕它做的一整套工程化处理——离线计算与在线服务分离、冷启动兜底策略、时效性衰减、评估脚本。我自己做完这一套最大的感受是协同过滤算法本身的代码不难难的是让它在真实业务场景里跑得动、跑得稳。很多教程一上来就扔给你一个Python的推荐算法demo但它没有告诉你模型训练完怎么上线数据从哪里来多久更新一次新用户怎么办商品下架了怎么办如果只是照抄demo你会发现你的Java系统里的推荐功能根本塞不进去。这个项目就是一次完整的“从公式到工程”的打通。如果你现在正准备做这个题目我给你三条实操建议第一先把用户收藏记录这个“最小数据源”跑起来就算别的行为都不埋点只要有收藏数据ItemCF就能出效果。很多项目最后来不及做推荐模块就是因为前期所有时间都耗在管理端CRUD上推荐连数据都没采到。第二一定留一份tb_recommend_result结果表。这个表不只是缓存它保证了你以后想换算法、调整参