协同过滤在白酒销售系统中的落地:SpringBoot2+Vue3全栈实战 做白酒销售系统的推荐一开始我是有点怀疑的。白酒消费和买衣服、买书完全不一样用户的口味极其固定喝惯浓香的人不太可能因为推荐就切到酱香。那协同过滤算法在这种场景里到底能干什么等我把整套系统过完一遍才明白它的价值不在“替用户发现新口味”而在“把被库存埋没的酒推到对的客人面前”以及送礼、囤货这些场景里的关联销售。这篇文章就把我基于SpringBoot2Vue3MyBatis-PlusMySQL8.0搭的一套白酒销售系统项目代号黔醉酒业里的协同过滤落地过程完整拆一遍从算法选型、评分构建、后端封装到前端展示和踩坑记录给正在做推荐系统练手项目或者想了解全栈项目如何整合算法的同学一个参考。1. 需求拆解白酒销售场景里的推荐到底推荐什么很多人一想到推荐就自动带入天猫京东那套“猜你喜欢”的逻辑。但在白酒这种强口味、高复购、弱替换的品类里用户根本不需要“猜”。他上次买的是A牌酱香这次来大概率还是买A牌推荐一个B牌浓香上去他只会觉得系统不懂酒。所以开工之前我花了不少时间把这套系统的推荐需求重新定义了一遍。1.1 白酒消费画像高复购、强口味、弱替换白酒订单的数据里有一个很明显的特征老客户的复购订单占比非常高但复购的商品高度集中。也就是说用户不换酒换的是购买时机。这和啤酒、红酒都不一样啤酒低客单价、即兴消费强红酒送礼场景和收藏属性强而白酒属于典型的“认准了一个口味就长期锁死”。这种画像决定了协同过滤的切入点不是“相似用户发现了新品牌”而是“购买组合上的相关性”。比如数据里经常出现这样的情况买某款整箱酱香的人往往同时会带一套分酒器买某款礼盒装的人经常搭配购买同品牌的另一款小规格酒用作“顺手礼”。这些组合关系靠人工很难在几十个SKU里逐条维护但用协同过滤去算共现关系非常合适。我之前看到过一个挺经典的类比叫“啤酒与尿布”说的是沃尔玛发现啤酒和尿布经常一起出现在购物车里。白酒销售系统里的协同过滤实际跑的就是同一件事——找到那些隐藏的组合关系而不是去做“口味转移”这种高难度动作。想清楚这一点整个系统的算法选型思路就清晰多了。1.2 推荐系统要服务的四类角色这套系统虽然叫“销售系统”但推荐模块并非只给买单的人看。我把它拆成了四个使用场景普通客户登录后首页有个“为你推荐”模块逻辑是“你买过什么为你找和它搭得上的东西”以及“和你相似的人还在看什么”。门店销售员销售员在帮客户下单时能看到一个侧边推荐栏显示“该客户的常购商品”和“其他客户搭配购买的商品”辅助推销。管理员后台能看到推荐效果统计比如推荐位的点击率、转化率用来判断推荐算法有没有真的起作用。运营人员可以手动设置某些商品为“推荐置顶”比如新品上市或库存积压时干预算法的结果。这四个角色的需求差异很大但底层数据是同一套用户行为、订单明细、商品信息。算法的产出结果之所以能同时供给四类角色靠的是分层封装——同样的相似度矩阵在不同端口展示成不同形式。1.3 SpringBoot2Vue3MyBatis-PlusMySQL8.0是怎么分工的技术栈这块我选的是最稳妥的组合没有去追新版本。SpringBoot2负责后端服务编排和REST APIVue3负责前端页面和管理后台MyBatis-Plus负责持久层它能省掉大量单表CRUD的样板SQLMySQL8.0负责存业务数据和推荐结果。协同过滤算法本身没有引入额外的框架就基于Java自己实现。这里有一个容易被忽略的要点协同过滤的计算逻辑并不复杂真正的复杂度在于“数据从哪来、算完存哪、怎么更新”。MySQL8.0在这里承担了评分矩阵构建的中间层工作利用它的窗口函数和视图能力可以把用户行为流水直接加工成算法需要的输入这个在第3节详细说。技术栈整体围绕一个原则——用最小的系统复杂度跑通推荐闭环。2. 协同过滤选型UserCF和ItemCF之间怎么选协同过滤分两大类基于用户的协同过滤UserCF和基于物品的协同过滤ItemCF。我在做这个项目时两个方案都试过最后选了ItemCF也就是基于物品的协同过滤。这个决定不是凭感觉而是基于数据规模和业务形态算过的。2.1 UserCF与ItemCF为什么物品协同更适合酒类SKU先看两种算法的核心逻辑UserCF找到和我兴趣相似的其他用户把他们买过而我没买过的商品推荐给我。ItemCF找到和我买过的商品相似的其他商品推荐给我。注意这里的“商品相似”不是指外观或类别像而是“被同一批用户一起买”的概率高。对于黔醉酒业这类中小规模白酒销售系统用户量通常就是几千到几万而商品SKU少则几十、多则几百。如果用UserCF需要在线计算用户之间的相似度矩阵用户量从1万涨到10万时矩阵规模是爆炸式增长。ItemCF不一样它的核心是物品之间的共现矩阵物品数量稳定在百级算一次可以缓存很久后期扩展用户量几乎不影响在线推荐性能。对比维度UserCFItemCF计算对象用户与用户物品与物品在线开销随用户量增长随物品量增长适用场景新闻、资讯类电商、复购型商品推荐解释群体偏好物品搭配白酒场景不太合适合适还有一个业务层面的理由白酒的购买行为受“口味”驱动同一用户在不同时期可能会买不同品牌的同香型酒也可能复购同一款。物品间的共现关系天然带有“口味集群”的信息。ItemCF算出来的相似物品往往就是香型、价位、使用场景上确实有关联的商品推荐结果解释起来也更自然。2.2 余弦相似度的工程实现与共现矩阵构建ItemCF的核心是计算物品两两之间的相似度标准公式是余弦相似度similarity(i, j) |N(i) ∩ N(j)| / sqrt(|N(i)| * |N(j)|)其中N(i)是购买过物品i的用户集合分子是同时购买i和j的用户数分母是几何平均。换句话说如果两个商品经常被同一批用户购买它们的相似度就高。这里用余弦而不是皮尔逊相关系数原因在于我们的用户行为数据极端稀疏大部分用户的购买记录只有几条皮尔逊在向量维度极短时方差很大余弦更稳健。工程实现上分三步走。第一步扫描订单明细表构建“用户-商品”倒排表第二步用倒排表统计商品两两共现次数第三步套公式计算相似度并落库。核心Java代码骨架长这样// 1. 构建用户购买商品列表 MapLong, ListLong userItems new HashMap(); for (OrderItem item : orderItems) { userItems.computeIfAbsent(item.getUserId(), k - new ArrayList()) .add(item.getProductId()); } // 2. 统计商品共现次数 MapLong, MapLong, Integer coMatrix new HashMap(); for (ListLong items : userItems.values()) { for (int i 0; i items.size(); i) { for (int j i 1; j items.size(); j) { Long a items.get(i), b items.get(j); coMatrix.computeIfAbsent(a, k - new HashMap()).merge(b, 1, Integer::sum); coMatrix.computeIfAbsent(b, k - new HashMap()).merge(a, 1, Integer::sum); } } } // 3. 计算余弦相似度 for (Long a : coMatrix.keySet()) { for (Map.EntryLong, Integer e : coMatrix.get(a).entrySet()) { Long b e.getKey(); int nA productUserCount.get(a), nB productUserCount.get(b); double sim e.getValue() / Math.sqrt(nA * nB); // 存库或缓存 } }这里有个细节值得注意倒排表只保留购买行为不加浏览和收藏因为在白酒场景里“买过”和“看过”的信息价值差别巨大。后面会把其他行为加权算进评分矩阵但做物品共现时我用的是订单里的真实购买数据这样保质保量。2.3 行为数据转评分的加权与时间衰减系统没有做五颗星那种显式评分用户也不会真去打分所以协同过滤的输入得从隐式反馈里挖。我的做法是把用户的各种行为按照强度折算成分数然后喂给算法行为类型基础权重说明下单购买3.0最强烈的偏好信号加入购物车2.0明确购买意向收藏2.0有兴趣但未下决心浏览商品详情1.0弱信号只做保底光有权重还不够白酒的购买行为有明显的季节性比如春节前和中秋前的销量暴涨。如果半年前买过某款酒最近两三个月没买过这个信号应该衰减。我用了比较简单的时间衰减函数实际权重 基础权重 × exp(-间隔天数/30)也就是30天衰减到约37%90天后基本只留个痕迹。在SpringBoot的实现里我写了一个行为评分查询从多张行为表里按用户分组汇总。这个查询本身的权重计算不算复杂但在MySQL8.0里做减法很舒服可以直接用日期函数算间隔不用在Java里处理时区问题。3. 数据地基MySQL8.0中的表结构与评分构建算法能不能跑起来九成取决于数据表设计。我一开始在这个项目上吃了个亏第一版直接把用户评分矩阵设计成了一张大宽表行列是用户和商品跑起来又慢又难维护。后来全部推翻改成了“事件流水查询聚合”的模型整个推荐模块一下子清爽了。3.1 五张核心表的字段设计最终保留了五张表支撑推荐功能业务上主要靠它们CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, phone VARCHAR(20), member_level TINYINT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(200) NOT NULL, category VARCHAR(50) COMMENT 香型如酱香、浓香, degree DECIMAL(5,2) COMMENT 酒精度, capacity INT COMMENT 容量ml, price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2), status TINYINT DEFAULT 1 COMMENT 1待支付 2已支付 3已取消, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_created (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT DEFAULT 1, price DECIMAL(10,2), KEY idx_product (product_id), KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表; CREATE TABLE behavior_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, product_id BIGINT NOT NULL, behavior_type TINYINT COMMENT 1浏览 2收藏 3加购 4购买, behavior_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, behavior_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户行为日志表;商品表里的category和degree字段虽然不直接参与协同过滤计算但对后续做冷启动的策略很有用——比如新商品没有行为数据时可以按香型、价位带找相似的成熟商品做关联。这是我在设计时预留的后手。3.2 一条SQL从行为流水构建评分数据评分矩阵不需要真的落地成一张二维表而是通过SQL实时聚合出来。MySQL8.0的窗口函数和CASE WHEN让这段SQL写起来非常顺SELECT user_id, product_id, SUM(score) AS total_score FROM ( SELECT user_id, product_id, CASE behavior_type WHEN 4 THEN 3.0 WHEN 3 THEN 2.0 WHEN 2 THEN 2.0 ELSE 1.0 END * EXP(-TIMESTAMPDIFF(DAY, behavior_time, NOW()) / 30) AS score FROM behavior_log WHERE behavior_time DATE_SUB(NOW(), INTERVAL 180 DAY) ) t GROUP BY user_id, product_id;这个查询拆开来看就是两件事第一层把行为流水转成带时间衰减的评分第二层按用户和商品汇总。查询结果并不需要全部拉到应用层通常只取当前用户的行为评分和全局的“商品-用户”倒排表。注意这里的EXP衰减在MySQL里可以直接算省去了在Java里逐条处理的麻烦。但SQL聚合只能解决“数据准备”的问题真正的矩阵计算还是要在内存里做。3.3 索引与相似度结果表的设计MySQL里最大的性能风险是behavior_log表。用户行为日志增长快如果不加索引推荐接口每次都要全表扫。我给behavior_log建了(user_id, behavior_time)联合索引这样按用户拉最近行为的速度能控制在毫秒级。订单明细表给product_id单独建了索引因为计算物品共现时需要遍历所有订单明细按商品聚合。相似度计算的结果不建议每次都实时重算单独建了一张item_similarity表CREATE TABLE item_similarity ( product_id_a BIGINT NOT NULL, product_id_b BIGINT NOT NULL, similarity DECIMAL(10,6) NOT NULL, co_count INT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (product_id_a, product_id_b), KEY idx_product_b (product_id_b) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品相似度表;这张表由定时任务每天凌晨更新一次。为什么不用实时计算因为相似度矩阵的变化取决于历史订单的积累一天跑一次完全足够实时算反而浪费。排序查询直接用ORDER BY similarity DESC LIMIT N性能比内存里对着List排序还稳。decima(10,6)这个精度配合余弦公式结果在0到1之间够用。4. 后端落地SpringBoot2 MyBatis-Plus的推荐链路算法写明白之后后端工程化的重点就变成“怎么把算法嵌到一个稳定可维护的SpringBoot服务里”。我按照数据加载、相似计算、接口包装三个层次来做同时用MyBatis-Plus把持久层代码量压到最低。这一节就把整个推荐链路的细节过一遍。4.1 推荐引擎的分层设计推荐引擎的代码结构我分成三个包互不越界com.demo.recommend ├── data // 数据加载从MySQL取行为数据和相似度矩阵 ├── algorithm // 相似度计算、TopN推荐算法 └── api // 给Controller层用的Service接口data层负责查库。算法层不关心数据从哪来只认List 这种结构。api层把算法结果包装成REST接口能返回的DTO。这样做的最大好处是哪天想从ItemCF换成别的算法或者想接入实时流式计算只需要改algorithm包不需要动Controller和前端接口协议。Controller层的接口很薄核心就三个获取用户个性化推荐列表获取某商品的相关商品列表获取运营配置的热销推荐列表写过SpringBoot的人都知道Controller如果堆了一堆业务逻辑后面改起来有多难受。这套分层是我在实际编码里坚持得最彻底的地方宁可多写几个包装类也不让业务代码入侵接口层。4.2 MyBatis-Plus让持久层代码量大幅收缩推荐相关的持久层主要有两个需求查用户最近行为查相似度TopN。如果纯写MyBatis XML光是配置和SQL映射就得写不少代码。用MyBatis-Plus的BaseMapper加LambdaQueryWrapper绝大部分查询一行搞定。Service public class RecommendServiceImpl extends ServiceImplItemSimilarityMapper, ItemSimilarity implements RecommendService { Override public ListProductDTO recommendForUser(Long userId, int limit) { // 1. 查用户最近行为 ListBehaviorLog behaviors behaviorLogService.lambdaQuery() .eq(BehaviorLog::getUserId, userId) .ge(BehaviorLog::getBehaviorTime, DateUtil.offsetDay(new Date(), -180)) .orderByDesc(BehaviorLog::getBehaviorTime) .list(); // 2. 根据行为商品找相似商品 SetLong productIds behaviors.stream() .map(BehaviorLog::getProductId) .collect(Collectors.toSet()); if (productIds.isEmpty()) { return hotProducts(limit); // 兜底热销 } // 3. 汇总相似度分数 MapLong, Double scoreMap new HashMap(); for (Long pid : productIds) { ListItemSimilarity sims lambdaQuery() .eq(ItemSimilarity::getProductIdA, pid) .orderByDesc(ItemSimilarity::getSimilarity) .last(LIMIT 10) .list(); for (ItemSimilarity sim : sims) { scoreMap.merge(sim.getProductIdB(), sim.getSimilarity(), Double::sum); } } // 4. 去掉已有购买项取TopN return scoreMap.entrySet().stream() .filter(e - !productIds.contains(e.getKey())) .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(limit) .map(e - productService.getById(e.getKey())) .map(ProductDTO::from) .collect(Collectors.toList()); } }这段代码里有一个很关键的工程细节查询相似商品时调了一次lambdaQuery().eq(...).last(LIMIT 10)给每个已购商品只取相似度最高的10个候选再做累加。如果不加这个限制每个商品都拉全量相似商品一个买过10种酒的用户就要查10次全表接口就直接被打爆了。这种截断策略是我在压测时总结出来的实际效果影响很小但性能提升非常明显。4.3 接口稳定性热销兜底与结果缓存推荐接口不能因为算法问题挂掉更不能让用户看到空白页面。我做了两层兜底用户没有任何行为数据时直接返回热销商品榜算法层根本不需要参与。当前用户的行为商品在相似度表里查不到关联时也是返回热销榜。热销榜的SQL很简单按最近30天订单明细的数量和金额加权排序。这段逻辑我放到了推荐接口的默认分支里确保接口任何情况下都有数据返回。另外相似度矩阵每天才更新一次所以整个推荐结果其实没必要每次都现算。我用Caffeine做了一层本地缓存key是userId limitTTL设置15分钟。白酒销售系统的用户活跃度不像电商那么高15分钟内的数据新鲜度完全够。第一次请求打到底层数据库做全量计算后面的请求直接读缓存响应时间从200ms降到20ms以内体验提升非常明显。5. 前端呈现Vue3里推荐位的交互与埋点前端部分我用的Vue3 Vite Pinia Element Plus。页面整体结构分为面向管理员的控制台和面向客户的商城页面。推荐模块在这两端的呈现方式完全不同但数据源都是后端那套推荐接口。5.1 页面布局管理端与客户端各自的推荐位管理后台的推荐页面我设计成三个Tab用户画像选择一个用户展示他的行为轨迹和系统为他推荐的商品列表。这个功能给销售员用接待顾客时能快速调出对方的偏好。商品关联分析选择一个商品展示它的Top10相似商品和共现次数。这个给运营看用来做捆绑促销。推荐效果统计展示推荐位的曝光量、点击量和转化率。客户端的推荐位主要在商城首页。首页顶部是轮播图下面紧接着是“为你推荐”区块再往下才是全部分类商品。这块的逻辑是用户已登录且有推荐结果时展示个性化推荐未登录或推荐为空时展示“热门单品”。前端只判断接口返回的数据是否为空不需要关心后端算法细节。5.2 从axios到渲染一次完整的数据流Vue3的组合式API写法配合封装的请求工具整个数据流很直接// recommend.js import request from /utils/request export function fetchRecommendList(limit 10) { return request({ url: /api/recommend/list, method: get, params: { limit } }) }首页组件里用一个onMounted去拉数据把结果交给响应式变量script setup import { ref, onMounted } from vue import { fetchRecommendList } from /api/recommend const recommendList ref([]) const loading ref(false) onMounted(async () { loading.value true try { const res await fetchRecommendList(10) recommendList.value res.data.items || [] } finally { loading.value false } }) /script template div classrecommend-section h2 classsection-title为你推荐/h2 div v-ifloading classloading加载中.../div div v-else-ifrecommendList.length 0 classempty !-- 空态也展示默认热销前端不显示空白 -- /div div v-else classproduct-grid product-card v-foritem in recommendList :keyitem.id :productitem clicktrackRecommendClick(item) / /div /div /template这里有个交互上的细节推荐位加载时占位区域要保持高度否则页面会突然跳一下给用户的体感非常差。我先用loading状态控制骨架屏等数据返回后再填充卡片。5.3 前端埋点让推荐效果可量化推荐系统做完不代表结束得知道它有没有用。我在前端埋了点击事件每次用户从推荐位点击某个商品都会把productId和曝光位置发到后端日志表。后端再结合订单数据就能算“推荐点击率”和“推荐转化率”这两个核心指标。埋点看起来是小功能但如果没有它管理员根本无从判断算法效果。我见过不少项目把推荐模块做出来了但上线后没有任何反馈数据算法调优全靠猜。至少加一行曝光日志和一行点击日志整个系统才能形成闭环。有兴趣的同学可以在前端埋点的基础上继续加“本次推荐带来的直接下单金额”那数据就更好看了。6. 试运行踩坑冷启动、假相似与缓存同步这个项目从第一版跑通到最终稳定运行中间踩了几个让我印象深刻的坑。有些是算法层面的有些是纯粹工程层面的但每一个都直接影响推荐效果和系统稳定性拿出来分享一下。6.1 冷启动算法解决不了的问题靠产品策略解决新用户没有行为记录新商品没有被购买过这两个问题协同过滤都解决不了。面对冷启动最务实的方法是产品策略而不是算法策略。对新用户默认走热销榜同时在前端新用户专区展示高分评价商品。等用户产生一次浏览行为后就能用浏览记录里的弱信号跑推荐。实测下来用户第一次访问时看热销榜的接受度比看一个莫名其妙的“猜你喜欢”高得多。对新商品打上“新品”标签在运营后台临时置顶两周并且手动把它和同香型的热门商品建立关联。这种人为干预在推荐系统里叫“注入先验”等它积累了足够的真实购买数据之后再去掉人工关联。冷启动这件事在学术上有各种漂亮的方法论但实际项目里最有效的就是规则兜底加人工干预。我先把这个原则定下来后面很多决策就不纠结了。6.2 共同行为数过少的假相似问题第一个版本的相似度计算上线后我发现了一个很奇怪的现象某款低价小瓶装白酒和一款几千块的礼盒装白酒相似度高达0.99。一看数据才明白两个商品都只有一个人买过而这个人在同一笔订单里同时下了一瓶小酒和一箱礼盒直接导致共现次数为1余弦相似度等于1。这就是典型的稀疏矩阵下的“假相似”。解决方法是双管齐下。第一在相似度表里加一个co_count字段即共现次数查询时只保留co_count大于等于3的记录。阈值的选3不是随手写的我统计过数据里商品对的共现分布共现数在1和2的记录占了将近80%这些数据算出来的相似度普遍不可靠。第二对共现次数做“置信度惩罚”在原始相似度上乘一个惩罚系数adjusted_sim sim × (co_count / (co_count 5))这个公式是参考了了一些推荐系统里的置信度加权做法。当co_count很小的时候惩罚系数会把相似度压低当共现次数多了惩罚系数趋近于1相似度不会受太大影响。6.3 编码、时区与缓存刷新不起眼但致命的细节技术栈选型没问题算法实现也没问题但项目实际部署时还是有几个不起眼的坑每一个都能让系统表现出“奇怪的行为”。一是MySQL的乱码问题。建表时没用utf8mb4导致商品名里带“酱”“窖”这些字在接口返回时直接变成问号。解决方案是建表语句统一指定DEFAULT CHARSETutf8mb4现有表用ALTER TABLE改字符集。MySQL8.0默认已经是utf8mb4但如果是从老版本迁移来的库很容易栽在这里。二是时区问题。服务器设置的时区和MySQL的时区不一致导致behavior_log的时间记录偏移时间衰减函数算出来的评分全都不对。最后统一在SpringBoot的JDBC连接串里加了serverTimezoneAsia/Shanghai并且让Docker容器和宿主机的时区保持一致。三是定时任务刷新相似度矩阵时会遇到部分用户正在请求推荐接口。如果不做处理可能出现A时刻请求用了旧矩阵B时刻请求用了新矩阵推荐结果的巨大差异让用户产生困惑。我给定时刷新逻辑加了版本号管理——当前版本号n的请求不中断等新矩阵计算完毕并落库后版本号切到n1后续请求自动读新数据。最后说点实际的这个项目做完之后我对“推荐系统”这四个字的理解发生了不少变化。过去总觉得推荐算法是个很神秘的东西实际落地才发现真正决定推荐效果的往往不是算法本身的高深程度而是数据质量、业务理解和工程实现这些看起来不起眼的地方。协同过滤在白酒销售这种场景里断然比不上大厂那些深度融合的排序模型但它用最简单的逻辑把“老客户推荐”“关联购买”这些业务需求跑得明明白白。如果让我重做一遍这个项目我会在行为日志的埋点上提前做得更细一点比如记录用户在每个商品页的停留时长这会让隐式反馈的准确性再上一个台阶。另外评分矩阵构建部分的SQL可以再抽象成独立的存储过程定期物化成一张中间表减轻每次实时计算的数据库压力。这套SpringBoot2Vue3MyBatis-PlusMySQL8.0的组合整体跑起来非常稳复杂度适中很适合作为推荐算法的入门级全栈实战参考。希望这篇文章能帮你少走几个弯路。