短剧这两年有多火,不用我多说。一分钟一集、竖屏播放、反转密集,用户在地铁上、午休时打开App就能刷几十集。可内容一多,选择就成了问题——观众不知道下一条该看什么,平台也不知道怎么留住人。这时候推荐系统就派上用场了。我自己动手用SSM框架搭了一个基于Java的短剧推荐系统,把用户偏好收集、标签内容匹配、协同过滤、热门榜单兜底整条链路都走了一遍。这篇文章会从技术选型、数据库设计、算法实现到SSM三层代码落地,把整个项目的设计过程、关键代码和踩坑记录完整整理出来。适合正在做Java课程设计、毕业设计的同学,也适合想用SSM入门推荐系统、或者想搞清楚推荐逻辑到底怎么落地的开发新手。
1. 项目概述与核心思路拆解
1.1 技术选型:为什么选SSM,而不是直接上Spring Boot
现在新项目普遍用Spring Boot,但SSM(Spring + Spring MVC + MyBatis)依然是理解Java企业级开发底层逻辑的最好路径。SSM把事务管理、MVC路由、SQL持久化这三个层面的分工拆得特别清楚,尤其MyBatis这种半自动ORM框架,SQL由自己手写控制,在做推荐系统这种需要大量个性化查询、动态拼接SQL的场景里反而比JPA好用得多。
选SSM还有一个现实原因:很多高校的Java课程设计、毕业设计还在用这个技术栈,网上资料丰富,碰到问题也容易搜到解决方案。对新手来讲,SSM的配置过程虽然繁琐,但它会逼你把Spring容器、拦截器、事务边界这些基础概念真正搞明白。等你手写一遍SSM整合,再去看Spring Boot的自动配置就会觉得很多东西都是纸老虎。
1.2 系统功能模块怎么拆
短剧推荐系统本质上是一个内容分发系统,用户进入首页,系统根据他的行为历史推荐一批短剧,用户点开观看、点赞、收藏、分享,这些行为再回流到系统,更新下一次推荐。整个系统按角色和数据流向拆成五个核心模块:
- 用户模块:注册、登录、个人偏好设置(只看甜宠还是偏爱悬疑)。
- 短剧管理模块:后台维护短剧基本信息、封面、集数、简介、上线状态。
- 分类与标签模块:短剧按题材分类(甜宠、逆袭、悬疑、战神、家庭),同时打上更细粒度的标签(重生、穿越、赘婿、复仇、双洁、虐恋)。
- 行为收集模块:记录用户观看时长、点击、点赞、收藏、分享、评分等行为数据。
- 推荐引擎模块:基于内容推荐、协同过滤、热门榜三种策略组合产出推荐列表。
推荐引擎是核心,但很多初学者的误区是一上来就写算法,把数据库表和用户行为设计搭得太轻。实际上推荐系统的地基是数据——没有干净完整的用户行为记录,任何算法都跑不出效果。所以我建议先想清楚谁能产生数据、数据存在哪、哪些数据能复用,再动算法。
1.3 推荐策略的组合逻辑
单靠一种推荐策略很难撑住一个系统。我的方案是三路召回加统一排序:第一路基于内容标签匹配,适合新用户冷启动;第二路基于用户协同过滤,找口味相似的人看过的短剧;第三路是热门榜兜底,不管什么用户都得有东西可推。三路结果拉出来之后,再去掉用户已经看过的、去掉下线的,最后按综合得分排序。这个过程在实际系统里叫召回和精排,我做的是简化版,但思路能对上。
2. 数据库设计与核心表结构
2.1 基础信息表设计
先把基础四张表的DDL列出来,这是我反复调过之后的结构,字段不多,但每张表都有它存在的必要性。
-- 用户表 CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(255) NOT NULL, `preferred_tags` varchar(255) DEFAULT NULL COMMENT '主动选择的偏好标签,逗号分隔', `register_time` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 短剧表 CREATE TABLE `drama` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL, `cover_url` varchar(255) DEFAULT NULL, `description` text COMMENT '剧情简介', `category_id` bigint NOT NULL COMMENT '分类,如甜宠、悬疑、战神', `total_episodes` int DEFAULT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1上架 0下架', `created_at` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 标签表 CREATE TABLE `tag` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 短剧-标签关联表 CREATE TABLE `drama_tag_rel` ( `drama_id` bigint NOT NULL, `tag_id` bigint NOT NULL, PRIMARY KEY (`drama_id`, `tag_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;分类和标签要分开存,原因是分类是粗粒度的一个领域划分,标签才是描述内容特征的细粒度信息。比如《闪婚老公是大佬》分类上属于甜宠,但标签可以是闪婚、隐婚、霸总、双洁、先婚后爱。推荐算法里做相似度计算主要靠标签,分类可以用来做粗筛或频道页聚合。
2.2 用户行为表设计
推荐系统的核心资产是行为数据。如果说表结构和算法是骨架,行为数据就是血液。我的行为表设计成统一结构,用behavior_type字段区分行为类型,而不是每种行为建一张表,这样扩展新行为类型时不用改表结构。
CREATE TABLE `user_behavior` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `drama_id` bigint NOT NULL, `behavior_type` tinyint NOT NULL COMMENT '1点击 2观看 3点赞 4收藏 5分享 6评分', `duration_seconds` int DEFAULT NULL COMMENT '观看时长,观看行为才有', `score_value` tinyint DEFAULT NULL COMMENT '评分值,评分行为才有', `behavior_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`), KEY `idx_drama` (`drama_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表的数据量会涨得很快,所以必须给user_id和drama_id建索引。在实际部署里,行为表的查询模式非常固定:查某个用户最近的行为、查某部剧被哪些用户看过。建立联合索引或单列索引就能扛住百万级数据量。如果再大,可以考虑按月分表或者把行为数据异步写入消息队列,但课程设计和中小型项目不需要上到那个复杂度。
隐式反馈的量化是这里的关键。观看时长和完播率是最重要的信号,权重最高;点赞、收藏、分享是主动的正向反馈;点击行为权重最弱;评分行为权重高但用户很少触发。我用的加权公式是score = 观看行为权重×min(duration/1800, 1)×3 + 点赞×4 + 收藏×5 + 分享×6 + 评分×5,具体权重可以调,但思路是行为越主动、权重越高。
2.3 推荐结果表要不要建
我建议建一张推荐结果表,但不是用来存用户最终看到的完整列表,而是存每条推荐剧集的计算得分。这样用户每次请求推荐接口,直接查表按得分排序,不需要实时跑算法。
CREATE TABLE `recommend_result` ( `user_id` bigint NOT NULL, `drama_id` bigint NOT NULL, `score` decimal(10,4) NOT NULL, `strategy_type` tinyint NOT NULL COMMENT '1内容推荐 2协同过滤 3热门', `create_time` datetime NOT NULL, PRIMARY KEY (`user_id`, `drama_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表我建议用定时任务每天凌晨更新一次。实时推荐虽然听起来高级,但短剧推荐的更新时效性并不需要做到分钟级,用户今天看的剧明天才反映到推荐里完全可接受。离线算好、线上查表,这是性价比最高的方案,也是很多工业级推荐系统的简化版思路。
3. 推荐算法实现与计算细节
3.1 基于标签的内容推荐:把短剧变成向量
内容推荐的核心思路是:给每个短剧打标签,给用户建立一个标签偏好向量,通过向量相似度找出用户可能感兴趣的短剧。
先看怎么给短剧建向量。假设系统里有N个标签,每部短剧用一个N维向量表示,向量的第i个位置表示这部短剧与第i个标签的关联强度。关联强度最简单的算法是0或1——打上了这个标签就是1,没打上就是0。但更精细的做法是引入TF-IDF思路:如果一个标签在很多短剧里都出现,那这个标签区分度就低,权重应该降下来。
用户标签偏好向量则由用户的行为来构建。用户看过很多甜宠剧,那甜宠相关标签的权重就高。我用一段Java代码实现标签向量的构建和余弦相似度计算:
public class ContentBasedRecommender { /** * 构建用户标签偏好向量 * 根据用户行为累加标签权重,观看权重高,点击权重低 */ public Map<Long, Double> buildUserTagVector(Long userId, List<UserBehavior> behaviors, Map<Long, List<Long>> dramaTagMap) { Map<Long, Double> tagVector = new HashMap<>(); for (UserBehavior behavior : behaviors) { List<Long> tagIds = dramaTagMap.getOrDefault(behavior.getDramaId(), Collections.emptyList()); double weight = getBehaviorWeight(behavior.getBehaviorType(), behavior.getDurationSeconds()); for (Long tagId : tagIds) { tagVector.put(tagId, tagVector.getOrDefault(tagId, 0.0) + weight); } } return tagVector; } /** * 余弦相似度计算 */ public double cosineSimilarity(Map<Long, Double> userVector, Map<Long, Double> dramaVector) { double dotProduct = 0.0; double userNorm = 0.0; double dramaNorm = 0.0; // 点积只计算用户向量里出现过且短剧向量里也有的标签 for (Map.Entry<Long, Double> entry : userVector.entrySet()) { Long tagId = entry.getKey(); double userWeight = entry.getValue(); double dramaWeight = dramaVector.getOrDefault(tagId, 0.0); dotProduct += userWeight * dramaWeight; userNorm += userWeight * userWeight; } for (Double weight : dramaVector.values()) { dramaNorm += weight * weight; } if (userNorm == 0 || dramaNorm == 0) { return 0.0; } return dotProduct / (Math.sqrt(userNorm) * Math.sqrt(dramaNorm)); } private double getBehaviorWeight(int behaviorType, Integer durationSeconds) { switch (behaviorType) { case 1: return 0.5; // 点击 case 2: // 观看,按时长加权 if (durationSeconds == null || durationSeconds == 0) return 1.0; return Math.min(durationSeconds / 1800.0, 1.0) * 3.0; case 3: return 4.0; // 点赞 case 4: return 5.0; // 收藏 case 5: return 6.0; // 分享 case 6: return 5.0; // 评分 default: return 0.0; } } }这段代码看着简单,但已经覆盖了内容推荐的三个关键点:行为权重差异化、时长归一化、向量相似度。有个细节必须注意:计算用户向量范数时,只遍历用户向量里的标签就能算点积,但用户范数需要在同一个循环里累加,短剧范数单独算。这两块不能混,我刚开始写的时候就在这犯过错,算出来的相似度全是错的。
3.2 协同过滤:用别人的口味补全你的片单
内容推荐有一个问题——它永远只会推荐用户已经喜欢的那类短剧,很难帮用户发现新题材。协同过滤正好弥补这个缺陷:找到和你口味相似的其他人,把他们看过而你没看过的短剧推荐给你。
基于用户的协同过滤(UserCF)分两步走:
第一步先算用户相似度矩阵。用户相似度我用Jaccard相似度来算——两个用户共同看过的短剧数除以两个用户看过的短剧并集数。这个公式比余弦相似度直观,也适合短剧这种大量长尾物品的场景。
public double jaccardSimilarity(Set<Long> userAViewed, Set<Long> userBViewed) { if (userAViewed.isEmpty() || userBViewed.isEmpty()) { return 0.0; } Set<Long> intersection = new HashSet<>(userAViewed); intersection.retainAll(userBViewed); Set<Long> union = new HashSet<>(userAViewed); union.addAll(userBViewed); return (double) intersection.size() / union.size(); }第二步是找兴趣最相似的K个用户,把他们的观看记录加权汇总,对目标用户没看过的短剧打分排序。权重就是用户相似度本身,相似度越高,他的观看记录越值得参考。
实际跑下来我会给相似用户的观看记录再加一个时间衰减因子。用户三个月前看的剧和昨天看的剧,参考价值完全不同。时间衰减我用的公式是exp(-days/30),30天为一个半衰期,超过30天的行为权重降到0.37左右。这个衰减系数在冷启动期和新用户多的时候效果特别明显,能让推荐列表始终保持新鲜。
3.3 排序细节:别用冒泡排序去做推荐结果
召回阶段可能拿到几十条候选短剧,排序直接用JDK提供的Collections.sort或者Java 8的Comparator就能解决。我在代码审查的时候见过有人自己写冒泡排序给推荐结果排序,数据量小还能忍,候选集一多就是白白浪费CPU。
List<RecommendItem> candidates = new ArrayList<>(); // 填充候选数据... candidates.sort((o1, o2) -> Double.compare(o2.getScore(), o1.getScore()));这里有个面试常考但实际也常用的细节:Comparator的comparing方法配合thenComparing可以做多级排序。比如先按综合得分降序,得分相同时按短剧的上架时间降序,让新剧有更多曝光机会。
candidates.sort(Comparator .comparingDouble(RecommendItem::getScore).reversed() .thenComparing(RecommendItem::getPublishTime).reversed());排完序之后还要做一步去重和过滤:用户已经看过的短剧一定要去掉,状态为下架的也要去掉。这一步可以放在SQL里做,也可以放在内存里做。放在SQL里做减少数据传输量,但动态拼接条件会复杂一些;放在内存里做逻辑直观,适合数据量不大时。我的选择是SQL过滤一部分、内存过滤一部分,两边都做,保证接口返回的数据干净。
4. SSM三层架构实现与关键代码
4.1 Mapper层:动态SQL是MyBatis的灵魂
SSM架构里,MyBatis的Mapper层是数据访问的边界。推荐系统最常碰到的SQL需求是“查询某个用户没看过的短剧,并且按某种条件过滤”。这个需求天然需要动态SQL。
<select id="selectCandidateDramas" resultType="com.example.entity.Drama"> SELECT d.* FROM drama d WHERE d.status = 1 AND d.id NOT IN ( SELECT drama_id FROM user_behavior WHERE user_id = #{userId} AND behavior_type IN (1, 2, 3, 4, 5) ) <if test="categoryId != null"> AND d.category_id = #{categoryId} </if> <if test="tagIds != null and tagIds.size() > 0"> AND d.id IN ( SELECT drama_id FROM drama_tag_rel WHERE tag_id IN <foreach collection="tagIds" item="tagId" open="(" separator="," close=")"> #{tagId} </foreach> ) </if> ORDER BY d.created_at DESC LIMIT #{limit} </select>这个SQL的NOT IN子查询是很自然的思路,但有个隐患:如果user_behavior表数据量特别大,NOT IN的性能会变差。实际项目里可以改成LEFT JOIN ... WHERE ub.id IS NULL的写法,效果一致但性能更好。我写SQL的时候也会把这两者的性能差异讲清楚,这是MyBatis面试里经常问到的一个点。
Mapper层还有一个常见坑是N+1查询问题。推荐结果里有10部短剧,每部短剧查一次标签和分类信息,那就是10+1次查询。解决方式是连表查询一次性把标签带出来,或者用<collection>标签做嵌套结果映射。我最终选择了一条SQL把短剧和标签都查出来:
<resultMap id="DramaWithTagMap" type="com.example.entity.Drama"> <id property="id" column="id"/> <result property="title" column="title"/> <result property="coverUrl" column="cover_url"/> <result property="description" column="description"/> <collection property="tags" ofType="java.lang.String"> <result column="tag_name"/> </collection> </resultMap> <select id="selectDramaWithTags" resultMap="DramaWithTagMap"> SELECT d.*, t.name AS tag_name FROM drama d LEFT JOIN drama_tag_rel dtr ON d.id = dtr.drama_id LEFT JOIN tag t ON dtr.tag_id = t.id WHERE d.id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>4.2 Service层:推荐引擎的编排逻辑
Service层是真的产出推荐结果的地方。我把推荐引擎设计成一个门面类,内部组合三个推荐策略。这样各策略实现自己的逻辑,门面负责调度和合并结果。
@Service public class RecommendFacade { private final ContentBasedRecommender contentBasedRecommender; private final UserBasedCollaborativeRecommender cfRecommender; private final HotDramaRecommender hotRecommender; public List<RecommendItem> recommend(Long userId, int limit) { // 1. 先取用户行为集合,没行为直接走热门 List<UserBehavior> behaviors = behaviorService.listRecentBehaviors(userId); if (behaviors.isEmpty()) { return hotRecommender.recommend(userId, limit); } // 2. 三路召回 List<RecommendItem> contentItems = contentBasedRecommender.recommend(userId, behaviors, limit); List<RecommendItem> cfItems = cfRecommender.recommend(userId, behaviors, limit); List<RecommendItem> hotItems = hotRecommender.recommend(userId, limit); // 3. 合并,同一部剧取不同策略得分的加权和 Map<Long, RecommendItem> merged = new LinkedHashMap<>(); merge(merged, contentItems, 0.4); merge(merged, cfItems, 0.4); merge(merged, hotItems, 0.2); // 4. 排序、截断 return merged.values().stream() .sorted(Comparator.comparingDouble(RecommendItem::getScore).reversed()) .limit(limit) .collect(Collectors.toList()); } private void merge(Map<Long, RecommendItem> target, List<RecommendItem> items, double weight) { for (RecommendItem item : items) { RecommendItem old = target.get(item.getDramaId()); if (old == null) { item.setScore(item.getScore() * weight); target.put(item.getDramaId(), item); } else { old.setScore(old.getScore() + item.getScore() * weight); } } } }加权合并的权重比例(0.4、0.4、0.2)是我在真实小规模用户测试中调出来的。内容推荐和协同过滤各占四成,热门占两成,既能保证个性化,也给了新剧露脸的机会。如果某个策略短时间表现不好,调一下权重比重,比改算法本身要快得多。
Service层还需要处理事务问题。用户行为写入时要保证一致性——不能出现行为记录写了一半、推荐结果表更新失败的情况。我给行为写入方法加上@Transactional注解,同时注意事务只应该包住数据修改操作,不要在事务里调用远程接口或执行耗时的推荐计算,否则事务长时间不提交,数据库连接池会被占满。这个坑在开发环境不明显,一压测就暴露。
4.3 Controller层与前端接口设计
Controller层在这个项目里相对薄,主要工作是参数接收、鉴权、调用Service、结果返回。我设计了四个核心接口:
POST /api/register 注册 POST /api/login 登录 GET /api/recommend 获取推荐列表 POST /api/behavior 上报用户行为推荐接口返回的JSON结构是这样:
{ "code": 200, "data": [ { "dramaId": 1001, "title": "闪婚老公是大佬", "coverUrl": "https://example.com/cover/1001.jpg", "description": "简介文本", "tags": ["闪婚", "霸总", "先婚后爱"], "score": 23.54, "reason": "因为你喜欢:先婚后爱、霸总" } ] }这里“reason推荐理由”是体验上的一个加分项,告诉用户为什么推荐这部剧,比冷冰冰地丢一个列表出来更容易建立信任感。用户如果觉得推荐得不准,也会产生明确的反馈动机——这点对后续收集负反馈信号很有帮助。
Controller层还有一个容易被忽略但很重要的点:防止爬虫。短剧推荐系统的数据不算特别敏感,但接口如果被爬虫大量刷,会导致推荐结果表被查询到数据库压力增大。我在拦截器里做了一个简单的频率限制:同一个token每分钟最多请求30次,超出直接返回429。这个策略用Spring MVC的HandlerInterceptor实现,几十行代码,效果立竿见影。
5. 性能优化与实战调优
5.1 冷启动问题:新用户和新短剧怎么办
冷启动是推荐系统里怎么也绕不开的问题。新用户没有任何行为数据,内容推荐和协同过滤都跑不起来,直接用热门榜兜底是最稳妥的方案。
热门榜的实现不能简单按总播放量排。播放量会被老剧霸榜,新剧永无出头之日。我的热门榜算法是把播放量、观看人数、点赞数、收藏数、时间衰减因子综合起来,热度随时间指数衰减。简化公式如下:
热度分数 = 播放量×0.5 + 点赞数×3 + 收藏数×5 + 分享数×8 时间衰减 = 1 / (1 + 0.5 × 上架天数) 最终热度 = 热度分数 × 时间衰减这套公式的效果是:新剧上架前三天有强势加权,即使播放量不高也能冲进榜单;老剧如果没有持续的新增播放,热度分数会被时间衰减慢慢拉低。我实际用这套逻辑跑了两个月的数据,新剧曝光率明显提高,用户完播率也上升了。
新短剧的冷启动处理则靠后台打标签。运营人员在录入短剧时,标签打得越全、越准,新剧进入内容推荐候选池的速度就越快。如果一个新剧一个标签都没有,推荐算法永远找不到它,只能靠榜单硬推。所以我在后台管理端做了标签补全的提示功能:没有标签的短剧,后台列表置顶提醒运营人员补录。
5.2 缓存设计:哪些数据适合提前算好
推荐系统性能最大的敌人是每次请求都实时算一遍相似度。我给系统加了三个层面的缓存。
第一层是数据库查询结果缓存。短剧基本信息、标签信息这类很少变化的基础数据,第一次查询后放进缓存,设置30分钟的过期时间。用Spring Cache配合简单的ConcurrentHashMap就能实现,不需要引入Redis这样重的中间件。
第二层是推荐结果缓存。推荐结果表本来就每天凌晨更新,但用户量大时并发查这个表还是会有压力。我的做法是把热门推荐结果直接缓存在内存里,热门榜单全站用户看到的是同一份数据,这个缓存的性价比是最高的。
第三层是用户相似度矩阵缓存。协同过滤计算中,用户相似度矩阵是最耗时间的部分——需要遍历所有用户的行为记录。这个矩阵我每天只算一次,算完放在缓存里,当天推荐请求直接读取。实时计算用户相似度的后果我试过,两万用户的数据量就足以让推荐接口响应慢到一个无法接受的程度。
5.3 定时任务:离线计算的执行策略
定时任务我用Spring自带的@Scheduled注解实现,没有引入Quartz,因为这个项目的定时任务足够简单。每天凌晨2点执行一次,错开用户访问高峰。
@Component public class RecommendTask { private final RecommendService recommendService; // 每天凌晨2点执行 @Scheduled(cron = "0 0 2 * * ?") public void refreshRecommendResults() { // 1. 计算用户相似度矩阵 Map<Long, Map<Long, Double>> similarityMatrix = recommendService.calcUserSimilarityMatrix(); // 2. 批量计算每个用户的推荐结果 List<Long> userIds = userService.listAllUserIds(); for (Long userId : userIds) { List<RecommendItem> items = recommendService.recommendByStrategies(userId, similarityMatrix); recommendService.saveRecommendResults(userId, items); } } }这个定时任务在数据量大以后会跑得比较久,需要监控执行时长。我现在用的方式是把任务拆成三步:算出矩阵后先落盘(内存临时表),再批量给用户算推荐,最后一次性写入推荐结果表。如果某一步失败,第2天还能重新执行,不会影响线上用户的正常访问。
5.4 Java环境与部署细节
SSM项目部署时常见的环境变量问题也得提一嘴:一台机器上装多个JDK版本时,JAVA_HOME配置不好容易导致项目起不来。我一般把不同版本JDK装在独立目录,通过修改/etc/profile或bashrc中的JAVA_HOME与PATH来切换。Spring的SSM项目通常要求JDK 8或JDK 11,JDK 8就能跑得很好,不需要盲目上高版本。部署时用打war包放到Tomcat的webapps目录,或者用mvn clean package打可执行jar包都可以。SSM的传统方式是war包,Spring Boot式的方式是jar包,但既然用SSM就按SSM的规矩来,别把两种方式混在一起,容易出一些莫名其妙的类加载问题。
6. 常见问题与排查技巧实录
6.1 中文乱码:SSM项目最常见的坑
SSM项目中文乱码的根源就三个地方:请求参数编码、数据库连接编码、页面响应编码。我遇到最多的是MySQL连接URL忘加characterEncoding=utf8,导致存进去的中文变成问号。排查方法很简单:先看数据库表数据的实际存储内容,如果是问号,说明写入时就乱码了,问题出在连接串;如果数据库里正常但页面显示乱码,问题出在响应编码过滤器。
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter>这个过滤器要放在web.xml过滤器链的最前面,否则请求参数在进入Controller之前就已经被错误解码了。所有表统一用utf8mb4字符集,这个字符集支持表情符号,短剧评论和描述里经常有emoji表情,用utf8存会直接报错或丢字符。
6.2 MyBatis N+1查询问题
推荐列表页加载出的每个短剧都要额外查询它的标签和分类,导致SQL执行次数剧增,这是N+1问题。排查方法是在MyBatis配置里开启SQL日志,看看一次请求打了多少条SQL到数据库。我记得第一次测试的时候,推荐10部短剧,居然打出了21条查询语句。后来改成连表查询加<collection>映射,一次请求降到2条SQL,响应时间从800毫秒降到120毫秒。
6.3 懒加载导致的LazyInitializationException
SSM项目里配置了Hibernate才容易遇到LazyInitializationException,但MyBatis也有类似问题。事务提交后,Session关闭,再去访问延迟加载的属性就会报错。解决办法有两个方向:一是在Service层事务方法内把需要的数据全部查好,二是在XML中显式指定fetchType="eager"。我推荐第一种——保持事务边界的清晰,Service层返回的数据一定是完整的,Controller层就不需要关心数据加载状态。
6.4 用户列表里行为数据量太大怎么办
当用户行为记录累积到一定规模后,协同过滤计算会变得异常缓慢。我的排查日志里,两万用户、四十万行为记录的场景下,全量计算一次相似度矩阵需要将近十分钟。我到后期把用户按活跃度分了两批处理:活跃用户每天更新推荐结果,非活跃用户每三天更新一次。活跃度判定标准是近7天是否有行为。这个策略直接让定时任务的执行时间缩短到五分钟以内,推荐效果几乎没有下降。
6.5 冷启动用户推荐列表不稳定问题
新注册的用户没有行为数据,推荐系统总是返回同一份热门排行榜。用户刷了几次之后再打开,发现推荐列表一点变化都没有,体验非常差。我的处理方案是给热门榜增加随机扰动因子:同一份热门候选集,每次排序时乘一个0.9到1.1之间的随机系数。这样同一个用户多次刷新,推荐顺序会有细微变化,但整体内容仍然是热门的,既保证了稳定性又不至于太死板。
下面把问题排查方法整理成一个速查表,方便大家对照处理:
| 问题现象 | 排查方向 | 解决方案 |
|---|---|---|
| 中文乱码 | 数据库连接串、Filter顺序 | 加characterEncoding=utf8,Filter放最前 |
| 每次请求执行大量SQL | 开启MyBatis SQL日志 | 使用连表查询或<collection>映射 |
| 推荐接口响应慢 | 是否在请求内实时算相似度 | 改定时任务离线计算+查表 |
| 新用户推荐列表无变化 | 热门榜缺随机扰动 | 加随机系数扰动排序 |
| 行为数据入库失败 | 事务边界过大/过小 | 检查@Transactional范围,避免包含耗时计算 |
| 部署后项目起不来 | JDK版本、环境变量 | 检查JAVA_HOME、PATH,确认Tomcat版本匹配 |
6.6 数据一致性:行为记录和推荐结果不同步
用户今天看了五部剧,但明天的推荐列表里还有这三部剧,说明行为记录没同步到推荐引擎。这个问题通常是异步上报行为失败导致的。我的方案是行为上报改成同步写入数据库,失败时接口返回错误码,前端可以重试。虽然极端情况下会增加响应时间,但数据一致性的优先级更高。如果量级上来之后可以引入本地消息表,行为先写本地表再异步转发,兼顾一致性和性能。
最后分享一个我个人做这个项目的体会:推荐系统不是一个算法难题,它本质上是一个数据工程问题。SSM本身并不负责推荐逻辑,它只是把用户行为收集、数据存储、结果展示这件事变得规范可控。你在很多工业级推荐系统文章里看到的复杂架构,拆到底也无非是召回、排序、兜底这三件事。先把这三件事用最简单的技术栈跑通,比研究花哨的算法模型要实在得多。这套SSM短剧推荐系统完全可以作为基础,后续想扩展时再加上Redis缓存、消息队列、AB测试框架,把推荐列表的效果用点击率和完播率持续优化下去。