协同过滤在白酒销售系统中的落地:SpringBoot2+Vue3全栈实战

做白酒销售系统的推荐,一开始我是有点怀疑的。白酒消费和买衣服、买书完全不一样,用户的口味极其固定,喝惯浓香的人不太可能因为推荐就切到酱香。那协同过滤算法在这种场景里到底能干什么?等我把整套系统过完一遍才明白,它的价值不在“替用户发现新口味”,而在“把被库存埋没的酒推到对的客人面前”,以及送礼、囤货这些场景里的关联销售。这篇文章就把我基于SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0搭的一套白酒销售系统(项目代号:黔醉酒业)里的协同过滤落地过程完整拆一遍,从算法选型、评分构建、后端封装,到前端展示和踩坑记录,给正在做推荐系统练手项目或者想了解全栈项目如何整合算法的同学一个参考。

1. 需求拆解:白酒销售场景里的推荐,到底推荐什么

很多人一想到推荐,就自动带入天猫京东那套“猜你喜欢”的逻辑。但在白酒这种强口味、高复购、弱替换的品类里,用户根本不需要“猜”。他上次买的是A牌酱香,这次来大概率还是买A牌,推荐一个B牌浓香上去,他只会觉得系统不懂酒。所以开工之前我花了不少时间把这套系统的推荐需求重新定义了一遍。

1.1 白酒消费画像:高复购、强口味、弱替换

白酒订单的数据里有一个很明显的特征:老客户的复购订单占比非常高,但复购的商品高度集中。也就是说,用户不换酒,换的是购买时机。这和啤酒、红酒都不一样,啤酒低客单价、即兴消费强,红酒送礼场景和收藏属性强,而白酒属于典型的“认准了一个口味就长期锁死”。

这种画像决定了协同过滤的切入点不是“相似用户发现了新品牌”,而是“购买组合上的相关性”。比如数据里经常出现这样的情况:买某款整箱酱香的人,往往同时会带一套分酒器;买某款礼盒装的人,经常搭配购买同品牌的另一款小规格酒用作“顺手礼”。这些组合关系,靠人工很难在几十个SKU里逐条维护,但用协同过滤去算共现关系,非常合适。

我之前看到过一个挺经典的类比叫“啤酒与尿布”,说的是沃尔玛发现啤酒和尿布经常一起出现在购物车里。白酒销售系统里的协同过滤,实际跑的就是同一件事——找到那些隐藏的组合关系,而不是去做“口味转移”这种高难度动作。想清楚这一点,整个系统的算法选型思路就清晰多了。

1.2 推荐系统要服务的四类角色

这套系统虽然叫“销售系统”,但推荐模块并非只给买单的人看。我把它拆成了四个使用场景:

  • 普通客户:登录后首页有个“为你推荐”模块,逻辑是“你买过什么,为你找和它搭得上的东西”,以及“和你相似的人还在看什么”。
  • 门店销售员:销售员在帮客户下单时,能看到一个侧边推荐栏,显示“该客户的常购商品”和“其他客户搭配购买的商品”,辅助推销。
  • 管理员:后台能看到推荐效果统计,比如推荐位的点击率、转化率,用来判断推荐算法有没有真的起作用。
  • 运营人员:可以手动设置某些商品为“推荐置顶”,比如新品上市或库存积压时,干预算法的结果。

这四个角色的需求差异很大,但底层数据是同一套:用户行为、订单明细、商品信息。算法的产出结果之所以能同时供给四类角色,靠的是分层封装——同样的相似度矩阵,在不同端口展示成不同形式。

1.3 SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0是怎么分工的

技术栈这块,我选的是最稳妥的组合,没有去追新版本。SpringBoot2负责后端服务编排和REST API;Vue3负责前端页面和管理后台;MyBatis-Plus负责持久层,它能省掉大量单表CRUD的样板SQL;MySQL8.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. 构建用户购买商品列表 Map<Long, List<Long>> userItems = new HashMap<>(); for (OrderItem item : orderItems) { userItems.computeIfAbsent(item.getUserId(), k -> new ArrayList<>()) .add(item.getProductId()); } // 2. 统计商品共现次数 Map<Long, Map<Long, Integer>> coMatrix = new HashMap<>(); for (List<Long> 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.Entry<Long, 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 ServiceImpl<ItemSimilarityMapper, ItemSimilarity> implements RecommendService { @Override public List<ProductDTO> recommendForUser(Long userId, int limit) { // 1. 查用户最近行为 List<BehaviorLog> behaviors = behaviorLogService.lambdaQuery() .eq(BehaviorLog::getUserId, userId) .ge(BehaviorLog::getBehaviorTime, DateUtil.offsetDay(new Date(), -180)) .orderByDesc(BehaviorLog::getBehaviorTime) .list(); // 2. 根据行为商品找相似商品 Set<Long> productIds = behaviors.stream() .map(BehaviorLog::getProductId) .collect(Collectors.toSet()); if (productIds.isEmpty()) { return hotProducts(limit); // 兜底热销 } // 3. 汇总相似度分数 Map<Long, Double> scoreMap = new HashMap<>(); for (Long pid : productIds) { List<ItemSimilarity> 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, Double>comparingByValue().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 + limit,TTL设置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 class="recommend-section"> <h2 class="section-title">为你推荐</h2> <div v-if="loading" class="loading">加载中...</div> <div v-else-if="recommendList.length === 0" class="empty"> <!-- 空态也展示默认热销,前端不显示空白 --> </div> <div v-else class="product-grid"> <product-card v-for="item in recommendList" :key="item.id" :product="item" @click="trackRecommendClick(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 CHARSET=utf8mb4,现有表用ALTER TABLE改字符集。MySQL8.0默认已经是utf8mb4,但如果是从老版本迁移来的库,很容易栽在这里。

二是时区问题。服务器设置的时区和MySQL的时区不一致,导致behavior_log的时间记录偏移,时间衰减函数算出来的评分全都不对。最后统一在SpringBoot的JDBC连接串里加了serverTimezone=Asia/Shanghai,并且让Docker容器和宿主机的时区保持一致。

三是定时任务刷新相似度矩阵时,会遇到部分用户正在请求推荐接口。如果不做处理,可能出现A时刻请求用了旧矩阵,B时刻请求用了新矩阵,推荐结果的巨大差异让用户产生困惑。我给定时刷新逻辑加了版本号管理——当前版本号n的请求不中断,等新矩阵计算完毕并落库后,版本号切到n+1,后续请求自动读新数据。

最后说点实际的

这个项目做完之后,我对“推荐系统”这四个字的理解发生了不少变化。过去总觉得推荐算法是个很神秘的东西,实际落地才发现,真正决定推荐效果的往往不是算法本身的高深程度,而是数据质量、业务理解和工程实现这些看起来不起眼的地方。协同过滤在白酒销售这种场景里,断然比不上大厂那些深度融合的排序模型,但它用最简单的逻辑,把“老客户推荐”“关联购买”这些业务需求跑得明明白白。

如果让我重做一遍这个项目,我会在行为日志的埋点上提前做得更细一点,比如记录用户在每个商品页的停留时长,这会让隐式反馈的准确性再上一个台阶。另外,评分矩阵构建部分的SQL,可以再抽象成独立的存储过程,定期物化成一张中间表,减轻每次实时计算的数据库压力。这套SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0的组合,整体跑起来非常稳,复杂度适中,很适合作为推荐算法的入门级全栈实战参考。希望这篇文章能帮你少走几个弯路。

相关新闻

vue3-element-admin 消息提示实战:ElMessage 与 Notification 通知中心怎么选,推送链路怎么跑通

vue3-element-admin 消息提示实战:ElMessage 与 Notification 通知中心怎么选,推送链路怎么跑通

vue3-element-admin 消息提示实战&#xff1a;ElMessage 与 Notification 通知中心怎么选&#xff0c;推送链路怎么跑通 【免费下载链接】vue3-element-admin &#x1f525;基于 Vue 3 Vite 7 TypeScript element-plus 构建的后台管理前端模板&#xff08;配套后端源码&#…

2026/10/21 1:31:17 阅读更多 →
esp_sensorless_bldc_control 比较器反电势过零检测与六步换相源码解读

esp_sensorless_bldc_control 比较器反电势过零检测与六步换相源码解读

esp_sensorless_bldc_control 比较器反电势过零检测与六步换相源码解读 【免费下载链接】esp-iot-solution Espressif IoT Library. IoT Device Drivers, Documentations and Solutions. 项目地址: https://gitcode.com/GitHub_Trending/es/esp-iot-solution 这篇文章以…

2026/10/21 1:31:17 阅读更多 →
从图像分类到Embeddings:tf_mixnet_l.in1k的3种官方用法详解

从图像分类到Embeddings:tf_mixnet_l.in1k的3种官方用法详解

【免费下载链接】tf_mixnet_l.in1k 项目地址&#xff1a; https://ai.gitcode.com/hf_mirrors/timm/tf_mixnet_l.in1k 点击查看 免费下载 tf_mixnet_l.in1k 是 timm&#xff08;PyTorch Image Models&#xff09;提供的一款 MixNet 图像分类模型&#xff0c;在 ImageNet-1k 上…

2026/10/21 1:31:17 阅读更多 →

最新新闻

AI辅助STM32开发实战:从提示词设计到代码审查的完整协同流程

AI辅助STM32开发实战:从提示词设计到代码审查的完整协同流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 2:07:44 阅读更多 →
ZCC485高速超低功耗RS485芯片实战解析

ZCC485高速超低功耗RS485芯片实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 2:07:44 阅读更多 →
YiShaAdmin部署避坑指南:10分钟修好启动与连接问题

YiShaAdmin部署避坑指南:10分钟修好启动与连接问题

YiShaAdmin部署避坑指南&#xff1a;10分钟修好启动与连接问题 【免费下载链接】YiShaAdmin 基于 .NET Core MVC 的权限管理系统&#xff0c;代码易读易懂、界面简洁美观 项目地址: https://gitcode.com/GitHub_Trending/yi/YiShaAdmin 大多数人卡在两处&#xff1a;dot…

2026/10/21 2:07:44 阅读更多 →
canmatrix解析DBC文件速度差十倍?字节相同但性能迥异的根因与排查

canmatrix解析DBC文件速度差十倍?字节相同但性能迥异的根因与排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 2:07:44 阅读更多 →
Robomaster硬件基础实战指南:从冒烟故障到系统级校准

Robomaster硬件基础实战指南:从冒烟故障到系统级校准

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 2:07:44 阅读更多 →
Sicar-Training-show-V16工业标准程序复习指南:架构、模块化与易错点

Sicar-Training-show-V16工业标准程序复习指南:架构、模块化与易错点

简介&#xff1a;Sicar-Training-show-V16程序是一套面向工业自动化领域学习者的Sicar标准程序训练示例包&#xff0c;适合正在接触西门子PLC编程、PROFINET通信及分布式IO组态的工程师与院校学生&#xff0c;用于理解标准化程序框架与设备描述文件的组织方式。压缩包为rar格式…

2026/10/21 2:06:44 阅读更多 →

日新闻

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事&#xff1a;AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里&#xff0c;按宣传口径&#xff0c;本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”&#xff0c;而是这话背后的含金量——它把过去只能…

2026/10/21 0:00:11 阅读更多 →
AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

1. 从“封号重开”这个行为说起&#xff1a;用户到底在追求什么“封号重开”这四个字&#xff0c;放在任何产品语境里都带着一股决绝的味道。一个账号从零开始积累使用记录、调教偏好、沉淀工作流&#xff0c;背后是大量的时间和精力成本。正常情况下&#xff0c;没有人愿意主动…

2026/10/21 0:00:11 阅读更多 →
ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

1. 为什么在ROS里硬刚YOLOv3不是“炫技”&#xff0c;而是工程落地的必经之路我第一次在某高校机器人实验室看到那台搭载Jetson TX2的移动底盘原地打转、反复识别错垃圾桶时&#xff0c;就意识到&#xff1a;单纯跑通darknet_ros demo和真正让YOLOv3在ROS里“干活”&#xff0c…

2026/10/21 0:00:11 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →