ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot旅游路线推荐系统实战:从推荐算法到数据库设计的完整方案

Spring Boot旅游路线推荐系统实战:从推荐算法到数据库设计的完整方案 做后端开发这些年接过的项目类型不少但旅游推荐这类需求总有一种特别的吸引力它不像纯粹的 CRUD 后台更像是在做一个“有判断能力”的产品。这次用 Spring Boot 做的山东济南旅游路线智能推荐规划系统核心就解决一个问题——游客到了济南怎么用最短的时间获得最适合自己的游玩路线。用户不再需要打开七八篇攻略来回比对也不再需要把景点一个个拖进地图看距离系统根据兴趣标签、游玩天数和出发地直接把一条经过排序的路线推到他面前。这个项目适合三类人参考。第一类是正在准备 Java 方向毕业设计的同学可以从这里看到推荐系统从数据库设计到接口实现的全过程第二类是刚接触 Spring Boot 的开发者可以学到一套比较规范的分层结构和配置方法第三类是产品经理或者旅游行业从业者想了解智能推荐是怎么结合业务落地的。全文按设计思路、技术选型、数据库建模、核心实现、问题排查五个部分展开里面不少细节是实际开发中踩过坑之后的经验总结不是文档里能直接查到的东西。1. 系统定位与总体设计思路1.1 济南旅游路线的真实痛点先说需求背景。济南的旅游景点分布呈明显的“老城区集中、周边分散”格局趵突泉、大明湖、芙蓉街、曲水亭街这些核心景点都在老城区范围用脚走或者骑共享单车就能串起来但千佛山、山东省博物馆、南部山区这些地方是要专门安排一整块时间过去的。游客做攻略时最大的麻烦不是不知道哪里好玩而是不知道怎么把不同类型的景点组合成一条顺路的路线。我在需求阶段做过一轮比较粗的调研发现用户吐槽最多的三类问题路线太绕景点顺序不合理导致同一个区域反复经过时间预估不准低估了趵突泉这类热门景点的排队时间行程过于饱和一天安排了八九个景点最后实际完成不到一半。基于这些反馈我把系统的核心目标定为在用户给定的天数、出发位置和兴趣标签下输出一条时间合理、地理顺路、节奏舒服的路线。“顺路”和“舒服”这两个词听着抽象落到代码里就是一组约束条件——同一天内的景点之间平均移动时间不能超过一定阈值热门景点的停留时长单独配置每天都保留至少一餐的休息时间。提示做这类系统千万不要把需求只停留在“展示景点列表”上真正值钱的部分是路线组合和排序逻辑。游客要的是“我今天应该先去哪再去哪”不是一份静态的景点手册。1.2 Spring Boot 作为项目基座的三个理由在技术选型阶段我确实也考虑过用 Python Flask 或者 Node.js 来做推荐服务毕竟推荐算法在 Python 生态里的库更丰富。但整体项目最终还是定在 Spring Boot 上原因有三。第一系统不是只有推荐引擎还有后台管理、用户体系、景点信息维护、行为日志统计这些模块用 Spring Boot 统一承载可以让一套技术栈贯穿整个项目协作和维护成本最低。第二项目的并发量不大但数据一致性和事务性要求不低比如用户评分后要同步更新聚合表这个时候 Spring 的声明式事务和 MyBatis-Plus 的更新机制能发挥很大价值。第三部署和运维层面的工具链成熟打包成 jar 之后只要服务器装了 JDK 就能跑后续做 Docker 化也非常顺手。提示如果你只做一个纯算法演示 Demo单用 Python Flask 可以快速出效果但如果要落地成完整可交付的系统Spring Boot 仍然是性价比很高的选择。1.3 功能模块划分与整体流程整个系统拆成六个模块用户中心、景点管理、标签管理、推荐引擎、行为收集、后台管理。这六个模块里推荐引擎是核心其他模块都在为它服务。一次完整的推荐请求大致会经过这么几个步骤用户提交游玩参数先做参数校验和标签匹配推荐引擎从数据库加载景点数据和用户行为数据做一轮内容匹配计算得到候选景点列表随后进入路线规划阶段把候选景点按地理邻近度和时间可用性组装成完整路线最后把结果写入缓存、返回前端展示。整个过程对用户来说是秒级的因为计算量大的部分都做了缓存处理后面我会详细说这块的设计思路。功能模块的划分有一个关键原则推荐引擎必须独立于景点管理。景点信息是会频繁变化的而推荐算法依赖的数据快照和权重模型应当相对稳定。如果混在一起每次改景点信息都可能触发权重重算维护成本会非常高。2. 技术选型与系统架构剖析2.1 技术栈盘点Spring Boot 版本与依赖组合项目最终的依赖清单大概是这样的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependencySpring Boot 版本我选的是 2.7.18这是 2.x 分支比较后期的版本该修的 bug 大多修掉了稳定性有保障。如果你不是特别需要 Spring Framework 6 和 jakarta 命名空间带来的新特性选 2.7.18 比直接上 3.x 省很多兼容性上的折腾。尤其是项目里可能用到旧版数据库驱动、旧版第三方 SDK 时javax 和 jakarta 的切换非常让人头疼。我这边最初在本地顺手建了个 Spring Boot 3.2 的项目结果引入某个老版本 Excel 导出工具时直接报ClassNotFoundException: javax.servlet.*。后来老老实实退回 2.7.18把老工具包原样拉进来五分钟搞定。这个经验不算高大上但真的很实用。2.2 缓存方案Redis 在推荐计算中的角色Redis 在这个项目里绝对不是摆设。推荐引擎做实时计算时需要读取景点数据、聚合评分表、路线模板表和用户偏好如果每一次请求都去 MySQL 里查一遍数据库压力会非常大。我采用了多级缓存的思路来缓解。热门景点的基础信息放在 Redis 缓存里key 设计为attraction:info:{id}有效期一小时。推荐结果缓存放在recommend:route:{userId}:{days}:{tagIds}这种结构的 key 里有效期十分钟。行为数据异步写入 MySQL同步更新聚合表。这样设计的好处是推荐接口的响应基本控制在 100 毫秒以内。即便遇到用户短时间内反复请求数据库也不会被打穿。路线推荐结果的缓存时间我特意没有设太长因为推荐结果需要跟随热度值和用户最新行为动态变化十分钟是一个比较合理的平衡点。2.3 前端与接口交互方式Vue 与 RESTful 接口设计系统的用户端采用 Vue 3 Element Plus管理员端复用同一套后端接口通过路由权限做访问控制。前后端交互统一使用 RESTful JSONJWT 做登录态管理。没有选择 SSR 或服务端模板渲染是因为推荐类系统的前端交互比较重路线地图展示、标签筛选、时间轴组件都是典型的客户端场景前后端分离之后各自迭代效率会更高。关于接口设计有一个细节值得说返回推荐路线时我额外设计了一个route_summary字段它是纯文本摘要方便移动端首页直接展示。很多人会忽略这一点只返回结构化的 JSON结果前端为了渲染一个列表要拼很久的文本。后端多花一点心思前端体验会好很多。2.4 推荐算法选型混合策略的选择逻辑很多人在做推荐系统时容易进入一个误区觉得一定得用协同过滤、矩阵分解才算“智能推荐”。实际上算法选型完全取决于项目所处阶段和数据量级。这个系统上线初期几乎没有用户评分数据冷启动问题非常明显。如果强行只用协同过滤推荐结果会因为缺少相似用户数据而变成随机推荐。我采用的混合策略是这样第一层用基于内容的推荐计算用户兴趣标签和景点标签之间的匹配度第二层等用户行为数据积累到一定规模后用基于用户的协同过滤寻找相似偏好用户把他们去过的高评分景点补充进候选集。两层结果做加权融合权重系数可以在后台动态配置。这套方案开发复杂度不高效果却比单模型稳得多。注意不要迷信复杂算法。冷启动阶段一个带权重的内容匹配算法往往比模型训练出来的效果好得多。你首先要解决的是“有没有推荐”然后才轮得到“推荐得准不准”。3. 数据库设计与核心数据模型3.1 表结构设计静态信息与行为信息分离数据库设计是整个推荐系统里最容易被低估的部分。我的核心设计原则是把静态信息和动态行为信息分开建模。景点表attraction承载静态数据包括名称、坐标、开放时间、票价、简介、类型分类。行为表user_behavior_log记录每一次用户浏览、收藏、评分的流水。聚合表user_attraction_score存储用户对每个景点的最终评分值由流水表异步聚合而来。为什么需要聚合表因为协同过滤算法计算相似度时需要快速拿到“用户-景点评分矩阵”这个矩阵可以直接从聚合表一次性查出来。如果每次计算都去流水表现算百万级数据量会把查询拖垮。两张表之间通过 user_id 和 attraction_id 关联写入时用事务保证一致性读取时只依赖聚合表。3.2 标签体系与多对多关联景点和标签之间是多对多关系用户和偏好标签之间也是多对多关系。我建了两张关联表attraction_tag_rel和user_preference_rel。在用户偏好关联表里我特意加了一个 weight 字段用来表示用户对这个标签的重视程度。新增 weight 字段是一个容易被忽略但非常关键的细节。假设用户同时选择了“泉水文化”和“美食”两个标签如果没有权重系统会把它们当同等条件处理实际上用户可能更在意美食。这时候权重高的标签就应当在推荐计算里占更大比例。后台管理页面可以让用户通过滑杆设置权重前端交互成本很低对推荐效果的影响却很明显。3.3 关键 SQL 与索引设计推荐引擎里最核心的查询是“根据标签集合匹配候选景点”对应的 SQL 大致是SELECT a.id, a.name, a.location, a.type, a.hot_value, SUM(CASE WHEN tr.tag_id IN (1, 3, 5) THEN 1 ELSE 0 END) AS match_score FROM attraction a LEFT JOIN attraction_tag_rel tr ON a.id tr.attraction_id GROUP BY a.id, a.name, a.location, a.type, a.hot_value HAVING match_score 0 ORDER BY match_score DESC, a.hot_value DESC LIMIT 30;这种写法在数据量不大时性能没问题数据量到十万条以上后建议把标签关系单独建表并建索引或者引入搜索引擎来处理标签检索。索引方面行为流水表的查询基本都会带上 user_id 和 create_time所以这两个字段要建联合索引hot_value 这个排序字段也要建索引否则排序会走临时文件数据量上来后会成为性能瓶颈。4. 核心实现推荐引擎与路线生成全流程4.1 项目骨架与配置文件从初始化到多环境配置创建 Spring Boot 项目我用的 Spring Initializr选好 Maven、Java 8、Spring Boot 2.7.18然后手动添加前面提到的依赖。这里有一个小技巧在.gitignore里尽早把target/、*.class这些目录排除掉否则后面迭代多了仓库会非常乱。核心配置文件application.yml我拆成了三份application-dev.yml、application-prod.yml和主配置。主配置只放通用项环境相关配置单独放。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/jinan_travel?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto用${MYSQL_PASSWORD}这种环境变量占位方式可以避免把数据库密码写死在配置文件里部署时只需在服务器上配置对应的环境变量。map-underscore-to-camel-case一定要打开数据库字段的下划线命名会自动映射到 Java 的驼峰属性能省掉大量手动映射代码。开发环境建议把log-impl设为StdOutImpl这样可以在控制台直接看到 SQL 语句排查问题时非常直观生产环境记得关掉否则日志量太大会影响到性能。4.2 实体层与持久层实现MyBatis-Plus 的使用要点实体类这边用了 MyBatis-Plus 的注解来定义表映射比如景点实体Data TableName(attraction) public class Attraction { TableId(type IdType.AUTO) private Long id; private String name; private String location; private String openTime; private BigDecimal price; private String description; private Integer type; private Integer hotValue; }Mapper 层继承BaseMapperT之后单表的 CRUD 基本不用写 XML。复杂查询再在 XML 文件里维护。这里要特别提醒一点不要把复杂的推荐计算逻辑写进 SQLSQL 只负责取数计算放在 Service 层做。原因有两方面一是 SQL 里的计算逻辑很难做单元测试二是数据库资源非常宝贵能用应用层算完的内容尽量不要压到数据库上。4.3 推荐计算 Service 层四步完成核心推荐推荐引擎的核心是一个RecommendService类入口方法接收RecommendRequest对象返回推荐路线列表。整个方法做了四件事加载候选景点、计算内容匹配分、计算协同过滤补充分、融合权重。public ListRecommendedRoute recommend(RecommendRequest request) { // 1. 从缓存或数据库加载候选景点 ListAttraction candidates attractionService.listCandidates(request.getTagIds()); // 2. 基于内容的匹配打分标签重合度 * 0.6 热度 * 0.4 MapLong, Double contentScores candidates.stream() .collect(Collectors.toMap(Attraction::getId, a - contentScore(a, request.getTagIds()))); // 3. 协同过滤补充分找到相似用户推荐他们高评价的景点 MapLong, Double cfScores collaborativeFilterService .recommendBySimilarUsers(request.getUserId(), request.getTagIds()); // 4. 加权融合 MapLong, Double finalScores mergeScores(contentScores, cfScores, 0.6, 0.4); // 5. 排序并截取前 N 个景点 ListAttraction selected rankAndLimit(finalScores, 10); // 6. 交给路线规划器生成完整路线 return routePlanner.plan(selected, request); }这段代码省略了很多边界处理但核心思路都在。mergeScores方法里我用了简单加权求和同时做了一个去重处理——如果一个景点在内容推荐和协同过滤里都有较高得分就取两者加权后的值避免重复计算。权重系数 0.6 和 0.4 不是拍脑袋定的而是在小范围测试里调出来的。数据量少时协同过滤可信度低内容匹配权重要压高一点后期用户数据多了可以把协同过滤权重慢慢往上调。这两个参数放在后台配置表里运营人员可以随时调整。4.4 核心协同过滤算法实现与相似度计算基于用户的协同过滤实现分三步建立用户-景点评分矩阵、计算目标用户与其他用户之间的相似度、加权汇总相似用户的评分来预测目标用户对未游览景点的评分。public MapLong, Double recommendBySimilarUsers(Long userId, ListLong candidateTagIds) { // 1. 获取所有用户的评分记录按 userId 分组 MapLong, MapLong, Double userScores scoreService.getAllUserScores(); MapLong, Double similarity new HashMap(); MapLong, Double predictScores new HashMap(); MapLong, Double currentUserScores userScores.getOrDefault(userId, Collections.emptyMap()); // 2. 计算当前用户与其他用户的余弦相似度 for (Map.EntryLong, MapLong, Double entry : userScores.entrySet()) { if (entry.getKey().equals(userId)) { continue; } double sim cosineSimilarity(currentUserScores, entry.getValue()); similarity.put(entry.getKey(), sim); } // 3. 找出最相似的 k 个用户 ListMap.EntryLong, Double topK similarity.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(5) .collect(Collectors.toList()); // 4. 加权累加相似用户的评分 for (Map.EntryLong, Double entry : topK) { Long otherUserId entry.getKey(); double sim entry.getValue(); userScores.get(otherUserId).forEach((attractionId, score) - { if (!currentUserScores.containsKey(attractionId)) { predictScores.merge(attractionId, sim * score, Double::sum); } }); } return predictScores; } private double cosineSimilarity(MapLong, Double a, MapLong, Double b) { double dot 0, normA 0, normB 0; for (double v : a.values()) { normA v * v; } for (double v : b.values()) { normB v * v; } for (Map.EntryLong, Double entry : a.entrySet()) { if (b.containsKey(entry.getKey())) { dot entry.getValue() * b.get(entry.getKey()); } } if (normA 0 || normB 0) { return 0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); }这个余弦相似度实现比较朴素真实项目里还要考虑评分偏置和不同用户的评分习惯差异。比如有的用户打分普遍偏高有的用户打分普遍偏低直接算余弦相似度会导致结果失真。更稳妥的做法是先对评分做中心化或均值归一化再计算相似度。我在系统里加了评分均值偏移的修正逻辑代码量很少效果提升却很明显。4.5 路线规划器的实现把景点串成路线推荐出候选景点只是第一步更麻烦的是怎么把这些景点按合理顺序串起来。路线规划器解决三个子问题排序、分组、填充时间。排序的核心依据是地理位置。我先调用地图服务的驾车距离 API得到景点两两之间的移动时间矩阵然后用贪心策略从出发地开始每次选择距离当前位置最近且未访问的景点。这个算法简单有效虽然不保证全局最优但对旅游路线场景已经足够好——游客不会因为多走几百米就抱怨但会因为路线绕了一个大圈而崩溃。分组解决跨天路线问题。两天以上的行程需要把景点按区域划分比如第一天老城区第二天南部景区。我的分组逻辑是先用 KMeans 对景点坐标做简单分区再结合开放时间微调。KMeans 在这种小规模数据上很够用完全不需要引入重型方案。时间填充是最后一步。每个景点的建议停留时长放在景点类型表里常规景点一小时热门景点两到三小时再加上交通移动时间基本能排出一个合理时间轴。如果总时长超过当天可游玩时间就自动裁剪评分最低的景点。4.6 Controller 层与前端交互细节对外暴露的接口是POST /api/recommend/route。接口层做了两层处理第一层用Valid注解做参数校验防止天数为负数、标签 ID 不存在这类异常请求进入推荐引擎第二层用RestControllerAdvice做全局异常处理返回统一格式的错误信息。RestController RequestMapping(/api/recommend) public class RecommendController { Autowired private RecommendService recommendService; PostMapping(/route) public ResultRecommendResponse recommend(RequestBody Valid RecommendRequest request) { RecommendResponse response recommendService.recommend(request); return Result.ok(response); } }这里的ResultT是统一响应封装类包含 code、message、data 三个字段。前端不管成功还是失败都按同一结构解析不需要每个接口单独写错误判断。统一响应格式虽然是小设计但在实际联调中能省很多时间。4.7 行为收集与异步更新机制行为收集模块是推荐系统持续进化的数据来源。用户在查看景点详情、收藏路线、评分之后前端都会调用行为收集接口把行为数据写进流水表。写入动作使用Async异步执行避免阻塞主业务接口。Async(behaviorExecutor) public void collectBehavior(BehaviorLog log) { behaviorLogService.save(log); // 同步更新聚合表 upsertScore(log); }异步线程池我在配置类里用ThreadPoolTaskExecutor创建核心线程数 5、最大线程数 20、队列容量 200应对正常业务量绰绰有余。这里我踩过一个坑忘了在主启动类上开启EnableAsync导致异步方法同步执行接口响应时间一下子飙到两三百毫秒。排查了半天才发现是 Spring 的异步注解需要显式开启。这个细节很容易被忽略写出来提醒一下。5. 常见问题与排查技巧实录5.1 数据库连接时的时区问题这大概是 Spring Boot 项目里出现频率最高的问题之一。使用 MySQL 8 驱动时连接串如果没带serverTimezone参数直接报The server time zone value CST is unrecognized。解决方案就是在 JDBC 连接串上加serverTimezoneAsia/Shanghai同时把 MySQL 数据库自身时区也设置成北京时间避免两边时间不一致导致 Redis 缓存过期时间错乱。5.2 跨域配置导致前端请求被拦截前后端分离项目里跨域问题几乎绕不开。如果前端跑在 8081 端口后端在 8080 端口直接请求就会遇到 CORS 错误。最简单的方案是写一个配置类加全局 CORS 映射。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意一个细节allowedOrigins(*)和allowCredentials(true)不能同时使用浏览器会直接拒绝。要么改用allowedOriginPatterns(*)要么把具体的前端域名列出来只允许指定来源访问。5.3 MyBatis-Plus 分页插件不生效用了 MyBatis-Plus 之后很多人会发现分页查出来还是全量数据原因是分页拦截器没有注册。MyBatis-Plus 3.5.x 版本需要手动添加配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个配置类不写Page对象只会拿到全量数据分页字段完全不生效。我刚开始也在这里浪费了半天时间后来翻文档才意识到 3.5.x 已经不再自动注册分页插件。5.4 内存溢出与 JVM 参数调优推荐计算在数据量增大之后内存会明显吃紧。协同过滤需要把用户评分矩阵放进内存用户量到十万级别后矩阵会非常大。我的建议是分三步优化第一相似度计算加限制只取评分记录超过一定条数的高活跃用户参与计算第二定时任务在凌晨低峰期预计算相似度结果并写入 Redis第三如果还扛不住就要考虑向量数据库或离线批量计算。JVM 启动参数方面我这里给一个参考配置java -Xms512m -Xmx1024m -XX:UseG1GC -jar jinan-travel-server.jar-Xms512m指定起始堆内存-Xmx1024m指定最大堆内存。生产环境具体要多少还是取决于数据量建议先从 1G 堆开始持续观察 GC 日志和内存曲线再调整。最后再分享一个我在这个项目里比较深的体会做推荐系统算法永远只是其中一部分真正决定效果的反而往往是数据质量和工程细节。济南这个项目上线初期我在调协同过滤相似度阈值上花了很多时间后来发现把景点标签数据清理干净、把运营维护的热度值调准效果提升比调算法参数更明显。如果你也准备做类似的系统我的建议是先把基础数据做扎实再考虑算法优化。路线推荐本身是一个很有延展性的方向后续还能接入实时天气因素、节假日客流预测、用户实时定位微调行程等能力这些都会让整个系统越来越有实用价值。
返回列表