
1. 从零搭起推荐系统后端为什么我选了Django而不是其他框架做短视频推荐系统很多人第一反应是上Spring Cloud那一套微服务或者直接拿Python的FastAPI写几个接口完事。但我这次的项目偏偏选了Django而且是在已经预估到推荐模块计算量不小、并发压力也存在的前提下做的选择。原因不复杂Django自带的那套全家桶在一个人或者一个小团队从零开始做完整系统时能省下来的时间远比想象中多。先别急着争论框架优劣我实际对比过。FastAPI的异步性能和轻量确实有优势但问题在于短视频推荐系统不只是推荐算法本身它还需要管理用户体系、视频资源、行为日志、后台审核、数据看板总要有一个能把这些东西统一管起来的底座。Django的ORM、Admin后台、认证系统、迁移工具这些开箱即用的能力在这个场景下非常匹配。我一个人从数据库表设计到接口联调再到后台管理不需要额外引入太多第三方组件。另外Django的ORM在建模用户-视频-行为这对关系上相当顺手。推荐系统里最核心的动作就是记录用户看了什么、给什么点过赞、在哪个视频上停留了很久这些天然就是多对多的关系模型。用Django的模型继承和ForeignKey直接搭好表结构后续写推荐逻辑时不必反复写SQL拼接逻辑顺序要清晰得多。再谈到项目的资料配套。这套系统最终要交源码、论文和答辩PPT技术上选Django还有一个隐性好处它的项目结构本身就是一种可讲解的架构范式。模型、视图、服务、任务、配置分得清清楚楚写论文时画架构图、描述模块划分非常顺畅Manager和Service层可以一一对应。如果选了FreeMarker或模板渲染逻辑和业务逻辑混在一起论文里怎么写清楚模块划分都会麻烦。提示实际的系统开发中框架选型取决于项目规模和团队构成。一个人开发完整系统Django的电池齐全优势大于性能损耗如果是大团队高并发项目当然有更好的选择。我这里说的是从零到完整上线的场景。2. 推荐的本质是匹配问题数据模型与特征设计2.1 核心模型用户、视频、行为日志的建模思路短视频推荐系统的底层数据结构绕不开三张核心表用户表、视频资源表、用户行为表。但我真正踩过坑的地方在于行为日志表的设计——很多初学者把它当成简单的操作记录放几个字段就完事了结果后面做特征提取时发现数据根本不够用。我采用的设计是保留一份完整的行为日志明细表核心字段包括用户ID、视频ID、行为类型曝光、观看、点赞、收藏、分享、不喜欢、行为时间、观看时长、观看时长占视频总时长比例、行为来源推荐流、搜索、关注页。后面几个字段尤其关键。举例来说光记录用户看了视频A没有用要判断用户是否真的喜欢视频A更重要的是他看了多久、看到什么时候划走的、是不是推荐流给的曝光还是用户自己搜出来的。视频资源表的字段也不能只存标题和地址。我额外做了视频时长、封面图、分类标签多对多、上传时间、作者信息、基础热度值、平均完播率缓存字段。这些字段在推荐排序时会直接参与特征计算后期配合定时任务定期刷新。用户表除了常规注册信息我加了一个用户兴趣向量快照字段用的是JSON格式存储。这个字段看起来冗余但作用很大用户在每次登录、每次刷新推荐流时可以直接读取上次计算好的兴趣向量不必每次临时跑一遍完整的历史行为统计。具体的兴趣向量更新由异步任务完成避免推荐接口同步算特征。设计原则补充在行为日志表上一定要做索引。我最初没加索引结果行为量到几万条时查询就开始拖慢整体响应后来在user_id和video_id上加了联合索引性能问题立刻缓解。2.2 特征工程不是所有行为都值得平等对待推荐系统的数据模型建好了接下来才是重头戏怎么把原始行为日志变成能参与计算的用户特征和视频特征。我在特征处理上做的一个关键决定是行为加权分桶。不同类型的用户操作代表不同的兴趣强度直接全部平铺处理会导致推荐结果失真。实际权重我大致设置如下行为类型权重说明曝光0.1系统推了但用户没反应弱正反馈完整观看1.0用户看完了强正反馈点赞2.0主动表达喜欢收藏3.0强烈兴趣有重复消费意图分享3.5愿意把视频扩散给他人不喜欢-5.0明确负反馈需要降权处理观看时长的处理也比较特殊不是直接拿秒数用而是先算完播比观看时长除以视频总时长再映射到0到1区间的分数。例如一个3分钟的视频看了1分钟和30秒的短视频看了1分钟参照系完全不同。映射函数我用了分段线性映射并做了饱和截断长视频看一半比短视频看一半往往更能说明用户对内容有耐心。用户特征最终汇总成三个维度的向量兴趣类别分布、活跃时段偏好、内容消费深度。这里的内容消费深度是我自己加的维度用来衡量一个用户倾向于看热门爆款还是长尾小众内容后续做召回策略选择时会用到。这些特征都定期通过Celery异步任务从行为日志中重新统计再写回用户画像表。2.3 冷启动问题的处理新用户没有历史行为数据推荐系统最容易陷入没法猜测喜好的尴尬。我做了三个层次的冷启动策略第一注册时选兴趣标签。用户注册时选择3-5个感兴趣的分类标签系统根据标签直接生成初始兴趣向量。这部分向量参与召回但不参与精排避免初始偏差被过度放大。第二热门内容兜底。新用户的推荐流前N条以全局热门为主穿插少量分类热门。原理上就是利用群体行为缓解个体数据缺失。第三快速试探机制。新用户的前几次刷新系统会刻意混入多个不同类别的内容通过点击和观看时长反馈迅速修正兴趣向量。这个策略在推荐系统里叫exploration缺点是短期可能让用户觉得推得不准但长期收益显著。经验新用户的推荐效果评估不能看单次会话要看注册后一周内的次日留存和人均观看时长有没有提升。用这两个指标来调试探机制的力度比主观感觉要靠谱得多。3. 从协同过滤到向量召回推荐算法在Django中的落地细节3.1 两阶段推荐架构召回与精排任何推荐系统在线上运行都要考虑一个现实问题全量计算用户对所有视频的兴趣分数在线场景下根本做不到。所以业界通用做法是拆成召回和精排两个阶段。我在这个项目里也是按照这个架构来实现的。召回阶段的目标是从海量视频库中快速筛出几百个候选视频。这阶段不在乎精度只在乎速度与覆盖率策略可以叠加多个基于用户的协同过滤找相似用户看过的东西、基于物品的协同过滤找相似视频、热度召回、分类偏好召回、新鲜内容召回等。精排阶段的目标是对这几百个候选视频精算分数并排序输出。这阶段我用了加权特征融合模型对视频的多个特征维度热度、时效性、与用户兴趣的匹配度、行为预测分数做归一化后线性加权输出最终排序分数。召回和精排分开的意义在于召回可以用更重的算法但离线完成精排在线上只做轻量计算两者相互配合既保证效果也保证响应速度。这也是当时答辩时被追问得最多的设计讲清楚为什么要拆开基本就等于把推荐系统的工程思维展示明白了。3.2 协同过滤算法的Django实现我在项目里先落地了**基于物品的协同过滤ItemCF**作为基础推荐算法原因很简单短视频场景里用户量远大于视频量物品之间的相似度计算和维护成本更低。ItemCF的思路并不复杂如果用户A和用户B都对视频X表现过正反馈而视频Y也被用户A喜欢过那么视频Y和视频X之间存在相似性可以推荐给用户B。实际实现时算法分这么几步第一步从行为日志中筛出所有正反馈行为点赞、收藏、完整观看、分享。第二步构建用户-视频倒排表即每个用户对应一份他交互过的视频ID列表。第三步计算视频两两之间的共现矩阵视频被同一个用户喜欢过的次数越多相似度越高。第四步为每个视频取TopN相似视频存为离线结果表。最后一步很关键候选集表不能每次实时计算。我做法是每天凌晨用Celery定时任务全量跑一遍把视频ID - 相似视频TopN含相似度分数存进一张Redis或数据库表。线上推荐时找到用户最近正反馈的视频直接从这个表里捞候选集。这里面有个细节容易被忽略计算视频相似度要不要做惩罚不做惩罚的话热门视频和所有视频的相似度都会虚高导致推荐结果趋同。我引入了热门惩罚系数共现次数除以视频流行度的对数可以在一定程度上缓解热门物品过度中心化的问题。# 简化版ItemCF核心计算逻辑 def build_item_similarity(interaction_dict): interaction_dict: {user_id: [video_id, ...]} co_occur_count {} # (video_a, video_b) - 共现次数 video_popularity {} # video_id - 被交互用户数 for user, videos in interaction_dict.items(): for v in videos: video_popularity[v] video_popularity.get(v, 0) 1 for i in range(len(videos)): for j in range(i 1, len(videos)): a, b videos[i], videos[j] co_occur_count[(a, b)] co_occur_count.get((a, b), 0) 1 co_occur_count[(b, a)] co_occur_count.get((b, a), 0) 1 similarity {} for (a, b), cnt in co_occur_count.items(): # 热门惩罚cnt / (pop_a * pop_b) 的某个幂次alpha在0~1之间 alpha 0.6 denom (video_popularity[a] ** alpha) * (video_popularity[b] ** alpha) similarity[(a, b)] cnt / denom return similarity3.3 向量召回用Embedding弥补协同过滤的泛化短板ItemCF的问题在于冷启动和稀疏性。新视频没有任何交互记录根本无法被相似度计算纳入体系用户行为稀疏时候选集也很有限。为了弥补这个短板我在项目中引入了向量召回方案。做法是借助内容标签生成视频的Embedding向量。短视频在上传时会打上分类标签我把标签通过哈希映射生成一个稀疏向量再通过SVD降维成稠密Embedding。用户的Embedding则由他交互过视频的Embedding加权平均得到。这样算相似度时不再依赖共现矩阵而是直接算余弦距离。这一步在工程上难度其实不大难的是如何把数据转换流程做成常态化任务。我的方案是标签向量存储在PostgreSQL的JSON字段中离线脚本定期读取标签并更新视频Embedding表用户Embedding表则在用户每次交互行为后异步更新。用向量召回替换掉一部分ItemCF召回好处是新视频一旦有了标签就能进入推荐池不需要等待积累行为。实测下来新视频的曝光率提升了不少。3.4 排序层的特征加权与分数归一化精排阶段的本质问题可以概括为假设召回来的候选集有100个视频怎么把这100个排出一个让用户满意且平台目标也达成的顺序。我实现的精排采用多特征加权求和每个特征先归一化到0-1区间再乘以权重系数。归一化这一步特别容易踩坑——不同特征的量纲完全不同不归一化直接加权数值大的特征会完全支配结果。我用的是最大值最小值归一化并针对分布不均的特征加了log平滑避免单个离群值把整个序列压扁。实际参与精排的特征有这么几个用户与视频类别匹配度、视频热度分、视频新鲜度分、用户对视频作者的过往偏好、视频完播率预测分用视频历史完播率近似。初始权重靠经验设置上线后用线上数据持续调参。# 精排打分示例 class RankService: WEIGHTS { category_match: 0.35, hotness: 0.25, freshness: 0.15, author_preference: 0.15, completion_rate: 0.10, } def score(self, features: dict) - float: normalized {k: self._normalize(v) for k, v in features.items()} total 0.0 for key, weight in self.WEIGHTS.items(): total normalized.get(key, 0) * weight return total我建议精排模块的权重不要硬编码在视图函数里单独拆一个RankService类后续调参只需要改配置或加一张权重表不用动业务代码。当时这么做了之后后期调参省了很多事情。4. 缓存、异步与队列高并发场景下的性能优化实践4.1 Django同步接口的性能瓶颈在哪里推荐系统的核心接口是获取推荐流前端每次下拉刷新都会请求这个接口。如果接口内部要实时跑一遍用户行为查询、视频候选集查询、特征拼接、排序打分那响应时间一定撑不住。我用一个形象的说法来描述这个场景假如用户打开App看到10个视频背后可能要处理这个用户过去三周的几百条行为记录从候选表里捞出几百个视频再逐个算出特征分。这些计算如果放在Django的同步视图里同步执行用户请求会被迫等几百毫秒甚至几秒对视频类产品来说是灾难。所以我在架构设计时做了三件重要的事本地缓存热数据、异步任务处理重计算、批量接口读取降低数据库压力。4.2 本地缓存Redis缓存分层推荐结果不是每次请求都要实时计算的。用户在短时间内反复刷推荐流看到的应该是连续刷新的不同内容但如果几十秒内连续请求系统完全可以直接返回一份缓存的推荐列表。我做了两级缓存第一级是本地进程内缓存只缓存当前推荐接口最近一次返回结果的JSON字符串TTL设置30秒第二级是Redis缓存为每个用户存放最近三批推荐结果的视频ID列表TTL设10分钟。用户每次请求推荐流时Django视图先读本地缓存没有则读RedisRedis也没有才走完整的回归计算链路。关键数据的处理路径大约能减少80%以上的重复计算。实际测试中推荐流接口的P95响应时间从接近1000ms降到了200ms以内这个性能提升可以直观感受到。缓存过期后的重建过程也要注意我单独封装了一个函数用Redis的分布式锁保证同一时刻只有一个线程在计算某个用户的推荐结果防止缓存雪崩时大量请求同时击中后端导致数据库压力飙升。# 缓存读取与重建的简化逻辑 def get_recommendations(user_id, page): cache_key frec:{user_id}:{page} result cache.get(cache_key) if result is not None: return result with redis_lock(flock:{cache_key}, timeout5): result cache.get(cache_key) # 双重检查 if result is None: result build_recommendation_list(user_id, page) cache.set(cache_key, result, ex600) return result4.3 Celery异步任务与定时推荐预计算除了缓存推荐系统里还有一类天然适合异步化的任务周期性离线计算。比如每日用户兴趣向量更新、每日视频相似度表更新、每日全局热门榜计算这些任务都不需要用户实时触发只需要每天定时跑一次。我引入了Celery消息队列框架配合Redis作为Broker。定时任务通过Celery Beat调度核心任务包括每天凌晨2点重建视频相似度表每天凌晨3点更新所有活跃用户的兴趣向量每30分钟更新一次全局热门视频榜每10分钟扫描新上传视频并生成标签EmbeddingCelery任务跑完后把结果写回数据库或Redis缓存线上推荐接口只负责读这些已经算好的中间结果。这样做了之后推荐接口的核心业务逻辑退化为三个步骤查缓存、读候选表、排序输出复杂度大大降低。经验如果项目部署的内存比较有限Celery的Worker数量不要开太多。实际经验是4个Worker左右基本能满足中等规模内容平台的吞吐需求设置过高会导致内存耗尽引起不必要的OOM问题。4.4 数据库层面的索引与读写分离思考即使有了缓存和异步传统关系型数据库依然是系统的底座索引设计没有做好缓存失效的那一瞬间就是灾难。我在行为日志表上的索引设计是这样的联合索引(user_id, created_time)、联合索引(video_id, behavior_type)、单独索引(created_time)。视频表上加了联合索引(category_id, status, created_time)。这些索引的建立依据完全来自查询模式——先确认线上SQL的WHERE条件组合再创建对应索引而不是盲目把所有字段都加索引。读多写少的场景下后期也可以考虑读写分离把行为日志写入操作分到从库推荐接口读取走主库。项目规模不大时这个配置不是必须的但预留了这样的方向。数据库层面还有一个隐藏的坑行为日志表的数据量增长很快如果不做分区或者定期归档一年后查询性能会明显恶化。我的做法是每个月月初手动归档上个月数据到历史表线上只保留近90天的热数据更早的数据离线分析用。5. 反馈闭环采集用户行为数据并持续修正推荐结果5.1 曝光与点击日志的准确埋点推荐系统上线只是第一步真正的价值在于根据线上反馈持续修正算法。而反馈数据从哪里来从用户实际的使用行为中来。这就要求埋点必须准确。我定义了一套前端上报协议前端在推荐流视频卡片进入视野至少1秒后上报曝光事件用户点击播放时上报点击事件播放结束后上报完播事件并携带观看时长。每个事件都包含用户ID、视频ID、请求批次ID、位置序号等字段。批次ID很重要它能把用户看到了第几个位置的视频和用户是否点击了该视频关联起来是后续计算推荐位点击率的基础。埋点上报统一走一个Django API接口先放入内存缓冲队列批量写入数据库而不是每条日志一次写操作。这样可以有效降低写压力减少对推荐接口的干扰。5.2 离线指标计算与推荐效果评估推荐系统上线后我一直关注三个核心指标推荐位点击率CTR、人均观看时长、次日留存率。这三个指标分别反映推荐系统能不能吸引用户点、点进来能不能留住用户、用户愿不愿意再来。我写了一个离线统计脚本每天晚上把当日曝光与点击日志聚合算出整体CTR以及各个推荐位置的CTR差异。从位置维度的数据里能看出不少门道比如第3到第5位的视频点击率通常最高第1位反而不是刷到第10个视频以后用户点击意愿明显下降说明用户已经到了疲劳区下一批内容需要调整策略。人均观看时长则需要与视频播放记录关联统计用户在推荐流中实际观看的总时长除以活跃用户数。这个指标与推荐质量直接相关如果推荐的都是用户不感兴趣的内容观看时长一定上不去。5.3 在线A/B实验不要拍脑袋改算法修改推荐策略时最忌讳的是拍脑袋上线。我一开始调试时也犯过这个错误把协同过滤的召回数量从50改到100直接全量上线结果第二天数据波动分不清是正常波动还是策略带来的变化。后来我落地了最简单的A/B实验机制在用户表上加一个实验分组字段推荐接口根据用户所在分组走不同的策略版本离线对比两个版本的CTR和人均观看时长。这样就能比较客观地评估策略变化的实际影响。举一个实际例子把不喜欢按钮的负反馈权重从-5.0调整到-8.0后实验组的推荐位CTR没有明显下降但用户取关率下降了说明更激进的负反馈处理能抑制用户对推荐流的反感。建议任何推荐策略改动灰度上线是底线。先放5%的用户流量跑一天对比核心指标后再逐步放量能有效避免策略失误影响全部用户。6. 部署上线前的自检清单与避坑经验6.1 数据一致性问题推荐列表不能出现已下架视频视频资源会存在审核下架、作者删除等情况但缓存和候选表可能仍然保留了这些视频的ID。用户刷新推荐流时如果刷到了不存在或者无法播放的内容体验影响会很明显。我在部署前做了一轮数据清洗核心思路是每天定时任务扫描线上推荐候选集状态把已下架视频从各张候选表和缓存中移除。同时推荐接口读取最终视频详情时做一次状态过滤保证输出的每条视频都是可播放状态。这个看似不起眼的问题实际影响很直接。第一次上线时因为没有数据处理环节测试阶段就出现了黑屏视频混在推荐流里的问题后来专门补了这道过滤。6.2 部署架构与配置参数整个系统部署在单台服务器上架构是Nginx作为前端静态资源服务和反向代理Gunicorn作为Django应用服务器Celery Worker处理异步任务Redis承载缓存与消息队列PostgreSQL作为主存储。Gunicorn的Worker数量我设置为CPU核心数的2倍加1。这个配置在大部分场景下算经验值既充分利用多核又不至于因为Worker过多导致上下文切换开销过大。再看Celery的并发数我设置为4配合内存上限配置能稳定运行较长周期。提示Django项目的SECRET_KEY、数据库密码等敏感信息不要硬编码在settings.py中我习惯放到环境变量文件里部署时单独加载。这样做既避免泄露又方便不同环境切换配置。6.3 常见故障及应对方式这里整理几个部署和测试阶段最常踩的问题按照从高到低的发生频率列出问题根因应对方式Redis连接池耗尽并发请求量超过默认连接池大小在Django Redis配置中设置连接池上限并开启连接复用Celery任务堆积任务生产速度大于Worker消费速度拆分任务优先级高优任务单独队列降低冗余任务频率数据库连接数达到上限Gunicorn Worker数乘以每个Worker的数据库连接数过高使用连接池管理数据库连接设置最大连接数推荐接口偶发超时Redis缓存失效瞬间触发了全量重算调整缓存TTL优化重建链路的查询计划6.4 论文与答辩准备中的心得这篇项目的论文写作和答辩思路我有一条主线需求分析-系统设计-算法设计-工程实现-效果评估。这个逻辑不只是为了论文结构本身也是做项目的完整思路。论文中一定要有算法对比实验这是答辩的加分项。我对比了只用热门推荐的baseline和加入协同过滤向量召回后的效果明确数据显示推荐策略的加入能让CTR和人均观看时长明显提升。实验过程也能反过来验证系统中的算法实现是真实有效的而不是摆设。答辩时最容易遇到的追问是你的推荐算法相比业界最新模型有什么不足这种问题不需要辩解更值得做的是诚实承认当前方案在特征表达能力和模型复杂度上的局限同时说明下一步的改进方向——比如引入深度神经网络模型替换线性加权排序。这样的回答体现的是思考深度而不是一味推卸。7. 写在最后的几点提醒整个项目从零搭建到完成我最深的体会是推荐系统的工程难度不在算法推导而在于把算法安稳地放进业务系统里跑起来。数据清洗、特征存储、缓存更新、异步调度、效果评估这些工程细节拼在一起才构成了一个真正可用的推荐产品。如果你打算复刻这个项目我给三个优先级的建议数据库建模时重视行为日志表的扩展性给它预留字段。后续每次调整算法都可能会需要新的特征来源。缓存和异步任务要在项目一开始就设计好不要等接口压测不过了才回头补。返工的成本远高于一开始就做好规划的成本。给自己留出至少两周的测试和调优时间推荐效果和接口性能都不是一天能调出来的。最后再分享一个小技巧把推荐系统里的每个算法模块都设计成可以独立开关的插件模式。比如召回阶段可以只开热度召回精排阶段可以只算热度分。这样排查线上问题时能通过逐一关闭模块快速定位是数据问题还是算法问题。我在项目debug时靠这个习惯节省了大量时间。