ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于微服务的旅游推荐系统:协同过滤与数据可视化大屏实战

基于微服务的旅游推荐系统:协同过滤与数据可视化大屏实战 1. 项目概述与整体设计思路先说这个项目是干什么的一套面向旅游场景的个性化推荐系统用户登录后能看到“猜你喜欢”的景点推荐后台管理者能在可视化大屏上看到游客画像、热门景点排行、流量趋势这些核心指标。技术栈是标题里面那串——SpringBoot做业务服务SpringCloud做微服务治理Vue做前端页面协同过滤算法做推荐核心最终前台推荐加后台大屏分析一体从数据采集、行为建模、算法推荐到指标可视化是一条完整的链路。我做这个项目的初衷很简单就是发现在实际旅游平台里用户面对一堆景点列表往往不知道怎么选而运营人员想看数据却只能翻数据库表格。一套系统同时解决这两头的问题才叫“旅游数据分析与推荐系统”。这个项目适合谁参考呢准备做毕设的同学、刚接触微服务想搞一个真实场景的开发者、或者公司内部想搭一套推荐中台雏形的团队——都能从这个项目里找到可以直接落地的东西。说下整体设计思路。我的核心指导思想是“两个闭环”一是业务闭环用户从注册登录、浏览景点、点击/收藏/下单行为行为数据落到库里推荐引擎根据行为数据更新用户的个性化结果二是数据闭环行为数据被聚合分析后统计成热门景点、客流趋势、游客画像等大屏指标运营看了指标再去调整推荐策略或运营活动再影响用户行为。两个闭环互相咬合系统才真正“活”起来而不是一个冷冰冰的CRUD。技术选型上我做了几个重要取舍。第一注册中心和配置中心我选了Nacos而不是Eureka因为Nacos除了服务注册发现之外还带了配置中心能力项目里多服务的配置统一管理会方便很多第二服务间调用用OpenFeign而不是直接用RestTemplate写起来更符合接口化的直觉第三网关用SpringCloud Gateway路由、鉴权、跨域都在这一层解决。前端大屏用Vue3加ECharts 5图表渲染和交互都比较顺手。这些选型不是拍脑袋每一条后面都有踩坑经验撑着我会在对应的章节里详细说。2. 微服务架构拆分与服务规划2.1 服务怎么拆才不会一上来就翻车微服务最怕的就是“为了微服务而微服务”一上来拆十几个服务结果服务间调用绕成蜘蛛网部署一次想哭。我的经验是先按业务域画边界再看数据独立性最后才落服务。旅游推荐系统按用户、景点、行为、推荐、分析五个域来拆刚好是五六个服务的规模既体现了微服务的协作方式又不至于过度设计。五个核心服务分别是user-service负责注册登录、用户画像标签scenic-service维护景点基础信息、景区详情、分类标签behavior-service负责收集用户浏览、点击、收藏、下单等行为数据recommend-service跑协同过滤算法产出TopN推荐列表analysis-service定时聚合行为数据输出大屏需要的统计指标。再加上一个gateway网关做统一入口一个Nacos Server做服务注册与配置中心。这里有个关键设计经验行为数据一定要单独拆一个服务出来。因为后面的推荐服务和分析服务都要依赖行为数据如果行为数据跟着别的服务走会导致服务间大量的跨库调用。拆成独立服务之后用户行为这个核心资产就掌握了独立入口推荐服务只要通过接口或者消息队列拿数据就行各自的数据边界非常清晰。2.2 服务治理基础设施与数据存储规划微服务不是把服务拆了就完事治理才是大头。Nacos作为注册中心所有服务启动时注册到Nacos消费者通过服务名调用不直接写死IP端口。我在实际配置里把Nacos的namespace按环境拆了dev和prod各一个命名空间避免开发环境注册的服务把生产环境搞混。这个坑我踩过——spring.cloud.nacos.discovery.namespace千万要写对不写的话默认走public空间服务间互相能发现还好一旦有同名服务调用直接乱套。数据存储这块我分了三层。MySQL存业务主数据用户表、景点表、订单表、评论表。Redis做缓存和临时的用户行为缓冲比如用户最近浏览的景点ID列表就存在Redis里“recent:view:{userId}”的key下面这样推荐服务拿行为特征时不用频繁打MySQL。还有一个独立的统计分析库用定时任务从行为表里聚合出宽表数据大屏接口直接查宽表而不是每次都跑全量SUM和COUNT这一步对大屏性能提升非常关键。每个微服务单独连接自己的数据库服务之间不共享库这是微服务数据独立性的铁律。比如behavior-service只能访问行为库不能直接连用户库需要用户维度信息时通过OpenFeign接口去user-service拿。这样做的代价是接口调用变多了但换来的是服务的独立演进、独立扩容能力——比如大促时behavior-service压力大我可以单独给它加两个实例其他服务不受影响。2.3 服务间通信与分布式事务取舍服务间通信我用的OpenFeign同步调用加异步解耦双轨制。核心链路比如用户下单需要实时扣库存、更新订单状态这种用OpenFeign同步调用等待结果返回但用户浏览了某个景点这个行为就没必要同步等待直接发一条消息到RabbitMQbehavior-service异步消费落库。主流程和辅助流程分开处理系统响应速度明显更稳。关于分布式事务我只分享一句实在话旅游推荐系统这个场景别轻易上Seata之类的分布式事务框架。多数操作都是最终一致性可以接受的。比如用户收藏了一个景点收藏服务和用户积分服务之间偶尔不一致对业务影响很小定时任务补偿一下就行。如果硬要做强一致不但性能损耗大排查问题也特别费劲。把需要强一致的边界好好设计一下把跨服务调用的复杂度尽量降到单服务内部事务里这才是务实的做法。3. 协同过滤推荐算法怎么落地到微服务里3.1 UserCF和ItemCF到底选哪个协同过滤就两大流派基于用户的协同过滤UserCF和基于物品的协同过滤ItemCF。UserCF的思路是“物以类聚人以群分”——找到和你行为相似的一批用户他们喜欢什么就推荐给你适合用户量不是特别大、时效性要求高的场景ItemCF的思路是“猜你喜欢”——分析用户历史喜欢的物品找和这些物品相似的物品来推荐适合物品数相对少的精细化推荐场景。旅游景点这个场景我做了个很重要的判断用ItemCF做主体用UserCF做补充。为什么因为旅游景点的数量级通常远小于用户数量级一个城市也就几百个可推荐的景点计算物品相似度矩阵的代价比用户相似度矩阵低得多。而且用户的旅游兴趣相对稳定喜欢了山水风光短期内不大可能突然转向主题乐园ItemCF能给出“看过了西湖给你推荐乌镇”这类合理的结果。UserCF则用于冷启动阶段给新用户推荐同城市同龄人喜欢的景点先用规则顶上攒够行为数据后再切回ItemCF。实操代码我之前核心逻辑简化成了下面这段基于物品的同现矩阵计算相似度// 通过用户行为日志计算物品两两同现次数 MapLong, MapLong, Integer cooccurrenceMatrix new HashMap(); for (UserBehavior behavior : behaviorList) { ListLong itemIds behavior.getItemIds(); for (int i 0; i itemIds.size(); i) { for (int j i 1; j itemIds.size(); j) { Long a itemIds.get(i), b itemIds.get(j); cooccurrenceMatrix.computeIfAbsent(a, k - new HashMap()) .merge(b, 1, Integer::sum); cooccurrenceMatrix.computeIfAbsent(b, k - new HashMap()) .merge(a, 1, Integer::sum); } } }这段代码在数据量小的时候没问题行为数据一上来之后会有性能隐患。所以实际项目里同现矩阵的统计我是用离线任务做的在Spark任务里跑算完把相似度结果写回Redis推荐服务实时查询Redis这样在线服务的压力就小多了——这也是标题里“分布式协同过滤”落地的一个关键点矩阵计算的横向扩展交给了离线计算集群而不是让在线接口硬抗。3.2 相似度计算与评分预测的关键公式协同过滤绕不开相似度计算。我对物品相似度用的是余弦相似度的改进版在计算之前先做行为权重赋值用户对一个景点点击算1分收藏算3分下单算5分评论算4分浏览时长超过一定阈值额外加1分。行为加权非常重要如果不加权点击10次和下单1次在算法眼里一样重要推荐结果就会偏掉。公式在这里sim(a, b) 同时与a和b发生行为的用户加权评分之和 / 对a发生行为的用户评分模长 × 对b发生行为的用户评分模长。假如有用户对西湖点击了一次对乌镇收藏了一次权重就是1和3乘积3多个用户都这样求和后分母归一化就得到了相似度。我用了Apache Commons Math库做向量运算省去手写矩阵运算的麻烦代码可读性也更高。评分预测阶段我计算用户对未交互景点的预测分公式为pred(u, i) 用户u对所有已交互景点j的评分 × 景点j与景点i的相似度 的累加和 除以 相似度绝对值之和。这里有个细节必须说相似度分母除以的是绝对值之和不是简单和否则正负相似度互相抵消预测值就失真了。算完所有候选景点的预测分之后按分数取Top20再过滤掉用户已经去过的景点比如订单状态为“已完成”的景区剩下的就是最终推荐列表。3.3 冷启动和其他算法工程化问题冷启动是协同过滤最头疼的问题没有之一。新用户没有任何行为数据再NB的协同过滤也白搭新景点没有任何人看过也永远进不了相似度矩阵。我的解决方案是三段式混合推荐策略——冷启动期用规则推荐按城市热门度、季节适配度、价格区间做筛选比如给来杭州的新用户推荐西湖、灵隐寺、西溪湿地这类热门景点先让用户产生第一批行为成长期行为数少于15条用UserCF的宽松版放宽相似用户的匹配阈值找弱行为相似的同伴成熟期再切回ItemCF精细推荐。新景点问题我是在后端逻辑里加了个“探索机制”推荐列表里固定留一个位置给系统甄选的新景点或冷门优质景点虽然短期点击率低一点但换来的是推荐系统生态的长期健康。生产环境里千万不能只看线上点击率指标用户画像单调化是很隐蔽的陷阱。算法结果离线评估也不能少。我按天把用户的历史行为切成训练集和测试集用召回率和精确率来评估推荐质量。刚开始的版本召回率只有6%左右后来加上了行为加权、季节因子夏天不硬推滑雪场和多路召回融合之后召回率稳定在了18%上下。这个数字不算惊艳但对旅游这类低频消费场景已经够用——毕竟用户一年去不了几次景点样本稀疏本质是领域特征决定的算法能做的就是尽量抓住有条件响应的机会。4. 旅游数据分析与可视化大屏的完整实现4.1 大屏指标体系怎么设计可视化大屏最忌讳的是把一堆图表怼上去就完了指标之间没有逻辑关系领导看了等于没看。我设计大屏指标的核心逻辑是“两个视角看数据”流量视角看量——来了多少人、看了多少景点、产生了多少订单转化视角看质——从浏览到收藏的转化率、从收藏到下单的转化率、订单的平均客单价和复购率。两个视角合在一起才能回答“平台现在健康不健康”。按这个思路我最终定了 8 个核心指标布局左上角是核心KPI卡片今日访问量、今日订单量、客单价、转化率中间是中国地图展示景点热度分布右边是热门景点Top10柱状图底部左下是游客年龄分布饼图、中间是月度客流趋势折线图、右下是客源地Top10条形图。后来还加了一个游客画像雷达图从“亲子友好度、文化偏好、休闲偏好、刺激偏好、消费力”五个维度刻画当前来访游客的整体偏好结构这个雷达图是大屏上被问得最多的图因为运营看了能直接决定下个季度的主推方向。每个指标在代码里都对应一个聚合查询但底层不直接查明细表而是查提前算好的宽表。比如热门景点Top10每天凌晨由定时任务把前一天的订单和浏览数据汇总到scenic_daily_stats表里字段包括景点id、日期、访问量、订单量、收藏量、销售额这些。大屏接口只做简单的升序降序性能非常稳定。这套设计的关键认知是明细数据是流水宽表是报表大屏只认报表不认流水。4.2 大屏前端怎么用Vue加ECharts搭起来前端大屏我用的Vue3加ECharts5管理后台的框架用的Vue2加ElementUI为什么拆两个前端工程因为大屏页面追求的是全屏自适应、炫酷动效和极简交互而后台管理系统追求的是表单繁多、权限管理和功能密度两者放一个工程里代码会很难维护。所以我单独起了个vue-screen工程专门做大屏。大屏页面的核心是自适应布局。我用的方案是rem加vw/vh混搭设计稿按1920乘1080规格来根元素font-size结合屏幕宽度动态计算图表容器宽度按百分比布局。要注意一个深坑ECharts图表在容器从display:none切换成显示时经常出现图表宽度为0的问题必须显式调用chart.resize()方法。我在大屏页面的mounted钩子和窗口resize事件里都绑定了resize逻辑并且用了一个400毫秒的防抖函数实测下来平滑很多。图表的数据绑定流程是这样的每个图表组件在created钩子里请求analysis-service的接口拿到JSON数据后用echarts的setOption方法填充配置项。我封装了一个useScreenChart的组合式函数Composition API统一处理loading、错误重试、resize这些逻辑这样每个图表组件的代码量从150行降到了40行左右维护起来舒服多了。数据刷新我用了一个全局定时器每30秒重新拉取一次热点数据在setInterval里串行地调用各个图表组件暴露的refresh方法避免同时刷新导致后端瞬时压力加大。4.3 大屏数据接口的性能优化实践聚合查询的性能最直接的教训来自我刚开始的版本大屏8个图表每个图表独立查询订单表、行为表、景点表一条大屏请求把MySQL并发打到了十几个查询接口响应时间在2秒到5秒之间波动视觉上大屏在转圈。这个体验是完全不可接受的。优化方案分了三步走。第一步就是前面说的宽表预计算把每天的统计结果提前算好存入统计库大屏接口查询行数从百万级降到了几百行这步做完接口耗时已经稳定在1秒以内。第二步加了Redis缓存大屏接口的key设置为screen:overview:latest过期时间60秒配合30秒的前端定时刷新基本能保证每次请求都命中缓存。第三步是数据库层面做了索引优化宽表查询条件里的日期字段和景点id字段全部建了联合索引最频繁的“按日期范围查景点排行”查询直接走覆盖索引EXPLAIN结果从全表扫描变成了Using index。这波优化做完之后的实测数据接口平均耗时从3秒降到了280毫秒大屏页面从打开到全部图表渲染完成不到2秒。做数据分析系统的同学可以参考这个思路先看慢在哪再决定用预计算、缓存还是索引千万别一上来就上搜索引擎或者换列式数据库常规手段在绝大多数场景已经够用了。5. 从用户点击到大屏更新的全链路设计与部署5.1 一次完整推荐请求的技术链路拆解这个项目最值得讲清楚的其实是一条完整的请求链路——用户打开首页页面同时要拿到推荐列表和热点数据背后整整跨越了6个微服务。我把这条链路按时序拆解一下能帮读者把之前的分散知识点串起来。第一步用户请求经Nginx转发到SpringCloud Gateway网关网关做了统一鉴权校验token的有效性然后根据路径前缀把请求路由到对应的服务。第二步前端调recommend-service的/api/recommend接口带上用户id和城市参数recommend-service先从Redis缓存里查有没有该用户当天已经算好的推荐列表有就直接返回没有就走协同过滤算法计算再把结果写回Redis并设置过期时间。第三步recommend-service需要用户的近30天行为数据通过OpenFeign调用behavior-service的接口获取同时调用scenic-service获取候选景点的最新信息避免推荐一个已经暂停售票的景区。第四步算法算出Top20后还要过一次过滤规则排除已下单景点、排除距离过远的跨省景点、按季节做微调最终取前10个返回给前端。链路最后一步前端埋点脚本把用户到底看了哪些推荐位景点上报给behavior-service行为落库之后既进入推荐系统下一轮训练的素材又通过MQ异步同步给analysis-service做实时增量统计。大屏上的“实时访问量”指标就是这么来的。一条小小的点击最终驱动了推荐更新和数据分析两个方向的流转这就是系统“活”起来的原因。5.2 分布式部署要点和Docker化实践部署这块我用的方式是每个微服务打成独立Jar包再用Docker容器化运行配上docker-compose统一编排。先说明一点微服务部署和单体最大的区别是“依赖管理”——services之间有启动顺序的要求Nacos必须最先起来业务服务才能注册成功MySQL和Redis也需要提前就绪。我写了两个编排文件一个docker-compose-infra.yml管中间件MySQL、Redis、Nacos、RabbitMQ另一个docker-compose-app.yml管业务服务。分两个文件的好处是中间件不会因为业务服务重启而跟着重启省了很多等待时间。每个业务服务的Dockerfile我统一了基础镜像、端口暴露和启动命令比如recommend-service就是这样的逻辑FROM openjdk:11-jre-slim MAINTAINER yourname ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone COPY target/recommend-service-1.0.0.jar /app/app.jar ENTRYPOINT [java, -Xms512m, -Xmx512m, -jar, /app/app.jar]之所以统一用JRE镜像而不是JDK镜像是因为运行环境不需要编译器镜像体积能少150MB左右。每个服务容器都设置了资源限制我用的是memory: 512M和cpus: 1.0。内存限制尤其重要不然Java应用默认的堆大小策略可能在容器里申请超过限制的内存直接触发OOMKilled——这个坑我踩过看着容器日志没有报错却总是被杀掉查了半天才发现是内存超限导致内核杀进程。配置管理上我把每个环境的配置都放进了Nacos配置中心本地配置文件只保留spring.application.name、spring.cloud.nacos.server-addr和spring.profiles.active三个必须项。这样一来改配置不用重新打包镜像直接在Nacos控制台改然后发布一下就能生效对运维来说实在太方便了。但这里也有个必须注意的点Nacos配置中心里的数据Id和项目里的spring.application.name必须一致并且需要指定${spring.application.name}-${spring.profiles.active}.yaml这个格式的Data ID我一开始没注意命名规则导致服务启动后配置一直加载不上查了半天才发现是Data ID写成了默认值。6. 常见问题排查与避坑经验实录6.1 服务注册不上和调用直接失败很多第一次搭SpringCloud项目的同学都会遇到这个问题Nacos控制台能看到服务但服务间调用一直报连接拒绝或者找不到服务。我梳理了三个高频原因。第一个是Nacos客户端版本与服务端版本不匹配比如Nacos服务端是2.x但项目里引的是1.x客户端会出现注册成功但心跳续约失败的情况。第二个是网络问题服务部署在Docker容器里时容器到宿主机的Nacos地址要正确配置localhost在容器里指的是容器本身一定要改成宿主机的局域网IP。第三个是消费者调用时服务名大小写不一致OpenFeign的FeignClient(name scenic-service)和Nacos注册名必须完全一致连横线都不能差。排查这类问题的通用思路我建议这样先看Nacos控制台的服务列表确认服务在不在再看服务的健康状态和集群名对不对最后用curl命令在服务容器里测试Nacos的8848端口网络通不通。这样一步步做下来90%的问题能定位到。6.2 协同过滤接口响应太慢推荐接口最开始的实现是每次请求都实时算相似度矩阵和预测分一次请求跑了300多毫秒并发一高服务器就得打满。我把这个问题定位成“把离线工作塞进了在线链路”解决方案前面也讲过相似度矩阵改为凌晨定时任务离线计算并缓存到Redis推荐结果的候选集也提前算好缓存。上线后接口P99耗时从420毫秒降到了65毫秒这就叫“把算力前移把时间留在后头”。如果你是初学者我给一条非常实在的建议先把算法的结果打日志或者写入调试表人工校验推荐得合不合理再去做性能优化。很多时候你以为是性能问题查了很久发现是算法逻辑里的除零异常或者空指针在拖累接口。日志里多打几个关键步骤的时间点永远比猜来得快。6.3 大屏加载白屏和图表闪烁大屏白屏有两个高频原因。一个是路由模式问题我在开发环境用的history模式直接访问某个子路由刷新会404后来改成hash模式解决了部署体验稳定很多。另一个是ECharts在数据没回来时初始化图表区域还是空的渲染出来没有高度需要给容器设置一个明确的高度值比如calc(100vh - 40px)而不是裸的百分比否则图表可能渲染成一条线。图表闪烁的问题则几乎都是数据刷新策略导致的。我之前用setInterval同时刷新所有图表几秒钟一次结果页面在重新加载图表配置时出现了明显的闪烁。改成“数据更新但图表配置项打补丁”的方案后闪动完全消失。具体做法是使用setOption的第二个参数传notMerge: true并且只更新数据变化的series部分而不是重建整个option对象这个细节对大屏体验提升非常明显。下表是我在项目里总结的最常见的几个问题读者可以直接对照排查问题现象可能原因排查方法解决方式服务注册不上Nacos客户端服务端版本不匹配查看Nacos日志统一升级到2.x版本Feign调用超时默认超时时间1秒太短查看Feign日志调整ribbon连接/读取超时时间推荐结果全一样相似度矩阵没有差异化检查相似度是否被幂等化加入行为权重和季节因子大屏接口慢实时全表聚合查询EXPLAIN看执行计划宽表预计算加Redis缓存图表宽高为0容器隐藏后未resize浏览器F12看元素宽高显式调用chart.resize()配置加载不了Data ID命名不规范Nacos控制台查配置列表按应用名-环境.yaml命名6.4 几个值得单独强调的避坑点先说配置文件的坑。SpringBoot 2.6之后SpringCloud Gateway的CORS配置和SpringMVC的CORS配置不能共存否则会报The Access-Control-Allow-Origin header contains multiple values的错误。我当时前端页面明明配了跨域却一直调不通接口就是这个问题。解决方案是在网关层直接把CORS配置写进路由过滤器放弃了WebMvc的配置两边只留一边。再说日志的坑。微服务链路排查非常依赖日志上下文我在所有服务里统一集成了SpringCloud Sleuth用traceId串联起一次请求经过的所有服务日志。如果没有链路追踪一个请求报错了你得依次去翻网关日志、推荐服务日志、行为服务日志定位效率极低。集成Sleuth之后只要用一个traceId把日志全部摘出来非常方便。最后说缓存和数据库一致性这个经典问题。推荐列表缓存我采用了“先更新数据库再删缓存”的方案删除失败时用MQ做兜底删除。之所以不先删缓存再更新数据库是因为并发场景下先删缓存会导致缓存穿透数据库直接被打满。这个经验不仅适用于推荐系统所有Redis缓存业务场景都可以参考。7. 最后分享一点项目实操体会这个项目做完之后我对全栈推荐系统有了一个更实在的认知一个推荐系统能不能落地三分靠算法七分靠数据工程和服务治理。协同过滤本身并不神秘核心思路我在前面也讲透了真正的复杂度都藏在数据怎么采集、行为怎么清洗、矩阵怎么离线算、结果怎么高性能往外吐这些环节里。把数据和工程基础打扎实了算法才能发挥价值。给我印象最深的一个小经验是一定要给推荐结果做人工可解释性预留。我在recommend-service里每次生成推荐列表时都会记录推荐理由格式大致是“因为你近期浏览过西湖所以为你推荐乌镇”。这个字段上线后用户的点击率提升了将近5个百分点。用户看到一个推荐结果如果知道“为什么推荐给我”信任感会完全不同在个性化推荐里“解释”本身就是推荐效果的一部分强烈建议读者在项目里也加上这个设计。最后再分享一个小技巧也是我后来才养成的习惯所有定时任务和数据统计脚本都要有幂等性设计。比如离线统计宽表的任务重复跑一遍不能翻倍我在任务幂等键上用了“日期统计维度”的组合重复执行时先删后插保证结果正确。这种在工程细节上的偏执在项目上线之后会省掉非常多的麻烦。整个系统从需求拆解、微服务搭建、算法实现到大屏开发我在这个项目里踩过不少坑也积累了很多经验。如果你们也想做类似的旅游推荐系统或者对微服务落地方案、协同过滤实践、数据大屏实现有疑问欢迎在评论区和我有来有回地聊看到都会回。
返回列表