
简介这是一份基于Java的新闻推荐系统设计与实现完整项目资源面向Java学习者、毕业设计者及推荐系统入门开发者。系统覆盖数据采集、数据处理、推荐算法、用户界面等核心模块融合协同过滤与基于内容的推荐策略并涉及SQL数据库、Redis缓存、Spring Security安全框架及多线程性能优化等工程实践。压缩包共294个文件以134个Java源码为骨架辅以JSP页面、XML配置、CSS/JS前端资源、SQL脚本和项目说明文档同时包含PNG/JPG设计图与图标字体等素材整体仅6.76MB结构清晰便于导入IDE快速运行与二次改造可快速定位页面、配置、脚本与代码的对应关系。已有657人学习下载。借助这套资源可梳理新闻推荐系统从数据层到展示层的完整实现链路理解推荐算法在Java工程中的落地编码方式参考库表设计与前后端交互细节学习并发缓存等调优手法为毕业设计或求职项目积累可复用的实战范本也可作为课程设计报告的有力支撑。1. 从“找新闻”到“算新闻”Java推荐的第一个分水岭日更上千条的新闻App首页还在按发布时间倒序用户翻两屏就关掉这几乎是所有内容型产品从0到1都会撞上的墙。新闻推荐系统和电商推荐、短视频推荐的最大区别在于它要同时回答“用户想看什么”和“这篇新闻现在还值不值得看”物品生命周期短则几小时用户兴趣按天漂移新注册用户又没有一点行为记录。一个基于Java的新闻推荐系统核心不是套某个酷炫算法而是把“召回-打分-排序-兜底”这条链路用工程手段稳定跑起来。适合在Java技术栈内从零搭推荐模块的团队默认你已经会Spring Boot和MySQL下面从算法选型讲到能上线验证的最小闭环。2. 先定算法再写代码新闻场景为什么首选ItemCF与内容相似度2.1 别急着上UserCF用户行为稀疏时算不准“UserCF 和 ItemCF 的区别”在Java面试八股文里是高频题但面试答案和工程答案往往是两回事。面试里喜欢说“电商用ItemCF、社交用UserCF”新闻场景更特殊用户今天看科技明天看体育按人聚合的相似矩阵很快失效再加上新用户没有行为、老用户行为集中在少数几篇爆款上用户-物品矩阵稀疏到算出来的相似用户并不可靠。常见做法是优先ItemCF算新闻与新闻之间的相似度用户看过A就把与A相似的B、C推给他。物品相似度的计算频率远低于用户相似度适合离线批量算、在线只做查询。同时还要叠加一层基于内容的相似度新闻文本本身携带主题信息即使两篇新闻没有被同一个人看过只要分词后的关键词重叠度高也能建立关联。两者结合的好处是能覆盖“行为不足”和“行为稀疏”两类情况。维度UserCFItemCF内容相似度计算依据用户行为矩阵用户行为矩阵新闻文本特征更新频率每次行为都影响半小时/小时级重算新闻入库时即可算新用户效果无行为时完全失效有1条点击后生效可配合兜底策略新闻时效性滞后最明显略好最好计算成本与用户数平方相关与新闻数平方相关与新闻数和词数相关如果只保留一个结论新闻场景下先做ItemCF加内容相似度用户数上来、行为稠密之后再考虑UserCF做补充通道而不是把它当主通道。2.2 把一篇新闻变成向量分词、TF-IDF与余弦相似度基于内容的相似度前提是把文章变成可计算的向量。Java里做中文分词常用的是IKAnalyzer这类词典分词器加自定义领域词典分词后过滤停用词再用TF-IDF给每个词赋权重。TF部分体现词在这篇文章里的重要程度IDF部分抑制“的、了、新闻”这类到处都有的词。权重公式tf 词在该文中出现次数 / 该文总词数idf log(总新闻数 / (包含该词的新闻数 1))weight tf × idf新闻量级不大的时候完全可以用Java自己维护词表量级上来再交给Elasticsearch或Spark离线算好特征灌入Redis。先看一个能跑通的最小实现public class NewsVector { private final long newsId; // 词 - tf-idf权重实际工程里可以换成 float[] 稠密向量 private final MapString, Double weights; public NewsVector(long newsId, MapString, Double weights) { this.newsId newsId; this.weights weights; } public double cosine(NewsVector other) { MapString, Double a this.weights; MapString, Double b other.weights; // 遍历词数更少的那个 Map减少无效查找 MapString, Double small a.size() b.size() ? a : b; MapString, Double large a.size() b.size() ? b : a; double dot 0.0; for (Map.EntryString, Double e : small.entrySet()) { Double v large.get(e.getKey()); if (v ! null) { dot e.getValue() * v; } } double normA norm(a), normB norm(b); return normA 0 || normB 0 ? 0 : dot / (normA * normB); } private double norm(MapString, Double m) { double sum 0; for (double v : m.values()) { sum v * v; } return Math.sqrt(sum); } }这段代码想说明两件事一是余弦相似度只看向量夹角不看向量长度正好适合新闻这种“长文和短文篇幅差异大”的场景二是遍历时固定走小的Map能省掉一部分无效key查找这在候选集上万时体会更明显。真正生产环境不建议自己维护稀疏Map做全量遍历但用这个结构理解相似度计算、做单机验证足够了。2.3 混合打分把内容相似度、行为相似度和热度揉进一个公式单靠某一种相似度推新闻效果一定跑偏。内容相似度会推荐“和上一篇一模一样”的稿子ItemCF会偏向老文章热点新闻则可能因为太新没有任何行为特征。所以最终排序分常见做法是加权求和score α × contentScore β × itemScore γ × hotScore三个权重加起来等于1经验起点是 α0.4、β0.3、γ0.3上线后按小流量实验慢慢调。contentScore是用户最近读过的新闻与候选新闻的内容相似度最大值itemScore是候选新闻在用户已读新闻的sim集合里出现的最高相似度hotScore就是下一章要讲的热度分。参数初始值调大后表现调小后表现α 内容相似度0.4推荐更聚焦、容易同质化推荐更发散β 行为相似度0.3推荐更依赖历史行为新用户不友好历史行为作用减弱γ 热度分0.3更追热点、点击率可能高更个性化、长尾更明显调参不是拍脑袋先把候选集固定住只调一组权重看指标变化否则权重和召回策略同时动你根本不知道是谁起的作用。到这里算法层面的骨架已经立住了下面把它落到Spring Boot的工程代码里。3. 用Spring Boot把新闻推荐链路跑通表结构、缓存与“猜你喜欢”接口3.1 三张表撑起最小推荐系统假设JDK和Maven环境已经就绪直接从数据库设计开始。实体关系ER上就三张核心表新闻表、用户行为表、相似关系表。新闻表和用户行为表用MySQL相似关系表在数据量小时也可以落MySQL量大了以后更推荐放Redis理由在3.2说明。先看建表语句CREATE TABLE news ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, content TEXT, category_id INT NOT NULL, publish_time DATETIME NOT NULL, status TINYINT DEFAULT 1 COMMENT 1可推荐 0下架, INDEX idx_category_time (category_id, publish_time) ) ENGINEInnoDB; CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, news_id BIGINT NOT NULL, behavior_type TINYINT COMMENT 1点击 2收藏 3分享, create_time DATETIME NOT NULL, INDEX idx_user_time (user_id, create_time), INDEX idx_news_time (news_id, create_time) ) ENGINEInnoDB;news表最需要注意的是 idx_category_time 这个联合索引冷启动阶段按“分类加时间”捞新闻是最高频的查询索引没建好、列表页和推荐兜底都会慢。user_behavior表按 create_time 和 user_id 建索引是为了快速回答“这个用户最近看了什么”而 idx_news_time 是为了算新闻热度时按天扫描行为数据。行为类型用 TINYINT 而不是字符串扩展时加数字枚举即可不用改表。相似关系表如果落MySQL只需要 news_id、related_news_id、similarity 三个字段加一个更新时间主键用 (news_id, related_news_id)。但全量新闻的相似度是 N×N 量级新闻场景又要求“只保留每篇Top30”这种稀疏关系用Redis的ZSet比MySQL更顺手。3.2 用Redis存TopN相似关系离线算、在线查相似度的计算可以做成定时任务每小时扫描最近48小时入库的新闻与全量在线新闻两两算相似度把每篇新闻相似度最高的30条写进Redis。这种“离线算、在线查”的分工能避免推荐接口里实时算相似度把CPU打满。相似关系、特征向量和点击计数这三类数据用不同的Redis结构和TTL来管理具体设计如下Key类型说明TTL建议sim:{newsId}ZSet与该新闻最相似的Top30score为相似度1天vec:{newsId}Hash新闻特征向量field为词、value为权重1天click:cnt:{yyyyMMdd}:{newsId}String当日点击计数配合热度分使用3天写入时用一行代码就能完成stringRedisTemplate.opsForZSet().add( sim: newsId, String.valueOf(relatedNewsId), similarity );注意ZSet的member是relatedNewsIdscore是相似度查询时用 reverseRangeWithScores 能直接拿到从高到低的前N条。TTL设为1天而不是永久是因为新闻相似关系会随内容更新老化保留足够长的窗口让定时任务覆盖即可省得手动清理。从Redis读的时候还有一个容易忽略的优化点如果你在for循环里逐条查 sim:{newsId}用户最近看了20篇就要发20次网络请求。Java里把Redis连接换成pipeline批次提交或者直接用opsForZSet的批量能力接口RT能降一个量级。离线批量重算相似度时也建议用线程池并行计算候选对全部完成后统一写Redis等线程全部完成可以用CountDownLatch或CompletableFuture.allOf。这些都属于“先跑通再优化但一开始就值得写对”的地方。3.3 实现“猜你喜欢”接口召回、合并、过滤、排序接口不复杂复杂的是把链路写清楚。Controller层暴露一个REST接口Service层按四步走取用户最近行为、召回相似新闻、过滤已读、打分排序。RestController RequestMapping(/api/recommend) public class RecommendController { private final RecommendService recommendService; public RecommendController(RecommendService recommendService) { this.recommendService recommendService; } GetMapping(/news) public ListLong recommend(RequestParam Long userId, RequestParam(defaultValue 20) int size) { return recommendService.recommend(userId, size); } }Service的核心逻辑Service public class RecommendService { private final NewsMapper newsMapper; private final BehaviorMapper behaviorMapper; private final RedisTemplateString, String stringRedisTemplate; public RecommendService(NewsMapper newsMapper, BehaviorMapper behaviorMapper, RedisTemplateString, String stringRedisTemplate) { this.newsMapper newsMapper; this.behaviorMapper behaviorMapper; this.stringRedisTemplate stringRedisTemplate; } public ListLong recommend(Long userId, int size) { // 1. 最近3天点击/收藏过的新闻最多取20篇作为种子 ListLong seedNews behaviorMapper.findRecentNewsIds(userId, 20); if (seedNews.isEmpty()) { return fallbackByCategory(userId, size); } // 2. 召回每篇种子取Top20相似新闻合并到一个候选Map MapLong, Double candidates new HashMap(); for (Long newsId : seedNews) { SetZSetOperations.TypedTupleString tuples stringRedisTemplate.opsForZSet() .reverseRangeWithScores(sim: newsId, 0, 19); for (ZSetOperations.TypedTupleString tuple : tuples) { long candId Long.parseLong(tuple.getValue()); double sim tuple.getScore() null ? 0 : tuple.getScore(); // 同一候选被多条种子命中时保留最大相似度 candidates.merge(candId, sim, Math::max); } } // 3. 过滤掉用户已经读过的新闻 candidates.keySet().removeAll(seedNews); // 4. 暂按相似度倒序返回前size条 return candidates.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(size) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }这段代码里的candidates.merge(candId, sim, Math::max)是Java集合里很顺手的一个API同一个候选新闻如果和两篇种子新闻都相似只保留最高的相似度作为代表值。为什么不直接累加因为累加会让被很多种子命中的“大众爆款”排名虚高而新闻场景更看重“和用户最近兴趣的贴近程度”取max更稳。改成加权和是后续精排的事不建议在召回阶段做。behaviorMapper.findRecentNewsIds对应的SQL大致是SELECT news_id FROM user_behavior WHERE user_id #{userId} AND create_time DATE_SUB(NOW(), INTERVAL 3 DAY) GROUP BY news_id ORDER BY MAX(create_time) DESC LIMIT 20;这里先用GROUP BY news_id把重复点击折叠再用ORDER BY MAX(create_time) DESC保证取到的是用户最近碰过的新闻而不是最早碰过的。这个排序方向写反是新人最容易犯的错正序取出的结果看起来几乎一样一旦用户兴趣发生漂移推荐内容会整体偏旧。4. 冷启动与热度衰减新闻推荐的两个“隐形杀手”怎么处理4.1 热度分公式点击量要压缩时间衰减要陡新闻推荐里算法排序做得再好也不能忽视“新”这个维度。一条新闻发布两小时后正是传播高峰过了24小时就很少有人主动点开排序时不把时间因素焊进分数里推荐列表会被旧新闻占据。而且新闻的点击量分布极不均匀爆款新闻点击量可能比垂直新闻高几个数量级直接用点击量排序等于永远推大路货。常见做法是参考社区常用的重力模型把点击量取对数压缩、把时间差做指数衰减hotScore (clicks 2 × shares) / (ageHours 2) ^ 1.5clicks取近3天点击量shares是分享数分享权重给2是因为分享行为比点击更能代表内容质量。分母里的 2 防止刚发布0小时的新闻除零1.5是重力因子。给出Java实现public double hotScore(NewsView news, long nowMillis) { long publishMillis news.getPublishTime().getTime(); long ageHours (nowMillis - publishMillis) / 3_600_000; return (news.getClickCount() 2.0 * news.getShareCount()) / Math.pow(ageHours 2, 1.5); }参数说明重力因子越大老新闻被压得越快。新闻场景建议从1.5起步如果首页还是偏旧就调到1.8如果热点新闻总是撑不过两小时可以降到1.3。点击量不是实时去数据库count常见做法是新闻详情接口里用RedisTemplate的increment做计数再通过定时任务每5分钟把计数落库一次既扛住峰值也不会因为Redis重启而全部丢失。注意RedisTemplate的opsForValue().increment()要求key当前的值必须能被解析成整数。如果同一个key之前被set过小数、带引号的字符串或超范围大整数会抛“ERR value is not an integer or out of range”。计数key和缓存key分开命名空间比如上面表的click:cnt:可以避开这类问题。4.2 新用户没有行为分类兜底、热门兜底、人工干预新用户进来没有任何行为ItemCF和内容相似度全都用不上。这时系统要能自己降级。我一般会做三层兜底第一层根据注册时选择的兴趣分类或首次访问落地页的来源分类直接推送该分类下最新发布的新闻第二层连分类信息都没有时推全站热度分最高的50条第三层每次返回的结果里固定插入1到2条编辑人工置顶的内容给运营留一个干预位。分类兜底的SQL很简单SELECT id FROM news WHERE status 1 AND category_id #{categoryId} ORDER BY publish_time DESC LIMIT 50;这段SQL取回来之后仍然要过一遍热度分排序否则“最新”会变成“最旧”。注意冷启动列表要控制重复如果用户对兜底新闻有过一次点击下一轮就把这篇从列表里拿掉否则用户连续两次看到同一批内容会立刻觉得“这App没内容”。还可以在兜底列表里掺入一定的多样性70%来自用户选中的分类30%来自全站热度避免只推单一分类形成信息茧房。4.3 效果不对时按链路反查三个地方推荐系统出了问题大部分不在算法而在数据或者调用方式。按下面顺序排查比盯着模型调参有效。第一返回列表为空先看Redis里有没有 sim:{newsId} 数据。第二列表内容全是两天前的旧闻检查热度分里的时间单位本地时间与数据库时间不在同一个时区就会导致ageHours偏大。第三推荐结果和“按时间排序”几乎一样多半是候选集过小把召回时每篇种子的TopN从20调到50看列表多样性是否改善。还有一种常见情况用户连续几天没有新行为seedNews一直命中同一批旧新闻这时要把行为时间窗口从3天缩短到24小时让列表跟着兴趣漂移。# 查某篇新闻的Top20相似新闻是否已经写入Redis redis-cli ZREVRANGE sim:1001 0 19 WITHSCORES # 查当天点击计数确认热度分输入是否正确 redis-cli GET click:cnt:$(date %Y%m%d):1001这两条命令能解决“离线任务没跑”“计数key写错日期”这两类最常见的问题。如果ZREVRANGE是空直接去看定时任务的日志里有没有报错不要先怀疑推荐策略。还有一个小技巧Redis里ZSet的score精度是双精度浮点写入0.99999999这种值时取出后可能显示成近似值展示层只保留4位小数避免前端看到一串尾巴。5. 新闻推荐上线后验证用历史行为算HitK再上小流量A/B5.1 先算离线HitK拦截明显的回归推荐系统上线前最怕“感觉变好了实际变差了”。最便宜的回归测试是HitK把用户前6天的行为重放生成推荐列表看用户第7天真正点击的新闻是否出现在列表前K个里。这个指标不追求完美但能拦住“候选集短路”“兜底覆盖了所有个性化结果”这类致命伤。计算时先取用户第7天实际读过的新闻集合再和推荐列表前K个比较命中任意一条即为1public double hitAtK(ListLong recommend, SetLong actuallyRead, int k) { ListLong topK recommend.subList(0, Math.min(k, recommend.size())); return topK.stream().anyMatch(actuallyRead::contains) ? 1.0 : 0.0; }按用户求平均前记得把第7天之前已读的新闻从测试行为里剔除否则用户昨天读过的新闻今天再推荐一次HitK会虚高。多组实验对比时K固定取5或10不要一组用5一组用10否则没有可比性。5.2 小流量分桶与降级开关改动再小也要留退路离线指标只代表过去新闻推荐必须在线验证。不一定要搭完整的实验平台按用户ID分桶就能跑userId对100取模小于10的桶进实验组10到20的桶进对照组比较两组在同一时间窗内的点击率、人均阅读数和次日留存。分桶代码用Math.floorMod而不是%避免用户ID为负数时出现负桶号int bucket Math.floorMod(userId, 100); boolean inExperiment bucket 10;实验期间不要同时调多个参数一次只改一个变量不然指标变化归因不清。最后给推荐接口加一个降级开关Redis访问异常时不要抛异常把首页打挂而是直接退回数据库查最新新闻。try { return recommendService.recommend(userId, size); } catch (RedisConnectionFailureException e) { log.warn(redis unavailable, fallback to latest news, userId{}, userId); return newsMapper.findLatest(size); }这个降级接口返回按发布时间倒序的列表牺牲个性化但保证首页不空白。把降级逻辑放在Service外层、用异常捕获而不是主动探测Redis连通性调用方无感知也不会给Redis增加额外探测流量。等Redis恢复下一次请求自动回到正常推荐链路。本文还有配套的精品资源点击获取