
最近正好把一个“springboot基于WEB的旅游推荐系统”从设计到落地完整做了一遍整个过程踩了不少坑也总结了一些实战经验。这类项目在课程设计、毕业设计或者个人练手场景里非常常见但很多人容易把它做成一个“景点展示网站”推荐功能只是挂个名。这篇博文我会从整体设计思路、数据库设计、推荐算法落地、实测过程到问题排查完整拆解这个项目到底应该怎么做哪些环节是考核点哪些细节面试官或导师一看就知道你真正动手写过。项目编号里的11757没有特殊含义就是个标识咱们直接看技术本身。1. 项目整体设计与思路拆解1.1 核心需求解析它到底是什么系统从需求描述看它需要实现的是“基于WEB的旅游推荐系统”不是简单的景点信息管理后台。核心区别在于三点有用户体系有完整的注册登录、个人信息维护闭环有行为足迹用户浏览、收藏、评分、评论的行为要被记录和沉淀有推荐逻辑系统要根据用户的历史行为给用户推送“你可能感兴趣”的景点很多同学做这类项目最大的误区是花大量时间做景点CRUD增删改查推荐模块只是在页面写死几条数据。从评分角度看CRUD部分是基础分推荐算法才是真正的拉开差距的地方。哪怕算法逻辑不算复杂只要形成了“采集行为 → 计算相似度 → 生成推荐列表”的完整闭环这个项目的价值立刻就不一样了。1.2 技术选型为什么是Spring Boot 2.x Thymeleaf MyBatis-Plus技术栈选择上有必要单独说说因为这里决定了你后续的开发效率和升级空间。我用的这套组合是主流且稳妥的Spring Boot作为后端基础框架负责整合所有模块MyBatis-Plus做数据访问层分页查询和条件构造器能省不少代码前端用Thymeleaf做服务端渲染它和Spring Boot的整合度最高不用像Vue那样单独启动一个前端项目单机跑起来特别方便。为什么不选JSPSpring Boot官方已经不建议JSP内嵌Tomcat跑JSP要额外配置一堆东西Thymeleaf的语法更接近HTML原生标签学习成本也低。为什么不强制用Spring Cloud单机单体项目分布式的那套东西用不上硬上反而给自己添堵。数据库用MySQL 5.7或8.0都行我实测下来8.0的窗口函数在某些统计场景下更好用。提示如果你手头Java版本是17或更高建议用Spring Boot 2.7.x系列兼容性比2.5系更好也比Spring Boot 3.x少了很多Javax到Jakarta的迁移麻烦对大部分教程和资料更友好。1.3 推荐方案选型逻辑基于协同过滤还是内容推荐旅游推荐和电商推荐有个很大的不同点旅游决策是低频、高客单价的消费行为用户不可能像买纸巾一样频繁浏览景点。所以纯基于用户行为协同过滤会有冷启动问题——新用户没有行为数据推荐不出来。我的方案是“基于内容的推荐为主 简单协同过滤修正”具体来说基于内容提取景点的类型、所在城市、标签、热度建立特征向量计算用户历史偏好和景点特征的匹配度协同过滤修正根据收藏/评分数据找相似用户对内容推荐结果做加权这套思路不复杂但很符合旅游场景的实际业务逻辑。后面第3节我会给出具体计算过程。2. 数据库设计旅游推荐系统的数据根基2.1 关键数据表结构说明数据库表是整个系统的地基。我总共设计了8张核心表这里挑最关键的5张详细说明剩下的3张是基础字典表大家可以根据需要扩展。用户表travel_user不必多说字段就是常用的id、username、password、nickname、avatar。密码记得用MD5或者BCrypt加密存储明文密码在面试时会被追问到死。景点表travel_scenic是唯一信息源也是推荐计算的数据基础。我设置了以下核心字段字段名类型说明idbigint主键namevarchar(100)景点名称cityvarchar(50)所在城市typevarchar(50)景点类型如“自然风光”“历史文化”“主题乐园”pricedecimal(10,2)门票参考价格scoredecimal(3,1)综合评分页面展示用heatint热度值初始为0被浏览时加1tagsvarchar(255)标签多个用逗号分隔比如“亲子,避暑,摄影”descriptiontext详细描述cover_imagevarchar(255)封面图片路径收藏表travel_favorite记录用户的收藏行为字段包括id、user_id、scenic_id、create_time。建议加一个唯一约束(user_id, scenic_id)防止重复收藏。行为记录表travel_behavior是后期做推荐时的关键数据来源。设计得稍微多几个字段CREATE TABLE travel_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, behavior_type TINYINT COMMENT 1浏览 2收藏 3评分, score_value INT COMMENT 评分值1-5仅behavior_type3时有值, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_scenic (scenic_id) );这张表的设计思路很简单行为统一建表不做分表存储。浏览行为在用户点击详情时记录收藏在点击收藏时记录评分在提交评分时记录。所有行为都带时间戳后续做时间衰减加权时直接用create_time。2.2 表关系与查询路径规划表关系其实不复杂用户对景点是多对多关系中间的关联就是收藏表和评分表评分我设计在行为表里面评论是一对多一个景点可以有多条评论。在查询路径上最容易出现的性能问题就是“景点列表页的关联查询”。我踩过的一个坑是景点列表直接N1查询评论数和收藏数最后优化成用子查询或者冗余字段解决。景点表上加一个favorite_count冗余字段每次收藏和取消收藏时用事务同步更新这样列表页就不用反复统计了。-- 列表页推荐查询SQL示例避免N1 SELECT s.*, (SELECT COUNT(*) FROM travel_comment c WHERE c.scenic_id s.id) AS comment_count, u.nickname AS creator_name FROM travel_scenic s LEFT JOIN travel_user u ON s.creator_id u.id ORDER BY s.heat DESC LIMIT #{offset}, #{pageSize}记住一个核心原则读多写少的统计字段直接冗余在业务表上读少写多且实时性要求高的再做实时统计。旅游系统的景点收藏数、评论数都属于前者。3. 核心推荐模块的实现细节3.1 基于内容推荐的标签匹配算法这个模块是整个系统真正有技术含量的地方也是跟别人拉开差距的地方。核心思路给每个用户算一个“偏好向量”给每个景点算一个“特征向量”算它们的余弦相似度。余弦相似度不理解没关系你可以理解为两个向量在方向上的重合程度值越接近1说明越相似。用户偏好向量的计算方式如下找出该用户最近N条比如30条浏览、收藏、评分记录收藏记3分、评分记2分、浏览记1分把分数累加到对应的景点标签上比如用户收藏了“杭州西湖”标签是“自然风光,亲子,摄影”评过分“故宫”标签是“历史文化,建筑”给过“上海迪士尼”3条浏览标签是“主题乐园,亲子”最后统计每个标签的总分亲子4分、自然风光3分、摄影3分、历史文化2分、建筑2分、主题乐园3分归一化以后得到用户偏好向量景点特征向量是把景点表里的type和tags处理成one-hot格式然后拼成一个多维向量。最后用余弦相似度计算用户偏好和每个景点的相似度排序取TopN作为推荐列表。这一步用Java的HashMap和数组向量就能完成不需要额外引入Spark或者Mahout甚至不需要引入什么第三方推荐引擎完全可控、零额外部署。3.2 协同过滤部分基于用户相似度的修正逻辑内容推荐的问题是“只看标签容易推出一堆相似景点”比如用户喜欢自然风光就一直推荐山山水水缺少多样性。所以我加了一个简单协同过滤来修正结果。具体做法从收藏表中找出有过收藏行为的TopK个用户用余弦相似度算当前用户和他们的相似度相似度公式用“共同收藏的景点数量 / 两个人收藏总量乘积的平方根”俗称调整后的余弦相似度找出最相似的2~3个用户把这几个用户收藏过但当前用户没收藏过的景点找出来按相似度加权排序插入到推荐列表的中间位置协同过滤不必求全重点是逻辑闭环。实操心得 网上资料经常强调必须安装推荐引擎、写MapReduce、用Spark这完全是过度设计。中小型推荐场景中内存数组算几万条数据毫秒级就完成了。先用最朴素的方式跑通了推荐链路再去优化性能才是正路。3.3 处理冷启动新用户和新景点怎么办这块是项目里最容易暴露“纸上谈兵”的问题。新用户没有任何行为记录偏好向量是零向量余弦相似度算出来全是0怎么办我设计了三个层次第一层默认推荐。用户登录后首次访问首页没有行为记录时按热度倒序推荐全国热度Top10景点让用户先看到内容产生行为。第二层行为后的即时推荐。用户浏览某个景点详情后详情页旁边推荐该景点标签相似度最高的4个景点这属于基于物品的即时推荐能引导用户产生更多浏览行为。第三层定时任务补齐。我写了一个定时任务每天凌晨3点重新计算所有用户的偏好向量把计算结果同步到一张“用户推荐缓存表”里。这样用户第二天访问时首页能从缓存表秒开不需要实时跑算法。新景点的冷启动处理更简单景点上线后设置一个初始热度值比如50热度也会参与推荐排序。等用户行为积累到一定量热度值和标签就会慢慢参与计算自然过渡到正常推荐。3.4 推荐结果的缓存策略与查询时的用户体验推荐计算不复杂但反复查库也没必要。我设计了一张推荐缓存表字段包括user_id、scenic_id、recommend_type0为内容推荐1为协同修正、score、create_time。主键用(user_id, scenic_id)联合主键。用户访问首页推荐列表时先从缓存表取缓存表没数据就实时算算完后写缓存。为了保持新鲜度每次用户产生新的行为就把该用户的缓存记录删掉下次访问自动触发重算。这里有个很容易犯的错误每次删除后立刻触发重算如果用户连续点击多个景点会导致多次重算数据库压力变大。我的优化方案是加了一个简单的内存请求合并机制同一个用户1分钟内的重算请求只执行一次避免重复计算。代码不复杂用一个ConcurrentHashMap记录用户上次重算时间即可。Override public ListScenicVO recommendForUser(Long userId, int limit) { // 1. 尝试从缓存读取 ListScenicVO cached recommendCacheMapper.selectByUser(userId, limit); if (cached ! null cached.size() limit * 0.6) { return cached; } // 2. 缓存不足或没有重新计算 ListScenicVO result doRecommend(userId, limit); // 3. 覆盖写入缓存并删除过期记录 recommendCacheMapper.deleteByUser(userId); recommendCacheMapper.batchInsert(userId, result); return result; }4. 前端页面与交互设计参考4.1 页面架构和关键交互流程页面说多不多说少不少。我用Thymeleaf模板引擎整体页面结构是这样的首页/推荐页index.html展示推荐列表按推荐分数排序瀑布流布局景点列表页list.html支持按城市、类型筛选按热度、评分、价格排序景点详情页detail.html介绍、图片、评论区、相关推荐栏目个人中心profile.html我的收藏、我的评论、基本信息编辑登录注册页login.html/register.html表单校验前后端交互以传统的表单提交和局部Ajax请求为主。收藏按钮、评分提交、评论提交用了Ajax局部刷新不需要整页重新加载体验会好很多。像这样$.ajax({ url: /favorite/toggle, type: POST, data: {scenicId: scenicId}, success: function(result) { if (result.status success) { // 更新收藏状态和数量 $(#favBtn).text(result.favorited ? 已收藏 : 收藏); $(#favCount).text(result.favCount); } } });为什么推荐页用瀑布流布局因为旅游推荐天然适合“图片先行”的视觉呈现用户在瀑布流上浏览的意愿比传统列表高得多。首页推荐位上尽可能放高质量图片是提高点击率的低成本手段。4.2 热门搜索与榜单展示模块系统里还加了一个热门搜索模块记录用户在搜索框输入的每个关键词按月统计搜索次数展示前10个关键词做“热搜榜”。这个是加分彩蛋成本极低但让整个系统看上去很完整。在景点详情页底部还放置了“看过此景点的用户还看了什么”的推荐栏目这其实是协同过滤的一种变种表达。用户感知上会觉得“这个系统确实懂我”面试或答辩时也能聊出来你看这里用了基于物品的协同过滤逻辑。热搜榜的实现不用复杂框架一个简单的MySQL group by就能解决SELECT keyword, COUNT(*) AS cnt FROM travel_search_keyword WHERE create_time DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY keyword ORDER BY cnt DESC LIMIT 10;5. 实测与排坑实录5.1 推荐算法结果不理想时的调优路径第一次把系统跑通后推荐效果非常差劲基本全是重复相似的景点甚至出现“用户看过上海迪士尼推荐了上海野生动物园再推荐了上海海洋水族馆”这种令人哭笑不得的情况。排查后发现三个问题第一标签设计太粗。自然风光、历史文化这几个大分类区分度太低一个用户可能因为喜欢“水乡古镇”被贴了“自然风光”标签结果推荐的全是山岳景区。解决方案是引入更细粒度的标签体系古镇、海滨、山川、草原、雨林、亲子乐园、博物馆、ins风打卡地等。第二行为权重不合理。浏览1分、收藏3分、评分2分的问题在于浏览行为量远远大于收藏评分量导致浏览行为主导了偏好计算收藏评分反而被淹没。我把权重调整为收藏10分、评分5分、浏览1分效果立刻改善了很多。第三缺少时间衰减。用户上个月搜索过“滑雪”这个月还想看“滑雪”的概率已经大幅下降。我引入了时间衰减因子通过SQL日期函数算权重3.0/(1 TIMESTAMPDIFF(DAY, create_time, NOW()) / 30)最近7天的行为权重最高30天以上的几乎衰减为0。5.2 性能瓶颈与分页慢查询优化景点表数量到几千条时列表页的分页查询就出现了明显的卡顿。查了执行计划后发现是排序字段没走索引。解决方案是给相关字段加索引ALTER TABLE travel_scenic ADD INDEX idx_city_type (city, type); ALTER TABLE travel_scenic ADD INDEX idx_heat (heat DESC); ALTER TABLE travel_scenic ADD INDEX idx_score (score DESC);加完索引后列表页从800ms降到了几十毫秒效果立竿见影。另外景点列表页的分页查询我还做了一个前台缓存把热门筛选条件的查询结果缓存到本地内存5分钟极大减轻MySQL压力。用Spring Cache的Cacheable注解就能实现不需要引入Redis。5.3 容易出现的内存溢出与循环依赖问题系统中我踩过的另一个坑是循环依赖。定时任务计算推荐时直接依赖了推荐Service而推荐Service内部又调用行为Service行为Service反向依赖了推荐Service形成循环启动时直接报错。解决办法有两种构造器注入改成Lazy延迟加载治标把推荐计算中依赖的查询逻辑下沉到独立Mapper层打破Service层循环治本我建议用治本的方法把推荐需要的数据查询统一封装到新的推荐DAO组件中Service之间的依赖就清晰了。另外定时任务批量计算推荐时如果一次性加载所有用户很容易内存溢出。我采用分批处理每批500个用户计算完就写入缓存并释放引用实测大批量数据也不会有内存压力。6. 常见问题与排查技巧实录问题现象根本原因解决方式登录成功后刷新页面又跳回登录页Session会话超时或Cookie没有写入检查Spring Boot的server.servlet.session.timeout配置以及前端Axios是否携带Cookie收藏按钮反复点击数据错乱前端未做防重复提交在按钮点击后立即禁用同时在Controller层对重复收藏做唯一约束捕捉推荐结果全是一样的内容用户偏好向量没更新缓存过期策略失效检查行为记录表和用户偏好缓存表的时间戳删除缓存后重新触发计算图片加载缓慢或裂图Nginx静态资源路径配置错误统一走/upload/**路径映射到本地上传目录用Nginx处理静态图片加过期头搜索中文关键词乱码数据库连接未设置UTF-8JDBC URL追加characterEncodingutf8并核对数据库字符集定时任务不执行没配置EnableScheduling注解在启动类上添加EnableScheduling推荐算法计算缓慢每次实时计算没有缓存用推荐缓存表 定时任务批量预热除了上面表格里的问题还有一个最容易踩的坑数据库表字段用驼峰命名和MySQL的下划线命名混淆导致MyBatis-Plus的自动映射失效。建议全局开启map-underscore-to-camel-case: true并且字段名统一用下划线风格这个配置必须提前确认。另一个值得提的经验是Ajax请求中文乱码往往不是后端代码问题而是前端请求头没指定Content-Type: application/json;charsetUTF-8。我在评论提交接口上排查了很久最后发现是前端请求头少了charset加上后一切正常。我在实际开发过程中项目启动后推荐首页白屏的几率最高的原因不是代码逻辑问题而是静态资源CSS/JS路径配置失败。用Thymeleaf时静态资源路径必须用{/css/style.css}这种标准表达式不要写死绝对路径否则换了访问根路径就全部404。最后再分享一个扩展思路如果想让推荐效果继续提升不需要引入多复杂的架构只需要在行为记录里增加“停留时长”这个维度用前端setInterval统计用户在详情页的停留时间就能把推荐效果再提高一个档次。原因是停留时长往往能反映用户对景点的真实兴趣程度远比点击行为更能体现偏好。这一点很多公开的项目博客都没提到过。