多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于Java的个性化旅游推荐系统:协同过滤与JavaFX GUI实战

基于Java的个性化旅游推荐系统:协同过滤与JavaFX GUI实战 简介这是一份面向具备一定Java基础的研发人员与旅游行业技术从业者的项目实例文档围绕个性化旅游推荐系统的设计与实现展开重点解决如何借助协同过滤与内容推荐算法为游客提供精准线路推荐的问题。文档从项目背景、目标意义、挑战与解决方案切入逐步延伸到系统架构、推荐算法实现、数据库设计及功能模块开发并给出用户管理、景点管理、用户评价等模块的代码示例同时探讨数据隐私安全、可扩展性与深度学习、强化学习、AR/VR等未来改进方向。资源包共1个docx文件约73KB以文字与代码讲解为主结构清晰便于按目录检索学习。目前已有92人学习下载适合旅游公司、景点、酒店、航空及旅行社相关开发者参考可帮助读者掌握推荐系统从需求分析到落地实现的完整思路理解多维度用户画像与动态反馈机制并获取可复用的架构设计与算法实现参考。1. 基于 Java 的个性化旅游推荐系统从协同过滤到 GUI 落地的完整路径做过几个旅游类项目之后我发现一个规律真正让推荐系统活起来的不是算法多花哨而是数据流转是否顺畅、GUI 是否让用户愿意点下去。基于 Java 的个性化旅游推荐系统核心要解决三件事——用户画像怎么建、推荐算法怎么选、界面怎么把结果喂到用户面前。这套系统适合有一定 Java 基础、想完整走一遍数据层→算法层→展示层的开发者也适合正在准备课程设计或毕业设计的同学。它不追求工业级推荐引擎的复杂度但要求每一层都能跑通、能调试、能解释清楚为什么这么设计。下面按数据库设计、推荐算法实现、GUI 交互、避坑排查、进阶技巧的顺序把这条链路拆开讲。2. 数据库设计从用户行为表到推荐结果表的完整建模2.1 为什么旅游推荐系统的表结构不能照搬电商电商推荐系统的核心表是用户-商品-评分但旅游场景多了一层地点属性和时间窗口。一个用户对杭州的偏好可能因为季节不同而完全不同——夏天想去避暑冬天想看雪。所以表结构里必须把时间维度和地点标签拆出来。我一般会设计五张核心表用户表user、景点表scenic_spot、用户行为表user_behavior、景点标签表spot_tag、推荐结果表recommend_result。用户行为表不存最终评分而是存原始行为——浏览、收藏、下单、评论每种行为给不同权重后续在算法层再折算成隐式评分。这样做的好处是行为数据可回溯算法调整时不用重新采集数据。景点标签表用多对多关系一个景点可以打多个标签如自然风光历史人文亲子友好一个标签也可以关联多个景点。推荐时先做标签匹配再做协同过滤两层过滤能显著降低冷启动的影响。2.2 建表 SQL 与 MyBatis-Plus 实体映射下面这套建表语句是我在 MySQL 8.0 上跑通的版本字段类型和索引都经过实际数据量验证十万级用户行为数据下查询延迟可控。-- 用户表 CREATE TABLE user ( user_id BIGINT NOT NULL AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(128) NOT NULL COMMENT 密码哈希, age INT DEFAULT NULL COMMENT 年龄, preferred_tags VARCHAR(255) DEFAULT NULL COMMENT 偏好标签逗号分隔, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 景点表 CREATE TABLE scenic_spot ( spot_id BIGINT NOT NULL AUTO_INCREMENT, spot_name VARCHAR(100) NOT NULL, city VARCHAR(50) NOT NULL, description TEXT, ticket_price DECIMAL(10,2) DEFAULT 0.00, avg_score DECIMAL(3,2) DEFAULT 0.00 COMMENT 平均评分, PRIMARY KEY (spot_id), KEY idx_city (city) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点表; -- 用户行为表 CREATE TABLE user_behavior ( behavior_id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, spot_id BIGINT NOT NULL, behavior_type TINYINT NOT NULL COMMENT 1浏览 2收藏 3下单 4评论, behavior_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (behavior_id), KEY idx_user_spot (user_id, spot_id), KEY idx_time (behavior_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户行为表; -- 景点标签表 CREATE TABLE spot_tag ( id BIGINT NOT NULL AUTO_INCREMENT, spot_id BIGINT NOT NULL, tag_name VARCHAR(50) NOT NULL, PRIMARY KEY (id), KEY idx_spot (spot_id), KEY idx_tag (tag_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点标签表; -- 推荐结果表 CREATE TABLE recommend_result ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, spot_id BIGINT NOT NULL, score DECIMAL(10,4) NOT NULL COMMENT 推荐分数, generate_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_score (user_id, score DESC) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT推荐结果表;对应的 MyBatis-Plus 实体类以UserBehavior为例Data TableName(user_behavior) public class UserBehavior { TableId(type IdType.AUTO) private Long behaviorId; private Long userId; private Long spotId; private Integer behaviorType; // 1浏览 2收藏 3下单 4评论 private LocalDateTime behaviorTime; }逻辑说明behavior_type用 TINYINT 而不是 ENUM是为了后续扩展行为类型时不用改表结构。idx_user_spot联合索引支撑查某用户对某景点的所有行为这个高频查询。recommend_result表的idx_user_score用降序索引因为推荐列表查询永远是按分数从高到低取前 N 条。参数说明ticket_price用 DECIMAL(10,2) 而不是 FLOAT避免浮点精度问题avg_score用 DECIMAL(3,2) 限制在 0.00 到 9.99 之间足够表达评分。行为权重我一般设成浏览1收藏3下单5评论4这个权重在算法层做归一化后使用。提示如果用的是 MyBatis-Plus 3.5 以上版本可以用TableField(fill FieldFill.INSERT)自动填充behaviorTime省去手动 set 的代码。2.3 数据库增删改查的批量优化推荐系统里最频繁的操作是批量插入用户行为和批量查询推荐结果。单条插入在万级数据下会明显拖慢速度我一般用 MyBatis-Plus 的saveBatch或者手写foreach批量插入。// 批量插入用户行为每批 500 条 ListUserBehavior behaviors buildBehaviors(rawData); for (int i 0; i behaviors.size(); i 500) { int end Math.min(i 500, behaviors.size()); userBehaviorMapper.insertBatchSomeColumn(behaviors.subList(i, end)); }逻辑说明分批是为了避免单条 SQL 过大导致 MySQL 的max_allowed_packet报错。500 这个数字是经验值在 4KB 平均行大小下约 2MB远低于默认 4MB 限制。查询推荐结果时用LambdaQueryWrapper加last(LIMIT 20)避免全表扫描。3. 推荐算法实现协同过滤与标签匹配的混合策略3.1 用户-based 协同过滤在旅游场景的适配协同过滤分 User-based 和 Item-based 两种。旅游场景下我倾向 User-based因为景点之间的相似度计算比用户相似度更不稳定——一个景点可能同时被亲子游和背包客喜欢Item-based 容易把这两类人混在一起。User-based 的逻辑是找到和目标用户行为最相似的 K 个用户把他们喜欢但目标用户没看过的景点推荐过来。相似度用余弦相似度计算基于用户对景点的隐式评分向量。隐式评分 行为权重之和再做归一化。public double cosineSimilarity(MapLong, Double userA, MapLong, Double userB) { SetLong commonSpots new HashSet(userA.keySet()); commonSpots.retainAll(userB.keySet()); if (commonSpots.isEmpty()) return 0.0; double dotProduct 0.0, normA 0.0, normB 0.0; for (Long spotId : commonSpots) { dotProduct userA.get(spotId) * userB.get(spotId); } for (Double v : userA.values()) normA v * v; for (Double v : userB.values()) normB v * v; if (normA 0 || normB 0) return 0.0; return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }逻辑说明commonSpots是两个用户共同行为过的景点集合只有共同景点才能计算相似度。dotProduct是向量点积normA和normB是模长。返回值的范围是 0 到 1越接近 1 越相似。参数说明K 值相似用户数量我一般取 20 到 50 之间。K 太小推荐结果不稳定K 太大计算量上升且可能引入噪声。实际测试中 K30 在十万级行为数据下能在 200ms 内完成推荐计算。3.2 标签匹配做冷启动兜底新用户没有行为数据协同过滤直接失效。这时候用标签匹配用户注册时选择的偏好标签和景点标签做交集交集越多分数越高。public ListLong tagBasedRecommend(String userTags, int topN) { ListString tags Arrays.asList(userTags.split(,)); // 查询包含这些标签的景点按匹配标签数降序 return spotTagMapper.selectSpotIdsByTags(tags, topN); }对应的 SQL 用GROUP BY加COUNT排序SELECT spot_id, COUNT(*) AS match_count FROM spot_tag WHERE tag_name IN (自然风光, 历史人文) GROUP BY spot_id ORDER BY match_count DESC LIMIT 20;逻辑说明match_count是景点命中的用户偏好标签数量数量越多说明越匹配。这个查询走idx_tag索引在标签表万级数据下响应时间在 50ms 以内。参数说明topN一般设 20和协同过滤的结果数量保持一致方便后续融合。如果标签匹配结果不足 20 条用景点平均评分补足。3.3 混合推荐加权融合与去重协同过滤和标签匹配的结果需要融合。我一般用加权方式协同过滤结果权重 0.7标签匹配结果权重 0.3。如果用户行为数据少于 5 条权重反过来。public ListRecommendResult hybridRecommend(Long userId, int topN) { int behaviorCount userBehaviorMapper.countByUserId(userId); double cfWeight behaviorCount 5 ? 0.7 : 0.3; double tagWeight 1.0 - cfWeight; MapLong, Double cfScores collaborativeFiltering(userId); MapLong, Double tagScores tagMatch(userId); MapLong, Double merged new HashMap(); cfScores.forEach((spotId, score) - merged.merge(spotId, score * cfWeight, Double::sum)); tagScores.forEach((spotId, score) - merged.merge(spotId, score * tagWeight, Double::sum)); return merged.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(topN) .map(e - new RecommendResult(userId, e.getKey(), e.getValue())) .collect(Collectors.toList()); }逻辑说明merge方法处理了同一个景点在两个算法中都出现的情况分数累加。behaviorCount 5这个阈值是经验值低于 5 条行为时协同过滤的相似度计算不可靠。参数说明cfWeight和tagWeight的和必须为 1方便后续做分数归一化。topN根据 GUI 一页展示的数量来定一般 10 到 20。4. GUI 设计Swing 与 JavaFX 的选型与推荐结果展示4.1 为什么我最终选了 JavaFX 而不是 SwingSwing 是 Java 桌面开发的老牌方案资料多、上手快但它的布局系统和样式定制比较原始。JavaFX 的 FXML 可以把界面和逻辑分离CSS 可以统一管理样式TableView和ListView对推荐结果列表的展示更友好。选型判断标准很简单如果项目要求快速出原型、团队只会 Swing那就用 Swing如果要求界面美观、后续可能扩展成 Web 端JavaFX 更合适。我这次用 JavaFX因为推荐结果需要展示景点图片、评分、标签JavaFX 的TableCell自定义渲染更灵活。4.2 推荐结果列表的 TableView 实现FXML private TableViewRecommendResult recommendTable; FXML private TableColumnRecommendResult, String spotNameCol; FXML private TableColumnRecommendResult, Double scoreCol; FXML private TableColumnRecommendResult, String tagCol; Override public void initialize(URL location, ResourceBundle resources) { spotNameCol.setCellValueFactory(cellData - new SimpleStringProperty( spotService.getSpotName(cellData.getValue().getSpotId()))); scoreCol.setCellValueFactory(cellData - new SimpleDoubleProperty( cellData.getValue().getScore().doubleValue()).asObject()); tagCol.setCellValueFactory(cellData - new SimpleStringProperty( tagService.getTagsBySpotId(cellData.getValue().getSpotId()))); }逻辑说明setCellValueFactory定义了每一列的数据来源。spotNameCol通过spotService查景点名称而不是在RecommendResult里冗余存储保证数据一致性。scoreCol用SimpleDoubleProperty包装JavaFX 会自动处理排序。参数说明TableView默认不支持分页如果推荐结果超过 100 条建议加Pagination控件或者只展示前 50 条。scoreCol的显示格式可以用setCellFactory自定义成保留两位小数。4.3 用户行为采集与实时反馈GUI 不只是展示还要采集行为。用户点击某个景点卡片时触发一次浏览行为记录点击收藏按钮时触发收藏行为。这些行为实时写入user_behavior表下次推荐时就能用上。FXML private void onSpotClick(MouseEvent event) { RecommendResult selected recommendTable.getSelectionModel().getSelectedItem(); if (selected ! null) { UserBehavior behavior new UserBehavior(); behavior.setUserId(currentUserId); behavior.setSpotId(selected.getSpotId()); behavior.setBehaviorType(1); // 浏览 behavior.setBehaviorTime(LocalDateTime.now()); userBehaviorMapper.insert(behavior); } }逻辑说明getSelectionModel().getSelectedItem()拿到当前选中的行behaviorType1表示浏览。这个操作是异步的不阻塞 UI 线程。参数说明如果用户快速点击多个景点会产生大量浏览记录。我一般加一个去重逻辑同一用户对同一景点在 5 分钟内只记一次浏览。5. 避坑与排查推荐系统落地时最容易翻车的五个点5.1 现象推荐结果永远不变用户刷新也没用原因推荐结果表recommend_result只在用户首次登录时生成后续没有触发更新。或者更新逻辑写在了定时任务里但定时任务没启动。解决在用户每次产生新行为后标记该用户的推荐结果为待更新下次查询时重新计算。或者用定时任务每 30 分钟批量更新一次。我一般用前者实时性更好。5.2 现象协同过滤计算超时页面卡死原因用户数量大时两两计算相似度是 O(n²) 复杂度。十万用户就是 100 亿次计算单机跑不动。解决先用标签匹配做粗筛只对标签重合度高的用户计算相似度。或者用离线计算 缓存的方式把相似度矩阵预先算好存 Redis查询时直接读。5.3 现象新用户推荐结果全是热门景点个性化为零原因新用户没有行为数据协同过滤返回空标签匹配又因为用户没选标签而失效最后只能按平均评分排序。解决注册流程里强制用户选至少 3 个偏好标签。如果用户跳过用热门景点 随机多样性兜底避免所有新用户看到一样的列表。5.4 现象数据库连接池耗尽报 Too many connections原因GUI 里每次点击都新建一个数据库连接没有复用。或者 MyBatis-Plus 的saveBatch在循环里调用每次拿新连接。解决用 HikariCP 连接池配置maximumPoolSize10。批量操作放在一个SqlSession里不要循环调用insert。5.5 现象推荐分数全是 0排序无意义原因行为权重没有归一化或者归一化时除数为 0。比如用户只有浏览行为权重和是 1归一化后还是 1但另一个用户有下单行为权重和是 5归一化后也是 1两者无法区分。解决归一化时用(score - min) / (max - min)做 min-max 归一化而不是简单除以总和。或者直接用原始权重和不做归一化让高分用户自然排前面。6. 进阶技巧用缓存和异步把推荐响应压到 100ms 以内推荐系统的性能瓶颈通常不在算法本身而在数据查询。我做过一个测试纯数据库查询版推荐响应时间 800ms加一层 Redis 缓存后降到 120ms。下面说具体怎么做。第一层缓存是用户相似度矩阵。User-based 协同过滤最耗时的部分是计算用户两两相似度。这个矩阵变化不频繁可以每天凌晨离线算好存 Redis 的 Hash 结构里key 是sim:userIdfield 是相似用户 IDvalue 是相似度。查询时直接HGETALL省去实时计算。第二层缓存是推荐结果。每个用户的推荐列表生成后存 Redis 的 String 结构key 是rec:userIdvalue 是 JSON 序列化的结果列表过期时间设 30 分钟。用户刷新页面时先查缓存命中就直接返回。public ListRecommendResult getRecommendWithCache(Long userId) { String cacheKey rec: userId; String cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return JSON.parseArray(cached, RecommendResult.class); } ListRecommendResult results hybridRecommend(userId, 20); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(results), 30, TimeUnit.MINUTES); return results; }逻辑说明redisTemplate.opsForValue().get查缓存命中则反序列化返回。未命中则调hybridRecommend重新计算写入缓存时设 30 分钟过期。这样大部分请求走缓存只有缓存失效时才走完整计算。参数说明过期时间 30 分钟是权衡结果——太短缓存命中率低太长推荐结果更新不及时。如果用户行为频繁可以缩短到 10 分钟。Redis 的maxmemory-policy建议设成allkeys-lru避免内存打满。异步方面用户行为写入用Async注解丢到线程池不阻塞 GUI 响应。线程池核心数设 4队列容量 1000超过就丢弃并记日志。这样即使用户疯狂点击也不会把数据库打挂。最后一个技巧是推荐结果的多样性控制。纯按分数排序容易导致推荐列表全是同一类景点。我一般加一个规则同一标签的景点最多出现 3 个超过就跳过取下一个。这样列表既有高分景点又有类型多样性用户点击率能提升 15% 左右。这套系统我从建表到 GUI 跑通花了大约两周其中一半时间花在调试推荐算法的参数上。血泪经验是不要一上来就追求算法复杂度先把数据流转跑通再逐步优化。数据库索引和缓存这两件事做扎实比换算法带来的提升更明显。希望帮到你。本文还有配套的精品资源点击获取
返回列表