做 Java 旅游推荐类项目的时候几乎所有人都会遇到同一个场景景点库里躺着几千条数据用户打开首页却不知道选哪个。个性化旅游景点推荐网站要解决的就是这种“选择焦虑”。这类 Java 实战项目最近特别常见核心其实就是用 SpringBoot 搭一个服务端平台把景点展示、用户偏好分析、个性化推荐、行程规划、门票预订这些环节串成一条完整的业务链路。它不只是一个简单的 CRUD 后台真正的价值在于“怎么根据用户行为把合适的景点放在合适的推荐位”。这篇文章我会从需求拆解开始把表结构、推荐打分逻辑、SpringBoot 核心接口、预订并发控制都讲一遍最后再分享一批我在实际项目里踩过的坑。如果你正在做 Java 方向的项目想接触 SpringBoot 综合开发或者对旅游推荐这类业务场景感兴趣下面这些内容基本可以拿来当参考。1. 项目概述与需求拆解一台“懂用户”的旅游服务平台要做什么1.1 个性化旅游推荐的业务痛点传统旅游平台的首页通常是一个“热门景点排行榜”加上搜索框。热门榜看着省事但有个致命的问题它是给所有人看的不是给“当前这个用户”看的。一个经常带娃出门的用户和一个喜欢打卡拍照的用户理想中的推荐列表应该是完全不同的。如果系统只按销量排序亲子用户大概率会反复看到酒吧街、极限运动这类不匹配的内容点了几次没兴趣用户就走了。个性化推荐网站要解决的问题就是把“景点的属性”和“用户的偏好”做一个动态匹配。落到业务上系统需要做三件事第一记录用户点击、收藏、下单等行为第二给每个景点打上可计算的标签第三用一套打分规则把行为数据和标签数据换算成一个推荐分最后按分数排序展示。这三个环节缺一个推荐就只是“伪个性化”本质上还是热门排序。这个项目里我经常把业务模块拆成四个部分用户端负责浏览和预订推荐引擎负责生成候选列表行程规划负责把景点排进具体某一天管理后台负责维护景点与标签。四个部分之间通过 SpringBoot 的 Restful 接口通信数据统一走 MySQL热点数据走 Redis。整体逻辑不复杂但每个部分都有不少细节。1.2 为什么技术选型锁定SpringBoot很多 Java 项目的老底子是 SSM也就是 Spring SpringMVC MyBatis。SSM 不是不能做但每次新建项目都要配一大堆 XML数据源、事务管理器、视图解析器、过滤器每一样都得手工声明。SpringBoot 把这些东西大部分收敛成了“自动配置”一个 spring-boot-starter-web 依赖拉进来写一个 main 方法就能启动 Web 服务。对旅游推荐这种业务链路比较长的项目开发效率会差很多。另外SpringBoot 的生态对中小型系统特别友好。拿数据持久层来说可以选 Spring Data JPA也可以选 MyBatis-Plus两者都能在 SpringBoot 里通过 starter 快速集成推荐结果要缓存直接 spring-boot-starter-data-redis接口要做登录校验可以用 Spring Security 或者写一个简单的 HandlerInterceptor。这种“要什么加什么”的方式非常贴合业务迭代节奏。从学习角度讲SpringBoot 也是 Java 岗位面试里绕不开的关键词。做这个项目时我建议不要停留在“能启动、能写接口”的层面而是把自动配置原理、Bean 生命周期、事务传播机制这些底层问题一并搞明白。项目本身是业务载体真正沉淀下来的是你对 SpringBoot 运行时机制的理解。1.3 功能模块规划先画清楚边界动手写代码之前先花半小时把功能模块拆出来比直接建表重要得多。我的习惯是画一张模块清单区分“用户端”和“管理端”再区分“核心流程”和“支撑流程”。用户端核心流程有四个用户注册登录手机号或邮箱 密码登录后签发 Token。景点浏览与搜索支持按城市、类型、关键词筛选。个性化推荐首页推荐、相似景点推荐、猜你喜欢。行程规划与预订选择日期和城市系统生成行程并对行程中的景点门票下单。管理端核心流程相对简单主要是景点信息维护、标签管理、订单管理、用户行为查询。支撑流程包括 Redis 缓存、定时任务清理失效数据、统一异常处理、接口参数校验。边界清楚了后面建表、写接口、做分页都会有方向。很多项目做到一半改来改去就是因为一开始没想清楚“推荐”和“搜索”到底是不是一回事。在这个系统里搜索是用户主动表达需求推荐是系统猜测用户需求前者用 SQL 过滤后者用打分排序两者不能混在一个逻辑里。2. 系统架构与核心数据模型设计2.1 分层架构从Controller到Mapper的职责划分SpringBoot 项目最常用的分层方式是 Controller、Service、Mapper、Entity、DTO。很多新手喜欢把业务逻辑写在 Controller 里接口一多就乱套。这个项目我推荐按下面这种结构组织controller只接收请求参数调用 Service把 DTO 转成 JSON 返回。service处理业务逻辑推荐算法、行程规划、下单事务都放在这一层。mapper操作数据库一个方法对应一条 SQL 或一个 MyBatis 映射。entity数据库表的映射实体字段和表结构保持一致。dto接口传输对象按前端需要裁剪字段避免把实体直接暴露出去。实体和 DTO 为什么要分开最典型的场景是推荐接口。景点实体里有创建时间、管理员 ID、上下架状态前端根本不需要。如果你直接把实体序列化返回不仅多传了一堆无用字段还可能因为实体里的懒加载关联对象触发序列化异常。这个坑我后面会详细说。2.2 数据库表设计推荐系统能不能跑靠的是这5张表旅游推荐系统的表结构并不复杂但设计时一定要围绕“标签”和“行为”这两个核心来建。我把最常用的几张表列出来表名作用核心字段user用户基本信息id, nickname, phone, password, register_timescenic景点信息id, name, city, address, cover_url, description, score, stocktag标签字典id, tag_name, tag_typescenic_tag景点与标签关联id, scenic_id, tag_iduser_behavior用户行为记录id, user_id, scenic_id, behavior_type, create_time其中scenic_tag是典型的多对多关联表用来打破景点与标签之间的“多对多”关系。user_behavior是整个推荐系统的数据基础每一次点击、收藏、下单都会往这张表里写一条记录。如果项目需要做更复杂的偏好分析也可以加上user_tag_pref表专门存储用户对某个标签的累计偏好分避免每次推荐都实时扫描行为表。在字段设计上有几个细节值得强调。第一scenic.stock必须设为非负并在 SQL 里加约束这是后面做库存扣减的基础。第二行为类型的字段不要用字符串乱写最好用数字枚举比如 1 浏览、2 收藏、3 下单。第三所有关联字段都要建索引尤其是user_behavior.user_id和scenic_tag.tag_id不然推荐接口一上线就是慢查询。2.3 用户行为与景点标签推荐系统的“原料仓”推荐系统能不能给出可信的结果取决于“原料”干不干净。这里的原料就是用户行为和景点标签。景点标签要尽量可控不要任由管理员随便乱填。我习惯在管理后台限制标签必须从字典里选择并且规定一个景点最多打 5 个标签。太少了信息量不足太多了会让相似度计算失去区分度。用户行为的记录要选对埋点位置。比如浏览行为应该记录在景点详情页的打开动作上而不是列表页的曝光。因为列表页可能一口气展示了 20 个景点用户只是扫了一眼并不能说明他对这些都感兴趣。收藏和下单行为更接近真实意图权重应该更高。行为数据会有很多噪音。比如用户误点了详情页刚进去就退出来这条浏览记录如果参与打分会把推荐带偏。我后来加了个简单过滤浏览详情页超过 10 秒才算有效行为。虽然不太精确但成本很低效果提升很明显。3. 个性化推荐机制的实现思路3.1 行为采集与权重定义推荐引擎的第一步是把原始行为转换成可计算的数值。我用的权重方案很简单有效浏览1 分收藏2 分加入行程3 分下单购买5 分为什么要这样设因为不同行为代表的心理热度完全不同。用户可能随便浏览十个景点但只会收藏两三个真正愿意下单的更少。如果一个景点能触发用户下单说明它和用户偏好的匹配度远高于“被划到了一眼”。这里没有标准答案你可以根据自己的业务场景调整但切记要保持“下单大于收藏、收藏大于浏览”的直觉逻辑。行为记录不需要实时写入推荐分。我一般是先写user_behavior表然后通过一个定时任务或者延迟队列每隔几分钟把新增行为批量折算进用户偏好缓存里。如果每次请求推荐接口都实时算原始行为数据库压力会非常大。3.2 基于偏好标签的用户画像打分用户画像打分的过程其实就是“把用户历史行为映射到景点标签再做加权求和”。我举一个具体的计算例子。假设用户最近有 4 条行为记录浏览了景点 AA 的标签是“亲子”、“公园”行为权重 1。收藏了景点 BB 的标签是“亲子”、“动物园”行为权重 2。浏览了景点 CC 的标签是“历史”、“博物馆”行为权重 1。下单了景点 DD 的标签是“历史”、“古建筑”行为权重 5。那么用户对“亲子”标签的偏好分 1 2 3对“历史”标签的偏好分 1 5 6对“公园”、“动物园”、“博物馆”、“古建筑”这些标签也分别按行为权重累加。这样得到的就是一个「标签 - 偏好分」的 Map。接下来给候选景点打分逻辑是景点 E 的标签是“历史”、“古建筑”景点 E 的推荐分 6 6 12。景点 F 的标签是“亲子”、“动物园”推荐分 3 2 5。最后按分数排序历史类景点排在前面。真实现实中还要对偏好分做归一化比如把最高分压到 1.0避免某个行为特别高频的用户把所有推荐结果都锁死在同一个标签上。归一化公式不复杂finalScore score / maxScore。如果你看到推荐结果里全是同类景点多半是漏了这一步。3.3 基于景点的物品相似度推荐除了按用户偏好打分我还喜欢加一路“基于物品相似度”的候选来源。原理是用户喜欢景点 X系统就去计算哪个景点和 X 的标签最像然后把相似景点也推荐给用户。这在推荐算法里叫 Item-based Collaborative Filtering。计算相似度最简单的方式是余弦相似度。每个景点可以表示成一个标签向量比如景点 X 的向量是[亲子:1, 公园:1, 摄影:1]景点 Y 的向量是[亲子:1, 公园:1, 徒步:1]两者重合的标签越多、向量夹角越小相似度越高。如果直接用 SQL 算太复杂可以做成内存计算每次景点标签变更时预计算好一份“相似景点 Top 10”列表存到 Redis 里用户请求时直接取。为什么不用更复杂的 User-based 协同过滤因为这个项目的用户规模没有那么理想“用户 A 和用户 B 相似所以把 B 看过的推荐给 A”这种逻辑在冷启动阶段很难生效而且实时计算用户间相似矩阵的成本很高。基于物品相似度更稳解释起来也更直观。3.4 冷启动与推荐结果兜底新用户没有历史行为新景点没有用户反馈这就是推荐系统里的冷启动问题。我的处理策略分三层新用户注册时让用户主动勾选兴趣标签比如亲子、历史、美食、徒步。这个动作能直接生成初始画像跳过“攒行为”的过程。用户没有做任何选择时推荐全局热门景点。热门不是单纯按浏览量排序而是按“浏览 收藏 下单”加权后的热度排序避免大量无效点击上榜。新景点上线后如果没有任何行为数据先按标签匹配进推荐流同时给一个基础加权分让运营可以手动“置顶推广”。兜底策略还要考虑一个细节用户已经下单或者明确不感兴趣的景点不应该再出现在推荐列表里。我每次推荐查询都会带上一个EXCLUDE_SCENIC_IDS参数从用户已购记录里取出来在 SQL 或内存里直接过滤掉。有些系统不做这步用户刚买完东西首页还在推同一个景点体验非常差。4. 基于SpringBoot的核心接口与业务实现4.1 推荐接口的设计与落地推荐接口我习惯设计成 GET 请求返回一个带推荐理由的列表。请求参数包括用户 ID、城市、条数上限比如GET /api/recommend/scenic?userId1city杭州limit10响应结构大致是这样的{ code: 0, data: [ { scenicId: 101, name: 西湖, score: 98.5, reason: 近30天你有3次历史类景点浏览记录 } ], total: 10 }推荐理由这个东西很容易被忽略但对用户体验影响很大。用户看到一个“为什么推荐给我”的说明会觉得系统是懂自己的而不是一个冰冷算法扔出来的结果。实现时我是在推荐打分过程中把“命中的标签”保留下来然后根据权重最高的标签生成一句模板文案。核心 Service 的逻辑可以这样理解public ListScenicRecommendVO recommend(Long userId, String city, int limit) { MapString, Double tagPref buildUserTagPreference(userId); if (tagPref.isEmpty()) { return listHotScenic(city, limit); } ListScenic candidates scenicMapper.selectByCity(city); ListScenicRecommendVO result new ArrayList(); for (Scenic scenic : candidates) { double score calcRecommendScore(scenic, tagPref); ScenicRecommendVO vo ScenicRecommendVO.from(scenic); vo.setScore(score); vo.setReason(buildReason(scenic, tagPref)); result.add(vo); } result.sort((a, b) - Double.compare(b.getScore(), a.getScore())); return result.stream().limit(limit).collect(Collectors.toList()); }这里有一个在实际开发中很重要的点不要把整个推荐逻辑一股脑塞进 Controller 或 Mapper。上面的buildUserTagPreference、calcRecommendScore、buildReason都是独立的私有方法方便单元测试。我后来把打分和理由生成拆成了独立策略类接其他推荐算法时只需要替换实现。4.2 行程规划把推荐结果排成一张能执行的时间表推荐出景点之后用户往往还有“帮我安排一下路线”的需求。行程规划模块的核心是把选中的多个景点分配到某一天尽量让路线顺路、时间不漏。我采用的方式是贪心排序先按景点经纬度计算用户指定出发点到各个景点的距离把距离近的排在前面再按“游玩时长 交通时长”累加单日总时长控制在 8 小时左右。比如上午 9 点出发景点 1 游玩 2 小时交通 0.5 小时下一个景点 11 点半开始中午预留吃饭时间下午接着排。如果当天排不下就把剩余景点顺延到第二天。SpringBoot 实现时行程单itinerary和行程明细itinerary_item是一对多关系。生成行程是一个事务性操作先插入主单再插入多个明细。如果某个景点已经下架或者库存不足整个生成事务回滚。我遇到过把Transactional加在私有方法上导致事务失效的情况所以这里要提醒一下Spring 的事务代理是基于接口或类代理的自调用不生效。事务注解要加在 public 方法上并且通过注入的 Service Bean 调用。4.3 门票预订与库存并发控制预订功能最考验细节。用户选好景点后要给该景点对应的门票库存扣减一条。表设计时我在scenic表里直接维护了一个stock字段。扣减时绝对不能先查库存再在 Java 代码里判断因为并发请求下两个线程可能同时读到相同的库存数然后一起执行减一导致超卖。一个简单的正确做法是使用数据库的条件更新UPDATE scenic SET stock stock - 1 WHERE id ? AND stock 0;如果后端收到的影响行数是 1说明扣减成功影响行数是 0说明库存已经为 0要返回“已售罄”的提示。这个方案在并发量不是特别高的时候完全够用。如果预期并发很大可以把库存先放到 Redis 里用 Lua 脚本保证“判断库存 扣减库存”原子执行。下单成功后再把订单写入 MySQL通过异步消息或者定时任务做最终一致性。这个项目里我出于简单考虑用了数据库条件更新加 Redis 预扣减的组合逻辑简单也足够稳定。4.4 后台管理与接口鉴权推荐系统不只是给用户看前端页面的管理员还需要维护景点、标签和订单。后台接口和用户端接口要隔离最直接的做法是分别放在/admin/**和/api/**路径下再用拦截器做权限控制。我用 SpringBoot 的HandlerInterceptor实现了 Token 校验。用户登录成功后服务端生成一个 UUID 作为 Token存到 Redis 里并设置过期时间。前端每次请求带上Authorization头拦截器里查一下 Redis 是否存在这个 Token。管理端则额外校验用户角色只有ADMIN角色能访问/admin/**。这个方案比直接用 Spring Security 简单也更容易理解。要注意的是Token 过期时间不要太长我通常设置成 7 天。管理后台操作要记录操作日志至少包括操作人、操作时间、操作内容。这些日志在排查推荐位数据异常时特别有用。5. 实操过程与高频问题排查5.1 从零搭建SpringBoot项目环境准备上我用的是 JDK 17、Maven 3.8、MySQL 8.0、Redis 6.2。创建 SpringBoot 项目时官方推荐的 Spring Initializr 已经很好用了。核心依赖只有这几个spring-boot-starter-web提供 Web 能力与内嵌 Tomcat。spring-boot-starter-validation做参数校验。mybatis-plus-spring-boot3-starter数据持久层。spring-boot-starter-data-redis缓存与 Token 存储。mysql-connector-jMySQL 驱动。依赖版本最好选一个稳定的正式版不要一味追求最新。最近很多项目启动报错都是因为把 SpringBoot 升级到了太高版本导致第三方的 starter 还没有适配启动时要么 ClassNotFoundException要么 Bean 循环依赖。做项目讲究“能跑优先”等业务稳定后再考虑升级。5.2 经典报错JPA懒加载导致JSON序列化失败如果你用 Spring Data JPA 而不是 MyBatis会经常遇到LazyInitializationException。原因是实体里的关联关系设置成fetch FetchType.LAZY后在事务结束、Session 关闭的情况下Jackson 序列化时还会尝试加载关联对象结果底层连接已经不可用。这个报错我见的太多了。解决方案有三种在实体关联字段上加JsonIgnore让序列化忽略关联对象。使用 DTO 对象不直接返回实体。查询时用EntityGraph把需要关联的字段一次性查出来。我个人最推荐第二种DTO。它不仅能解决懒加载问题还能避免把敏感字段比如用户密码一起返回前端。如果你用的是 MyBatis 或者 MyBatis-Plus可以配置合理的 resultMap 或者用扩展类思路是一样的。5.3 推荐结果不准、重复怎么办推荐结果不准先不要急着换算法八成是数据问题。我排查的顺序是用户行为是否成功写入很多“不准”其实是行为记录丢失导致画像为空。标签是否合理如果 A 景点同时打上“亲子”和“夜店”画像自然混乱。权重是否生效可以打印出用户标签偏好 Map看看历史分数是否和自己手工算的一致。是否有缓存如果 Redis 里缓存了旧推荐结果新行为可能需要一段时间才生效。推荐结果重复的问题多半是没有做联合去重。我的做法是最终推荐列表先放“用户偏好推荐”再去掉用户已经下单、收藏过的景点最后再按分数排序。如果还有重复检查是不是同一个景点的多个门票规格被当成了多条记录。5.4 性能优化接口从800ms降到120ms的小改动推荐接口早期上线时响应时间经常超过 800ms。为了低于 300ms我做了一次性能优化。先定位瓶颈发现每次推荐都要查用户行为表、查候选景点、再查每个景点的标签SQL 执行了几十条是典型的 N1 问题。优化方式是三层第一层用户行为查询改成只查最近 30 天数据避免扫描历史全表。第二层景点标签批量查出后放到内存 Map不再循环查单条。第三层推荐结果按城市和用户标签维度缓存到 Redis有效期 5 分钟。用户请求时直接命中缓存只有缓存过期才重新计算。改完之后接口平均耗时降到 120ms 左右。这里想强调的是性能优化不是炫技而是先把 SQL 和对象模型调顺再引入缓存。很多项目一上来就加 Redis结果缓存穿透、雪崩问题一大堆得不偿失。6. 常见问题速查表与我的经验总结6.1 高频问题速查表问题常见原因解决方式推荐接口返回很慢循环查数据库导致 N1批量查询减少循环数据库访问用户看到重复景点已购景点未过滤推荐查询前排除已购和已收藏 ID收藏功能一直报 500懒加载序列化异常返回 DTO不返回实体下单库存超卖先查库存再扣减使用UPDATE ... WHERE stock 0条件更新中文乱码数据库连接未指定 UTF-8在 JDBC URL 加characterEncodingutf8新用户没有推荐内容冷启动策略缺失注册时选择兴趣标签或返回热门榜Token 校验失效Redis 过期策略配置问题设置合理的过期时间并刷新 Token行程生成一半失败事务边界设置错误确保事务注解在 public Service 方法上6.2 项目落地时容易被忽略的5个细节第一个细节是接口参数校验。推荐接口的limit如果传 10000你的排序和内存计算都可能被打满。我会用Min、Max注解限制参数范围同时在 Service 里做二次防御。第二个细节是日志。推荐算法是“黑盒”如果没有日志出了问题基本无从查起。我习惯在关键节点打印结构化日志比如用户 ID、候选景点数量、最终推荐列表前 5 个 ID。这样用户反馈“推荐不准”时可以直接追到当时的输入输出。第三个细节是数据库索引。user_behavior表我只建了两个索引一个是user_id create_time一个是scenic_id。这个字段组合能覆盖 90% 的查询场景不要给每个字段都建索引否则写入性能会明显下降。第四个细节是配置文件的环境隔离。开发环境、测试环境、生产环境的数据库地址和 Redis 地址是不同的。用application-dev.yml、application-prod.yml分开管理启动时通过spring.profiles.active指定能避免很多线上事故。第五个细节是统一异常处理。我用RestControllerAdvice做了一个全局异常处理器把参数异常、业务异常、系统异常分别归类返回。前端只需要处理一个统一结构的错误 JSON不用每个接口写 try-catch。6.3 做完这个项目之后我想说几句实话这个项目让我最深的体会是推荐系统不是越复杂越好。很多架构文章会讲 TensorFlow、向量数据库但实际做业务系统时可能一张user_behavior表加一套标签打分规则就能解决大部分问题。与其盲目追新不如先把手里的行为数据和标签质量打磨好。另一个体会是SpringBoot 项目能不能做得顺很大程度上取决于数据模型。表结构设计清楚关联关系明朗后面写接口就是一层层翻译业务表结构混乱每写一个功能都像在补窟窿。如果你正在准备类似的项目我建议把六成时间花在需求和表设计上写代码反而是最快的一部分。最后再分享一个小技巧推荐接口上线后一定要留一个“人工干预”的口子。运营可以手动调整某个景点的基础权重、置顶或者下线。再智能的系统也需要业务兜底这个口子看着不起眼但能帮你和运营同学都省下大量沟通成本。做技术不是只会写代码能把业务顺畅地跑起来才是真正有价值的事。
阅读完成 · 觉得有帮助?