ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot跳蚤市场推荐系统:UserCF协同过滤与冷启动实战

SpringBoot跳蚤市场推荐系统:UserCF协同过滤与冷启动实战 1. 跳蚤市场推荐的场景特殊性为什么常规电商方案在这里失真做推荐系统这些年我一直有个体会很多人把协同过滤当成通用配方只要拿到用户行为数据套上算法就完事。但推荐效果行不行七成取决于对业务场景的理解三成才轮到算法本身。这次做基于SpringBoot的跳蚤市场商品推荐系统我对这点体会尤其深。跳蚤市场和普通电商最大的区别在于数据形态的底层逻辑完全不同。普通电商是标品长尾结构一件商品有固定SKU库存几百上千件上线周期按年算用户评价和购买数据密集地堆积在同一个商品ID下面。所以基于物品的协同过滤ItemCF可以很自然地工作——你买了A我推荐B因为买A的人也买了B这个规律很容易在大量数据中被总结出来。跳蚤市场恰恰相反每一件商品都是孤品。二手相机卖完了就是卖完了页面下线数据作废同一个链接根本不存在再来一件的说法。这个特性带来三个连锁反应第一ItemCF的直接应用会失灵。你没法把和你正在看的相机相似的相机推给用户因为相似的二手相机很可能已经被卖掉了。基于商品ID做共现统计的协同过滤在孤品场景里从根上就站不住。第二二手商品的内容描述极其不规范。卖家的标题可能是出个自用相机成色好可小刀没有标准化的属性标签和类目体系。这让基于内容属性匹配的推荐在冷启动阶段也很难直接依赖。第三用户行为数据非常稀疏。二手交易的频次天然低于标品电商一个人一年可能就买卖几次。用户-商品评分矩阵稀疏得跟渔网一样直接套用经典协同过滤公式算出来的相似度全是噪声。所以在动手之前我给这个项目定了一个原则不能照搬电商推荐模板必须为跳蚤场景重新设计推荐链路。这也是这篇文章想最先聊透的部分——先把场景约束搞清楚再决定用什么算法而不是反过来。1.1 跳蚤场景下好推荐的定义变了普通电商调推荐核心指标是GMV、转化率、复购率。但跳蚤市场的交易结构不同——买家买完一件二手商品短期内大概率不会再买同类商品复购率天然就低。真正有价值的指标是匹配效率。对买家来说推荐系统要帮他在一堆孤品里快速找到想要的东西节省翻找时间对卖家来说推荐系统要让商品更快遇到有兴趣的人缩短挂售周期。所以我把推荐目标定为基于用户历史行为的个性化排序而不是单纯追求点击率。这个目标定位直接影响了后面算法选型、评估方式、乃至接口设计。比如在评估阶段我重点看的是推荐列表里商品的浏览转化率和收藏转化率而不是曝光量。1.2 我最终确定的推荐链路在场景分析做完之后推荐链路大致是这样的主链路基于用户行为的UserCF基于用户的协同过滤。找出和目标用户行为相似的用户把他们最近收藏、购买过的商品推荐过来。辅链路对商品做属性层面的相似度计算同品类、同价格带、同成色区间解决新上架商品没有行为数据的问题。兜底链路热门商品加权排序、新品时间衰减加权保证所有用户登录后都有内容可看。这三层结构其实是应对孤品稀疏非标描述三个约束的最直观方案。后面所有代码和配置都是围绕这条链路展开的。2. 算法选型推演UserCF、ItemCF与混合策略的设计依据场景聊透了现在进入正题到底该用哪种协同过滤2.1 UserCF与ItemCF的适用边界先简单过一遍理论。协同过滤分两大类UserCF核心是人以群分。先算用户之间的相似度然后找到和目标用户最像的K个邻居把这K个邻居喜欢而目标用户没见过的商品推荐过去。ItemCF核心是物以类聚。先算商品之间的相似度然后根据用户历史喜欢过的商品找出和这些商品最像的其他商品推荐过去。它们在工业界的经典分工是UserCF更适用新闻、内容社区这类兴趣快速变化、个性化需求明显的场景ItemCF更适用电商、在线视频这类商品相对稳定、用户行为丰富的场景。电商里ItemCF占绝对主流原因有两个一是商品数量通常大于用户数量商品相似度矩阵比用户相似度矩阵小得多好计算二是用户对商品的兴趣相对稳定看了A又看B的共现数据非常稠密。但在跳蚤市场里这两个理由都不成立了。前面说过孤品导致商品ID维度几乎没有共现关系——一件二手商品被两个不同用户看过的概率有但被大量用户产生密集行为的概率极低。你把商品相似度矩阵建出来里面全是零。所以我的判断是跳蚤市场的主链路必须用UserCF。用户的兴趣倾向相对稳定行为虽然稀疏但相似用户在同一个平台上的行为模式——比如都关注数码类二手、都偏好高性价比——还是有可挖掘空间的。2.2 为什么不完全放弃ItemCF不过完全放弃ItemCF也不对。我在实际跑数据时发现跳蚤市场还有一个变体场景同一个卖家可能会上架多件同类商品比如一个人搬家甩卖同时挂了三件同类物件或者商品虽然孤品但属性高度接近的产品同型号、近似成色会周期性出现。这种场景下如果只看商品共现确实算不出相似度但如果把相似度定义放宽为属性相似——同品类、价格带接近、成色等级相近——就能把同类商品召回出来。我给这个分支起了个名字基于属性的轻量级物品相似。它不依赖行为共现只依赖商品自身的标签字段。这样做的好处是新上架的商品也能进入推荐池不用等积累行为数据。代价是它不算真正的协同过滤更像一种规则化的内容匹配所以我把它放在辅链路用来补充主链路的盲区。2.3 行为权重与时间衰减的设计无论用哪种策略核心输入都是用户行为数据。行为数据怎么变成评分决定了相似度计算的可靠性。我在这里用的是最朴素也最实用的加权方案行为类型权重说明浏览1.0被动兴趣信号收藏3.0主动兴趣信号下单5.0强购买意向信号权重的直觉是用户主动程度越高信号越可靠。浏览可能是误点但收藏和下单基本代表了真实兴趣。除此之外还有一个时间衰减因子。跳蚤市场的用户兴趣会漂移——三个月前你天天看球鞋不代表你现在还在搜球鞋。我在构建评分矩阵时对每个行为乘以一个指数衰减系数score baseWeight * pow(0.9, ageDays / 7)这个公式的含义是行为发生距今每过7天权重衰减到原来的90%。也就是一个月前的一个收藏权重大概只相当于当下的0.53。这样既能保留历史兴趣信息又能让近期行为主导推荐走向。这些参数不是拍脑袋定的后面第6部分会讲怎么用离线实验去调它们。3. SpringBoot工程结构与数据表设计把推荐引擎装进项目3.1 技术栈与整体架构技术栈的选择我没有搞得很花哨。这种类型项目稳定优先别人接手容易部署方便。后端框架SpringBoot 2.7.xORM层MyBatis-Plus分页和条件构造器都是现成的不用手写一堆XML数据库MySQL 8.0缓存Redis放推荐结果和热门榜前端Vue 2 Element UI用户端和管理端都够用构建工具Maven定时任务Spring Scheduled没引入Quartz够用就行工程结构按包分层组织但把推荐相关的代码独立成recommend包不让它跟业务代码混在一起com.example.fleamarket ├── controller // 接口层 ├── service // 业务服务层 │ └── recommend // 推荐服务子包 │ ├── core // 算法核心实现 │ ├── loader // 数据加载与矩阵构建 │ └── strategy // 各推荐策略 ├── mapper ├── entity ├── dto ├── config // 配置类包括定时任务开关 └── task // 定时任务这样拆的原因是我吃过算法和业务耦合的亏。之前做过一个项目协同过滤代码散落在好几个service里后面想换算法改得头皮发麻。推荐引擎应该是可以被替换的模块输入是行为数据输出是推荐结果中间过程不跟业务绑定。3.2 核心数据表设计表设计是整个系统能否顺畅运行的地基。我一共设计了四张核心表。用户表user字段包括user_id、username、prefer_category用户填写的偏好类目用于冷启动、status。这张表本身不复杂但prefer_category这个字段很重要。新用户没有行为数据时就靠它做主链路的种子。商品表product字段包括product_id、seller_id、category_id、title、description、price、condition_level成色等级1-5档、status上下架/在售/已售出、created_time。其中status字段很关键。推荐结果生成后必须实时校验商品状态不然推一个已售出的商品给用户体验极差。用户行为表user_behavior字段包括behavior_id、user_id、product_id、behavior_type1浏览/2收藏/3下单、create_time。这是推荐引擎最核心的输入表。要注意的是必须建联合索引user_id, product_id和create_time不然数据量一上来查询直接慢到不能忍。推荐结果表rec_result字段包括id、user_id、product_id、score、strategy_type1用户协同/2属性相似/3热门兜底、expire_time、create_time。把算好的推荐结果落库或放Redis是为了接口响应速度。用户请求推荐时直接查表返回不用现场跑算法。另外还有一张用户相似度表sim_user字段是user_id、sim_user_id、similarity。这张表是离线任务算完UserCF相似度之后落库用的省得每次推荐都把用户行为矩阵捞出来重算。3.3 为什么选择离线计算加缓存这里提前回答一个很多人会问的问题推荐为什么不能在线实时算理论上可以当用户量小的时候现场拉行为数据、现场算相似度、现场生成Top-N一套流程走下来可能也就一两秒。但问题是接口响应时间会被算法计算占据而且每个用户请求都要做一遍矩阵运算流量稍大就容易扛不住。我的方案是每天凌晨用定时任务跑一遍离线计算把每个用户的推荐结果算好存到rec_result表和Redis里白天请求直接读缓存。单个用户的实时反馈比如刚收藏了一个商品通过一个失效标记机制处理这部分后面会细说。这套方案在数据量不大——万级用户、十万级商品——的毕设和中小型项目里性价比是最高的。4. 协同过滤核心代码实现从相似度计算到结果落库4.1 评分矩阵的构建代码实现从最核心的评分矩阵开始。离线任务第一步是把user_behavior表里的原始行为聚合成用户-商品评分矩阵。这个矩阵我用Map嵌套结构保存MapLong, MapLong, Double userItemMatrix new HashMap(); // 外层key是userId内层key是productIdvalue是加权评分构建逻辑很简单查三个月内的行为数据按行为类型乘权重再乘时间衰减系数累加到对应格子。三个月这个窗口是我实测后的选择——超过三个月的二手行为参考价值太低反而让矩阵更稀疏。虽然在代码里用Map存但逻辑上这就是一个稀疏矩阵。对跳蚤市场来说稀疏是常态我们要做的是在算法层面对稀疏性做容错。4.2 用户相似度计算构建完矩阵接下来是UserCF的灵魂用户相似度计算。我用的是余弦相似度关键是代码怎么写才能既正确又高效。下面是我实际使用的核心方法public double cosineSimilarity(MapLong, Double userVectorA, MapLong, Double userVectorB) { // 取两个用户共同评分过的商品集合 SetLong commonItems new HashSet(userVectorA.keySet()); commonItems.retainAll(userVectorB.keySet()); // 最容易踩坑的地方没有任何共同行为直接返回0 if (commonItems.isEmpty()) { return 0.0; } double dotProduct 0.0; double normA 0.0; double normB 0.0; for (Long itemId : commonItems) { double scoreA userVectorA.get(itemId); double scoreB userVectorB.get(itemId); dotProduct scoreA * scoreB; normA scoreA * scoreA; normB scoreB * scoreB; } if (normA 0.0 || normB 0.0) { return 0.0; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }代码逻辑不复杂但有两个细节必须注意。第一个是commonItems为空时的判断。稀疏矩阵里两个用户没有任何共同行为是大概率事件。不加这个判断后面的循环会执行但结果恒等于0白白浪费时间。第二个是计算复杂度。如果对所有用户两两计算相似度复杂度是O(n^2)。用户量到了几万后这个循环很可能跑不完。我的优化方案是只对近三个月行为数大于等于5的活跃用户计算相似度。活跃用户之间的相似度才有意义纯路人用户靠冷启动策略兜底。这个过滤一下参与计算的人数往往能砍掉一半以上。4.3 预测评分与Top-N推荐相似度算完之后就是给目标用户生成推荐列表。对目标用户u对他没行为过的每个商品i预测他可能打多少分score(u, i) SUM(sim(u, v) * r(v, i)) / SUM(sim(u, v))翻译成人话把和目标用户最相似的K个用户找出来他们对商品i的评分按相似度加权求和再归一化。这样相似用户评价高的商品在预测评分里就越高。K值我取了30只取Top30相似用户。K值不是越大越好——跳蚤市场本来数据稀疏K取太大反而把一堆相似度极低、几乎不相关的用户拉进来稀释推荐精度。生成推荐时还要过三道过滤// 伪代码过滤逻辑 ListLong candidateItems allScores.stream() .filter(s - s.getScore() threshold) // 1. 分数过低不要 .filter(s - !userHistoryIds.contains(s.getItemId())) // 2. 已行为过不要 .filter(s - productStatusChecker.isOnSale(s.getItemId())) // 3. 已售出/下架不要 .sorted(Comparator.comparingDouble(Score::getScore).reversed()) .limit(TOP_N) .collect(Collectors.toList());这里请注意第三道过滤已售出商品的过滤。跳蚤市场里一件二手家具的推荐热度再高只要商品卖出去了就必须从推荐列表消失。我是通过生成推荐时实时查商品状态表实现的虽然多一次查询但保证了体验。这个坑后面还会专门说。4.4 离线定时任务与结果落库推荐算完后结果要写入rec_result表和Redis。整体逻辑放在一个定时任务里用的是Spring自带的ScheduledComponent public class RecommendTask { Scheduled(cron 0 0 3 * * ?) // 每天凌晨3点执行 public void runOfflineRecommend() { // 1. 加载近三个月行为数据构建用户-商品评分矩阵 // 2. 计算活跃用户之间的相似度写入sim_user表 // 3. 为每个活跃用户生成Top-N候选商品 // 4. 配合属性相似策略和热门兜底策略合并结果 // 5. 写入rec_result表并同步刷新Redis缓存 } }选凌晨3点是因为这个时段用户访问量最低跑离线任务不影响线上服务。任务执行完之后把推荐列表写入Rediskey是user:recommend:{userId}过期时间24小时。用户请求推荐接口时先查Redis没有再查rec_result表还没有就触发兜底策略。这套链路跑下来的核心时间开销主要是在用户相似度计算那一层。我在一台4核8G的机器上实测一万用户、一批十万级行为数据的情况下离线任务整体能在10分钟以内完成完全够用。5. 上线前绕不开的三道坎冷启动、稀疏矩阵与推荐时效5.1 冷启动用户、商品、平台三个层面都要管冷启动是推荐系统里最老生常谈的话题但跳蚤市场把它变得更难。普通电商至少还有浏览记录可以用跳蚤市场的新用户常常连一个行为都没有。我的处理分三层。新用户冷启动。注册时让用户勾选感兴趣的商品类目数码、家居、图书、服饰等生成一个初始偏好向量。推荐时直接用偏好类目匹配高分的在售商品按热门程度排序。这样新用户打开首页至少能看到和自己兴趣沾边的东西。有研究说推荐系统的首屏体验决定了用户的去留所以在冷启动阶段给点安全牌很重要。新商品冷启动。商品一上架没有任何行为数据UserCF完全无法覆盖它。我的辅链路属性相似度就在这一步起作用提取新商品的类目、价格带、成色等级和最近一个月内用户互动多的同类商品做相似匹配把新商品塞进相似商品槽位。同时给新商品一个时间衰减的热度加成上架前三天在同类推荐里有较高的露脸机会。平台早期冷启动。如果全站行为数据连一千条都不到UserCF算出来的东西没有意义。我的做法是设置一个阈值当有效行为数据量不足一万条时全站推荐退化为热门秒杀榜也就是最近7天收藏加下单量最多的在售商品榜。数据量上来之后再平滑切到协同过滤。这个阈值可以根据实际数据调节但思路是明确的——算法必须在有足够数据支撑时才启动。5.2 稀疏矩阵的容错处理跳蚤市场的用户-商品矩阵稀疏度随便都能干到99%以上。这种极端稀疏情况下有几个容错手段是必须加的。第一只保留有意义的商品。参与矩阵构建的商品至少要有两个以上的用户行为。只有一个人行为过的商品无法为相似度计算提供任何判别信息却会占用大量内存。过滤掉它们矩阵规模能缩小一大截。第二相似度为0的用户直接抛弃。UserCF计算时如果目标用户和所有邻居的相似度都是0就别硬凑了直接走兜底策略不要浪费时间算预测分。第三设置可信评分下限。预测评分低于阈值比如0.3的商品不进入候选池。宁可推荐少一点也不要推荐一堆明显不相关的因为低质量推荐对信任的伤害远超推荐本身的价值。5.3 推荐时效用户刚收藏完就想看到变化离线计算有一个天然的短板用户刚才收藏了一件东西推荐结果不会立刻更新要等到第二天凌晨重算。对跳蚤市场这种低频交易场景24小时的时延其实能接受但我还是做了一个简单的触发式更新机制体验会好很多。实现思路是在用户执行收藏或下单操作时除了记录行为数据还把Redis里的user:recommend:{userId}这个key标记为过期。用户下次请求推荐接口时发现缓存过期就先用一个轻量级增量逻辑快速重算——把该用户最新收藏的商品的同类目热卖商品插入推荐列表头部其余保持缓存里的旧结果。等到离线任务下次全量重算时再统一修正。这个方案不追求完美但很实用。既避免了每个用户实时全量计算的性能压力又让用户感觉到系统在响应他的操作。对二手交易平台这种用户量没那么大的应用这个平衡点找得刚好。5.4 在线接口的降级设计最后提一下接口的降级。推荐接口调用链上任何一个环节出问题都不能让用户看到空白页。我在接口层做了一级降级查Redis失败降级查MySQL的rec_result表查表失败降级查热门榜热门榜也没有返回空列表前端展示默认运营位内容。这种能降级就降级的思路对任何内容推荐类接口都适用。毕竟推荐只是供给信息的一个渠道不是服务的命根子。6. 效果评估与踩坑实录推荐系统不是写完算法就结束6.1 推荐质量怎么量化推荐做完最怕的就是感觉还行这个评价。没有指标你根本不知道某个参数改得对不对。我在项目里用了四个指标做离线评估指标计算方式说明PrecisionN推荐列表N条中被用户产生行为的比例直接反映推荐命中率RecallN用户实际行为过的商品中被推荐到的比例反映召回能力覆盖率被推荐过的商品数 / 在售商品总数防止推荐集中在头部多样性推荐列表中不同类目占比的熵让用户看到不同选择具体做法是把历史行为数据按7:3切成训练集和测试集用训练集跑推荐看测试集里用户实际行为过的商品有多少出现在推荐列表中。指标跑出来的数值就是调参的依据。比如我最初K值取50Precision10只有0.23左右把K调到30后上到0.29时间衰减系数从0.95调整到0.9后因为近期行为权重更大Precision又涨了一点。这些参数全靠指标说话没有指标就只能靠感觉靠感觉的项目最后都走不远。6.2 我在这个项目里踩过的五个坑说点干货里的干货这些坑我实际都踩过。如果你正在做类似的项目可以提前绕开。第一个坑推荐结果里出现已售出商品。这是跳蚤市场特有的坑。我的推荐结果表设计之初没有保存商品状态快照离线任务算的时候商品还在售第二天用户打开推荐商品已经卖出去了点击进去直接404。后来我在生成推荐时强制关联商品状态并且每晚定时清理rec_result里已售出商品的记录。第二个坑稀疏矩阵里忘记处理commonItems为空。这个导致初版测试时报了诡异的评分——两个毫无关联的用户相似度居然是NaN。排查了半小时最后发现是除零问题。现在我在相似度函数里第一行就判断共同集合是否为空一步到位。第三个坑行为数据里有脏数据。测试阶段有同事反复点同一个商品的收藏再取消产生了上百条重复行为。这些异常行为如果不处理会把某个商品的分值异常拉高。后来我在评分矩阵构建阶段加了一个去重和频次上限同一个用户对同一个商品行为类型相同只取最新一条该商品对该用户的总权重封顶。第四个坑定时任务的时区问题。凌晨3点跑离线任务结果统计出来的近三个月行为经常莫名少一天。排查后发现是MySQL连接时区配置和服务器时区不一致导致的边界问题。解决方案很简单确认MySQL连接串带上serverTimezoneAsia/Shanghai固定时区。第五个坑Redis缓存和数据库不一致。离线任务重算后Redis里的老缓存还没过期用户看到的是昨天的结果。我在任务代码里加入了缓存清理步骤重算结束、新结果写入后主动删除旧key或者用版本号key来解决。这五个坑里前两个靠代码逻辑解决后三个靠数据规范和部署配置解决都属于那种不做不知道、做了就刻骨铭心的问题。6.3 这套方案还能往哪里走项目做到这个程度该有的链路已经有了但说实话也留了不少可以继续深挖的空间。如果数据量再上几个量级离线计算加定时任务的模式就会吃力。那时候可以考虑引入流式计算框架对用户行为做实时增量更新相似度矩阵拆成离线全量加在线增量两块。社区里经常能看到SpringBoot整合Flink这类方案但中小项目不建议一开始就上架构复杂度会翻好几倍。另一个值得做的方向是结合文本处理。跳蚤市场的商品标题虽然不规范但里面信息密度很高——出个自用iPhone 13256g有划痕出了。如果有NLP能力可以用分词和实体抽取把非标文本结构化作为内容特征反哺冷启动。之前看到有人在项目里用HanLP在SpringBoot中做分词思路就可以借鉴把品牌、型号、成色描述这些关键实体抽出来作为商品标签。在冷启动问题上还可以加一层基于品类偏好的先验规则——用户注册时勾选了数码后续即使没有行为数码类目的商品也能获得基础分。把规则和算法混排本来就是很多工业级推荐系统的日常操作。6.4 一点做项目的体会最后说点题外话。这套系统从设计到落地最花时间的不是写算法而是在理解跳蚤市场的约束上。孤品导致ItemCF失效、稀疏导致数据准备复杂、非标描述导致内容匹配困难——每一个约束都在逼着你简化方案、增加兜底。真正的推荐系统不是实验室里的公式而是由一堆规则、缓存、降级、容错堆出来的工程产物。如果你也正在做类似的毕设或项目我的建议是先把场景里的约束列全再决定技术方案先把指标定义清楚再调参数先把异常情况和降级逻辑想清楚再谈算法创新。按这个顺序走成功率高很多。我最初犯的错就是把算法想得太重、把场景想得太轻回头一看真正让我稳住阵脚的反而是那些不起眼的过滤规则和兜底逻辑。
返回列表