ARTICLE DETAIL

资讯详情

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

非遗平台推荐系统落地实践:SpringBoot+Vue微服务与协同过滤

非遗平台推荐系统落地实践:SpringBoot+Vue微服务与协同过滤 去年我接了一个非遗数字化平台的项目需求文档里最扎眼的一句话是“能不能让用户逛完一个非遗专题后自动给他推相关的非遗项目”一开始我还天真地想做个推荐位按地区或类别筛选一下不就行了。结果业务方又补了几条要求平台要支持微信小程序、App和运营后台推荐结果不能只按热度排要“像回事”管理端要有一个可视化大屏直接看到全国非遗分布和用户行为。这几个需求凑在一起单体应用很难收拾最后我们选型为SpringBootVueSpringCloud搭了一套微服务架构在推荐服务里实现了协同过滤算法再用ECharts做了可视化大屏用MinIO和M3U8解决了非遗视频资源的存储与在线播放。这套方案从设计到上线大概花了四个月这篇把思路和踩过的坑完整记录下来。1. 非遗推荐系统的业务模型先搞清楚数据长什么样再谈算法和微服务1.1 非遗数据的天然特殊性做非遗推荐和做电商推荐最大的区别在数据形态。电商商品有清晰的类目、规格、价格、销量用户行为也有明确的转化目标。非遗数据就不一样了一个非遗项目往往包含了项目名称、级别国家级、省级、所属类别传统技艺、传统美术、民俗、传统戏剧等、申报地区、传承人信息、项目简介、图片、视频资源甚至还有历史典故和工艺流程。同一个“苏绣”项目在不同地区的申报材料、视频讲解、传承人故事可能完全不同。这意味着推荐系统的数据基础不是简单的“商品ID”而是一堆半结构化内容。我们最初的数据库设计只建了一张heritage表把简介、图片、视频URL全部塞进去结果后续做标签提取和相似度计算时苦不堪言。后来重新梳理了数据模型把非遗项目主表、传承人表、非遗视频表、非遗标签表、用户行为表分开。标签表是重点因为协同过滤算的是“用户-项目”的交互但冷启动的时候必须依赖项目自身的内容标签后面会详细说。1.2 用户行为采集是整个推荐系统的地基协同过滤的核心是用户行为数据。没有行为数据算法再花哨也是空的。我们当时梳理出的关键行为包括浏览用户在非遗详情页停留时长、是否完整看完介绍。收藏用户主动点击“收藏”按钮。点赞对视频、文章、传承人故事点赞。搜索用户搜索了“剪纸”“皮影”“古琴”等关键词。观看视频类的非遗项目用户播放、暂停、拖动进度。采集方式不能只靠前端上报。我们做了一个统一行为埋点接口接入Spring Cloud Gateway网关通过Post请求把行为日志发到用户服务。用户服务先校验登录态然后把原始行为写入RabbitMQ消息队列再由日志消费服务异步落库。为什么不直接同步写入MySQL因为行为数据量大而且高频同步写入会拖垮主流程尤其是视频观看进度这类数据用户每几秒就上报一次同步写库非常不划算。落库后的行为表结构大概是这样的user_id、heritage_id、behavior_type、score、duration_seconds、create_time。其中score是我们自定义的隐式反馈分数。比如浏览得1分收藏得5分点赞得3分搜索到并点击得4分。之所以给行为赋分是为了让协同过滤计算的矩阵不是全0/1这样相似度区分度更高。1.3 为什么推荐系统和微服务必须绑定最开始我也有疑虑推荐模块单独做成一个服务是不是过度设计单体应用里写个相似度计算接口不也行吗后来发现不行。协同过滤的离线计算是个重活。如果推荐用户量变大我们要把用户行为矩阵读出来计算物品相似度矩阵然后定时更新推荐结果。这个计算任务如果放在单体应用进程里一个Full GC就能让整个系统卡死。而且推荐服务的在线接口QPS并不高但离线任务是CPU密集型的两者放在同一个服务互相影响。拆分之后推荐服务可以独立部署离线任务用单独的线程池跑在线接口走Redis缓存互不干扰。另外推荐系统的调用量会随着前端大屏、App首页、运营后台推荐管理多个端同时使用。把推荐服务独立出来以后就算换推荐引擎也不用动其他业务代码。所以我们最终确定微服务不是“为了分布式而分布式”而是因为推荐模块的资源特征和业务模块差异太大。2. 微服务拆分与技术选型SpringBootVueSpringCloud的模块边界2.1 单个服务拆到什么粒度我们按业务域拆成了这些服务用户权限服务登录、注册、角色权限管理集成Spring Security JWT。非遗项目服务非遗项目的CRUD、分类、地区、传承人、标签管理。推荐服务协同过滤计算、推荐接口、冷启动推荐、热门补救。统计服务用户行为聚合、热度统计、大屏数据接口。文件服务图片、视频上传对接MinIO对象存储。网关服务基于Spring Cloud Gateway做路由、鉴权、限流。后台管理服务运营人员使用的后台管理系统API。这个拆分粒度是综合考虑后定的。比如“文件服务”独立出来是因为非遗的视频和图片资源很多单独做上传、转码、访问控制比较合适“统计服务”独立是因为大屏要的数据要聚合多个服务如果统计逻辑散在各个服务里前端要调五六个接口才能拼出大屏数据。统计服务统一做聚合前端只调一个接口省事很多。2.2 技术栈清单和版本陷阱模块选型说明注册中心Nacos 2.x同时做配置中心网关Spring Cloud Gateway基于WebFlux不能和Servlet混用服务调用OpenFeign声明式HTTP客户端熔断限流Sentinel网关和部分核心接口接入ORMMyBatis-Plus简化单表CRUD缓存Redis推荐结果缓存、用户会话消息队列RabbitMQ行为日志异步处理对象存储MinIO图片和视频文件存储搜索Elasticsearch非遗项目全文检索前端Vue3 Element Plus ECharts可视化大屏和后台版本这里一定要提醒SpringCloud和SpringBoot有严格的版本对应关系。我们当时用了SpringBoot 2.7.x对应的SpringCloud版本是2021.0.x也就是Jubilee。如果你乱配SpringBoot 3.x和旧版SpringCloud服务启动直接报错。网上很多教程讲的是旧版本看的时候一定要先确认版本号。另一个坑是Spring Cloud Gateway基于Netty属于WebFlux体系如果项目里不小心引入了spring-boot-starter-web网关启动时会出现冲突我们踩过一次花了一晚上才定位到是依赖冲突。2.3 服务间调用链设计前端请求进来先走网关路由到具体服务。用户在小程序端打开首页网关识别到访问的是产品服务路径将请求转发到非遗项目服务首页同时要展示“猜你喜欢”网关将请求转发到推荐服务推荐服务内部调用非遗项目服务获取项目详情再从Redis里取推荐ID列表最后拼装结果返回。用户点击进入某一个非遗项目详情前端会调用行为埋点接口把行为异步写入消息队列。这里有一个很关键的调用链设计推荐服务不要每次都去调非遗项目服务。我们要求推荐服务只返回heritage_id列表前端拿到ID后再调非遗项目服务批量获取详情或者在网关层做一次聚合。一开始我们图省事让推荐服务直接调用非遗服务拉详情结果推荐服务一旦脑裂大量重复请求把非遗服务打挂了。后来改成推荐服务只维护ID和基础标题完整详情统一由前端发起二次请求系统稳定了很多。3. 协同过滤算法落地ItemCF与冷启动方案的组合拳3.1 为什么不用深度学习选了协同过滤当时团队里也有人提议用WideDeep或者Graph Embedding说效果更好。但实际情况是平台上线初期用户量很少行为数据稀疏深度学习模型根本训练不起来。而且非遗推荐有个特点是解释性很重要用户被推荐了“古琴艺术”产品经理希望页面能告诉用户“因为你看过‘昆曲’而‘古琴艺术’和‘昆曲’同属传统表演艺术并且很多用户同时喜欢”。ItemCF天然可以给出这种解释。所以我们最终选择了基于物品的协同过滤ItemCF辅以基于内容的冷启动推荐。这个方案在数据量不大时足够稳定而且实现简单、算得快。3.2 ItemCF核心实现ItemCF的基本逻辑分三步根据用户行为计算每一个用户对不同非遗项目的评分。利用评分矩阵计算非遗项目之间的相似度。用户访问推荐接口时根据该用户偏好的项目找出相似项目生成TopN推荐结果。相似度计算我们用了改进的余弦相似度乘上一个热度惩罚因子。为什么要惩罚热门项目因为像“春节”“中秋”这类大众认知度极高的项目和所有项目都有共现相似度会被拉高推荐出来全是热门非遗失去个性化意义。public class ItemCF { // userId - heritageId - score private MapLong, MapLong, Double userData; // heritageId - similar heritageId - similarity private MapLong, MapLong, Double similarityMatrix; public MapLong, Double recommend(Long userId, int topN) { MapLong, Double userScores userData.getOrDefault(userId, Collections.emptyMap()); MapLong, Double recommendScores new HashMap(); MapLong, Double simTotal new HashMap(); for (Map.EntryLong, Double uv : userScores.entrySet()) { Long itemId uv.getKey(); double userScore uv.getValue(); MapLong, Double similarItems similarityMatrix.getOrDefault(itemId, Collections.emptyMap()); for (Map.EntryLong, Double si : similarItems.entrySet()) { Long simItemId si.getKey(); if (userScores.containsKey(simItemId)) { continue; } double sim si.getValue(); recommendScores.put(simItemId, recommendScores.getOrDefault(simItemId, 0.0) sim * userScore); simTotal.put(simItemId, simTotal.getOrDefault(simItemId, 0.0) sim); } } return recommendScores.entrySet().stream() .map(e - new AbstractMap.SimpleEntry(e.getKey(), e.getValue() / simTotal.getOrDefault(e.getKey(), 1.0))) .sorted((a, b) - Double.compare(b.getValue(), a.getValue())) .limit(topN) .collect(Collectors.toMap( Map.Entry::getKey, Map.Entry::getValue, (oldVal, newVal) - oldVal, LinkedHashMap::new )); } }相似度矩阵不需要每次推荐实时计算。推荐服务每天凌晨跑一次离线任务读取近三个月的用户行为数据计算所有非遗项目之间的相似度并写入Redis。为了控制内存和计算量我们只保留了每个项目相似度最高的前50个项目因为再往后相似度已经非常低保留对推荐结果几乎没有影响还能大幅减少计算时间。3.3 冷启动怎么办HanLP分词构建内容画像冷启动是协同过滤最常见的难题。新用户没有行为数据新上架的非遗项目也没有人看过。这时候我们补了一套基于内容的推荐。做法维护nonheritage的内容标签表。标签来源有三块管理员上传项目时手动打标签比如“刺绣”“戏曲”“江南”对项目简介和传承人故事做HanLP分词提取名词和关键词作为自动标签从项目所属类别、地区、级别等结构化字段映射出基础标签ListTerm terms HanLP.segment(heritageInfo.getIntro()); for (Term term : terms) { Nature nature term.nature; if (nature.toString().startsWith(n) term.word.length() 1) { tagService.addTag(heritageId, term.word); } }自动标签需要做过滤去掉“我们”“这个”“发展”这类无意义词。我们维护了一个停用词表并只保留词性为名词的词。新用户注册或者新项目入库后推荐服务先基于内容标签计算“用户当前感兴趣的标签”和“项目标签的匹配度”给出一个初始推荐列表。等用户积累了至少10条有效行为后再切到协同过滤主模型。内容推荐看起来简单但效果比预想好。尤其是非遗平台用户浏览一个“苏绣”后大概率对“云锦”“缂丝”这类同属传统技艺的项目感兴趣因为标签重合度高。3.4 推荐接口的缓存和降级设计推荐接口不能每次都算。在线接口从Redis缓存取结果缓存key设计成recommend:user:{userId}:top20缓存时间两小时。离线任务更新完相似度后主动把受影响用户的缓存删掉保证老用户第二天能看到新结果。如果协同过滤结果为空或者推荐服务挂了前端会降级到热门项目接口。热门榜来自统计服务按近七天的用户点击量排序。这个降级逻辑必须做因为推荐位是首页流量入口接口不能因为算法模块出了问题就整个挂掉。4. 可视化大屏非遗地图、热度榜单和用户画像怎么呈现4.1 数据从哪来可视化大屏不是前端随便画个图那么容易后端统计服务才是核心。我们的统计服务定时从MySQL行为表汇聚数据用Redis做实时计数最后通过接口输出三类数据全国非遗项目分布地图数据按省统计项目数量、级别分布热门TOP榜按观看、收藏、点赞行为加权排序用户画像年龄、活跃时段、感兴趣类别Top10查询接口还要支持时间范围筛选方便运营看周报。大屏展示的是只读聚合数据我们额外做了本地缓存避免每次刷新都打爆数据库。统计服务所有接口都放在内网通过网关暴露给前端前端大屏只读。4.2 大屏模块怎么设计大屏前端用的是Vue3 ECharts。整体布局参考了数据可视化大屏常见做法顶部标题栏中间是主视觉全国地图左侧放推荐核心数据和用户行为趋势折线图右侧放热门非遗榜单和类别占比饼图。地图部分用的ECharts geo组件地图数据用GeoJSON。这里有个实际细节中国省级GeoJSON文件比较大打包时要注意体积建议从CDN异步加载或者单独抽成静态资源不要打进Vue的bundle里。我们第一次直接把地图GeoJSON作为js模块导入首屏加载直接多了1.2MB优化后改成静态文件异步加载首屏速度提升明显。用户画像模块我们做了词云图标签数据来自推荐服务的标签表。词云能直观看到当前平台用户最关注的非遗方向运营也可以根据词云调整内容采购方向。热门非遗榜则用柱状图滚动展示支持点击跳转到对应项目详情页。4.3 M3U8与视频播放非遗项目中有大量视频资源像传统戏剧、传统技艺的工艺流程都要靠视频展示。最开始我们是直接上传MP4前端用video标签播放。但问题来了MP4格式对网络要求高大视频拖动进度条要加载很久。后来文件服务接入了MinIO FFmpeg转码把上传的视频统一转成M3U8切片格式。Vue前端播放M3U8有两种方式原生video通过hls.js播放或者用video.js配置hls插件。我们最终选了hls.js体积更小可控性更强。import Hls from hls.js export function playM3u8(videoElement, url) { if (Hls.isSupported()) { const hls new Hls() hls.loadSource(url) hls.attachMedia(videoElement) hls.on(Hls.Events.MANIFEST_PARSED, () { videoElement.play() }) } else if (videoElement.canPlayType(application/vnd.apple.mpegurl)) { videoElement.src url videoElement.addEventListener(loadedmetadata, () { videoElement.play() }) } }注意HTTPS环境下才能正常播放M3U8生产环境我们部署了HTTPS证书这才没有出现播放器能加载却一直黑屏的问题。转码耗时问题也要提前规划FFmpeg转码很吃CPU所以上传后的视频先进待转码队列由单独的服务异步处理转码完成后把M3U8地址写回数据库。5. 联调与部署期的地狱级踩坑从Nacos配置到Vue打包5.1 Nacos配置中心动了服务却还是旧配置微服务配置集中到Nacos后最典型的问题是改了配置服务不生效。原因大多是配置没有自动刷新。我们在bootstrap.yml中引入了spring-cloud-starter-alibaba-nacos-config但忘了加RefreshScope注解导致改了Nacos里的配置运行中的服务拿不到新值。后来给配置实体类加上RefreshScope配置变更才生效。还有一个更隐蔽的坑Nacos从2.x版本开始默认不是完全兼容旧版客户端。我们有个服务用旧版spring-cloud-alibaba依赖连接Nacos日志里一直报“Request nacos server failed”服务启动不了。后来统一升级了spring-cloud-alibaba版本问题才解决。团队里如果有多个人各自新建服务一定要注意依赖版本统一最好在父POM里做版本管理。5.2 Gateway路由转发404和Feign超时Gateway路由配置看似简单但容易拼错路径。我们的/api/推荐服务前缀曾经写错导致前端调推荐接口一直404。排查方法很简单看Gateway日志确认实际转发路径再对比目标服务的Controller注解。网关里如果同时加了StripPrefix配置路径处理会很绕建议配置后写一个最小单元测试直接发Sprint Boot测试请求。Feign调用另一个容易踩的坑是超时时间太短。我们默认Ribbon超时是一秒结果推荐服务内部要批量查MySQL和Redis一秒经常不够。线上表现就是前端偶尔报“系统繁忙”点几次又好了。这个不是网络抖动而是Feign超时。我们把连接超时设为2秒读取超时设为5秒配合Sentinel熔断策略才稳定。# application.yml feign: client: config: default: connectTimeout: 2000 readTimeout: 5000Sentinel熔断参数也不是越大越好。我们一开始设置错误比例阈值为50%结果服务繁忙时熔断太晚拖垮了下游。后来改成20%并且设置了最小请求数5做到快速失败。5.3 Vue打包之后接口地址失效问题前后端分离部署Vue打包后放在Nginx里接口地址写的是 localhost:8080测试环境没问题打包到生产后所有请求全部走本地8080自然找不到后端。这个问题主要源于环境变量没有区分。我们用.env.development和.env.production区分接口地址生产环境构建时接口前缀要写服务器域名的网关地址。还有一个容易被忽视的Nginx配置Vue Router用了history模式后刷新二级路由页面会404。需要在Nginx里做try_files重写location / { root /var/www/html; index index.html; try_files $uri $uri/ /index.html; }如果不加这段用户刷新大屏页面直接白屏。5.4 MinIO的对象存储权限和访问域名MinIO部署后一开始文件服务上传成功但前端拿到的URL访问不了。因为MinIO默认桶权限是私有访问需要预签名URL。我们给图片和可公开访问的视频单独设置了public-read权限并配置了独立的访问域名让MinIO和文件服务分离。前端上传视频这个场景建议前端直传MinIO避免经过后端转一道否则大文件会把Java服务内存撑爆。我们是让前端先向后端申请一个临时上传凭证拿到凭证后直接传到MinIO上传完成后后端再收到回调更新数据库记录。这一套流程稳定后上传大视频就再也没出现OOM。5.5 JVM参数和数据库连接池部署阶段我们自己统计过推荐服务和统计服务的内存和CPU要求明显高于普通业务服务。推荐服务离线任务执行时年轻代对象大量增加如果不调大堆内存频繁Full GC会拖垮在线接口。我们把推荐服务的JVM堆设成2GB离线任务执行时用独立的线程池并限制并发数避免和在线请求抢资源。数据库连接池也踩过坑。多个微服务共用一个MySQL实例时每个服务默认的HikariCP连接池大小是10这不算大但服务多了也容易把数据库连接数打爆。我们统一把最大连接数调整为20并且只在真正高并发的服务上开事务。统计服务查询全部走只读副本主库只承担业务写入。6. 项目收尾后的几点实在建议6.1 先跑通最小闭环再补微服务和可视化如果时间倒流我可能会在第一版先把SPA单体应用做出来验证推荐逻辑是不是合理。但实际项目既然定了微服务也建议先拆3个核心服务跑通非遗项目服务、推荐服务、网关服务。用户权限、统计、文件这些可以后续逐步补。微服务的复杂度和单体完全不是一个数量级团队如果没有足够的运维能力先把Nacos、Gateway、监控这些基础设施搭好否则开发期会因为环境问题浪费大量时间。6.2 协同过滤的收益要提前对齐预期非遗平台的推荐场景和电商不一样用户总数少、行为稀疏协同过滤的效果不会像淘宝那样立刻带来转化率提升。我们在做项目汇报的时候强调了推荐系统的“发现性”价值让非遗爱好者能发现同一地区或同一类别的冷门项目。比如喜欢“皮影戏”的用户可能很少听说“唐山皮影”和“环县道情皮影”推荐系统能把这些关联项目推到用户面前。这才是非遗推荐相比热门榜真正的价值。6.3 后续可以扩展的方向这个项目的推荐服务目前还比较基础后续可以加上定时更新相似度矩阵时的增量计算避免每次全量跑。数据量上来之后也可以引入图数据库来构建非遗项目的知识图谱把传承人、工艺流派、代表作品都纳入推荐链路。可视化大屏后续可以加入实时视频热度地图结合M3U8播放器的统计数据动态展示哪个省的哪个非遗视频正在被观看。这套架构留出的扩展空间足够大不至于做到一半又要推翻重来。
返回列表