做新闻推荐系统和做电商推荐,完全是两种思路
先说结论:电商讲究“买过什么就推什么”,用户的兴趣相对稳定;新闻讲究“此刻大家在看什么”,时效性压过一切,昨天的爆款今天推给用户,用户只会觉得你系统傻了。这篇博文想聊的就是怎么用Python加上大数据技术栈,把一套能用的新闻推荐系统从零搭出来。
我之前在公司带过这个项目,从数据采集、清洗、特征工程、推荐算法到后端接口,全程用的是 Python 生态。网上讲推荐系统的文章很多,但大部分要么只讲算法不讲数据,要么只讲框架不讲落地。这篇我会把整个项目的核心链路、踩过的坑、还有最后线上运行的效果都写清楚,适合正在做推荐系统相关项目、或者想转大数据方向的同学参考。
先说一下整体规模:我们当时每天采集新闻约 20 万条,去重清洗后入库约 3 万条,覆盖十几个频道;用户行为日志每天约 500 万条。这套系统上线后,首页点击率比原来的编辑人工推荐提升了约 37%,平均阅读时长提升了 22%。当然这个数据和业务场景强相关,不用当成绝对指标,但至少说明这套方案的路子是通的。
1. 项目全貌:需求拆解与技术选型
1.1 新闻推荐和大数据到底怎么结合
新闻推荐系统,本质上解决的是信息过载的问题。用户每天面对海量的资讯,系统要做的事情是:在正确的时间,把正确的内容,推给正确的人。
但这里有个核心挑战——新闻的时效性极强。一条关于突发事件的新闻,它的价值可能在几个小时内就衰减大半;而一篇深度长文可能在一周内都有阅读价值。这种内容价值的快速变化,决定了新闻推荐不能完全依赖用户历史行为,还需要结合实时热度、话题趋势、内容相似度来做综合排序。
再说大数据技术在这一类项目里扮演的角色。推荐系统本身并不强制要求大数据技术栈,纯用 Redis + 内存计算也能跑一个单机版demo。但一旦数据量上来,比如每天几百万条行为日志、几十万篇文章入库,单机的瓶颈就非常明显了。大数据技术在这里解决的是三个问题:
- 数据存储:海量新闻内容、用户行为、画像数据的存储与管理。
- 数据处理:离线批量计算用户兴趣、新闻热度、内容特征,以及实时计算用户当前时刻的阅读兴趣漂移。
- 模型训练与推理:当数据量达到一定规模后,推荐模型的训练和在线推断需要分布式能力支撑。
用一句话概括:算法负责“推荐得准”,大数据负责“在数据大的时候依然推荐得快、算得动”。
1.2 技术栈选型的几个关键决策
整套系统的技术栈可以分成四层。我们最终选型以及选它的原因,我放在下面这张表里:
| 层级 | 技术选型 | 选择原因 |
|---|---|---|
| 数据采集 | Scrapy + NewsAPI | Scrapy 生态成熟,支持分布式采集;NewsAPI 作为备用补充源 |
| 数据存储 | MySQL + HBase + Redis | MySQL暂存结构化数据,HBase存储海量文章快照,Redis缓存热数据和实时榜单 |
| 数据处理 | Spark + Hive | 离线做特征统计和模型训练样本生成,Spark处理非结构化新闻文本比MapReduce快太多 |
| 推荐服务 | Python + Flask/FastAPI | 团队全是Python背景,FastAPI性能比Flask好,异步支持更舒服 |
| 前端呈现 | Vue + ECharts | 后台管理界面与分析大屏展示 |
这里要说一下为什么没有选 Flink。当时我们确实评估过 Flink,但最终决定先用 Spark Streaming 过渡。原因很简单——团队对 Spark 更熟,而且新闻推荐的实时性要求不像风控那么严苛,秒级延迟已经满足业务需要。Flink 留给二期再做。后来的经验证明这个决策是合理的,过早引入团队不熟悉的技术栈,带来的只有焦虑和事故。
数据存储上用 HBase 而不是全部放 MySQL,是因为新闻原文快照的量级太大,而且写入频率非常高,MySQL 在这种场景下要么分库分表累死运维,要么读写性能告急。HBase 天然适合这种海量、稀疏、列式存储的场景,扫描性能也够用。
1.3 系统架构的总体设计
整条链路可以这样理解:
新闻源采集 → 清洗去重 → 内容入库 → 特征工程 → 离线训练 → 推荐结果生成 → 对外接口 → 前端展示 ↑ ↓ 用户行为日志 → 实时热度计算 → 缓存服务(Redis)这个架构的关键在于,每条链路都有明确的数据出口。采集层产出的是“文章全量数据”,处理层产出的是“文章标签和特征向量”,推荐层产出的才是“用户最终看到的推荐列表”。每一层之间通过接口或消息队列解耦,避免一处挂了全部瘫痪。
当时我们把采集层和推荐层之间用 RabbitMQ 做了异步解耦。采集服务抓到的数据先进 MQ,由消费服务去做清洗和入库。这样做的好处是,即使某个新闻源的接口出问题,采集服务重试也不会影响已入库数据的下游计算。
2. 新闻数据链路:从多源采集到特征工程
2.1 新闻采集的策略与去重方案
新闻采集是整套系统的原料车间,质量决定上限。
我们当时的数据源分三块:RSS订阅源(约120个)、公开新闻网站的结构化接口、还有第三方新闻聚合API。这里得提醒一句:爬虫采集公开数据一定要遵守 robots 协议,控制请求频率,不要对目标站点造成压力。我们所有采集任务都做了限速,并发控制在单站点 2-3 个线程,并且在非高峰时段跑全量更新。
采集上最容易翻车的是"重复内容"。新闻领域有一个特征是转载极其严重——同一个事件,几十家媒体发的内容相似度高达95%以上。如果不去重,推荐系统里就会出现满屏一模一样的新闻,用户体验是灾难性的。
去重我们分了三级:
- URL去重:同一个新闻源里,URL相同的直接丢弃。
- 标题归一化去重:把标点符号去掉、繁体转简体、全角转半角,然后算标题的 SimHash,相似度超过0.85判定为重复。
- 内容指纹去重:对正文前500字提取特征词集合,做 Jaccard 相似度计算,超过0.75就合并为一组,只保留权威源那篇。
这里要重点讲一下 SimHash。为什么不用 MD5?因为 MD5 判定的是"完全一致",新闻转载场景下标题往往只有几个字的差异,MD5 根本拦不住。SimHash 的巧妙之处在于,它能把一段文本映射成一个64位的指纹,然后通过比较两个指纹的海明距离来判断相似度。海明距离小于等于3通常就判定为相近文本。我自己的经验是,标题级别的 SimHash 阈值设为 3 会有少量误杀,但配合内容指纹那层复核,整体精确率能做到95%以上。
2.2 文本清洗与分词的实战细节
采集下来的原始 HTML 里充满了标签、脚本、广告位代码。我们清洗流程是这样的:
- 用 lxml 和 BeautifulSoup 解析 DOM 树,抽取 article 标签或正文容器节点。
- 去除 script、style、nav、footer 等无用节点。
- 正则表达式去掉连续的空白符和特殊字符。
- 全角转半角,统一英文大小写。
分词用的是 jieba。这应该是 Python 生态里使用率最高的中文分词库。但直接用默认词典效果一般,因为新闻里大量的人名、机构名、新词是词典里没有的。我们做了两个优化:
- 自定义词典:把频道名、常见人名、产品名、热门事件关键词手动加进 user_dict。
- TF-IDF 加权:分词后不是光把词扔给推荐算法,而是计算每个词的 TF-IDF 权重。TF 是词在文章里出现的频率,IDF 是词在整个文章库里出现的逆文档频率。一个词在单篇文章里出现得多、但在全库出现得少,说明它更有区分度,权重就更高。
这两步做完以后,每篇新闻会得到一个"关键词 + 权重"的稀疏向量。这个向量后续是内容推荐和相似新闻计算的基础。
2.3 特征工程的三个层次
特征工程决定推荐效果的上限。我们从三个层面构建了新闻的特征体系:
内容特征:关键词向量、分类标签(体育/财经/科技/娱乐等)、内容长度、图片数量、发布时间、来源站点权威度(我们自己给每个源打了1-5分的权威分)。
热度特征:这个特别重要——对新闻来说,当前时刻的点击数、分享数、评论数、阅读增速,都是强信号。我们会计算一个**"热度衰减分"**,公式是:
热度分 = 基础分 * exp(-λ * 年龄小时数) + 实时行为加权λ 是衰减系数,根据新闻类型设定。突发新闻 λ 大,衰减快;深度长文 λ 小,生命周期长。这个公式看起来简单,但线上验证效果非常好,它保证了推荐列表里既有"刚刚发生的事",也有"值得慢慢读的深度内容"。
用户特征:用户的历史点击序列、频道偏好、平均阅读时长、活跃时间段。这些特征在离线阶段通过 Spark 批量计算得到,然后写入用户画像表。
2.4 数据清洗时要小心的时间陷阱
新闻数据的发布时间是个极其容易出错的字段。不同新闻源对时间格式的偏好五花八门:有的用时间戳,有的用字符串,有的不写年份,有的甚至不写时区。我们踩过一次大坑——某新闻源的时间解析因为时区没处理对,导致入库时间比实际时间晚了8小时,结果这个频道的新闻在推荐列表里连续两天全是"活在过去"的状态。
所以时间处理必须统一:采集端一律转成 Unix 时间戳存储,展示层再格式化。这样不管源站格式多乱,进库之后都是统一标准。
3. 推荐算法实战:协同过滤、内容推荐与热榜兜底
3.1 三个推荐引擎的分工与配合
单靠一个算法做新闻推荐是不现实的。我们系统里跑了三个推荐引擎,各管一段,最后通过加权融合产出最终结果:
| 推荐引擎 | 核心逻辑 | 适用场景 | 冷启动表现 |
|---|---|---|---|
| 基于物品的协同过滤(ItemCF) | 用户喜欢新闻A,A与B相似,则推荐B | 老用户、有历史行为 | 差 |
| 基于内容的推荐(CB) | 用户偏好科技频道,推荐相似标签的新新闻 | 新用户、新新闻 | 好 |
| 热门兜底 | 全站热度排序 | 所有用户缺数据时的补充 | 好 |
先说 ItemCF。它是协同过滤里最适合新闻场景的算法,因为它的核心假设是“喜欢这篇新闻的用户,也可能喜欢和这篇新闻相似的新闻”。具体实施时,我们基于用户行为日志构建“用户—新闻”的共现矩阵,然后算新闻之间的相似度,最后结合用户历史行为生成候选集。ItemCF 的优势在于可解释性强(“因为你看了X,所以推荐Y”),效果也稳定。但它的缺点也很明显——完全依赖用户行为,新新闻没有任何行为数据,冷启动基本靠其他引擎补位。
基于内容的推荐则完全走另一条路:不依赖用户行为,而是把新闻的关键词向量和用户画像中的兴趣向量做余弦相似度匹配。它的好处是新闻发布后几秒内就能进入推荐候选池,缺点是不够个性化——容易越推越窄,形成“信息茧房”。所以我们给基于内容的推荐加了多样性控制:最终结果里强行插入10%到20%的探索性内容,比如用户常看科技,系统偶尔给一条财经深度报道,看看反应。
3.2 ItemCF 计算新闻相似度的具体实现
这是整个推荐系统最核心的一段代码逻辑。我贴一段简化的实现,重点展示思路。
import pandas as pd from collections import defaultdict import math # 假设行为数据已加载到 DataFrame # user_id, news_id, score (1表示点击, 2表示点赞, 3表示收藏) def train_itemcf(behavior_data): # 第一步:构建用户-新闻倒排表 user_news = defaultdict(set) for row in behavior_data.itertuples(): user_news[row.user_id].add(row.news_id) # 第二步:统计新闻共现次数 news_cnt = defaultdict(int) news_sim = defaultdict(lambda: defaultdict(float)) for user, news_list in user_news.items(): for i in news_list: news_cnt[i] += 1 for j in news_list: if i != j: news_sim[i][j] += 1.0 / math.log(1 + len(news_list)) # 第三步:归一化计算相似度 news_news_sim = {} for i, related_news in news_sim.items(): tmp = {} for j, cnt in related_news.items(): tmp[j] = cnt / math.sqrt(news_cnt[i] * news_cnt[j]) news_news_sim[i] = tmp return news_news_sim这里有个细节值得展开。共现次数为什么要除以math.log(1 + len(news_list))?因为如果一个用户一天看了100篇新闻,那这100篇之间可能只是“顺便都看了”的关系,并不代表它们真的相似;而一个用户一天只看2篇,那这两篇之间的相关性往往很强。除以用户行为列表长度的对数,就是为了降权那些“过于活跃”的用户,他们的行为噪声太多。
第三步的归一化是用math.sqrt对两个新闻被不同用户看过的次数做惩罚。这个就是标准 ItemCF 的余弦相似度处理。最后算出来的news_news_sim[i]就是新闻 i 的相似新闻列表。
线上使用时,相似度计算是离线阶段跑完的,跑完后以 JSON 形式加载到 Redis,在线推荐时只需要查 Redis,毫秒级返回。这套方案的性能完全够用——我们线上大约50万篇文章,对每篇保存Top10相似文章,Redis 内存占用也就几百 MB。
3.3 热度算法:一套“不会让用户错过大事”的兜底逻辑
上面说了,推荐系统的个性化程度再高,也必须有一个基础的热度榜兜底。原因很简单——如果一个用户刚注册,没有任何行为数据,系统不可能给他计算个性化推荐,这时候最好的策略就是推给用户“大家都在看什么”。
热度分计算要防两个问题。第一是防刷防作弊:同一个IP短时间内的多次点击不计入热度;第二是防老新闻霸榜:如果不加时间衰减,一篇爆款能一直在榜上待一周,新内容永远上不来。时间衰减我们用的是指数衰减函数:
import time def hot_score(click_count, share_count, comment_count, publish_ts, current_ts=None): if current_ts is None: current_ts = time.time() age_hours = (current_ts - publish_ts) / 3600.0 base = click_count + share_count * 3 + comment_count * 5 decay = pow(0.9, age_hours) # 每过1小时,热度衰减为原来的90% return base * decay参数上,分享的权重是点击的3倍,评论是5倍——原因很简单,分享和评论的门槛远高于点击,愿意做这两个动作的用户,说明内容确实触达了他们。衰减系数选择0.9是实验调出来的:0.85衰减太快,深度好文在榜上撑不过12小时;0.95衰减太慢,一条过了48小时的旧闻还压着新内容。
3.4 推荐结果的融合与多样性控制
三个引擎各自输出候选结果后,需要一个融合策略把它们排成最终结果。我们用的是加权融合 + 多样性打散。
加权融合的公式大概是:
最终得分 = 0.5 * ItemCF得分 + 0.3 * 内容推荐得分 + 0.2 * 热度得分这个权重不是拍脑袋定的,是拿历史数据做了三组 AB 测试调出来的。但要特别强调,权重必须结合业务场景动态调整——比如重大突发事件期间,热度权重要临时调高,让推荐列表更快地反映全站热点,不然个性化的 ItemCF 结果会把用户困在旧兴趣里。
多样性打散用的是 MM(Maximal Marginal Relevance)算法思路:每选一条新闻放入最终列表时,计算它和当前已选新闻的相似度,如果太相似就往后排。实现不复杂,但对点击率的提升实打实有效——用户看到的是"既懂我,又带点新鲜感"的内容流。
4. 大数据存储与计算:离线训练与实时更新怎么做
4.1 为什么选 Spark 而不是 MapReduce
项目初期我们从蠡湖带过一代平台,MapReduce跑得让人崩溃,跑一个新闻分类模型的训练样本生成任务要两个小时。后来切到 Spark,同样数据量十分钟不到就跑完了。原因很简单:MapReduce 每一步都要落盘,Shuffle 中间结果全写磁盘;Spark 基于内存计算,把中间结果尽量留在内存里,在某些场景下能快十倍不止。
我们离线计算的典型任务有这么几类:
- 用户画像更新:每天凌晨跑一次,汇总用户过去7天的行为日志,更新兴趣权重。
- 新闻相似度计算:全量表跑一遍 ItemCF 相似度,产出 Top-N 相似新闻到 Redis。
- 热点榜单计算:加速热度分可以用 Spark Streaming 做微批处理,每5分钟更新一次热点频道TOP50。
- 训练样本生成:从行为日志中构造正负样本(点过的为正,曝光的为负),供后续排序模型训练用。
这些任务在 Hadoop YARN 集群上调度,Spark 跑计算,Hive 做数据仓库的汇总查询。集群规模不用很大——我们当时三台 worker 节点(每台 16核 64G 内存)就扛住了。
4.2 实时性怎么保证:缓存层与消息队列的妙用
新闻推荐里,最怕的是用户刷了一下首页,结果过10分钟再看还是同样的内容。所以除了离线计算,我们还搭了一条"准实时"链路用于更新热门新闻:
- 用户点击事件通过埋点 SDK 发到 Kafka。
- 一个 Python 消费服务从 Kafka 拉取消息,聚合出当前1分钟、5分钟、1小时三个时间窗口的点击统计。
- 聚合结果写入 Redis 的 Hash 结构,key 是 news_id,value 是"点击量、时间戳"。
- 推荐服务在拼装结果时,先查 Redis 里的热度数据,再叠加权重。
这套方案的延迟大概在30秒以内。虽然不如 Flink 的毫秒级,但对于新闻推荐场景完全够用。如果你刚起步,甚至可以用更简单的方式:Flask 接口里直接维护一个全局的正则字典作为热榜,每来一次点击就更新一次,等数据量上来再迁移到 Kafka + Redis 方案。
4.3 ClickHouse 在数据分析侧的价值
系统上线一段时间后,产品和运营开始频繁提出数据需求:“最近一周科技频道阅读量最高的20篇文章”“汽车频道的用户最喜欢在什么时间段活跃”“某某突发事件的流量曲线怎么走的”。这些查询如果用 Hive 跑,一个多小时出结果,产品根本等不了。
后来我们接入了 ClickHouse,把行为日志转化成列存表,大部分维度聚合查询秒级返回。ClickHouse 对这类统计分析场景极其适合——列式存储、向量化执行、内置大量聚合函数。我们只是把 Kafka 里的行为数据通过同步任务导入 ClickHouse,就完成了"数据可查"这一关。到这个阶段,整套系统才真正说得上是"大数据驱动"——不仅能推荐,还能针对业务快速响应分析需求。
5. 完整实操过程:从环境搭建到跑通一个推荐链路
5.1 环境准备:本地开发的最小配置
如果你只是想在自己电脑上把这个项目跑起来做学习或验证,没有必要直接上 Hadoop 集群。我建议先在本地搭一个简化版,用 SQLite 代替 MySQL,用 pandas 模拟 Spark 的计算过程,跑通了逻辑再往大数据环境迁移。
本地环境需要准备的东西:
- Python 3.8+,建议直接用 Anaconda,避免环境变量和依赖的坑。这里多说一句,Python 安装最容易出问题的就是 pip 源超时,国内直接把 pip 源换成清华或阿里镜像,下载速度从几十 KB/s 直接拉到几 MB/s。
- Redis 服务(Windows 有 Redis 的绿色安装版,Linux 直接 apt 或 yum 装)。
- MySQL 或者直接用 SQLite 起步。
- Flask/FastAPI 用于写推荐接口。
5.2 新闻采集与入库的步骤分解
第一步用 Scrapy 写一个新闻采集爬虫。Scrapy 的架构非常清晰:Spider 负责页面解析,Pipeline 负责数据清洗和入库。我们存在 MongoDB 里,因为文档格式和新闻的半结构化特征匹配度太好,改字段不需要做迁移。如果追求事务性和复杂查询,也可以存 MySQL,看个人偏好。
第二步是数据入库前的清洗,包括正文抽取(主要用 trafilatura 这个库,比纯 BeautifulSoup 正则抽取稳定得多,它在新闻页上的正文抽取准确率很高)、时间格式化、频道映射、来源站点打标。
第三步是分词和关键词提取。关键词提取我们用了 jieba 自带的 TF-IDF 算法初始化一个分词器,分词同时就给每个词算了权重。这一层产出的结果写到文章的 features 字段,作为内容推荐引擎要用作特征向量。实测在这个阶段用上 multiprocessing 多进程池,20万篇新闻全量重算特征可以在 40 分钟内完成。
5.3 训练推荐模型并生成推荐列表
训练逻辑其实不复杂,就是上面讲过的 ItemCF 和热度算法的结合。整个训练跑完,产出两个核心结果:
一个放 Redis 的news_id -> [(news_id, score), ...]相似新闻映射表,一个放 MySQL 的channel_id -> [news_id, ...]各频道热度榜单。
为了让推荐接口有好的响应速度,我们建议预生成两份数据后直接加载到内存或 Redis,不要在请求里去实时算相似度——除非用户量大到一个无法想象的程度,实时算一定撑不住。
5.4 本地启动推荐接口并测试完整链路
推荐接口用 FastAPI 实现,核心接口设计如下:
from fastapi import FastAPI, Query import redis import json app = FastAPI(title="News Recommendation API") @app.get("/api/v1/recommend") def recommend(user_id: str = Query(...), channel: str = Query(None)): # 1. 查用户最近的点击记录 user_history = get_user_history(user_id, limit=20) # 2. 基于 ItemCF 获取候选 candidates = get_itemsim_candidates(user_history, top_n=50) # 3. 结合内容标签做二次过滤和加权 candidates = content_based_filter(candidates, user_id) # 4. 加入热度榜内容做探索 candidates = add_explore_items(candidates, channel) # 5. 多样性打散 + 截断 result = rerank_and_dedup(candidates, top_n=30) return {"code": 0, "data": result}接口启动后,用 Postman 或者 curl 直接请求一次:
curl -G "http://localhost:8000/api/v1/recommend" --data-urlencode "user_id=u_1001" --data-urlencode "channel=tech"如果返回的数据里有新闻标题、新闻摘要、相似度来源、热度分说明,恭喜你,最简单的推荐链路就算跑通了。
6. 测试与性能优化:别等用户来告诉你系统慢
6.1 推荐接口的性能瓶颈与排查方法
系统上线最怕的就是服务突然变慢,用户端的直观感受就是"首页刷不出来"。排查性能问题,我建议先压测再调优。用 Locust 或 wrk 对推荐接口做并发压测,看看接口在 200 并发、500 并发下的响应时间和错误率。
我实际遇到过的性能瓶颈有两个:
第一个是 Redis 连接没走连接池。一开始简单粗暴每次请求新建一个 Redis 连接,压测时直接把 Redis 的连接数打满了,后续请求全部排队超时。解决方式很简单:用redis.ConnectionPool复用连接。
第二个是候选集排序时用了 Python 的复杂对象排序。把候选新闻从 Redis 取出后,在 Python 内做了大量的对象属性访问和列表排序,500 并发下 CPU 直接拉满。后来把排序对象替换成heapq.nlargest,配合轻量级的元组数据(score, news_id),性能提升非常明显。
这里要延伸一下,很多场景下 QTableWidget 加载大数据会卡顿,换成 QTableView + 自定义 QAbstractTableModel 只渲染可见行,是同一类问题的不同解法——核心思想都是只做必要计算,不要无脑全量处理数据。但这是 Qt 开发那边的领域,我们推荐服务这边靠自己优化,压测从 200 并发进去就开始大量超时,优化到 1000 并发稳定 200ms 以内,这是很能体现技术功底的一件事。
6.2 推荐准确率评估的思路
机器学习模型可以用离线指标(AUC、召回率等)评估,但推荐系统的最终效果必须回归业务本身。我们的评估策略是:
- 离线实验:从行为日志里切一份时间窗,把前7天的行为用来训练,后1天的用来验证。核心看 Ranking 类的指标:MRR、NDCG@10。
- 在线 AB 测试:把用户分成两个桶,一个用老推荐逻辑,一个用新逻辑,对比点击率、平均阅读时长、次日活跃度。
这里提醒一句:新闻推荐的离线评估和在线结果经常出现不一致。因为离线评估用的是历史行为,而新闻的时效性决定了今天的行为模式明天可能就变了。所以,离线指标只能用来筛选模型,真正的决断必须靠在线 AB。
7. 典型问题与排查技巧实录
7.1 冷启动问题的三个解决思路
新用户没有任何行为数据时,推荐接口里查用户历史是空的,候选结果直接退化成一个空列表。我们做了三件事:
- 新用户默认推荐当时全站热度Top20和用户所选频道的Top10。
- 通过前端引导用户选择兴趣标签(科技、体育、财经等),选完立刻应用基于内容的推荐,不用等行为积累。
- 用户完成第一次点击后,ItemCF 引擎就能立刻发挥作用——点击越持久,个性化程度越高。
新发布的新闻冷启动则更致命——一篇刚发布的新闻没有任何行为,ItemCF 和热度榜都不会选它。我们的做法是把基于内容的推荐引擎作为新新闻的主要出口:新闻发布后先加入内容候选池,用频道和关键词做粗匹配,只要用户兴趣向量里包含这些关键词,这篇新新闻就有机会进入推荐列表。让新新闻有展现机会,才能让用户行为数据积累起来,形成良性循环。
新频道冷启动就比较尴尬了——如果系统新开一个“游戏”频道,这个频道一篇新闻都没有。这时只能先做人工运营:让编辑从外部导入一批高质量种子新闻,把频道的初始内容池撑起来,再开始正常采集和推荐。
7.2 数据倾斜导致 Spark 任务跑崩
这是一个非常经典的大数据问题。我们用户在行为数据上分布非常不均匀——少数头部用户贡献了大部分行为日志,导致做用户维度聚合时,个别 Task 处理的数据量是其他 Task 的几十倍,Spark 的某个 executor 直接 OOM。
解决方案是加盐分桶。逻辑是:对行为多的用户先加一层随机前缀(比如 0 到 9 的盐值),把大用户的行散到 10 个不同的 Task 里算,最后再按盐值合并结果。这样虽然增加了几步计算量,但避免了单个 Task 崩溃导致整个任务失败。遇到数据倾斜,先看是不是 key 分布不均,再看哪里适合加盐分桶,这是大数据处理的必修课。
7.3 Redis 缓存一致性与过期策略
推荐结果缓存这层,最大的坑在于缓存的失效策略。如果缓存里存的旧推荐结果过期太慢,用户看到的列表和当前热点不一致,点击率就会往下掉;过期太快,缓存命中率变低,后端压力加大。
我们的做法是:推荐结果缓存两分钟。Redis 的 TTL 设成 120 秒,过期后下次请求重新计算。这个时间窗口内,即使有突发新闻上线,用户看到的列表也仅延迟120秒,可接受。另外热门榜单的维度可以单独缓存 30 秒,实时性要求更高,缓存时间就更短。
7.4 行为日志埋点的准确性
推荐系统效果好不好,首先取决于行为日志准不准。我们上线初期有一波点击率回落,排查半天发现是埋点代码在用户点击和页面加载的时序上出了 bug——用户点击了新闻,但点击事件还没上报就跳转了新页面,导致事件丢失,点击率被算低。
解决方案是:本地先缓存行为事件,页面切换时用异步批量上报,并加了本地持久化防止断网时的数据丢失。此外加了一个监控大盘,对日志上报数量做趋势比对,一旦掉得异常立刻告警。没有可靠的行为日志,再牛的推荐算法也是空中楼阁。
7.5 常见问题速查表
| 问题现象 | 可能原因 | 排查/解决方法 |
|---|---|---|
| 推荐列表大量重复内容 | 采集阶段没做好内容去重,或相似度阈值太低 | 调高 SimHash 海明距离阈值,加强内容指纹去重 |
| 新用户看不到任何推荐 | 冷启动策略缺失 | 增加热度兜底、兴趣标签引导、点击即时触发推荐 |
| 突发新闻进不了推荐列表 | 热度计算未覆盖新发布内容 | 缩短 Redis 缓存时间,在热度算法里给新内容加分 |
| Spark 任务 OOM | 数据倾斜 | 加盐分桶,大 key 分散到多分片处理 |
| 接口响应慢 | Redis 连接未复用或候选集排序低效 | 用连接池,替换排序方案为 heapq |
| 点击率下降 | 用户兴趣分布变化/热度更新不及时依赖项 | AB 实验重新调权重,检查热榜计算链路延迟 |
8. 这套系统还能怎么扩展
系统跑通以后,我们其实做了不少扩展,这里列几个大家问得多的方向:
一个是引入简单排序模型。当前这套系统主要靠规则权重和相似度计算,还不算真正的“机器学习排序”。后续可以给每个候选新闻加特征(内容质量分、用户偏好匹配度、历史点击数、发布时间、来源权威度),喂给 LightGBM 做 CTR 预估排序,效果还能再上一个台阶。
一个是多模态探索。新闻不仅仅有文本,配图、视频、甚至语音,都在影响用户是否点击。可以把配图的图像特征(用预训练 CNN 提取)和文本特征向量拼接起来,作为更丰富的内容表示。这一步做完,推荐准确率又会显著提升,但工程投入也要大很多。
再往后就是个性化和时效性的权衡调参。这永远没有一劳永逸的答案,必须在线上持续 AB 测试,根据数据反馈不断调整。
如果你正准备做类似的系统,我的建议是先搭通链路,把最简版本上线,然后花大力气去完善行为日志的质量,再逐步替换掉各个模块的规则逻辑。因为对推荐系统来说,数据和"数据能反映真实业务"这两件事,比所有算法都重要。
最后分享一个我们上线初期的小经验:第一版推荐系统上线后,你先打开内部测试账号去真实地刷一刷当天所有频道的首页,那种“咦这里居然推了一条两小时前的八卦新闻”的瞬间,会比你想象的更常见。把发现的问题记下来,一条条优化,你会发现系统会像真实用户一样越来越懂你,而这个过程本身,就是这个项目最让人上瘾的地方。