2026/10/16 14:38:23

Spring Boot旅游景点推荐系统:协同过滤与冷启动实战

Spring Boot旅游景点推荐系统:协同过滤与冷启动实战 做旅游类项目最怕的就是系统做出来像个“景点列表”用户打开看一眼就关掉。真正能留住人的是“推荐”两个字——用户没搜过“小众秘境”系统却知道他可能喜欢用户没查“亲子游”首页却推了三条适合带娃的路线。这篇就聊聊我基于 Spring Boot 搭建旅游景点推荐管理系统时的完整设计思路和实现细节覆盖推荐算法选型、数据库设计、接口规范、排序策略以及我实际开发中踩过的那些坑。这个系统适合正在做毕设、刚接触推荐场景的开发者也适合想从“CRUD 管理系统”往“带算法逻辑的业务系统”进阶的朋友。整体技术栈是 Spring Boot 3 MyBatis-Plus Redis MySQL推荐部分没有盲目上大数据框架而是用了加权混合策略——基于用户行为的协同过滤做主推基于景点标签的内容推荐做兜底再用热门度和新鲜度做动态干预。1. 项目整体设计与技术选型拆解1.1 为什么用 Spring Boot 而不是 SSM 或微服务说实话这种体量的项目用微服务就是给自己找麻烦。景点推荐系统核心链路是“用户 → 行为采集 → 推荐计算 → 结果输出”单机 Spring Boot 完全扛得住而且开发效率高。Spring Boot 最值钱的不是那些自动配置而是它让你把精力集中在业务逻辑上——你不需要关心 Tomcat 怎么配、MyBatis 怎么整合、JSON 序列化怎么开启这些框架级的东西 Spring Boot 都处理好了。我选 Spring Boot 3 还有一个原因JDK 17 的record类型对 DTO 太友好了配合MapStruct做对象转换代码量直接砍掉三分之一。如果你还在用 JDK 8Spring Boot 2.x 也完全够用核心逻辑不受影响。1.2 整体功能模块划分这套系统按角色分为游客端和管理端功能上不追求大而全而是围绕“推荐”这个核心链路设计游客端首页个性化推荐流按用户行为动态排序景点搜索与多条件筛选区域、主题、评分、价格区间景点详情页基本信息、图片集、评论、相似推荐收藏/点赞/评分/浏览行为采集历史足迹与偏好管理管理端景点信息管理新增、上下架、标签维护区域与分类管理省市区三级联动评论审核与敏感词过滤用户管理状态封禁、角色分配推荐策略配置权重参数在线调整1.3 推荐链路的整体设计推荐系统最核心的是数据流。在这个项目里我把链路设计成下面四条线线1行为采集 → 用户行为表浏览、收藏、评分、点评 线2离线计算 → 每晚跑一次协同过滤生成用户相似度矩阵和候选集 线3在线干预 → 用户在访问时触发实时规则地域优先、热度加权、新品加权 线4结果融合 → 多路召回结果合并 → 规则过滤 → 排序输出这里面有个容易被新手忽略的点协同过滤的候选集不能直接作为最终结果返回。因为它是离线算好的没办法感知“今天有新的爆款景点上线”或者“用户此刻搜索了某个区域”。所以必须有一层在线干预逻辑把实时信号叠加上去。2. 推荐算法选型与落地实现2.1 基于协同过滤的推荐逻辑这套系统的冷启动难点在于新用户没有行为数据新景点没有评分记录。我采用了“基于用户的协同过滤UserCF 基于物品的协同过滤ItemCF”双轨方案用户量小、行为稀疏时走 UserCF找相似用户喜欢的景点景点数量变动频繁时走 ItemCF因为用户对景点的偏好不容易漂移。具体实现上UserCF 的相似度计算我用的是余弦相似度。假设用户A对景点的评分为稀疏向量用户B同理计算两者的夹角余弦public double cosineSimilarity(MapLong, Double userVec1, MapLong, Double userVec2) { SetLong commonItems new HashSet(userVec1.keySet()); commonItems.retainAll(userVec2.keySet()); if (commonItems.isEmpty()) return 0.0; double dot 0.0, norm1 0.0, norm2 0.0; for (Long itemId : commonItems) { dot userVec1.get(itemId) * userVec2.get(itemId); } for (double v : userVec1.values()) norm1 v * v; for (double v : userVec2.values()) norm2 v * v; if (norm1 0 || norm2 0) return 0.0; return dot / (Math.sqrt(norm1) * Math.sqrt(norm2)); }相似度矩阵离线计算完成后存入 Redis用user:{id}:sim的 key 存储 topN 相似用户。为什么不用 MySQL 存因为相似度矩阵是MxM级别的稠密数据用户量到一万关系数量就是亿级MySQL 撑不住Redis 的zset天然适合存“用户 → 相似用户 相似度分值”这种结构。2.2 基于内容和标签的兜底推荐协同过滤有个致命弱点新景区完全没有行为数据永远不会被推荐。所以我的策略里必须有基于内容的推荐做兜底。我在景点表设计了三个标签字段theme主题类别如“自然风光”“人文历史”“亲子游乐”region_level区域标签如“市区必打卡”“郊区徒步”crowd_type适合人群如“情侣游”“亲子游”“独自旅行”。基于内容推荐的核心逻辑就是用户对某个景点产生正向行为收藏/评分 ≥ 4后系统提取该景点的标签再找同一标签体系下用户未见过的其他景点。public ListScenicSpot recommendByTags(Long userId, int limit) { // 1. 找出用户行为表中评分4的景点标签 ListScenicTag positiveTags getPositiveTags(userId); // 2. 统计标签权重权重 该标签对应景点数量占比 MapString, Double tagWeights weightedTags(positiveTags); // 3. 按权重分数召回候选景点排除用户已见过的 return candidateFilter(userId, tagWeights, limit); }这个方案实现简单但能保证“新用户、新景点”这俩极端场景都有内容可推。实操中我建议把标签控制在每个景点 3~5 个太多会稀释相似度计算的准确性。2.3 热度加权与“新鲜度惩罚”推荐系统做久了就会发现纯推荐算法容易让推荐列表变得非常“硬”——永远推那几类热门景点用户很快就会审美疲劳。我在推荐排序时加了两个动态调节因子热度衰减因子景点总浏览量、近7天搜索量、收藏增量按时间窗口滑动计算保证长江大桥这种老牌景点不会因为历史浏览量高就永远霸榜新景点加权景点上架时间在30天内排序时增加20%的权重让新景点有机会曝光。热度分的计算公式我简化成了public double heatScore(ScenicSpot spot, int dayRange) { long viewCount statisticMapper.queryViewCount(spot.getId(), dayRange); long favCount statisticMapper.queryFavoriteCount(spot.getId(), dayRange); double ageScore spot.getDaysSinceCreated() 30 ? 1.0 : 1.2; return (viewCount * 0.4 favCount * 1.2) * ageScore; }收藏的权重比浏览量高因为收藏代表用户的意图更强浏览量可能是误点或被动刷到。2.4 推荐结果的融合排序策略多路召回之后就是排序融合。我的融合策略是加权线性组合权重参数全部做成了数据库配置管理员后台可以实时调整召回来源权重占比排序因子协同过滤用户相似40%相似用户对该景点的平均评分内容/标签匹配30%标签重合度热度干预20%heatScore归一化值区域匹配在线干预10%用户常驻区域与景点区域是否匹配融合公式double finalScore 0.4 * simScore 0.3 * tagScore 0.2 * heatScore 0.1 * regionScore;为什么把协同过滤权重放最大因为在所有召回信号里相似用户的行为是最有说服力的——它能捕捉到“标签无法表达”的偏好比如“喜欢人文类景点的人往往也喜欢老建筑改造的咖啡馆”这种隐性关联标签体系根本无法表达。但注意权重是需要调参的。实操中我给管理后台做了 A/B 测试接口管理员可以配置不同的权重组合系统按用户ID哈希分流展示不同版本用点击率和收藏转化率作为衡量指标。这个细节在毕设论文或项目文档里写出来非常有亮点。3. 核心功能模块的设计与实现3.1 数据库模型设计数据库设计我踩过最大的坑就是一开始把景点信息做成了一张大表结果后面加标签、加区域等级、加图片集的时候天天在改表结构。后来我重构成了标准的三层结构scenic_spot景点主表ID、名称、简介、介绍、区域ID、评分、状态、创建时间 scenic_tag标签表ID、标签名、类型主题/人群/区域等级 scenic_spot_tag景点—标签关联表景点ID、标签ID景点图片则用一张独立的scenic_image表一个景点可以有多张图。为什么独立成表因为列表页只需要封面图详情页需要完整图集如果都塞在 JSON 字段里列表查询性能会很难看。核心表结构参考CREATE TABLE scenic_spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, summary VARCHAR(500) COMMENT 一句话简介, detail TEXT COMMENT 详细介绍, region_code VARCHAR(20) COMMENT 区域编码省市区, cover_image VARCHAR(255), avg_score DECIMAL(2,1) DEFAULT 0.0, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, online_time DATETIME COMMENT 上架时间用于新景点加权, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE scenic_spot_tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scenic_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, tag_type VARCHAR(20) COMMENT theme/crowd/region, UNIQUE KEY uk_scenic_tag (scenic_id, tag_id) ); CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, behavior_type VARCHAR(20) COMMENT view/favorite/rating/comment, rating_score TINYINT COMMENT 1-5分仅rating行为有值, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_behavior (user_id, behavior_type) );user_behavior是推荐系统的“油田”所有行为数据必须落表而且一定要加索引。我在测试环境里模拟了10万条行为数据如果没有(user_id, behavior_type)联合索引查询耗时直接冲到2秒以上加了索引后稳定在 50ms 以内。3.2 个性化推荐接口的实现细节推荐接口是系统的核心我设计成GetMapping(/api/recommend) public ResultListScenicVO recommend( RequestParam Long userId, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { // 1. 多路召回 ListRecommendItem cfItems recallByCollaborativeFiltering(userId, 30); ListRecommendItem contentItems recallByContentMatch(userId, 20); ListRecommendItem hotItems recallByHeatScore(userId, 10); // 2. 合并去重 MapLong, RecommendItem merged mergeItems(cfItems, contentItems, hotItems); // 3. 规则过滤下架景点、用户已收藏且明确不想看的 merged ruleFilter(userId, merged); // 4. 融合打分 排序 分页 ListScenicVO result rankAndPage(merged, page, size); return Result.success(result); }这里有个关键细节召回量必须大于最终展示量。如果页面上要展示10个那协同过滤至少要召回30个候选再往后排。因为融合打分后总会有一些“虽然相似度分高但热度太低”的景点被淘汰掉召回不足会导致列表末尾凑不满。分页的逻辑和普通列表不同。推荐流的分页不建议用页码深翻页而在实际项目中使用offset深翻页性能会急剧下降。推荐流更合理的做法是游标分页用上次返回列表最后一个景点的最终得分作为score_after下次查询只取分数小于该值的记录。这样用户往下翻时推荐流整体不回退体验上更像刷信息流而不是查列表。3.3 管理端景点推荐权重配置管理端除了常见的CRUD我额外加了“推荐策略配置”页面。管理员可以实时调整协同过滤权重40% 内容匹配权重30% 热度干预权重20% 区域匹配权重10% 新景点加权天数30 新景点加权分值1.2这些参数我设计成存在一张recommend_config表里Redis 缓存5分钟自动刷新。为什么要动态配置因为推荐系统的调优是一个长期过程尤其是在前期数据量少的时候协同过滤的效果可能不如热度推荐这时候就要把热度权重调高等用户行为积累多了再慢慢加大协同过滤的占比。3.4 用户行为上报接口的防抖设计行为采集接口很容易被反复调用——用户滑过同一个景点三次前端可能上报三次浏览记录。如果每次都落表行为数据就会出现大量噪声影响推荐准确性。我的做法是在接口层做了一层简单的防抖public void recordBehavior(Long userId, Long scenicId, String type) { // 浏览行为30秒内只记一次 if (view.equals(type)) { String redisKey view:debounce: userId : scenicId; Boolean firstTime redisTemplate.opsForValue().setIfAbsent(redisKey, 1, Duration.ofSeconds(30)); if (Boolean.FALSE.equals(firstTime)) return; } // 收藏/评分属于强意图行为不防抖每次都记录 behaviorMapper.insert(...); }这个细节看起来小但直接影响推荐质量。如果用户只是刷了几个景点详情页系统就以为他“喜欢”这几个景点协同过滤算出来的相似用户就会偏得离谱。4. 推荐系统中的关键问题排查与解决记录4.1 冷启动问题的落地解法新用户第一次进系统时行为表里什么数据都没有协同过滤和内容推荐都成了无源之水。我按下面优先级给冷启动用户设计了一套策略用户注册时选择偏好标签必选至少3个直接作为内容推荐的种子用 IP 定位城市优先推荐用户所在城市及周边“必打卡”景点仍未捕获信号时用全局热度榜 新景点加权填充首页。第1条最关键。注册引导里让用户选“自然风光/人文历史/美食探店/城市夜景”多选标签一是作为推荐的种子数据二是方便后续算法校验。4.2 相似度计算性能优化我第一次跑 UserCF 的用户相似度计算逻辑是用户两两之间全量比对用户数3000时跑了30多秒。后来我做了两步优化UserCF 预筛先按“共同评分过的景点数量”粗筛共同景点数低于3的直接跳过计算节省80%无效计算向量化计算只对行为矩阵的非零项做迭代避免遍历全表。优化后3000用户的相似度矩阵计算降到3秒左右。如果用户量再上台阶就该考虑用 Spark 或 Flink 做了但中等体量下数据库内解决完全足够。4.3 推荐的“惊度”问题相似度过高上线后遇到一个很典型的反馈推荐列表连续5个景点都是“同质化古镇”虽然用户确实喜欢古镇但一眼扫过去全是同一类型体验非常差。我在排序阶段加了一个MMR最大边际相关性去重策略public ListScenicVO diversityRank(ListRecommendItem candidates, int limit) { ListRecommendItem result new ArrayList(); // 候选先按分数降序 candidates.sort(Comparator.comparingDouble(RecommendItem::getScore).reversed()); result.add(candidates.get(0)); SetLong selectedTags new HashSet(candidates.get(0).getTagIds()); for (int i 1; i candidates.size() result.size() limit; i) { RecommendItem item candidates.get(i); SetLong tags new HashSet(item.getTagIds()); // 计算与已选结果的重合度 long overlap tags.stream().filter(selectedTags::contains).count(); double adjustedScore item.getScore() - overlap * 0.25; // 动态插到已排序结果中 ... } return result; }实际效果就是推荐列表里每隔几个景点就会穿插不同类型的景观不至于让人产生“我是不是被某一种内容包围了”的感觉。4.4 性能瓶颈推荐接口慢查询排查联调测试时发现推荐接口从点击到数据返回到达 1.8 秒明显太慢。日志定位后发现慢在查询用户行为这一步——每次请求都动态查询用户一年内的行为记录。优化的方法是引入行为缓存// 用户近30天行为摘要缓存 redisTemplate.opsForValue().set(user:profile: userId, JSON.toJSONString(userProfile), Duration.ofMinutes(10));同时把“热门榜”和“相似用户 topN 结果”各缓存5分钟推荐接口的响应时间从 1.8 秒降到了 230ms 左右。这个优化路径很通用推荐系统计算部分可以稍微“陈旧”但不能慢行为数据可以稍微延迟但结果要快。4.5 常见问题速查表场景现象原因解决方案新用户首页空白推荐接口返回0条行为表无数据导致召回为空冷启动策略注册选标签城市必打卡热度兜底推荐结果单一连续多个同类型景点排序层无去重逻辑加MMR多样性排序按标签重合度惩罚分数相似度计算超时后台任务跑很久全量用户两两比对预筛只算非零项向量新景点永远没曝光新上架景点无推荐冷启动且无行为数据上架30天加权1.2热度分乘系数行为数据噪声大推荐结果偏离用户确实偏好浏览行为重复上报加Redis防抖30秒内相同浏览只记一次管理端改权重不生效推荐结果没变化配置缓存未刷新配置存DBRedis缓存过期时间设5分钟5. 经验总结与项目扩展方向这套旅游景点推荐管理系统真正让我理解了“推荐不是算法题而是工程题”这句话。算法本身并不神秘协同过滤教科书里都有公式真正让一个推荐系统“能用”的关键要素我总结为三条行为数据采集质量比算法模型更重要。数据脏、上报乱、缺字段后面再牛的算法都白搭冷启动和多样性是同样需要认真对待的功能而不只是调参细节。用户注册引导、新景点加权这些“非算法”因素对系统体验的影响往往超过算法本身推荐结果必须能解释。给每个推荐项打上推荐理由——“因为您常看人文历史类景点”“您的好友经常收藏这里”——用户对推荐的理解度提升了点击率自然上来了。这一点我在详情页推荐位加了一句推荐说明整体点击率提升了大约11%。这个项目后续如果继续演进有两个方向我觉得价值很大一是把用户行为数据接入流式计算做到“刚刚搜索完、首页立刻反映”这属于实时推荐体系二是加知识图谱把景区之间的关系从标签升级为“相邻/同主题/同类人群聚集/历史背景关联”这种更丰富的语义连接会让推荐结果的解释性更强。如果从零开始复刻这个项目我建议的顺序是先搭 Spring Boot 骨架、建好五张核心表再实现行为上报和基础 CRUD推荐算法先做热度榜把链路跑通随后并行加入协同过滤和内容召回融合排序和多样性机制放在最后调优。少数用户在算法上磨太久重蹈了我第一版的覆辙——花了两周写了一个效果还不如热度榜的推荐算法把用户行为分析和数据链路建设的时间给挤掉了。最后分享一个经验建议推荐系统的效果衡量不要只看“准确率”要看“用户在推荐位上的点击率”和“收藏转化率”。准确率高可能只是因为你推荐的都是热门景点用户不反感罢了。真正能验证推荐价值的是用户点了一个他原来根本不会主动搜的景点并且产生了正向反馈——那时候你才可以说这套推荐系统是真正在起作用。