ARTICLE DETAIL

资讯详情

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

全链路商品推荐系统:SpringCloud+Spark+Vue实践

全链路商品推荐系统:SpringCloud+Spark+Vue实践 那段时间我一直在调推荐接口的返回结果前端的商品卡片要么刷不出来要么推荐得毫无逻辑最后发现问题根本不在算法而在服务之间互相等待。后来我把这套基于SpringBoot、SpringCloud、Vue和大数据技术的商品推荐系统重新梳理了一遍从爬虫采集商品数据开始到Spark离线计算再到微服务治理和可视化大屏展示才真正把整条链路走通。如果你也在做类似的推荐系统不管是课程设计、毕业设计还是想在公司内部搭一个可演示的推荐项目这篇文章会非常有参考价值。我会把架构设计的思路、代码骨架、踩过的坑以及能直接复用的配置都写出来。1. 项目定位与整体架构拆解1.1 这个系统到底在解决什么问题商品推荐的本质是在海量商品和用户有限注意力之间做匹配。假设平台上架了几万件商品用户不可能一个个翻完他打开App的真实诉求是“我感兴趣的东西直接出现在我面前最好不用我费心筛选”。要让这件事发生需要的技术链路比想象中长得多用户点了一个商品、停留了几秒、加购了一件外套这些行为要被抓下来清洗干净送去计算生成候选集再通过接口返回到前端页面。任何一个环节出问题用户看到的就是空白页或者一堆不相关的商品。我见过很多人一开始就埋头写推荐算法代码写了一堆结果数据没有、接口没有、前端页面也对不上最后整个系统根本跑不起来。这个项目跟那种做法正好相反核心目标是先把链路打通。你可以把整条路理解成“采数—存数—算数—服务化—可视化”每一步我都做了相对完整的实现。最终交付的是一个能演示、能二次开发、也能写进简历的完整项目而不是一个只能跑通单测的算法脚本。1.2 微服务的拆分边界怎么画这个系统如果做成单体应用也不是不能跑但维护性会很差。爬虫在跑定时任务推荐服务在做矩阵计算前端调用的在线接口可能因为资源竞争慢到超时三者互相抢内存和CPU问题很难定位。所以我按业务域拆成了几个独立服务每个服务只管自己那一摊事。微服务核心职责主要数据依赖组件商品服务商品增删改查、分类管理、商品搜索MySQL、ElasticsearchSpringBoot、MyBatis-Plus用户服务注册登录、用户画像、行为记录MySQL、RedisSpringBoot、JWT推荐服务离线计算、相似度矩阵、推荐列表生成MySQL、Redis、HDFSSpark、SpringBoot采集服务爬虫调度、数据清洗、日志入库MySQLQuartz、HttpClient、Jsoup可视化服务大屏统计接口、报表聚合MySQL、RedisSpringBoot网关服务统一路由、鉴权、限流RedisSpring Cloud Gateway这个拆分方式不是拍脑袋定的核心依据是“数据边界”。商品数据归商品服务管用户行为归用户服务管推荐结果归推荐服务管谁也不许随便去动别人的表。这样做的好处非常直接采集服务某个时间段内爬虫任务特别重时不会拖垮推荐服务推荐服务在做Spark离线任务时也不会影响用户登录和商品查询。另外采集服务和推荐服务的拆分还有一个隐藏价值开发时可以并行推进。两个人同时改代码一个人碰爬虫一个人碰推荐基本不会冲突。这也是微服务的初衷之一不是为了服务数量好看而是为了隔离和演化。1.3 技术选型与组件清单技术选型上我尽量用主流且稳定的组合。SpringBoot负责业务接口SpringCloud Alibaba体系负责微服务治理Vue3Vite做前端Spark做离线计算ECharts做可视化大屏。下面这张表是最终定下来的组件清单。技术版本/组件用途后端框架SpringBoot 2.7.x各微服务基础框架微服务治理Spring Cloud Alibaba注册、配置、网关、熔断注册配置中心Nacos服务注册发现 配置管理网关Spring Cloud Gateway统一入口和路由转发服务调用OpenFeign服务间声明式调用限流熔断Sentinel接口限流与降级前端框架Vue3 Vite Element Plus管理后台与推荐页面数据可视化ECharts WebSocket大屏图表实时展示离线计算Spark 3.x协同过滤离线任务数据存储MySQL Redis Elasticsearch业务数据 / 缓存 / 搜索任务调度Quartz爬虫定时采集与推荐任务刷新Nacos和Eureka之间我选了Nacos理由很实际它一个组件同时干注册中心和配置中心两件事演示环境不需要维护两套中间件控制台能直接看服务健康状态对排查问题特别方便。网关选了Spring Cloud Gateway而不是Zuul主要原因是性能和响应式编程模型更贴合后续扩展需求不过如果团队对Zuul更熟这里不算硬性指标。这套选型整体下来不冷门资料多遇到问题能搜到解决方案也比那种“为了炫技上K8sServiceMesh”的工程更务实。数据量没到几十亿级之前这套组合完全够用。2. 爬虫模块商品数据采集与清洗2.1 采集目标与库表设计爬虫模块要解决的是“数据从哪来”。真实电商平台的数据不会开放给你所以项目里我采用的方式是采集公开的商品信息页面同时结合脚本生成模拟用户行为数据两者结合起来形成推荐系统需要的数据集。采集字段主要包括商品标题、价格、原价、销量、分类、商品图片、详情链接、入库时间。设计商品表时我特意把price和original_price分开存因为很多商品会显示划线价推荐和统计时需要区分。销量字段用于热度兜底分类字段用于类目推荐图片地址则直接存原始URL前端渲染时用懒加载避免首屏图片请求太多导致卡顿。用户行为表是推荐算法的核心输入字段包括user_id、item_id、behavior_type、timestamp。behavior_type我分成了浏览、收藏、加购、购买四类后续计算相似度时会给不同行为不同的权重。这张表是增量写入的每次用户点击商品就通过接口写入一条记录离线Spark任务再从这张表里拉数据计算。这里有个容易忽略的细节爬虫采集到的数据不能直接拿来当推荐数据源。商品数据有了但没有用户和商品之间的交互数据协同过滤算法是跑不起来的。所以项目里我写了一个行为日志生成脚本按照“少数热门商品被大量交互、长尾商品交互稀疏”的分布规律给模拟用户批量生成浏览和购买记录。这一步做完推荐算法才有东西可算。2.2 Java爬虫的代码骨架与解析细节爬虫我用Java实现核心技术是HttpClient Jsoup整体流程很直接构造HTTP请求、获取HTML页面、用CSS选择器解析出商品卡片、存入数据库。先看核心代码骨架。public class ProductCrawler { private static final String USER_AGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36; public void crawlCategory(String categoryUrl) { // 1. 构造请求并设置请求头模拟真实浏览器 HttpGet request new HttpGet(categoryUrl); request.setHeader(User-Agent, USER_AGENT); request.setHeader(Accept, text/html,application/xhtmlxml); // 2. 发送请求获取HTML String html execute(request); Document doc Jsoup.parse(html); // 3. 解析商品卡片 Elements cards doc.select(div.product-card); for (Element card : cards) { String title card.select(.title).text(); String priceStr card.select(.price).text(); double price parsePrice(priceStr); String salesStr card.select(.sales).text(); int sales parseSales(salesStr); String image card.select(img).attr(data-src); Product product new Product(); product.setTitle(title); product.setPrice(price); product.setSales(sales); product.setImageUrl(image); productService.save(product); } } }解析页面的关键不在于代码多复杂而在于怎么找准选择器。我每次都是先用浏览器开发者工具审查元素找到商品卡片的公共class再写选择器去匹配。正式跑批量任务之前一定要先抓一个页面验证选择器能正确解析否则可能整个目录几万条数据全存成同一个标题。爬虫任务我用Quartz做了定时调度比如每30分钟跑一次分类页。请求之间加随机休眠避免固定间隔触发对方频率检测。采集范围限定在公开页面并且只做学习验证用途不冲击目标网站的正常运行。这里多说一句爬虫代码写得再漂亮合规是底线不能为了数据量去绕过对方限制这个边界要守住。2.3 数据清洗与行为日志的生成爬虫抓回来的原始数据远没有想象中干净。价格字段可能是“99.9元起”销量可能是“已售1.2万”标题里可能混着空格和换行。如果这些脏数据直接入库后续推荐计算会出一堆幺蛾子。所以我加了一层清洗逻辑规则很朴素但很有效价格字符串统一去除非数字字符保留小数转成Double类型。销量“1.2万”先转成12000“500”去掉加号。标题去掉首尾空白、HTML标签长度超过200截断。图片缺失的用默认占位图避免前端显示破图。商品去重用“标题价格”做唯一指纹重复数据直接跳过。行为日志生成这块我写了一个模拟脚本按幂律分布生成用户行为。注意看这里推荐系统里有个著名的现象叫“头部效应”少数热门商品占了绝大多数交互。如果行为数据均匀分布算出来的推荐结果未必有说服力。脚本思路是先生成一批热门商品ID让大部分行为集中在这批商品上再给随机用户分配浏览、加购、购买行为时间戳随机分布在最近7天内。这样产出的数据集更接近真实场景跑推荐算法的效果也更可信。3. 推荐引擎从协同过滤到Spark离线计算3.1 为什么选协同过滤而不是深度学习推荐算法那么多为什么这个项目选协同过滤一句话在数据规模和项目阶段上它性价比最高。深度学习模型动辄要特征工程、Embedding、GPU训练演示项目很难玩转而且解释性很差——用户问“为什么给我推这个”你答不上来。协同过滤不一样它的逻辑是人话“和你相似的人喜欢什么我也推给你”或者“你喜欢的商品和另一个商品经常一起出现所以我把另一个也推给你”。基于用户的协同过滤适合用户数量比较小的场景基于物品的协同过滤适合商品数量相对稳定、用户行为持续增长的场景。电商平台明显属于后者用户不断增长商品相对固定。所以我最终采用基于物品的协同过滤算法算的是商品与商品之间的相似度线上推荐时只要找到用户最近交互过的商品再找这些商品的相似商品就能组装出推荐列表。计算相似度我用的是余弦相似度。核心思路是把每个商品看作一个向量向量维度是所有用户向量值是用户与该商品的交互强度两个商品越相似它们共同被交互过的用户就越多余弦夹角就越小。这一步在Spark里做可以轻松扩展到几万个商品和几百万条行为数据。3.2 离线计算在线召回Spark任务怎么设计离线任务负责计算商品相似度和生成推荐列表在线接口负责把算好的结果快速返回给前端。两者分开既能保证推荐列表实时响应又不用每次请求都去跑一遍矩阵运算。Spark离线任务的流程大致如下从用户行为表读取行为数据过滤掉异常和测试数据。将浏览、收藏、加购、购买四类行为映射为不同权重购买权重最高。构建“用户-商品”交互矩阵统计每个商品被交互过的用户集合。计算商品之间的余弦相似度过滤低于阈值的组合。针对用户最近交互过的N个商品召回相似商品聚合排序。取每个用户TopN结果写入Redis或MySQL推荐结果表。在线推荐接口的逻辑则简单得多先根据userId从缓存里取推荐列表缓存没有就把商品热度榜将回来同时异步触发一次离线任务刷新。这套“缓存优先、兜底降级”的设计保证了接口即使在新用户没有历史行为时也有数据返回。很多人担心协同过滤的大矩阵算不动实际上热门商品截断就能解决大部分问题。我们不需要计算所有商品两两相似度只需要关注用户交互过的那几万件商品。Spark任务在跑之前加一个过滤条件比如“只保留交互次数超过5次的商品”矩阵规模会小很多计算时间从小时级降到分钟级。3.3 推荐效果评估与调参心得代码跑通不是终点推荐好不好用还得量化。我采用的方式是把行为数据按时间切分前7天做训练集最后一天做测试集然后看测试集里用户真正交互的商品有没有出现在我们生成的推荐列表里。三个关键指标分别是准确率、召回率和覆盖率。准确率理解起来最简单推荐列表里的商品有多少是用户真实交互过的。召回率则看用户交互过的商品里有多少被推荐出来了。覆盖率代表推荐结果覆盖面如果算法只推那几百个热门商品覆盖率肯定低用户体验也不会好。我在调参过程中的几个实际经验值得分享第一行为权重不能拍脑袋购买权重我给了浏览的5倍左右加购和收藏介于中间第二相似度阈值太低会导致推荐列表混入大量弱相关商品我最终设置在0.1到0.2之间太低就过滤掉第三时间衰减很重要用户三个月前买过的东西对当前推荐的影响应该远小于今天刚看的商品。加入时间衰减因子后推荐列表的相关性肉眼可见地提升。别小看这些细节推荐效果的差异往往就在这里拉开。4. SpringCloud微服务治理与联调4.1 Nacos、Gateway、Feign的配置笔记微服务拆完了如何让这几个服务互相发现、互相调用、统一对外就要靠SpringCloud这一套组合了。第一步是启动Nacos在控制台里建好命名空间和配置每个服务的yml文件都放到配置中心避免改个数据库地址还要逐个服务重启。网关配置是微服务架构的入口所有外部请求先经过网关再由网关转发到具体服务。路由配置的核心写法如下spring: cloud: gateway: routes: - id: product-service uri: lb://product-service predicates: - Path/api/product/** filters: - StripPrefix1网关层还统一加了鉴权除了登录接口其他请求都要先校验JWT Token。这样做的好处是不用在每个服务里塞一套鉴权逻辑改一处就全局生效。Redis在这里用来存Token黑名单用户退出登录后Token立即失效而不是等到自然过期。服务间调用我用OpenFeign接口定义跟写本地方法差不多实际上底层帮我们处理了负载均衡和连接管理。FeignClient(name recommend-service, fallback RecommendFallback.class) public interface RecommendClient { GetMapping(/recommend/list) ResultListLong getRecommendList(RequestParam(userId) Long userId, RequestParam(size) int size); }定义好Feign接口后商品服务就可以像调用本地Service方法一样调用推荐服务。强烈建议每个Feign接口都配一个fallback降级类哪怕只是返回默认兜底数据。因为微服务调用不可能百分之百成功如果不配降级推荐服务一旦抖动商品服务也跟着报错故障就像多米诺骨牌一样传导出去。4.2 服务间通信与数据一致性的取舍服务之间通信不可能全用同步调用。接口A调接口BB再调C链路一旦长起来任何一个环节慢都会拖垮整条调用链。我的处理原则是在线请求链路尽量短能一步到位就不绕路异步场景全部走MQ解耦。采集服务抓完一批商品数据后不需要立即通知所有服务。它把消息丢到RocketMQ商品服务消费者收到后更新商品索引推荐服务消费者收到后把“待刷新推荐”的标记写入Redis后台任务再去刷推荐列表。整个过程服务间不直接等待采集服务吞吐量再高也不会拖累在线接口。数据一致性方面我一开始也考虑过引入Seata做分布式事务但实际算了一笔账之后放弃了。这个项目涉及的商品入库、行为记录、推荐结果更新都不是强一致场景——就算有几秒延迟用户完全无感知。如果强行上分布式事务不但增加开发和维护成本还容易因为事务锁导致接口性能下降。最终用的是“本地消息表最终一致”方案核心业务先落库异步任务再同步到其他服务配合定时对账任务保证数据最终对齐。这里有一个我在实践中踩过的坑Feign调用默认超时时间是1秒推荐服务因为离线任务正在跑GC时间变长响应超过1秒就直接超时报错。后来在配置里把readTimeout调到3秒开启了重试同时注意所有被调用的接口都要做幂等设计。否则重试一次就重复插入一条数据问题反而更严重。4.3 配置管理、熔断降级与部署形态配置中心的价值平时看不出来等到需要改推荐阈值的时候才会体会。相似度阈值、热门商品数量、Redis过期时间……这些配置全部放在Nacos里改完直接发布服务不用重启。我在演示的时候经常现场改一个参数等几秒刷新页面就能看到推荐列表变化这个效果对评委来说非常直观。限流熔断我用了Sentinel规则线上配置好后推送到各服务。可视化服务的统计接口最容易被打满因为大屏数据一旦并发刷新SQL聚合压力会很大。我给它配了QPS限流超过阈值直接返回降级结果而不是把数据库拖垮。推荐服务则配置了熔断规则当错误率超过20%时熔断器打开直接走Fallback返回热门榜。部署形态上这套系统用Docker Compose编排最省心。MySQL、Redis、Elasticsearch、Nacos、网关、四个业务服务、前端Nginx全部写进一个docker-compose.yml一条命令拉起整个集群。本地开发时也可以用这种方式保证所有人生理环境一致不用花时间处理各种“在我电脑上明明能跑”的问题。5. Vue前端与数据可视化联动5.1 页面模块与路由设计前端我选了Vue3ViteElement Plus没有用Vue2因为Vue3的组合式API写起来更清爽Vite的冷启动速度在开发阶段非常提神。接口请求用Axios封装统一处理后端返回的数据结构如登录过期时跳转登录页。路由设计上项目分成用户端、管理后台、数据大屏三大部分。用户端的核心页面是推荐首页接口根据userId返回个性化推荐商品列表没登录时走系统默认推荐。商品详情页展示基本信息同时利用“看了又看”接口推荐同类商品。管理后台则负责商品管理、分类管理和爬虫任务状态查看。数据大屏单独占一个路由生产环境可以通过大屏展示当前平台的实时运营数据。Vue Router的动态路由我用来做权限控制用户角色不同看到的菜单不同。管理员能看到“爬虫管理”普通用户看不到。实现方式不复杂前端根据用户角色过滤路由配置路由守卫在跳转前做校验。但这块的坑在于刷新时路由会重置所以用户信息一定要在Pinia里持久化并且刷新后重新拉取用户角色再动态添加路由。5.2 ECharts可视化大屏与聚合接口可视化大屏是这套系统最直观的展示环节。我用ECharts画了四类核心图表商品分类占比饼图、价格区间分布柱状图、点击热度Top10排行榜、推荐点击转化率走势图。为了大屏效果颜色主题用了深色系数据刷新用WebSocket推送爬虫抓取新商品或推荐任务刷新时前端图表自动更新。后端为了支撑大屏单独设计了可视化服务提供聚合接口。这个接口的做法不是一张表查出来直接用而是组合几段SQLGetMapping(/dashboard/overview) public ResultDashboardVO overview() { // 1. 商品总量、用户总量、行为总量 // 2. 分类商品数量 group by category // 3. 价格区间分布 group by price range // 4. 热门商品Top10 order by interaction_count desc }这里容易踩的坑是SQL实时聚合性能差。数据量小时没感觉一旦行为表到了几百万行大屏每次刷新都要全表group byMySQL CPU会直接飙高。我后来加了一层汇总表定时任务每五分钟把统计数据写入汇总表大屏接口只查汇总表响应时间从几秒降到毫秒级。如果预算允许还可以把大屏数据放到Redis里再套一层缓存。ECharts初始化时还有一个细节图表组件的容器必须要有明确高度不能是“0px继承”。我一开始把容器写到flex布局里结果图表死活渲染不出来打开控制台才发现容器高度是0。给每个图表容器设定固定高度问题立刻解决。5.3 前后端联调与部署避坑前后端联调最大的障碍是跨域。开发阶段我让Vite代理请求到网关配置里把/api前缀转发到8080端口这样浏览器看到的请求是同源的。代码里这样写// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }生产部署时前端的dist目录直接用Nginx托管Nginx再把/api请求反代到网关。这里有一个我踩过很深的坑网关层和Nginx层都配置了跨域导致浏览器发出OPTIONS预检请求时两边都加了一遍跨域头直接报错“Access-Control-Allow-Origin重复”。最后的解决方案是只在网关层统一处理跨域Nginx只做转发不加跨域配置。打包路径也要注意。Vue打包默认是绝对路径部署在二级目录时资源全部404。我一般在vite.config.js里设置base: ./这样打包后资源路径变成相对路径无论部署在根目录还是子目录都能正常加载。6. 常见问题与实战排查记录6.1 微服务故障从一次雪崩说起这个项目开发过程中让我印象最深的一次事故是服务雪崩。当时推荐服务正在跑一次全量离线任务CPU几乎占满商品服务通过Feign调用推荐接口超时后线程并没有立刻释放而是继续等待。随着并发请求越来越多商品服务的线程池被打满紧接着网关也出现大面积超时整个系统几乎不可用。排查过程花了不少时间最后还是从Sentinel的监控面板上看到了端倪推荐服务的响应时间在某个时间点之后直线上升商品服务的线程活跃数同步飙升。解决办法分三步第一步在Feign调用上配置合理的超时时间和重试次数第二步给推荐服务配置Sentinel线程池隔离即使推荐服务响应慢也不能占满商品服务的线程第三步给推荐接口配置熔断降级失败率超过阈值直接返回热门榜。事后复盘这套组合缺一不可。超时保证单个请求不会被无限拖住隔离保证故障不跨服务传播降级保证用户体验不归零。微服务架构下防故障扩散比消灭故障更重要。6.2 推荐冷启动与数据稀疏应对推荐系统最头疼的问题永远是冷启动。新用户一条行为记录都没有基于协同过滤的算法完全无法工作新商品刚上架没有任何用户交互也不会被推荐出去。我在项目里用“兜底加权”的组合逻辑处理了这两个问题。新用户没有行为数据时推荐服务直接返回商品热度榜。热度榜不是简单按销量排而是综合销量、收藏量、最近点击量算出来的一个热度分。这样即使新用户第一次打开推荐页看到的内容也是平台上比较受欢迎、质量相对可靠的商品。新商品上架后给它在推荐权重里加一个“新品加权系数”上架时间越短权重越高保证新品有曝光机会。数据稀疏是另一座大山。用户动辄几万个但一个用户可能只有几条行为记录构建出来的矩阵稀疏度超过95%。稀疏矩阵直接算相似度结果会非常不稳定。我的做法是限制参与计算的商品范围只保留行为量排名前10%的热门商品参与相似度矩阵计算长尾商品用热度兜底。这样矩阵规模更小、计算速度更快推荐结果也更有集中度。6.3 爬虫、数据质量和大屏性能的坑爬虫模块的常见问题主要集中在反爬和数据质量上。有些站点会检测请求频率短时间请求太密集直接拒绝服务。我的应对很简单控制频率、随机延时、设置合理请求头。这些是正常爬虫应该具备的基础素养。数据质量的问题更隐蔽。比如价格字段“99.9元起”直接转Double会变成99.9吗不会因为中间有中文字符。正则表达式清洗时要考虑到所有异常格式。还有一类情况同一件商品两个页面价格不一致入库时就会出现同ID不同价格。我增加了按商品链接去重的规则同一链接只保留最近一次采集结果。大屏性能问题前面提过汇总表方案但还有一个关联问题大屏接口并发高时MySQL连接池会被打满。解决方式很简单给可视化服务配置独立的数据库连接池参数调大一点同时在网关层对大屏路由单独配置限流规则超过设定并发直接拒绝保护下游数据库。最后再分享一段我实际操作中的体会做这类全栈项目最大的难点其实不是某个单独的技术点而是把各个模块粘合在一起的能力。爬虫、算法、微服务、前端每一块都有大量现成的教程但很少有人讲它们之间如何协作、出问题时怎么排查。这个项目做完之后我对“工程化”这个词的理解比写十个算法题都深刻。如果你也想动手做一个类似的项目建议不要一上来就追求算法复杂度先把一条最简链路跑通再加微服务治理再加可视化每一步都稳扎稳打最后你会发现它能扩展的方向远比想象中多。
返回列表