做校园媒体活动推荐系统,说实话一开始我只是想解决一个很现实的问题:活动太多,用户挑花了眼,主办方也摸不准什么人会来。后来把“基于SpringBoot+Vue的媒体活动推荐系统”这个项目落地以后,我才发现推荐这件事真正难的不是算法,而是把用户意图、活动标签和行为数据串成一条完整的链路。这篇文章把我做这个系统的完整思路、数据库设计、推荐逻辑、前后端实现和部署排坑都梳理出来,适合正在做推荐系统课程设计或想入门前后端分离项目的开发者参考,也适合想给社团、公司内部文化活动搞一套推荐平台的同学直接抄作业。
整个系统说白了很简单:用户注册时选择兴趣标签,系统记录他浏览、收藏、报名活动等行为,后端用SpringBoot提供推荐接口,前端Vue负责展示推荐流和活动详情。但真正要把推荐效果做得不那么“智障”,需要花心思的地方其实非常多——标签相似度怎么算、新用户冷启动怎么办、候选活动怎么保证多样性,这些都不是堆几个MySQL查询就能解决的。所以我在项目里没有用复杂的机器学习框架,而是自己实现了一套轻量级推荐逻辑:基于标签的内容推荐为主,基于用户行为的协同过滤为辅,再用热门活动兜底。这套组合对中小规模数据量非常实用,部署成本低,效果也足够肉眼可见。
1. 需求拆解与系统设计思路
1.1 业务痛点:为什么需要这个系统
某高校媒体中心每年要办大量的活动:短视频创作大赛、校园歌手展演、摄影分享会、直播技术沙龙等等。以前活动宣传靠公众号推文和海报墙,但带来的问题很具体:一条推文覆盖所有人,感兴趣的人可能压根没看到,不感兴趣的人反复被刷屏。用户觉得烦,主办方也觉得投入大、转化低。
我当时调研了一圈,发现大家最需要的不是另一个活动发布工具,而是一个“知道我喜欢什么,并把合适的活动推到面前”的入口。这和电商推荐、短视频推荐的底层逻辑是相同的:用户的历史行为能反映出兴趣倾向,活动内容本身也有主题、类型、适合人群等属性,两者匹配后做排序,就可以把转化率拉起来。
所以这个推荐系统的核心需求可以拆成四块:
- 用户侧:能看到个性化推荐流,可以按兴趣筛选、收藏、报名、给不喜欢反馈。
- 活动侧:活动发布后自动打标签,支持分类浏览和搜索。
- 推荐引擎:综合兴趣标签、行为相似度、热度做排序。
- 管理侧:维护活动数据、查看用户行为统计。
去掉那些花架子以后,整个系统的业务闭环就是“用户产生行为 -> 行为沉淀成画像 -> 画像生成推荐 -> 用户再产生行为”的循环。
1.2 整体功能模块划分
我按前后端分离的思路把系统模块拆成了用户端和管理端两部分。
用户端围绕“找活动”展开:
- 注册/登录:注册时选择兴趣标签,这一步是冷启动的关键数据来源。
- 推荐首页:展示推荐活动列表,支持下拉分页加载,每个活动卡片上有浏览、收藏、报名、不感兴趣四个操作。
- 分类浏览:按活动类型(线上/线下)、标签、时间范围筛选。
- 活动详情:展示活动封面、时间地点、介绍、主办方信息,以及“和你兴趣的匹配度”。
- 个人中心:我的收藏、我的报名、浏览历史、兴趣标签管理。
管理端围绕“发活动和看数据”展开:
- 活动管理:新增/编辑/上下架活动,给活动勾选标签。
- 标签管理:维护标签库,比如摄影、音乐、讲座、竞赛等。
- 数据看板:展示活动浏览量、报名量、推荐位点击率等。
模块划分的原则是“高内聚低耦合”,我的做法是前后端通过RESTful接口通信,所有状态都用JWT管理,前端根据角色路由控制菜单,后端用拦截器校验权限。这样以后想接入App或小程序,接口可以直接复用。
1.3 权责与页面流程
这里要特别说一下“推荐”在整个流程中的位置。刚从需求文档上看,推荐好像只是一个首页接口,但实际上它需要依赖很多前置数据:用户注册时的标签选择、用户每次点击行为的上报、活动标签是否完整、活动是否在展示期。任何一个环节缺失,推荐质量都会崩。
页面流程我设计成三条线:
- 新用户:注册 -> 选择兴趣标签 -> 进入首页看到冷启动推荐列表 -> 产生浏览/收藏行为 -> 下一次刷新推荐结果更准。
- 老用户:登录 -> 进入首页 -> 后端根据历史行为生成推荐流 -> 用户点击不感兴趣/收藏 -> 行为数据上报 -> 下次请求推荐时权重调整。
- 主办方:新增活动 -> 设置标签和基本信息 -> 审核上架 -> 活动进入候选池 -> 被推荐或搜索到。
这三条线相互独立但又通过数据库中的行为表和标签关系表连接起来。我在做这套流程时最大的体会是:推荐系统不是放在“首页”这一个页面的,它应该渗透到搜索、详情、收藏、个人中心的所有环节。比如用户收藏了一个摄影活动,详情页推荐就可以提示“你可能还喜欢这些小众摄影展”,这种关联推荐往往比首页大而全的推荐更让用户觉得贴心。
2. 技术选型与整体架构
2.1 后端Spring Boot的取舍
后端技术栈我选的是Spring Boot 3.2.2 + MyBatis-Plus 3.5.5 + Redis + MySQL 8.0。为什么没有用传统的SSH或Servlet那种写法?主要原因是这个项目需要快速的接口开发,而且要做事务管理、参数校验、统一异常处理,Spring Boot的自动配置和生态能省掉非常多重复工作。
Spring Boot最让我舒服的一点是内嵌Tomcat,一个jar包就能跑,部署时不需要单独装容器。配合MyBatis-Plus,单表的CRUD几乎不用手写SQL,用LambdaQueryWrapper就能完成条件查询。我只需要把精力花在推荐算法和接口设计上。
对于推荐系统来说,后端实际上承担了两个角色:一是业务接口,为前端提供活动、标签、用户信息;二是计算引擎,根据用户行为去算候选集和分数。我在项目里把这部分单独拆成一个service包:recommend/RecommendService.java,里面不掺杂业务代码,这样以后想改成更复杂的算法,只动这一个类就行。
2.2 前端Vue3 + Element Plus的取舍
前端选了Vue3 + Vite + Pinia + Vue Router + Element Plus。之所以没选Vue2,是因为Vue3的组合式API写起来逻辑复用性更高,而且Vite启动速度快,开发体验比webpack舒服太多。
Element Plus主要负责管理端表单、表格、弹窗,用户端的推荐流我更多是手写卡片组件,这样视觉上更自由。Pinia用来管理用户登录状态和全局信息,比如用户注册时选的标签列表,我需要从登录页传递到推荐页,如果不停通过路由params传,会很狼狈。用Pinia存一份store,刷新页面后从本地持久化恢复,就非常稳。
前端工程里比较关键的两个组件是ActivityCard.vue和RecommendFeed.vue:前者负责展示单条活动卡片,带四个操作按钮;后者负责滚动加载、上报曝光事件、给卡片排序。这些组件内部的状态管理我采用了“自下而上”的方式:卡片只负责emit事件,由父组件统一调接口和更新列表,这样逻辑链路清晰,排查问题更容易。
2.3 数据库与中间件选型
数据库我选了MySQL,因为数据量在一万条以内时,单表查询加合理索引足够快,不必一上来就上分布式数据库。Redis用来缓存推荐结果和活动热榜,能明显减轻数据库压力。后面压测的时候发现,推荐接口在每秒并发30次的场景下,不缓存时数据库CPU能跑到70%,加了Redis缓存后直接降到15%。
中间件还用了Nginx做静态资源代理和接口反向代理。Vue打包后的dist目录放到Nginx里,/api请求转发到后端的8080端口,这样前后端统一了域名,浏览器不会有跨域问题。开发环境则通过Vite的proxy配置把请求转发到本地8080,体验很顺畅。
2.4 整体架构与请求链路
整个系统可以描述成“浏览器 -> Nginx -> Vue静态资源 + SpringBoot接口 -> MySQL/Redis”。在没有画复杂架构图之前,我用文字总结了一下请求链路的走向:
- 用户访问首页,浏览器加载Vue打包后的静态文件。
- Vue初始化时调用后端接口获取用户信息和推荐列表。
- 推荐接口先查Redis缓存,如果命中直接返回;不命中则调用
RecommendService计算候选集和得分。 RecommendService从MySQL读取用户标签、活动标签、行为记录,在内存中做相似度计算和排序。- 排序结果存入Redis,设置5分钟过期时间,然后返回前端。
架构上最大的特点就是“重接口、轻中间件”,没有引入消息队列和搜索引擎,因为在这个规模下它们带来的复杂度远大于收益。我建议做类似项目的朋友也克制一点,先把MySQL + Redis这套组合玩明白,比堆一堆中间件更有价值。
3. 数据库设计与核心表结构
3.1 用户与兴趣标签设计
用户表的设计比较常规,关键在于兴趣标签关系的存储。我建了两张表:user和user_tag:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `nickname` varchar(50) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `user_tag` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `tag_id` bigint NOT NULL, `weight` int NOT NULL DEFAULT 1, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_tag_id` (`tag_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里面的weight字段是我后来加的,一开始没有这个字段,后来发现如果用户在注册时给的权重都一样,推荐结果很容易被某个大标签带偏。比如用户既选了“音乐”又选了“科技”,但实际更喜欢音乐,如果没有权重,两者在推荐时的贡献就一样。后面我在个人中心允许用户拖动滑块调整标签权重,user_tag.weight就派上了用场。
3.2 活动与活动标签设计
活动表和活动标签表的结构我同样分离成两张表:activity和activity_tag。
活动表核心字段包括活动名称、描述、封面图、开始时间、结束时间、举办地点、主办方、状态(未开始/进行中/已结束)、上下架状态、浏览量、报名量。报名量这个字段在推荐里非常有用,可以作为热门兜底排序的分数。
活动标签表其实是一个多对多关系表:
CREATE TABLE `activity_tag` ( `id` bigint NOT NULL AUTO_INCREMENT, `activity_id` bigint NOT NULL, `tag_id` bigint NOT NULL, PRIMARY KEY (`id`), KEY `idx_activity_id` (`activity_id`), KEY `idx_tag_id` (`tag_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;为什么要单独拆出来而不是在activity表里加一个tag字符串?因为如果存成“摄影,展览,文艺”这种字符串,查询某个标签下的活动会非常痛苦,MySQL只能用like去模糊匹配,索引失效。拆成独立关联表之后,按标签查活动就是一次JOIN,还能顺便做标签维度的统计聚合。
标签表本身没有什么特殊的,就是id、name、sort、created_at。我在项目里预置了大概二十多个标签,覆盖常见的校园活动类型:摄影、电影、音乐、舞蹈、话剧、科技、户外、公益、讲座、比赛等。标签数量不需要太多,太细反而会导致用户选择困难,也会让标签矩阵极度稀疏。
3.3 用户行为记录表设计
推荐系统的核心资产不是活动本身,而是用户的行为数据。我设计了behavior_log表,记录四种行为:view(浏览)、collect(收藏)、signup(报名)、dislike(不感兴趣)。
CREATE TABLE `behavior_log` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `activity_id` bigint NOT NULL, `behavior_type` tinyint NOT NULL COMMENT '1-浏览 2-收藏 3-报名 4-不喜欢', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`, `create_time`), KEY `idx_activity_id` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;每种行为的权重不一样。我用的权重体系是:浏览算1分,收藏算3分,报名算5分,不喜欢算-5分。这个权重不是拍脑袋定的,而是根据“用户做出该行为的成本越高,说明兴趣信号越强”这个原则来设置的。浏览可能只是随手点了一下,收藏说明有一定兴趣,报名基本说明有很强的参加意愿,而不喜欢则是明确的负反馈。
这里要特别提一个细节:上报行为时,前端会在活动卡片的曝光阶段先把浏览行为上报到/api/behavior/report,但为了防止用户快速滑动导致曝光记录疯狂写入,我在前端做了节流——同一个活动在10分钟内只允许上报一次浏览。否则数据库会有一堆无意义数据,推荐计算也会被噪音干扰。
3.4 表关系与索引设计心得
表关系其实很清晰:用户和活动是多对多,通过行为表和报名表关联;用户与标签是多对多;活动与标签也是多对多。我用ER图来看的时候,中间会有三张关联表:user_tag、activity_tag、behavior_log,核心业务就围绕这三张表展开。
索引方面,我踩过一个坑:最开始所有表都只建了主键索引,后来查询某个用户的收藏列表时,随着收藏数据变多,单次查询要扫全表,接口从几十毫秒慢到了两三百毫秒。后来在behavior_log.user_id和activity_tag.activity_id上加了普通索引,查询立刻回到几十毫秒。索引不是越多越好,但关联表和按查询条件建的索引一定要建,这是数据量上来之后的底线。
另外,MySQL 8.0默认的字符集我用了utf8mb4,因为活动描述里会夹杂emoji和生僻字,如果用utf8,某些特殊字符会报错或乱码。这个小细节很多人一开始会忽略,等真正存数据时报错才回头改,白白浪费了不少时间。
4. 推荐算法落地:从“能推”到“推得准”
4.1 推荐的整体逻辑
我把推荐流程设计成三个步骤:召回、过滤、排序。
召回是指从所有活动中找出一个候选集合,不能拿全表几万条活动去做精细化排序,那样性能不行也没有必要。我的召回策略有三种:
- 兴趣标签召回:取出用户感兴趣的标签,找到这些标签下未结束、已上架的活动。
- 协同过滤召回:找到和当前用户行为最相似的Top 10用户,把这些用户喜欢但当前用户没接触过的活动拉出来。
- 热门召回:按报名量和浏览量降序取Top 20,用来保证新用户和冷门活动能得到曝光机会。
过滤阶段会做三件事:剔除已经结束的活动、剔除用户明确点过“不喜欢”的活动、剔除用户已经报名过的活动。这三类活动不应该再出现在推荐流里,尤其是“不喜欢”的负反馈,必须立刻生效。
排序阶段则给每个候选活动算一个综合分数,然后按分数降序输出。综合分数由三个部分组成:
score = 0.6 * interestScore + 0.3 * similarScore + 0.1 * hotScore这三个分数分别对应标签匹配度、协同过滤置信度、活动热度。权重是我在测试了几组数据后调出来的,先保证标签匹配的主导地位,再用协同过滤挖掘潜在兴趣,最后用热门兜底防止推荐结果太“偏”。
4.2 基于标签的内容推荐实现
内容推荐的核心是计算用户标签向量和活动标签向量之间的相似度,我这里用的是余弦相似度。假设用户有向量U = [u1, u2, ...]表示对每个标签的权重,活动有向量A = [a1, a2, ...]表示活动包含哪些标签,那么相似度就是:
cos = (U · A) / (||U|| * ||A||)为了让用户向量能表达“权重”,用户的标签来自user_tag表,权重存weight字段;活动向量是0/1布尔值,活动包含这个标签就是1,否则是0。这样计算出来的相似度能反映用户兴趣和活动内容的重合程度。
我在RecommendService中直接用Java实现了这个计算:
public double calcInterestScore(Long userId, Activity activity, List<Long> userTagIds) { double dot = 0; double normU = 0; double normA = 0; Map<Long, Integer> userWeightMap = userTagService.getWeightMap(userId); for (Long tagId : activity.getTagIds()) { Integer weight = userWeightMap.getOrDefault(tagId, 0); dot += weight * 1.0; normA += 1.0; } for (Integer weight : userWeightMap.values()) { normU += weight * weight; } if (normU == 0 || normA == 0) { return 0; } return dot / (Math.sqrt(normU) * Math.sqrt(normA)); }这段代码很短,但它完成了内容推荐最核心的相似度计算。一开始我只写了dot / normU,结果发现活动标签越多分越低,因为活动向量模长影响被忽略了。改成标准余弦相似度后,各活动之间的分数可比性变强,排序结果自然也更合理。
4.3 基于用户的协同过滤实现
协同过滤我实现的是基于用户的UserCF,思路分四步:
- 建立“用户-行为”映射:把用户对活动的体重度转化成行为分。
- 计算用户之间的相似度:两个用户共同产生过行为的活动越多,相似度越高。
- 找到最相似的Top 10用户。
- 把相似用户有正向行为的、当前用户没有行为的活动加入候选池。
用户相似度我用Jaccard相似度来计算,因为它比余弦相似度在稀疏行为矩阵下更直接:两个用户交互过的活动集合分别为A和B,相似度是交集大小除以并集大小。这样的好处是更看重“共同选择”,而不是活动向量里的零值。
public double calcUserSimilarity(Map<Long, Double> userBehaviors, Map<Long, Double> otherBehaviors) { Set<Long> set1 = userBehaviors.keySet(); Set<Long> set2 = otherBehaviors.keySet(); Set<Long> union = new HashSet<>(set1); union.addAll(set2); if (union.isEmpty()) return 0; long intersection = set1.stream().filter(set2::contains).count(); return (double) intersection / union.size(); }协同过滤在数据量少于一百个用户时效果不明显,因为可借鉴的邻居太少。我在测试环境只有几十个测试账号,发现推荐结果和内容推荐差别不大。后来拉了更多模拟数据才慢慢有效果。这也说明协同过滤非常依赖用户行为数据的累积,前期不要指望它承担主推责任。
4.4 冷启动问题的处理方案
冷启动是推荐系统里绕不开的坑,我拆成了三种场景分别处理。
新用户冷启动:用户在注册时强制选择至少3个兴趣标签,没有行为数据时,直接用标签召回活动,并掺入一定的热门活动。代码里对“用户没有任何行为日志”的判断是取behavior_log表中是否存在记录,如果没有就直接走“标签+热门”策略。
新活动冷启动:新上架的活动因为没有点击量,会一直排在后面。我的处理方式是在召回阶段给它一个“新活动加分”,根据活动创建时间距当前的小时数计算一个衰减因子:越新分数越高,48小时后衰减到正常水平。这样新活动至少能获得一波曝光,不至于永远躺库底。
负反馈冷启动:用户第一次点“不喜欢”之后,要立刻从推荐列表移除该活动,并且在下一次计算时把用户对那个活动的相关标签权重做降权。比如用户讨厌了一个摄影讲座,我会把“讲座”标签在用户向量里的权重减半,避免同类内容继续推荐。
冷启动没有一劳永逸的方案,只能在产品逻辑上做对抗。我的经验是:注册时引导用户选标签是性价比最高的冷启动手段,比任何算法都能更快建立初始画像。
5. 关键功能的前后端实现记录
5.1 注册时兴趣标签选择的前端交互
用户注册流程里,我把“兴趣选择”设计成一个单独步骤页。前端在用户输入账号密码后进行第二步:展示标签网格,用户最多选5个,至少选3个,选中的标签高亮并显示勾选状态,用户可以拖动排序表示兴趣优先级。因为后面推荐系统主要依赖这些标签,所以交互上要尽量鼓励用户多选、认真选。
这个组件我放在了components/TagSelector.vue里,内部维护一个selectedTags数组,点击标签时切换选中状态,同时更新排序权重。代码大致如下:
const selectedTags = ref([]); function toggleTag(tagId) { const index = selectedTags.value.indexOf(tagId); if (index >= 0) { selectedTags.value.splice(index, 1); } else if (selectedTags.value.length < 5) { selectedTags.value.push(tagId); } }注册提交时,后端UserController会接收List<Long> tagIds,然后在事务里同时插入user和user_tag表。这里一定要用@Transactional,否则用户表写成功而标签表写失败,就会产生一个没有兴趣画像的僵尸账号,后面推荐数据会莫名多一个冷启动用户。
5.2 后端推荐接口的实现与参数调校
推荐接口路径我设计为GET /api/recommend/list,参数是page和size,每次返回一页推荐活动。接口内部先查缓存,再走计算逻辑,最后返回给前端。核心Service的调用链大概是这样的:
public PageResult<ActivityVO> recommend(Long userId, int page, int size) { String cacheKey = "recommend:user:" + userId + ":" + page; PageResult<ActivityVO> cached = redisService.get(cacheKey); if (cached != null) return cached; List<Activity> candidates = recommendService.recall(userId); recommendService.filter(userId, candidates); List<Activity> sorted = recommendService.rank(userId, candidates); PageResult<ActivityVO> result = convertToPage(sorted, page, size); redisService.set(cacheKey, result, 5, TimeUnit.MINUTES); return result; }这里有一个调参心得:分页结果不能只缓存当前页,否则用户翻第二页时如果第一页的缓存过期,推荐顺序变了,用户会看到重复内容。所以我做了个比较笨但有效的处理:把排序后的完整推荐列表标识缓存起来,前端分页只是在这个有序列表上切片。当然这只适用于活动总量几千条的场景,如果到百万级,就要靠搜索引擎或Redis的ZSet维护全局列表了。
5.3 首页推荐流与“不喜欢”反馈
首页推荐流我用了无限滚动组件el-infinite-scroll,每次滚动到底部加载下一页。每次活动卡片曝光时,调用/api/behavior/report上报浏览行为,同时上报卡片位置、停留时间等信息。这些数据对后续排序调参非常宝贵,我在后台数据看板里能直观看到某个推荐位的点刷率。
“不喜欢”反馈是推荐系统里重要的负向信号。当用户点击卡片上的“不感兴趣”按钮时,前端调用/api/behavior/report带上behaviorType=4,后端在写入行为日志的同时,会删除Redis中的推荐缓存。这样用户刷新后“不喜欢”的活动立刻消失,体验很直接。如果不清缓存,用户可能要等五分钟缓存过期才能看到变化,非常影响感受。
async function dislike(activityId) { await axios.post('/api/behavior/report', { activityId, behaviorType: 4 }); removeActivityFromList(activityId); message.success('已经帮你减少这类内容'); }必须注意的是“不喜欢”不能做成永久拉黑,因为用户兴趣会变化。我只把这个负反馈在30天内生效,过期后就允许活动重新进入候选池。否则一个用户早期讨厌了某个标签,以后可能永远看不到这个主题下的好活动。
5.4 后台活动管理模块
管理端是另一个Vue工程里的独立路由,登录后根据角色显示。活动管理页我用Element Plus的el-table展示活动列表,支持按状态、标签、时间筛选。新增活动时,标签用el-select多选,把选中的tagId提交给后端。
活动发布时间有一个“定时上架”的小功能,当时是为了配合某活动晚上8点准时开抢门票的需求。这个功能后端用了一个简单的定时任务——每分钟扫描一次用于定时上架的表,把到了时间的活动改状态。也可以用Spring的@Scheduled,但我更喜欢用数据库驱动:一张activity_schedule表存待发布记录,消费完成后删除。这样系统重启也不会丢任务。
后台还有一个“推荐测试”页面,输入任意用户名,就能实时查看该用户的推荐列表,并显示每个活动的得分构成。这个页面调试推荐参数时帮助极大,不用去翻日志,能直接看到用户A为什么推荐了活动B,因为标签匹配了多少分、协同过滤加了多少分。我觉得任何推荐系统项目都应该做这样一个内部工具,它会让你优化算法的速度提升很多。
6. 部署实战与性能优化
6.1 本地启动与联调细节
本地开发时,后端跑在8080,前端跑在5173。Vite的vite.config.js里配置了代理:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }有了这个代理,前端开发时请求/api不会跨域,非常方便。生产环境则用Nginx处理/api的转发。这里有个小坑:后端如果加了ContextPath,比如server.servlet.context-path=/api,那么代理路径和后端路径需要对应好,我试过出现双/api/api的情况,排查了半天才发现是路径拼接重复了。建议统一约定:后端接口路径本身就是/api/xxx,Nginx直接转发location /api到后端。
6.2 服务器部署与Nginx转发
部署我选了一台腾讯云轻量服务器(2核4G),系统是Ubuntu 22.04。后端打包成jar包后放服务器上,用systemd作为服务守护,设置开机自启和异常重启。前端打包后生成dist目录,上传到Nginx的html目录。
Nginx配置的关键部分:
server { listen 80; server_name yourdomain.com; location / { root /var/www/media-recommend; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那行很重要,因为Vue是单页应用,如果没有这一行,用户直接访问某个路由地址比如/profile会返回404,加上后所有路径都回退到index.html,由前端路由接管。
我还加了Gzip压缩,对js和css文件的传输体积有明显优化。实测前端包体从1.8MB压缩到500KB左右,首页打开速度从2秒多降到1秒内。
6.3 推荐接口的缓存优化与压测体验
推荐接口是读多写少的典型场景,非常适合加缓存。我在Service层用了Redis做缓存,除了分页结果缓存外,还把用户标签权重、活动标签列表这些基础数据也缓存起来。
压测我用的是Locust,模拟60个用户同时刷推荐首页,测试5分钟。不加缓存时,推荐接口的平均响应时间是340ms,数据库CPU占用接近60%;加了Redis缓存后(缓存命中率大约85%),平均响应时间降到45ms,数据库CPU稳定在10%以下。这个对比足以说明缓存对推荐系统性能的重要性。
不过缓存也有副作用:行为上报后需要主动删除相关缓存。我设计了统一的事件清理方法:用户产生收藏、报名、不喜欢等行为时,清除recommend:*前缀的所有缓存。简单粗暴,但在用户量不大的情况下非常可靠,不用引入复杂缓存一致性方案。
压测过程中我还发现一个隐患:接口里在遍历活动列表时会分别查询每个活动的标签信息,产生了典型的N+1查询问题。用MyBatis-Plus时如果不注意,每个活动都会查一次activity_tag表,六十个活动的推荐列表就会产生六十多次SQL。后来我在Service层把当前页涉及的所有活动ID收集起来,一次性批量查出标签关系并组装到内存中,SQL次数从N+1降到了常数级别。这类优化在数据量变大后收益特别明显。
7. 常见问题与排坑实录
7.1 跨域请求被拦截
开发初期前后端分开跑,前端访问http://localhost:5173,后端是http://localhost:8080,必然触犯浏览器同源策略。我在后端加了一个CorsConfig,定义了允许的源地址、请求方法和请求头。
网上很多教程会让allowedOriginPatterns("*")直接放开所有来源,这么做在小项目里确实省事,但生产环境如果前后端同域名部署,其实根本不需要跨域配置。我的建议是:开发环境用Vite代理,生产环境用Nginx反代,后端统一不要开放跨域,这样更安全也能减少很多不必要的接口暴露。如果确实需要独立域名给小程序或App用,再单独配置白名单。
7.2 行为日志丢失
推荐算法对行为数据的完整性要求很高,但我一开始做前端上报时,用unmount里发送异步请求,结果页面关闭瞬间请求经常没发出去。后来改成el-infinite-scroll滚动加载时实时上报,并开启navigator.sendBeacon来处理页面卸载时的上报。
sendBeacon是个好东西,它能在页面关闭时把数据可靠发给服务端,非常适合埋点场景。具体使用方式就是构造一个Blob,然后调用navigator.sendBeacon(url, data)。后端接口仍然是一个普通的POST请求,格式稍微注意一下即可。这样行为日志基本不丢了。
7.3 推荐结果重复或覆盖不全
开发时遇到过同一个活动连续出现在两页推荐中,原因是缓存策略问题:缓存第一页时存的是一个有序列表,但分页切片时又把列表完整缓存,导致第二次翻页的缓存key只包含该页,刷新后两页之间产生重叠。解决办法就是前面提到的:把排序后的完整列表存一个固定key,前端翻页只做切片返回,而不是缓存每一页。这样既避免重复,又防止用户看到前后顺序不一致。
7.4 数据库连接被占满
有段时间活动详情页被外部爬虫刷,导致连接池被耗尽,整个系统响应超时。排查后发现是某些慢查询没有走索引,以及没有对接口做限流。处理方式有三步:给高频查询字段都加了索引;在Nginx层配置了每秒IP请求次数限制;给阿里云数据库设置了最大连接数和wait_timeout。
我强烈建议每个项目无论大小都加上基本的限流和监控,不一定要上Sentinel,哪怕是简单的计数接口也能帮你挡住一大半异常流量。我后来写了一个RateLimitInterceptor,用Redis的计数器模式给接口按用户和IP双重维度限流,超过阈值直接返回“请求太频繁”。这套简单的限流机制上线后,系统再也没有因为流量毛刺挂过。
写在最后
整个系统从需求到上线大约花了一个半月,其中推荐算法调参占掉了三分之一的时间。我个人最大的体会是:推荐系统的效果不是靠某个惊艳的算法,而是靠数据质量和产品反馈机制。用户愿意选几个标签、愿意点不喜欢、愿意花时间收藏,这些行为比你用再复杂的模型都更能反映真实兴趣。如果让我重做一遍,我会在项目一开始就规划好行为上报的埋点体系,而不是等到推荐效果不好再去补数据。
最后分享一个小技巧:在开发推荐系统时,一定要做一个“推荐解释”功能,就像电商里“因为你看过A,所以推荐B给你”。在活动卡片上显示“和你的兴趣匹配度89%”或“因为你在关注摄影类活动”,会让用户觉得推荐是合理的,点击率也会明显提升。这个功能实现成本很低,但对用户体验的提升非常明显。如果后续你还想继续扩展,可以考虑引入基于物品的协同过滤、排序模型或者简单的深度学习召回,底层架构完全可以复用。这套SpringBoot+Vue的骨架本身就是干净的,替换推荐引擎不会伤筋动骨。