Skip to content

抖音推荐流系统设计

面试场景: 字节跳动 / 快手 推荐系统 / 后端高级工程师 系统设计面试
高频指数: ⭐⭐⭐⭐⭐

题目背景

面试官常见提问方式:

"请设计抖音的推荐流系统(For You Page)。用户打开抖音,需要立即看到个性化的视频推荐,无限下滑不重复。系统需要达到亿级 DAU,首刷延迟 P99 < 200ms,你如何设计?"

业务背景:

抖音推荐流是字节跳动最核心的技术资产,也是其商业模式的基础。推荐系统决定用户每次打开 App 看到什么内容,直接影响用户留存和时长(用户日均使用时长超过 100 分钟)。与传统搜索不同,推荐不依赖用户主动查询,而是主动"猜测"用户兴趣。

抖音推荐的核心技术挑战:

  1. 实时性:用户点了"不感兴趣",下一条必须立刻变化
  2. 多样性:防止信息茧房,同时保证用户爱看
  3. 新内容消费:新发布的视频如何快速触达合适用户(冷启动)
  4. 冷启动:新用户如何快速建立兴趣模型

规模量级(2024年数据):

  • DAU:700,000,000+
  • 视频总量:15,000,000,000+(150亿)
  • 新增视频:10,000,000+ 条/天
  • 每次推荐请求返回:约 10-20 条视频
  • 推荐请求 QPS:700M DAU × 100min/day × 60s/min ÷ 30s/次 = 1400亿次/day,平均 QPS ≈ 162万次/s,峰值(2× spike)≈ 320万次/s,含预加载(×2)≈ 640万次/s
  • 首刷延迟 P99 < 200ms(含召回+粗排+精排+重排全流程)

关键指标估算

指标估算过程结果
推荐请求 QPS(含预加载峰值)700M × 100min × 60s ÷ 30s/次 ÷ 86400s = 162万/s,× 2(峰值) × 2(预加载)~640万次/s
精排模型推理 QPS每次推荐精排 ~200 个候选 × 800万请求/s理论约 1.6B/s(用批处理摊平)
Feature Store 写入700M用户 × 每分钟更新行为特征~12M 次/秒(Flink流处理)
用户兴趣模型大小700M用户 × 100维向量 × 4B~280 GB(需分布式存储)
去重布隆过滤器(Redis 热用户)每用户独立 Bloom Filter(per-user 1000 videos, FPR 0.1%,14.4 bits/element):每用户 ≈ 1.8 KB;DAU 热用户 1亿 × 1.8 KB≈180 GB(Redis),冷用户存 HBase
日志数据量700M用户 × 100次曝光 × 200B/次~14 TB/天

高层架构

核心设计决策

决策1:多路召回架构

召回的目标是从 150 亿视频中快速筛选出 2000 个候选,每路召回独立并行执行,最后合并去重。

召回路方法覆盖场景候选数量
协同过滤召回(User-CF)找相似用户,推荐他们喜欢的视频主力召回~500
Item-CF 召回基于用户喜欢的视频,找相似视频补充相关内容~300
内容召回(Content-Based)用户兴趣标签 × 视频标签匹配新视频冷启动~300
关注召回用户关注作者的最新视频维系社交关系~200
热门召回(Trending)全站热门视频(去重用户已看)兜底保证质量~200
地域召回同城/同地区热门内容本地化内容~100
搜索历史召回用户最近搜索词相关视频延续搜索兴趣~100

向量召回(ANN 检索):

协同过滤和 Item-CF 都依赖向量相似度检索:

python
# 视频 Embedding 存储在 Faiss(Facebook 开源 ANN 库)
# 每个视频 128 维 Embedding 向量(由视频内容 + 交互数据训练)

def recall_similar_videos(user_watched_video_ids, top_k=500):
    # 获取用户最近喜欢的视频 Embedding 的加权平均
    user_interest_vec = weighted_avg([
        get_video_embedding(vid) 
        for vid in user_watched_video_ids[-20:]  # 最近20个
    ])
    
    # Faiss ANN 检索(HNSW 算法,毫秒级检索150亿向量的近似结果)
    distances, video_ids = faiss_index.search(
        user_interest_vec.reshape(1, -1), 
        k=top_k
    )
    
    return video_ids.tolist()

注意:实际生产中不会对全量 150 亿视频做单一 HNSW 索引(内存需求约 900GB,即便使用 PQ 压缩也难以单机承载)。字节的实际方案是将视频向量按内容类别 + 时间窗口分片建立子索引(每个子索引约 1-5 亿向量),根据用户兴趣向量只检索相关类别的子索引;同时维护一个全量「热门视频」小索引(约 1 亿,按播放量 Top 选取)作为兜底召回。

决策2:排序三级漏斗

粗排(Coarse Ranking):

目标是快速从 2000 筛选到 1000,用轻量级模型(LightGBM/GBDT)快速打分:

特征:视频热度分、发布时间、用户与视频品类的历史交互率(离线特征为主)

精排(Fine Ranking)——DIN 模型:

DIN(Deep Interest Network)是字节系推荐的核心排序模型,关键创新是引入 注意力机制,对用户历史行为序列进行加权:

用户对候选视频的兴趣 = 注意力加权(用户历史行为序列, 候选视频)

用户看过 [搞笑视频A, 美食视频B, 搞笑视频C],推荐搞笑视频D时,历史中的搞笑视频会获得更高的注意力权重,让模型更准确地预估用户对D的偏好。

精排输出 pCTR(预估点击率)和 pVCR(预估完播率),最终分 = α × pCTR + β × pVCR(完播率权重更高)。

精排模型核心特征(DIN 输入特征示例):

特征类别特征名说明
用户画像user_age_bucket, user_gender, user_city_tier人口统计学特征
用户行为序列user_last20_watch_ids, user_last20_category_idsDIN Attention 的目标序列
用户实时行为user_ctr_1h_{category}, user_vtr_1h_{category}最近 1h 对该品类的点击/完播率
视频内容video_category, video_tags, video_duration_bucket视频元数据特征
视频统计video_7d_vtr, video_7d_ctr, video_author_fans7 天历史点击/完播率、作者粉丝数
交叉特征user_ctr_author_{author_id}, user_ctr_category_{cat}用户对该作者/品类的历史偏好
上下文特征hour_of_day, day_of_week, network_type, device_model请求时间、设备、网络环境
负反馈特征user_dislike_category_{cat}_7d用户近 7 天对该品类的不感兴趣次数

面试技巧:被问到"模型用哪些特征"时,分四类回答:用户画像特征、用户实时行为特征、物品特征、交叉特征,每类举 2-3 个例子即可。

重排(Re-ranking):

解决精排结果的多样性问题:

python
def rerank(candidates, user_id):
    # 1. 去除用户最近7天已看视频(布隆过滤器)
    bloom_key = f"watched:{user_id}"
    candidates = [v for v in candidates 
                  if not bloom_filter.contains(bloom_key, v.video_id)]
    
    # 2. 多样性控制:同一作者/标签不超过2条
    result = []
    author_count = defaultdict(int)
    tag_count = defaultdict(int)
    
    for video in sorted(candidates, key=lambda x: -x.score):
        if author_count[video.author_id] >= 2:
            continue
        if any(tag_count[t] >= 3 for t in video.main_tags):
            continue
        result.append(video)
        author_count[video.author_id] += 1
        for t in video.main_tags:
            tag_count[t] += 1
        if len(result) >= 20:
            break
    
    # 3. 广告插入(第3位、第7位插入广告,确保不连续)
    result = insert_ads(result, positions=[2, 6])
    
    # 4. ε-greedy 探索:5% 概率插入随机高质量视频(打破信息茧房)
    if random.random() < 0.05:
        explore_video = get_random_quality_video(exclude=result)
        result[random.randint(10, 19)] = explore_video
    
    return result

决策3:实时特征系统

推荐效果依赖特征的实时性,用户刚点了"不感兴趣",下一次请求就必须反映这个偏好变化。

实时特征流水线:

关键实时特征:

python
# Redis Hash 存储用户实时特征(TTL 1小时)
user_realtime_features = {
    "recent_tags": ["搞笑", "美食", "宠物"],     # 最近1h兴趣标签
    "recent_authors": ["author_123", "author_456"], # 最近互动作者
    "dislike_tags": ["广告", "政治"],             # 最近不感兴趣标签
    "last_watch_duration": 45.3,                  # 最近一次完播时长(s)
    "session_video_count": 23,                    # 本次会话已看视频数
    "negative_feedback_count": 2                  # 本次会话负反馈次数
}

在线特征存储容量规划:

规模参数数值
活跃用户 DAU7 亿
每用户实时特征大小~1 KB(约 50 个特征,Hash 存储)
热用户(在线)总存储7亿 × 1 KB = 700 GB
Redis 单节点内存上限(留 20% 余量)80 GB × 0.8 = 64 GB
所需 Redis 主节点数700 GB ÷ 64 GB ≈ 11 个主节点
含 1 副本总节点数22 个 Redis 节点

实际部署采用 Redis Cluster 按 user_id % N 哈希分片,N=11;冷用户(非当日活跃)特征存 HBase,容量不计入 Redis。

决策4:冷启动策略

新用户冷启动(没有历史行为):

python
def cold_start_recommend(user_id, device_info, location):
    # 第一步:展示热门视频(Top100,按完播率过滤低质量)
    base_videos = get_trending_videos(category="diverse", count=20)
    
    # 第二步:根据设备信息做基础个性化
    if device_info.language == "en":
        base_videos = filter_by_language(base_videos, "english")
    
    # 第三步:添加"探针视频"——覆盖不同品类,观察用户反应
    probe_videos = select_probe_videos(categories=[
        "搞笑", "美食", "音乐", "体育", "美妆", "科技"
    ])
    
    return interleave(base_videos, probe_videos)

# 用户观看3条后开始建立兴趣模型
def update_cold_start_model(user_id, watch_events):
    interested_categories = [
        event.category 
        for event in watch_events 
        if event.watch_ratio > 0.5  # 看了超过50%认为感兴趣
    ]
    
    if len(interested_categories) >= 2:
        # 已有足够信号,切换到正式推荐模型
        activate_personalized_recommend(user_id, interested_categories)

新视频冷启动(没有交互数据):

流量池机制(字节跳动公开的"赛马机制"):

新视频发布

第1层流量池:随机推送给 200 人
    ↓ 观察完播率、点赞率
完播率 > 30% → 进入第2层

第2层流量池:随机推送给 5,000 人

完播率 > 40% → 进入第3层

...逐层扩大,最终决定是否成为"爆款"

决策5:视频预加载

用户体验的关键——切换视频无卡顿:

python
# 客户端预加载策略
def preload_next_video(current_video, playlist):
    watch_progress = current_video.current_position / current_video.duration
    
    if watch_progress >= 0.5:  # 播放到50%时预加载下一条
        next_video = playlist[current_index + 1]
        
        # HTTP Range Request:只下载视频前3秒(首屏快开)
        preload_range = f"bytes=0-{next_video.first_3s_bytes}"
        http_client.prefetch(next_video.url, range=preload_range)

# 服务端:为每条视频生成首屏分段信息
video_metadata = {
    "video_id": "xxx",
    "url": "https://cdn.tiktok.com/video/xxx.mp4",
    "first_3s_bytes": 524288,    # 前3秒约 0.5MB(720p)
    "duration": 45.3,
    "bitrate_720p": 1200000      # bps
}

决策6:完播率作为核心指标

为什么完播率比点赞率更重要?

指标问题原因
点赞数可以被刷机器可以批量点赞
评论数量少噪声大只有少数用户会评论
点击率诱导性标题封面党、标题党会虚高CTR
完播率更难造假用户停留时长是真实兴趣的体现

完播率计算:

python
def compute_vtr(watch_events):
    """Video Through Rate = 完播率"""
    completed = sum(1 for e in watch_events if e.watch_ratio >= 0.9)
    return completed / len(watch_events)

# 样本标注策略
def label_sample(watch_event):
    ratio = watch_event.watch_ratio
    if ratio >= 0.8:
        return 1   # 正样本(感兴趣)
    elif ratio <= 0.2:
        return 0   # 负样本(不感兴趣)
    else:
        return None  # 中间区间不确定,丢弃(减少噪声)

详细设计

推荐请求时序

数据模型

视频索引(Elasticsearch)

json
{
  "video_id": "7123456789",
  "author_id": "author_001",
  "title": "这个猫也太可爱了",
  "tags": ["宠物", "猫咪", "搞笑"],
  "category": "宠物",
  "duration": 32.5,
  "upload_time": "2024-06-01T12:00:00Z",
  "status": "active",
  "quality_score": 0.85,
  "vtr_7d": 0.72,         // 近7天完播率
  "ctr_7d": 0.15,         // 近7天点击率
  "embedding": [0.12, -0.34, ...]  // 128维视频Embedding(存Faiss)
}

用户行为日志(用于模型训练)

user_id | video_id | action | watch_ratio | timestamp | context
-------------------------------------------------------------------
u_001   | v_123    | complete | 0.95      | 1716800000 | {session_id, device, network}
u_001   | v_456    | skip     | 0.08      | 1716800030 | {...}
u_001   | v_789    | like     | 1.0       | 1716800090 | {...}

Embedding 训练与更新流程

推荐系统中「召回用的向量」来自离线训练的双塔模型(Two-Tower Model),不是凭空产生的。

双塔模型架构:

  • User Tower:输入用户画像(年龄/地域/设备)+ 行为序列(最近 50 个点击视频 ID)→ 输出 128 维 user embedding
  • Item Tower:输入视频元数据(标签/作者/封面文本)→ 输出 128 维 video embedding
  • 训练目标:正样本(用户点击)内积 > 负样本(随机抽取)内积,in-batch 负采样

Embedding 更新频率:

场景更新策略延迟
视频 embedding每日离线重训全量T+1(约 24h)
用户 embedding实时流式更新(Flink)< 5min
新视频冷启动上传即生成(文本/封面 embedding,无点击数据)< 1min

索引更新策略(避免停服重建):

  1. 新建 shadow 索引(并行加载新 embedding)
  2. 流量灰度切换到 shadow 索引(1% → 10% → 100%)
  3. 旧索引下线

新视频从上传到进入召回的延迟约 1-2 分钟(封面/标题 embedding);基于用户行为的 embedding 需等到足够点击数据积累(通常 1 小时内获得初始效果)。

踩过的坑 / 生产经验

坑1:信息茧房——越推越窄

现象: 用户开始多样浏览,但推荐系统优化 CTR 后,给用户推的越来越集中在一个品类(比如用户稍微对健身感兴趣,系统就全部推健身视频),导致用户抱怨"只有一种内容",最终流失。

解决方案:ε-greedy 探索机制

python
def add_exploration(final_list, user_id, epsilon=0.05):
    n = len(final_list)
    explore_count = max(1, int(n * epsilon))  # 至少1条探索视频
    
    for _ in range(explore_count):
        # 从用户较少接触的品类中随机选取高质量视频
        underexplored_categories = get_underexplored_categories(user_id)
        explore_video = get_quality_video_from_category(
            category=random.choice(underexplored_categories),
            min_vtr=0.4  # 质量兜底
        )
        
        # 替换列表中靠后位置的一条(不影响前几条体验)
        replace_idx = random.randint(n // 2, n - 1)
        final_list[replace_idx] = explore_video
    
    return final_list

坑2:重复推送——用户已看过的视频再次出现

现象: 用户今天刷了某搞笑视频,第二天同一条视频又出现在推荐流中。用户体验极差。

解决方案:用户级布隆过滤器

python
# 每个用户维护一个布隆过滤器,记录最近7天看过的视频
# 误判率 0.1%(10个里最多漏掉1个,可接受)
# 存储在 Redis,Key = watched_bloom:{user_id},TTL 7天

class UserWatchedBloom:
    def __init__(self, user_id, capacity=10000, error_rate=0.001):
        self.key = f"watched_bloom:{user_id}"
        self.bloom = BloomFilter(capacity, error_rate)
    
    def add(self, video_id):
        self.bloom.add(str(video_id))
        redis.set(self.key, self.bloom.serialize(), ex=7*86400)
    
    def is_watched(self, video_id):
        cached = redis.get(self.key)
        if not cached:
            return False
        bf = BloomFilter.deserialize(cached)
        return bf.check(str(video_id))

布隆过滤器容量分析(为什么不用全局单一 Bloom Filter):

若使用全局方案(所有用户近 7 天看过的视频合并一个 Filter):

  • 元素总量:700M 用户 × 7天 × 100 视频/天 = 490 亿 elements
  • 1 bit/element(FPR ≈ 10%):490亿 ÷ 8 ≈ 61 GB(误判率过高,不可用)
  • 0.1% FPR(14.4 bits/element):490亿 × 14.4 ÷ 8 ≈ 882 GB → 远超 Redis 单集群承载能力

实际方案:每用户独立小 Bloom Filter,仅记录该用户最近看过的 1000 个视频(FPR 0.1%):

  • 单用户大小:1000 × 14.4 bits ÷ 8 ≈ 1.8 KB
  • 热活跃用户(DAU = 1 亿)存 Redis:1亿 × 1.8 KB = 180 GB(可承载)
  • 冷用户(非当日活跃,约 6 亿)存 HBase,推荐时按需加载,用后回写

坑3:模型更新不及时导致推荐"过时"

现象: 用户对某话题(比如"世界杯")突然感兴趣,但离线模型每天才更新一次,第二天才能捕捉到用户的新兴趣。推荐严重滞后。

解决方案:离线+在线双通道

  • 离线模型(每天更新):捕捉用户长期偏好,训练全量特征
  • 在线实时特征(Flink,<1s 延迟):捕捉用户本次会话行为(最近 N 条交互),实时拼接到精排特征中
  • 在线特征权重 > 离线特征权重,让最新行为快速影响推荐

坑4:精排 GPU 推理延迟不稳定(P99 超标)

现象: 精排使用深度学习模型(GPU 推理),正常 P50 约 50ms,但 P99 偶尔超过 500ms,导致整体推荐 P99 超标。

解决方案:

  1. 批处理推理:将多个用户的精排请求合并成 batch,提高 GPU 利用率(NVIDIA TensorRT 优化)
  2. 超时降级:精排 > 150ms 时,降级使用粗排结果作为最终结果
  3. 模型量化:FP32 → INT8 量化,推理速度提升 2-4 倍,精度损失 < 1%

扩展考点

追问方向

Q:如何评估推荐系统的效果(离线评估 + 在线评估)?

  • 离线指标:AUC(预估点击率的排序能力)、GAUC(分用户计算 AUC 再平均,更公平)
  • 在线指标(A/B Test):CTR、完播率、人均时长、留存率(次日/7日留存)
  • A/B Test 流程:流量分桶(用户级别,保证同一用户在实验期内稳定分组)→ 双侧 t 检验判断显著性 → 灰度放量 → 全量上线

Q:如何处理推荐系统中的位置偏差(Position Bias)?

靠前的视频天然有更高的点击率(不一定是因为视频质量好,而是因为排在第一位),如果直接用原始 CTR 训练模型,会强化位置偏差。解决方案:

  • 训练时加入位置特征,让模型学会区分"因为位置好才被点"和"因为内容好才被点"
  • Inverse Propensity Score(IPS)纠偏:给靠后位置被点击的样本更高权重

Q:推荐系统的架构如何应对 8M QPS?

  • 召回层:无状态服务,水平扩展

  • Feature Store:Redis Cluster(Partition by user_id),读 QPS 约 8M × 10特征读取 = 80M/s(Redis Cluster 可支撑)

  • 精排 GPU 集群:按 QPS 弹性扩缩容(K8s + GPU 节点池)

  • "推荐 Embedding 是怎么训练的?" → 双塔模型(Two-Tower),用户塔和视频塔各自独立编码,in-batch 负采样训练,离线每日全量重训 + 用户侧 Flink 实时增量更新

  • "新上传的视频多久能被推荐到?" → 基于内容 embedding(文本/封面)约 1 分钟进入召回池;基于行为 embedding 需积累点击数据,冷启动期靠内容 embedding + 热门兜底召回

边界 Case

  • 用户网络差:返回低码率视频(480p)+ 更少的预加载
  • 连续高频刷新(机器人):限流(同一用户 1 秒内最多 2 次推荐请求)
  • 视频被举报/违规下线:实时从推荐池中移除(布隆过滤器 + 黑名单 Set)
  • 用户清除历史记录:清除布隆过滤器 + 用户兴趣模型重置为冷启动状态

演进路径

v1.0:基于规则的推荐(热门 + 随机)

v2.0:协同过滤 + 内容召回

v3.0:Deep Learning 精排(Wide&Deep)

v4.0:实时特征 + DIN 注意力模型

v5.0:多目标优化(完播率 + 时长 + 互动 + 多样性 联合建模)

v6.0:强化学习推荐(以长期留存为reward,而非短期点击)

监控与告警指标

指标类型告警阈值说明
recall_latency_ms{stage="cf"}HistogramP99 > 50ms 触发告警协同过滤召回耗时,超时触发降级(仅用热门召回)
ranking_model_p99_latency_msHistogramP99 > 80ms 触发告警精排模型推理耗时(含特征拼接),GPU 利用率联动监控
feature_store_freshness_msGauge> 10000ms 触发告警实时特征延迟(用户最近行为到特征服务的延迟)
bloom_filter_false_positive_rateCounter> 0.1% 触发告警去重布隆过滤器误判率,高于阈值需扩容或重建
cold_start_coverage_rateCounter< 99% 触发告警新用户能获得推荐结果的比例
recommendation_diversity_scoreGauge< 0.6 触发告警推荐多样性分数(基尼系数),低于阈值说明信息茧房加剧

面试评分维度

维度基础分(60分)加分项(80+分)满分项(100分)
整体架构说出召回+排序两阶段说清楚三级漏斗(召回→粗排→精排→重排)完整的数据流 + 各阶段延迟预算分配
召回设计知道协同过滤和热门召回说出多路并行召回+合并去重ANN向量检索、流量池冷启动机制
排序模型知道机器学习排序说出Wide&Deep / DIN模型特点完播率作为核心目标,位置偏差处理
实时性提到实时特征说出 Flink 实时处理用户行为Feature Store 三层架构(实时/近实时/离线)
工程挑战GPU 推理延迟问题预加载策略,P99降级布隆过滤器去重 + ε-greedy 探索机制
生产经验了解信息茧房问题提出 ε-greedy 探索解法结合实际案例(模型更新滞后、GPU P99超标)说出优化方案