做智能电影推荐系统的后端,最容易被误解的一点是:推荐不是“训练好一个模型,然后封装成一个接口”那么简单。等真正上线以后你会发现,模型只是其中一环,数据管道、召回策略、特征拼接、缓存降级、日志埋点,每一个环节都有坑。这篇文章我拿自己做过的电影推荐后端项目来聊,适合正在搭推荐后端、或者想把自己的推荐服务从“能跑”优化到“能上线”的同学。标题既然是后端篇,我就尽量少讲数学推导,多讲工程选型和踩坑记录。
1. 先想清楚推荐后端到底要干什么
1.1 一个推荐接口背后的真实链路
产品侧看推荐后端,通常就是一个接口:传入用户ID和场景ID,返回一串电影ID。但接口内部必须回答几个问题:这个用户最近看过什么?全量片库里有哪几百部电影可以当候选?候选里哪些更适合他?返回列表怎么避免全是同一个系列?所以后端至少要覆盖:行为数据接收、离线特征计算、候选召回、排序打分、重排过滤、结果缓存、埋点日志。如果这些都堆在一个接口里写,代码会很快失控。
我不推荐一上来就搞微服务。第一步可以先用一个单体服务把链路跑通:内部按函数拆成召回、排序、重排三块,模块之间用内部方法调用。等候选集规模大了、排序模型单独需要扩容了,再按模块拆服务。推荐系统后端的瓶颈往往不是并发,而是数据链路的复杂度,先理清数据流比先拆分服务更重要。
1.2 离线、近线与在线三层的职责划分
后端数据流我习惯分成三层:离线层负责周期性训练模型和生成物品相似度、用户偏好快照;近线层负责消费实时行为流,更新用户临时特征;在线层负责接受请求,执行召回排序并返回。电影推荐里很多特征并不需要秒级更新,比如用户类型偏好,小时级更新完全够用。但也有些信号必须快,比如用户刚点开某部片子的详情页,此刻推荐列表里就不该再重复推这一部。所以在线层至少要能拿到最近几分钟的行为。
实际落地时,这三层不一定要拆成三个物理集群。离线任务可以用一套定时调度,近线消费用消息队列,在线服务单独部署。等业务量上来之后,再逐步把离线计算放到独立的计算集群里。只要能保证数据流向清楚,前期用简单的分层设计反而更好维护。
2. 数据层设计:行为、画像与特征存储
2.1 用户行为表怎么建才不后悔
用户行为是推荐系统的燃料。电影场景里常见行为包括曝光、点击、播放、收藏、评分等。我建表时固定几个核心字段:用户ID、电影ID、行为类型、场景ID、发生时间、行为扩展信息,用分区表按天分区。一个容易犯的错是只存正向行为,不存曝光数据。如果之后要做精排样本,负样本主要来自曝光未点击,没有曝光日志就没法构造真实负样本。补埋点时再补历史曝光数据,成本非常高。
具体表结构我用过类似这样的设计:
CREATE TABLE movie_user_behavior ( user_id STRING, item_id STRING, behavior_type INT, scene_id STRING, ts BIGINT, extra MAP<STRING, STRING>, dt STRING ) PARTITIONED BY (dt);behavior_type 用整数表示,1 是曝光,2 是点击,3 是有效播放,4 是收藏,5 是评分;extra 字段是 KV 结构,可以存播放时长、拖动次数、音量变化这些行为上下文,方便后续做样本筛选和特征工程。分区字段 dt 是日期字符串,离线任务扫一天的行为时直接按分区裁剪;如果不分区,几亿行行为记录全表扫描会把任务拖死。这里还要考虑数据倾斜,热门电影的记录量会远高于普通电影,后面单独说。
2.2 用户画像与物品画像的存储选型
画像分为用户侧和物品侧。物品侧相对稳定,电影的类型、导演、演员、上映年份、评分均值基本一周一变,适合放 MySQL 或者直接用离线表同步到 Redis。用户侧变化快,用户最近的类型偏好、看过列表、活跃时间段,需要支持毫秒级读取,我用 Redis 存。key 设计为user_profile:{user_id},value 用 Hash,字段可以是pref_genres、recent_click_items、watch_time_avg等。要注意 value 别存太大,单 key 超过几 MB 后 Redis 读写成本高,而且容易把 Redis 内存打爆。像“最近看过100部电影ID”这种字段,用 List 或独立小 key 保存更好。
但 Redis 不是唯一选择。如果已有 ClickHouse 这类列式存储,也适合做用户行为分析,可以直接从里面查用户最近行为。只是在线链路单独查数据库会产生网络开销,一般还是把高频特征灌进 Redis 或内存缓存,ClickHouse 用于离线统计和调试。
2.3 特征拼接:不要把逻辑散落在代码里
特征服务是推荐后端里很容易被忽视的部分。推荐模型输入的特征通常分成三块:用户特征、物品特征、交叉特征。用户特征来自画像,物品特征来自物品表,交叉特征比如“用户最近看过多少部该导演的电影”。我的经验是,统一做一个特征服务,对外提供get_features(user_id, item_id_list)接口,一次性返回候选列表中每个 item 的特征向量,避免在排序阶段循环调 Redis。
另外一定要给特征加版本号和产出时间。离线训练用 T 日特征,线上预测时如果用的特征逻辑和训练不一致,你会看到线上指标怎么调都涨不上去。最常出问题的就是“特征穿越”:训练样本里用了未来信息,线上预测时拿不到,导致 AUC 虚高。比如用评价分值做特征时,要确保该评分发生在样本的曝光时间之前。这块建议单独写一个特征一致性校验任务,每天对比离线特征分布和在线实时特征分布。
3. 召回层:从百万电影里把候选集捞出来
3.1 召回不是一套方案打天下
电影推荐召回层面对的是全量片库,不可能对每部电影跑精排模型,所以需要先粗筛。常见召回策略有:协同过滤召回、基于内容召回、热门召回、最近交互召回、向量召回。每路召回解决一个特定问题:协同过滤解决“和你类似的人喜欢什么”,基于内容解决“和你喜欢过的电影像不像”,热门召回兜底冷启动,最近交互召回保证推荐结果及时反馈。单一召回策略覆盖面有限,线上效果最好的时候往往是多路召回融合。
| 召回策略 | 核心思路 | 适用场景 | 候选量级 |
|---|---|---|---|
| ItemCF | 用户看过的电影,找相似电影 | 老用户日常推荐 | 500 |
| UserCF | 找到相似用户看过的电影 | 兴趣迁移、新电影 | 300 |
| 基于内容 | 类型/导演/演员相似 | 冷门电影发现 | 300 |
| 热门召回 | 全局热榜 | 冷启动、兜底 | 100 |
| 最近交互 | 用户最近点击/观看的电影关联 | 实时反馈 | 200 |
| 向量召回 | 向量近邻搜索 | 跨兴趣泛化 | 1000 |
候选量级不是固定的,主要看排序层的处理能力。我的原则是召回总量在 1000 到 2000 之间,排序层压力可控,召回不全的问题也相对少。如果精排模型很轻,比如只是 LR,候选集甚至可以放到 3000;如果模型里有 Transformer 这类重结构,候选集就要缩短。每一路召回的候选名额可以在配置中心动态调整,方便做召回层的 A/B 测试。
3.2 离线 ItemCF 的计算流程与代码
ItemCF 的思想是“如果很多用户都看了电影 A 和电影 B,那 A 和 B 就相似”。离线计算一般是两步:先按用户聚合他看过的电影列表,再统计电影两两共现次数,最后算相似度。相似度最常用余弦公式:
sim(A,B) = cooccur(A,B) / sqrt(cnt_A * cnt_B)
但需要注意的是,直接用共现次数会被热门电影带偏,比如某部商业大片和谁都共现很多。所以通常给活跃用户降权,或者用log(1 + count)做平滑。我在项目里会控制在曝光和点击行为上分别统计,点击行为的共现权重更高。
用 Python 伪代码描述离线计算的思路:
# 伪代码:ItemCF 离线计算 from collections import defaultdict import math user_items = defaultdict(set) for uid, iid in click_data: user_items[uid].add(iid) item_cnt = defaultdict(int) cooccur = defaultdict(lambda: defaultdict(int)) for uid, items in user_items.items(): for iid in items: item_cnt[iid] += 1 for jid in items: if iid != jid: cooccur[iid][jid] += 1 sim = {} for iid in cooccur: sim[iid] = {} for jid, cnt in cooccur[iid].items(): sim[iid][jid] = cnt / math.sqrt(item_cnt[iid] * item_cnt[jid])实际生产环境数据量大时,这段逻辑会用 Spark SQL 或 PySpark 跑,但核心逻辑一样。相似度矩阵生成后需要截断:每个电影只保留 Top N 个相似电影,比如 50 个,否则存储和读取都扛不住。还要定期重建,电影推荐中相似关系变化不快,一天一次或一周两次足够。
3.3 向量召回与近邻搜索的选型
向量召回是把用户和电影都映射成向量,然后用向量距离表示兴趣相似度。电影向量可以从模型里得到,简单的做法是用物品共现矩阵训练一个 embedding,或者用 Word2Vec 把电影 ID 当“词”训练。用户向量则把他看过的电影向量做加权平均,最后用 Faiss 建立高维索引,在线查询 Top K。
Faiss 的索引类型要根据向量维度和数据量选。百万级电影向量、128 维,用IndexIVFFlat性价比最高:训练聚类中心,查询时先找最近的几个桶,再做精细搜索。nlist 参数我一般取4 * sqrt(N)左右,比如一百万条向量,nlist 设为 4000。nprobe 控制查询多少个桶,取 8 到 16 时召回和耗时比较均衡。注意,Faiss 索引文件不能随便跨版本用,升级 Faiss 后要重新建立索引,否则检索结果可能异常甚至直接报错。
3.4 多路召回融合的基本原则
多路召回结果合在一起,直接 concat 会出现问题:某一路召回特别多,会挤掉其他路的结果。推荐的做法是分路保底。比如每路召回固定名额分配,ItemCF 最多 500,内容召回最多 300,剩下按随机或分数混合。也可以给每路一个权重,按权重做加权采样,既保证多样性又保证主路优势。融合后的候选列表也不要直接进模型,先做一次简单的规则过滤,把已经看过的、被拉黑的、需要屏蔽的影片剔掉,再交给排序层。
4. 排序层:精排模型与样本工程
4.1 粗排和精排的区别
候选集超过 1000 后,精排模型如果做得比较重,每个请求都要算上千个样本的特征,性能和资源压力会很大。所以一般加一层粗排。粗排可以是一个轻量模型,比如双塔模型,或者直接用召回阶段的分数加权。粗排把候选从 1500 过滤到 200 左右,精排再对 200 个候选详细打分。在电影推荐项目里,如果用户量和并发不高,直接用精排跑 500 个候选也能接受,但为了留出扩展空间,我会把粗排也做进去。
粗排的特征可以少一些,比如只用用户偏好、电影热门度、与用户历史兴趣的相似度;精排特征要全,包括上下文特征和交叉特征。两个模型的训练样本可以一致,但线上部署时粗排模型要更轻。粗排不追求排序完全准确,只要别把好电影提前淘汰掉即可。
4.2 训练样本构造:分清楚正样本和负样本
精排模型需要用户的历史反馈作为监督信号。在电影场景里,正向信号可以是点击、播放完成、收藏、评分等。但这里有个关键收集问题:只在用户点过的电影里取正样本,会导致样本选择偏差。离线训练时负样本不能只从全部电影里随机抽,因为线上推荐系统实际只展示了一小部分电影,没曝光的负样本和“曝光但没点”的负样本不是同一分布。很多团队刚开始没有曝光日志,用全部电影随机负采样训练,线上效果始终上不去,后来补上曝光日志才解决。
我在构造训练集时用三条规则:
- 正样本至少定义为“曝光后发生点击或播放”的事件,播放时长超过一定阈值才算有效正样本。
- 负样本优先取“曝光后没有点击”的行为。
- 额外补充一部分随机采样负样本,比例控制在 5%-20%,帮助模型学到全局分布。
如果项目没有曝光日志,只能用点击日志训练,训练出的模型容易偏向热门电影,因为它看不到被推荐但没点的情况。这也是为什么我在第二节强调要埋曝光日志。
4.3 精排模型选型:从简单模型开始
排序模型不需要一开始就上深度模型。我在项目里先从 LR + 特征组合开始,特征是离散特征做 one-hot 或 hash,再加一些数值特征做分桶。后来改成 GBDT + LR:GBDT 自动做特征交叉,把叶子节点作为新特征喂给 LR。这套方案实现简单、效果稳定,适合中小规模推荐系统。如果数据量大,再考虑 DeepFM、DIN 这类模型,但需要更多工程投入。模型效果提升的关键往往不在模型结构,而在样本质量和特征质量。
线上部署时模型一般要输出一个分数。我用的方法是训练时目标直接设为预测用户点击概率,线上拿到概率后按概率排序。概率值本身不用校准,因为推荐场景只关心相对顺序。如果是做 A/B 实验,桶号要稳定分配,同一用户在实验期间只能进同一个桶,否则实验组之间会互相污染。可以用 user_id 哈希后对分桶数取模,把桶号写进请求日志,后续分析时按桶号聚合指标。
4.4 线上推理的速度优化
排序阶段最耗时的部分是特征读取和模型推理。模型不大时,可以把它加载到内存,用进程内的 batch 推理一次处理多个候选。如果一个请求要排 200 个候选,不要循环 200 次调用模型接口,而是把 200 个样本拼成一个 batch,一次推理完。特征读取同样可以批量:用mget一次取多个电影的特征。我在上线前做过压测,优化 batch 后单请求响应时间能下降 30%-50%。
5. 重排与业务规则:让结果真正能看
5.1 多样性打散:避免整页都是同类型
电影推荐如果只用模型分数排序,经常会出现前 10 部都是同一系列或者同类型的情况。用户看着会觉得“你是不是只会推这一种”。重排阶段常用最大边际相关性(MMR)做打散。MMR 的核心思想是:每选一个电影,既看它和用户的匹配分,也扣掉它和已选电影相似度带来的冗余。
实现上不用从零写复杂的优化算法,贪心选择就行:每轮从剩余候选中选一个“当前分数最高,同时和已选结果相似度较低”的电影。相似度可以复用 ItemCF 算好的电影相似矩阵。一个简单实现是迭代候选列表,每次保留分数最高的,同时根据已选集合对候选分数乘一个惩罚系数。惩罚系数越大,结果多样性越强,一般取 0.7 到 0.9,具体要靠 A/B 实验调。
5.2 去重、过滤与置顶规则
重排也要处理一些看起来很琐碎但很影响体验的规则。用户已经看过的电影默认不再推;最近 24 小时曝光过但没有点击的电影,要降权或过滤掉;运营指定要置顶的影片,需要保留位置。这些规则用配置中心做比硬编码好。我在项目里把规则配置成 JSON 下发给服务,比如:
{ "filter_expired": true, "watch_history_filter_hours": 168, "top_item_ids": [123, 456], "diversity_penalty": 0.8, "max_continuous_same_genre": 2 }规则引擎太重的话,可以用简单的策略模式:每个规则是一个实现类,按顺序执行,最后给出重排结果。这样业务提需求时不用改推荐主流程,只加一个规则类,交付效率会高很多。不过规则之间可能有依赖,比如置顶规则要在过滤规则之后执行,所以策略执行顺序最好也做成可配置。
5.3 人工干预的后门
再智能的推荐系统也需要人工干预的后门。比如某部电影因为版权即将下线,需要立刻从所有推荐位消失;某部电影正在做独家首播,需要全量置顶。这类需求一般通过“黑白名单+优先级调整”实现。黑名单过滤要在召回前就做,白名单置顶要在重排后处理。注意人工干预的配置最好有生效时间和失效时间,避免运营配置完忘了删除,导致推荐结果长期异常。
6. 在线服务架构与性能优化
6.1 接口分层与调用关系
在线服务的调用关系大致是:客户端请求网关 -> 推荐服务 -> 召回服务(多路) -> 特征服务 -> 排序模型 -> 重排服务 -> 返回结果。每一层之间都用接口隔离,超时时间从后往前递减。我一般这样设置:推荐服务对调用方承诺 150ms,召回阶段预算 30ms,特征读取 30ms,模型推理 50ms,重排 20ms,预留 20ms。每层都有独立的超时控制,避免下游慢请求拖垮整条链路。比如特征服务 Redis 超时设 30ms,一旦超时就返回降级特征,而不是阻塞等待。
6.2 缓存设计与降级策略
推荐结果要不要缓存?要分场景。热门榜单结果可以用短时间缓存,比如 1 到 3 分钟;个性化结果也可以做“用户级结果缓存”,缓存时间 30 秒到 1 分钟。但缓存时间太长会导致用户反馈不过来,刚刚看过的电影还会出现在推荐位里。一个折中方案是缓存推荐结果 ID 列表,但返回前实时过滤掉用户最近 1 小时的已看电影,这样既保留缓存收益,又能满足新鲜度。
长期没有缓存的冷用户请求,要防止穿透到数据库。我的做法是:对于没推荐过的新用户,直接返回热门召回结果,并写入一个短时间空缓存,避免每个请求都打到召回和排序。另外,如果下游接口大面积超时,比如 Redis 不可用,推荐服务要能切换到本地内存兜底版本。本地兜底可以只返回热门电影,体验差一点,但至少服务不挂。
6.3 推荐日志埋点与监控指标
推荐系统的迭代依赖日志。推荐服务返回结果时必须把本次请求的 user_id、候选来源、每部电影的得分、重排后的最终位置、A/B 实验桶号记录到日志。这样后续做归因分析时,才能知道“某次推荐结果为什么这么排”。埋点字段里要有 request_id 贯穿全链路,方便排查单次请求的问题。
监控方面,我重点关注三个指标:接口 TP99 响应时间、推荐结果曝光到点击的转化率、以及推荐系统中各召回路的召回占比。召回路占比如果突然变为某一路独大,说明其他召回可能出了问题。比如向量召回索引文件过期或损坏,会导致该路返回为空,整体结果被其他路填满。日志中间件最好单开一个 Kafka topic,不要和业务日志混在一起,处理离线回放时压力会小很多。
7. 常见问题与排查技巧实录
7.1 新用户和新电影的冷启动
先分类看。新用户没有行为,直接走协同过滤召回没有结果,所以要有一层基于规则或热门内容的兜底召回。同时前端可以在新用户注册时收集偏好标签,比如喜欢哪些类型、有没有特别喜欢的导演,后端把这些标签作为内容召回的依据。新电影没有用户行为,ItemCF 算不出相似度,这时要靠物品内容特征做基于内容召回,或者用“看了影片 A 的人也看了新片 B”这样的关系在 ItemCF 中加冷启动补偿。冷启动不是完全消除,而是确保不出现空推荐。
7.2 离线计算中遇到的数据倾斜
电影推荐离线阶段最容易遇到的数据倾斜是热门电影共现次数过高。如果不做处理,ItemCF 里所有电影都会和热门电影相似,导致推荐结果同质化。解决办法是给用户行为加权重,活跃用户贡献的共现值按 log 缩放,或者直接在计算时限制单个用户最多贡献 N 对共现关系。我在实践中还会对电影共现矩阵做归一化,相似度再按行做 min-max 或 z-score 归一化,控制不同电影的相似度分数在同一量级。
还有一类倾斜是用户维度的热点分区,比如某天某部电影上热搜,行为量是平时的十倍。离线任务如果按用户聚合会严重长尾,跑半天都出不来。这时可以按用户ID哈希分桶重分区,或者用 Spark 的 salted join 减少热点 key 压力。我在实践中还要对用户行为表做抽样预览,看到单 key 数据量异常时先过滤再计算,不然整个任务都会卡在某一两个分区上。
7.3 特征穿越和样本泄漏
这是推荐系统最隐蔽的问题之一。典型场景:用 10 天的点击日志构造训练样本,每条样本的标签是“用户是否点击了这部电影”,但特征里包含了“该电影当天的总点击量”。这个总点击量是样本生成时刻之后才统计的,属于未来信息。模型在训练时好像学到了规律,线上却没有当天的总点击量,所以 AUC 虚高、线上效果崩。我的排查方法是:训练完成后抽查几个高权重特征,检查它们的统计口径是否在时间上早于标签。或者直接在特征表里强制加feature_time,任何特征字段必须标明产出时间,训练任务自动校验。
7.4 缓存穿透与推荐结果不变
推荐结果一成不变,先查缓存 key 的设计。如果个性化缓存 key 里没有版本号,模型重新训练后线上结果不会更新。所以每次模型或特征版本更新时,要在 key 里加入版本号,比如rec:{user_id}:{model_version}。但这样会导致旧缓存失效瞬间有大量请求穿透到排序服务,解决办法是在服务启动时预热热用户,或者用本地缓存做二级缓存,让老版本结果继续兜底 1 到 2 分钟。
缓存穿透还可能是因为用户 key 被设置了空值缓存,但后续已经产生了新行为,缓存过期时间太长导致结果不变。解决方法:行为发生后主动删除或刷新推荐缓存,或者把缓存过期时间缩短到 30 秒内,配合前端下拉刷新。
8. 最后再分享几个我踩过的坑
8.1 离线效果好但线上效果差的真凶
如果只让我留一条经验,那就是:推荐系统在上线前一定要核对好离线特征和线上特征的一致性。我自己遇到过模型离线 AUC 涨了 3 个点,上线后点击率反而跌了的情况。最后查下来不是模型问题,而是特征一致性出了问题:离线样本中有一个字段做了 log 变换,线上服务里却忘了做。从那以后我把离线特征工程代码和线上特征服务代码抽成同一个公共库,才彻底避免同类问题。
8.2 给候选集打来源标签的习惯
另一个小技巧是给每个候选集都打上来源标签。排查问题的时候,如果你的日志里记录了每个 item 来自哪一路召回,你能在几分钟内定位到是哪一路召回拖了后腿。我之前遇到过向量召回索引过期导致某一路不返回,但因为没打标签,查了很久才发现问题。给候选打来源标签花不了多少时间,但能让后面的调优过程轻松很多。推荐系统后端的迭代是一个长期优化过程,先把数据链路和日志做好,后面算法才有发挥的余地。