基于Spring Boot+Vue的旅游推荐系统实战:协同过滤、冷启动与工程落地
很多人把“旅游推荐系统”想成“管理系统 一个协同过滤算法包”觉得用户在界面点几下后端算个相似度把结果列在页面上就算交差了。但是真把一个基于 Spring Boot、Vue 和 AI 协同过滤算法的旅游推荐系统从零搭起来熬过需求梳理、数据库建模、算法调试、前端对接和上线前评测这一整条链路之后你会发现最难的根本不是“公式怎么推”而是三件事数据从哪来、推荐结果怎么让用户真正点得下去以及系统在数据和流量膨胀之后还能不能稳得住。这篇文章就是基于这样一个项目的完整复盘我怎么做需求拆解和数据建模怎么手写协同过滤、又为什么不在生产里直接用开源库冷启动阶段用了哪些土办法以及最后用四个指标和几次内测来判断“推荐效果到底行不行”。内容偏工程实践代码和配置都给到你适合正在做同类毕设、或者想在一个中小型项目里真正落地推荐系统的同学参考。1. 需求拆解先把“推荐什么”和“为什么推荐”想清楚1.1 旅游推荐和电商推荐不是一回事大多数推荐系统教程拿电商举例说用户买过 A系统就推荐 B。这个逻辑放到旅游场景里很容易翻车核心原因有三个。第一旅游行为是低频的。一个普通用户一年可能只在某平台下单两三趟行程但他在电商平台一天就能产生好几个行为。协同过滤依赖“用户行为矩阵”的质量低频意味着矩阵极度稀疏很多用户只有一两次浏览记录别说算相似度连“画像”都谈不上。第二旅游消费决策链条长。从“我想出去转转”到“定下目的地、排好行程、订住宿交通”中间隔着大量检索和前期的模糊诉求。用户今天搜了海边的酒店不代表他明天就想去海边他可能只是在比较预算。所以推荐系统不能只服务“马上就要下单”的决策末端还要在用户还处于“随便看看”的阶段就把真正符合他心意的内容顶到前面这是旅游推荐更偏“种草图”而不是“转化页”的原因。第三目的地受硬约束条件影响很大。你可以说“收藏了 A 连衣裙的人多半会喜欢 B 连衣裙”但你不能直接说“去过 A 景区的人一定喜欢 B 景区”。因为旅行计划受时间窗口、同行人、预算、出发地这些条件限制一个用户喜欢山野徒步但不代表他下个周末就能去他可能更想找一个城市周边适合带娃的公园。所以预热需求时我把项目目标重新定义为不是“猜用户最想去的那个地方”而是“在合适的场景里给他一份足够多样、有理由可以参考的候选清单”。前者对数据要求过高后者才符合当前阶段能拿到的数据规模。1.2 技术选型业务外壳、交互层和算法层各司其职技术栈定成 Spring Boot Vue不是因为它最“高级”而是因为这个组合在中小型项目里最好用、最容易凑齐开发资源。Spring Boot 负责业务外壳包括用户体系、景点管理、行为记录、推荐结果的分页与鉴权。为什么用它因为生态成熟Spring MVC 那套 REST 接口写起来快配合 MyBatis-Plus 做 CRUD 特别顺手后期加定时任务、拦截器、全局异常处理都有现成方案不必自己造轮子。我用的版本是 Spring Boot 2.7.xJDK 1.8MySQL 8.0Redis 6.x。Vue 负责管理端和用户端的交互层。管理端用 Vue 3 Element Plus主要做景点管理、用户行为报表、推荐效果可视化用户端用 Vue 3 Vite 构建推荐位、景点详情、行程收藏这些页面都走前后端分离的 REST 接口。Vue 的组件化很适合“推荐卡片”“热榜列表”这种复用频繁的模块改一版样式不用动后端逻辑。算法这块很多人喜欢直接引入某个推荐系统开源框架但我的建议是项目初期先别急着上框架手写在工程上更可控。原因很简单开源框架解决的是通用问题而旅游推荐项目里连“评分从哪来”都没定直接套框架只会带来一堆你用不上的配置复杂度。我先用 Python 快速验证了相似度计算的思路再移植成 Java 实现放到独立 service 模块里。后面上线后发现问题时我拿着 debug 日志直接修改核心逻辑心里完全有底。2. 数据建模与推荐接口设计协同过滤的“弹药库”2.1 用户、景点、行为三张表把推荐逻辑钉死推荐系统的地基是数据而数据仓库的核心又不复杂得知道“谁”“看了什么”“做了什么”。这里不能偷懒一张大宽表也许能撑起后台管理但撑不起推荐计算。用户表不用多说关键是字段设计要为推荐服务。除了常规的 id、用户名、头像我还加了pref_type旅行偏好类型比如海岛/人文/徒步/亲子、pref_budget预算档位和pref_pace节奏偏好休闲还是特种兵式。这些字段不参与协同过滤本身但它们是冷启动阶段最重要的“内容推荐”信号源。景点表是推荐系统的物品端字段设计直接影响内容召回质量。我建议至少包含景点名称、所在城市、景点类型、季节标签、适合人群、热度值、基础评分、封面图。这里最重要的不是基础评分而是“类型 季节 适合人群”这一组标签因为后面 ItemCF 的相似度计算太稀疏时内容标签可以兜底。行为表是整个系统最核心的一张表用来记录用户和景点之间发生的所有互动。我给了三个字段区分行为类型search、view、collect、order。这张表要记录 id、user_id、scenic_id、action_type、action_time 五个字段action_type 用一个小数字编码后续算隐式评分权重要用。CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, action_type TINYINT NOT NULL COMMENT 1搜索 2浏览 3收藏 4下单, action_time DATETIME NOT NULL, UNIQUE KEY uk_user_scenic_action (user_id, scenic_id, action_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里我踩过一个坑早期没有加唯一索引用户反复浏览同一个景点行为表里塞了几万条重复记录后算隐式评分时一个景点被同一个用户“刷”了几十分直接把推荐结果打崩。加了这个联合唯一索引之后重复行为会在写入层被温柔地挡住但 do nothing 会丢失时间信息后来我改成 ON DUPLICATE KEY UPDATE 更新最近时间。这个细节很重要别用暴力的“先查再插”那在大量行为写入时会有性能问题。2.2 推荐接口协议、降级和响应结构推荐接口不能像“字典查询”那样拍脑袋设计。因为任何推荐系统都不能保证 100% 召回好结果所以接口必须有降级方案用户端请求推荐时必须做到“接口永远不挂”。我的主推荐接口设计成这样GET /api/recommend/tourism/{userId}?size10strategyitemcf参数strategy支持 itemcf、usercf、coldstart、hotlist 四种用来标识当前这次请求使用哪条推荐逻辑。返回结构是统一包装的 Result 对象里面包含recommendList、strategy、reason三个核心字段。recommendList是推荐景点列表每项固定返回 scenicId、name、coverUrl、type、score、reason。strategy返回的是本次实际使用的推荐策略这个字段主要给我做日志分析比如我发现“coldstart 策略调用占比超过 20%”就知道冷启动问题还很严重要继续丰富种子数据。reason是推荐解释文案直接用于前端展示“为什么给你推这个”。降级链路我用了一张表来约束请求入口主策略备用策略兜底策略首页推荐位ItemCF 相似景点城市热门榜全国热度 Top20景点详情页ItemCF 相关景点同类型随机推荐城市热度榜用户收藏夹UserCF 相似用户收藏景点相似扩展全站热榜主策略缓存没命中、或者相似度矩阵还在构建期间就直接走备用策略和兜底策略。这段逻辑必须在网关层和 Service 层各做一层防御不能只在 Service 层做因为网关层负责跨服务调用万一推荐服务本身挂了网关层能直接返回一个静态热榜 JSON用户的页面不会白屏。3. 协同过滤算法落地手写一遍才能知道瓶颈在哪3.1 UserCF 与 ItemCF 怎么选协同过滤主流的两种思路UserCF基于用户的协同过滤看“和你相似的用户喜欢什么”ItemCF基于物品的协同过滤看你喜欢的东西和什么东西更像。绝大多数教程会给你放一张对比表但真正落到旅游项目里我的结论很明确以 ItemCF 为主UserCF 只做辅助。原因很简单旅游场景下用户之间的相似度太不可靠了。用户的旅行品味会随着时间、季节、预算、同行人变化但景点本身的属性相对稳定。而且 UserCF 的在线计算量随用户数增长当用户量到几万人的时候每一次请求都要实时为一个用户跑一遍邻居计算不缓存会直接拖垮接口。ItemCF 可以把“景点到景点的相似度”提前算好、存成一张静态矩阵线上做召回时只做查表延迟低很多。UserCF 不是没用它的价值在于发掘“意外惊喜”。ItemCF 的问题是会把人困在“同类茧房”里比如用户收藏了一个海岛ItemCF 后面连续推荐五个海岛而 UserCF 可能发现相似用户其实还喜欢某个古城。所以我在后排阶段会把两种结果做加权混合UserCF 的结果占 20%~30%。维度UserCFItemCF数据稀疏敏感度高用户行为太少时相似度算不准低景点之间的行为关联更容易聚集可解释性一般解释需要在“相似用户”这个抽象概念上做文章强“你看过 A所以推荐 B”非常自然实时性需要在线算用户邻居开销随用户数增长相似度矩阵离线算好线上查表多样性较好能跨类别带来惊喜容易趋同完全同类推荐旅游场景结论辅助使用主推3.2 相似度矩阵构建与预测评分实现手写实现的第一个重点是从行为数据里构造“用户–景点评分矩阵”。这里我用的是一个 Map 嵌套结构key 是 userIdvalue 是一个 MapscenicId, Double表示这个用户对每个景点打的分。协同过滤不关系业务字段它只认“用户、物品、分数”三元组。我先把 Python 探索版的代码贴出来因为用 Python 做原型验证比 Java 快很多尤其是矩阵运算。import math from collections import defaultdict def calc_item_similarity(user_item_dict): # user_item_dict: {user_id: {scenic_id: score}} item_score_sum defaultdict(float) item_occurs defaultdict(int) co_occur defaultdict(float) for user, items in user_item_dict.items(): for scenic_a, score_a in items.items(): item_score_sum[scenic_a] score_a item_occurs[scenic_a] 1 for scenic_b, score_b in items.items(): if scenic_a scenic_b: continue co_occur[(scenic_a, scenic_b)] score_a * score_b sim_matrix {} for (scenic_a, scenic_b), co_score in co_occur.items(): if scenic_a scenic_b: continue denom math.sqrt(item_score_sum[scenic_a] * item_score_sum[scenic_b]) if denom 0: continue sim_matrix[(scenic_a, scenic_b)] co_score / denom return sim_matrix这个代码的核心思想是如果两个景点经常被同一批用户打高分它们之间的共现加权分数就高再除以两个景点的总分模长做余弦归一化得到 [0,1] 之间的相似度。这一步跑完得到的是一个两两景点的相似度矩阵。线上服务的预测评分逻辑是这样的用户对某个景点 B 的预测分 该用户历史评分景点 A 的评分 × A 与 B 的相似度把所有这样的乘积累加再归一化。public Double predictScore(Long userId, Long targetScenicId, MapLong, MapLong, Double userItems, MapString, Double similarityMatrix) { MapLong, Double userRatings userItems.get(userId); if (userRatings null || userRatings.isEmpty()) { return null; } double sumScore 0.0; double sumSim 0.0; for (Map.EntryLong, Double entry : userRatings.entrySet()) { Long scenicId entry.getKey(); if (scenicId.equals(targetScenicId)) { continue; } Double sim similarityMatrix.get(scenicId - targetScenicId); if (sim null || sim 0.0001) { continue; } sumScore entry.getValue() * sim; sumSim sim; } if (sumSim 0) { return null; } return sumScore / sumSim; }我之所以强调手写是因为你在真实项目里会遇到文档里不会告诉你的事情比如预测分不能只算相似度加权平均还要跟景点本身的热度做线性融合否则相似度低但特别有名的新景点永远出不了头。这也是很多纯理论代码跑线上效果差的典型原因。3.3 离线计算、缓存和在线服务的衔接协同过滤在业务系统里不能做成“每次请求都现场算”。正确的做法是离线算好相似度矩阵存到 Redis线上只查缓存。我写了一个定时任务每天凌晨 2 点执行。流程是从 MySQL 捞最近 90 天的行为数据构建用户–景点评分矩阵算景点相似度把相似度矩阵和每个用户 TopN 推荐结果都写进 Redis。这个任务用 Spring 自带的 Scheduled 就行如果后期数据量大了换 XXL-Job 或者 Quartz 也不费劲。这里有一个非常重要的设计细节相似度矩阵不能所有 key 都用一个缓存命名空间。一开始我把整个矩阵序列化成一个 JSON 字符串存进一个 key600 个景点时 Redis 里一个 value 就有 3MB不仅写入慢查询时还要把 3MB 字符串反序列化单次接口延迟直接增加几十毫秒。后来改成按景点维度拆 key例如sim:scenic:123存的是景点 123 和其他所有景点的相似度数组查询时只取目标景点的那一行内存占用和查询时间都降了一个数量级。线上接口读缓存的逻辑简单直接先查用户的 TopN 推荐结果命中直接返回没命中就查相似度矩阵实时算一次 TopN再写回用户推荐缓存。Redis 里每个用户的推荐缓存过期时间我给了一天因为离线任务每天都会刷新结果太长会推旧数据太短又会击穿数据库。4. 冷启动与稀疏数据旅游场景最大的两块绊脚石4.1 新用户和新景点的启动策略新用户第一次进系统时行为表里一条数据都没有协同过滤根本算不了这时候如果硬上协同过滤返回的不是空列表就是全站热榜体验非常差。我的做法是两步走。第一步注册流程里强制让用户选三个偏好标签偏好类型、预算档位、出行节奏。这一步很关键很多系统知道要做但没把问题和景点标签体系对应好导致采集到的偏好数据是废的。我把景点表的标签字段设计成和注册问卷完全对齐比如用户选了“徒步”他第一次访问首页时召回逻辑直接按标签匹配景点再按热度排序。第二步是“城市级”兜底策略。旅游规划一定跟出发地和目的地强相关用户所在地是 G 市就直接把 G 市周边游、G 市飞热门航线目的地的攻略和景点先推给他。用户即便不填偏好问卷只要能拿到 IP 定位或注册城市这套策略就能顶上。新景点的冷启动我在前两个月做得并不好后来才意识到问题一个新景点刚录入系统没有用户给它产生行为相似度矩阵里它永远是零ItemCF 永远不会推荐它。解决办法是给它做“基于内容的初始相似度”把景点类型、季节标签、适合人群做编码跟已有景点做 Jaccard 相似度计算作为初始相似度注入矩阵。这样新景点不至于完全隐形等行为数据积累够了内容相似度再逐步让位给行为相似度。4.2 用隐式行为换算评分不能等用户“打分”很多推荐系统设计者会纠结“没有评分数据怎么办”但现实是旅游平台根本没有评分弹窗这种产品形态你也不可能强迫用户给每个景点打星星。答案就是用隐式反馈换算评分。我的做法是定一张行为权重表搜索算 1 分浏览算 0.6 分收藏算 2 分下单算 3 分。但这里有个关键细节不能只做简单累加要加“时间衰减”。用户半年前收藏过一个景点跟昨天刚刚搜索过同类景点这两个信号权重完全不一样。我用的递减公式是weight action_weight * Math.pow(0.9, daysFromNow / 7)也就是行为每过去一周权重按 0.9 倍衰减。实现起来很简单double weight actionWeight(actionType) * Math.pow(0.9, days / 7.0); ratingMap.merge(scenicId, weight, Double::sum);换算完成后再对每个用户的评分向量做一次标准化。这里我吃过亏如果不标准化活跃用户的行为特别多评分普遍偏高在计算相似度矩阵时评分低的冷门景点很容易被边缘化。标准化之后每个用户的评分都落在差不多的量纲上用户之间、景点之间的相似度计算才公平。4.3 热度阻尼与探索策略别让推荐列表永远是那几个网红点低频数据带来的另一个问题是我叫它“冷热不均”。热门景点的行为数据体量可能是普通景点的几十倍如果只按相似度排序那么推荐结果会被少数头部景点霸榜。后台报表里覆盖率指标一路走低的时候说明推荐系统已经退化成了“只有热门推荐”协同过滤的多样价值就没体现出来。我加了两道保险。第一道是热度阻尼推荐排序分不是“纯协同过滤评分”而是finalScore cfScore * (1 / (1 Math.log(1 hotValue)))热度阻尼打的是“什么概念”一个人气值 10000 的景点它的热度项经 log 压缩后只除以约 9.2人气值 100 的景点只除以约 5.6二者差距没那么悬殊了。第二道是探索策略在推荐结果里固定掺入 10%~15% 的随机但类型合理候选保证小众景点有露脸机会。这道策略刚开始看起来会让钱包指标短期略降但长期对用户留存是有帮助的否则你的系统永远在“讨好大多数人”最后所有人都觉得推荐无聊。5. 前端推荐位与交互从“有结果”到“用户觉得靠谱”5.1 三个推荐位的差异化设计后端把推荐算出来只是半个活前端的展示方式直接决定这些结果被不被认可。我在用户端一共设计了三个核心推荐位它们的侧重点完全不同。首页的“猜你喜欢”是大图卡片流一次展示 6~8 个景点每个卡片突出封面图、景点名、一句推荐理由。这个位置的目的是种草所以封面图质量要比信息密度重要。景点详情页的“相关推荐”用的是横向滑动小卡片展示 4~5 个相关景点突出“看过这个的人也看过”。行程计划的“智能补全”是让我花时间最多的位置它要在用户选好了几个景点之后推荐顺路、同城、同类型的景点来补全行程这里不能只看相似度还得把地理位置和游玩时长考虑进去我直接在后端重排阶段做了一版规则过滤。前端请求推荐时用了 axios统一封装了加载状态和空数据状态。空数据状态我今天特别强调很多前端同学只做“有数据”分支一返回空列表就白屏用户就会认为系统坏了。我的处理是空数据时默认展示兜底热榜并加一句“推荐正在学习你的偏好先看看热门”。5.2 推荐理由可解释性带来的真实点击变化推荐理由是最容易被忽略但性价比最高的模块。我第一版推荐卡片只展示“基于协同过滤推荐”用户根本不知道为什么是这几个目的地。后来我把后端返回的 reason 字段做成两种文案模板。ItemCF 的结果用“因为你收藏了 A所以想你也会喜欢相似的 B”A 取用户最近收藏且和 B 相似度最高的景点名。UserCF 的结果用“和你有相似旅行品味的人也去了 B”B 取相似用户中评分最高的景点。两种文案的生成逻辑都在后端算好前端只是原样渲染。我用 10 个人做了两轮内测第一轮没有理由第二轮有理由同样都是算法算出来的推荐列表有理由的那轮点击到详情的比例大约提高了 17%。这个数字不算严谨的 A/B 实验但足以说明旅游用户对“为什么推荐我”这件事是有付费意愿的他觉得推荐结果不是瞎猜才愿意点进去看细节。6. 评测指标与踩坑实录没有反馈闭环的推荐系统是“盲推”6.1 离线评测的四个指标和我的数据集划分方式做过推荐系统的人都知道没有评测就优化等于闭着眼睛开车。旅游推荐因为低转化、长决策线上流量又不大不可能像电商那样直接看 GMV 增量所以日常迭代主要靠离线指标加小范围人工评测。我把最近 90 天的行为数据按时间排序前 80% 作为训练集后 20% 作为测试集。训练集用来构建相似度矩阵测试集则拿用户的真实收藏行为来衡量推荐效果。四个指标我每次都跑精确率 Precision10推荐列表前 10 个里有多少出现在测试集的收藏列表里。精确率代表“推荐的东西用户确实喜欢”。召回率 Recall10测试集里用户收藏过的景点有多大比例被推荐进了前 10。召回率代表“用户喜欢的东西我们有没有漏推”。覆盖率 Coverage被推荐到的景点数量除以系统总景点数。覆盖率太低就是我前面说的热门霸榜问题。新颖度 Novelty推荐列表里景点的平均热度倒数度量“推荐结果是不是大家早都知道的”。当时我跑完首版算法看数据精确率 0.03召回率 0.06覆盖率只有 7%说白了就是“推荐了个寂寞”。后来加了隐式评分权重、时间衰减、热度阻尼之后这几项指标都有肉眼可见的提升调参方向终于有依据了。6.2 开发中遇到的三类顽固问题第一类问题预测评分系统性偏向高分。原因是我用隐式反馈换算的评分活跃度高的用户把收藏、下单都累加成很高的分算相似度时这个高评分把向量带偏了。解掉这个问题靠用户评分标准化实现很简单但排查的时候我花了两天时间看数据分布才发现。第二类问题Redis 缓存的大 key 和序列化开销。相似度矩阵全量 JSON 序列化塞进一个 key 是典型反面教材第一次上线后半夜缓存失效推荐接口全在打数据库数据库 CPU 直接飙红。改成按行拆分缓存 key 之后单次读取只反序列化几百个浮点数这个问题彻底消失。第三类问题日志链路里没有推荐详情。早期接口只记录了“推荐了哪些景点”没记录“用的是哪个策略、召回候选集多大、相似度阈值多少”。上线后用户反馈推荐不准我看日志完全不知道是算法问题还是数据问题。后来把 strategy、候选数量、过滤原因这些全打进了日志每个推荐响应里带了 traceId排查问题效率翻倍。6.3 性能调优从 80ms 到 30ms 的优化路线首版推荐接口在生产环境的平均响应时间约 80ms涵盖 Redis 缓存读取、相似度矩阵反序列化、候选集排序、前端渲染数据组装。对推荐场景来说不算慢但高峰期扛不住并发而且我觉得还有明显优化空间。第一刀砍在 Redis 序列化上。我原来用GenericJackson2JsonRedisSerializer存对象后来给相似度向量单独做了自定义序列化器用紧凑的字节编码替代 JSON 字符串相似度矩阵那一类 key 的读取时间直接降低了约 60%。第二刀是空结果缓存加布隆过滤器。有些用户确实行为太少直接返回空列表还每次都穿透到兜底查询我在网关层加了”已处理用户 ID“的布隆过滤器和对应空结果的临时缓存。第二次请求同一个冷启动用户直接返回兜底热榜不必再走一遍推荐流程。第三刀是把 TopN 结果和推荐理由一起缓存。既然离线任务每天都会把 TopN 算好那干脆连 reason 文案也一并生成并缓存线上接口基本退化成一次 Redis 查询加一次数据库回表拿景点详情。优化之后接口平均响应时间降到了约 30ms性能不再是被讨论的焦点。这套系统做完之后我又在脑海里把整个链路过了一遍最大的体会是推荐系统刚上线时效果差是常态关键不在于模型选得多花哨而在于数据地基稳不稳、兜底策略够不够、日志能不能回答“为什么推这个”。如果你也想复刻这样一个项目我建议把精力分配成 40% 数据清洗与行为埋点、30% 算法工程化、30% 前后端体验而不是一上来就钻进某个算法的数学细节里出不来。后续要扩展的话可以先做行程约束编码进重排再做向量化召回那又是一层完全不同的世界了。