設計動態消息(News Feed)

問題定義

設計社群媒體的動態消息功能:用戶發布貼文後,他的追蹤者能在動態牆看到按時間或相關性排序的內容。

ℹ️兩個核心子系統

  1. Feed Publishing:用戶發布貼文後,把貼文分發給追蹤者
  2. Feed Retrieval:用戶打開 App 時,取得個人化的動態消息列表

需求分析

Fan-out on Write vs Fan-out on Read 對比

| 比較維度 | Fan-out on Write(推模式) | Fan-out on Read(拉模式) | |----------|--------------------------|--------------------------| | 動作時機 | 發布貼文時立即執行 | 用戶打開動態牆時才執行 | | 讀取延遲 | 極低(直接讀 Cache) | 較高(需即時聚合多個來源) | | 寫入成本 | 高(每個粉絲都寫一份) | 低(只寫入作者自己的 Post 列表) | | 適合對象 | 粉絲數 ≤ 10 萬的普通帳號 | 粉絲數 > 10 萬的名人帳號 | | 資料新鮮度 | 發布後秒級可見 | 取決於查詢時的聚合速度 | | 儲存成本 | 高(每個粉絲一份 Feed Cache) | 低(只存作者的 Post 列表) | | 實務代表 | 早期 Twitter(全推模式) | Instagram 對名人帳號的處理 |

選擇標準(Decision Matrix)

注意事項

⚠️名人問題(Celebrity Problem)

一個有 1,000 萬粉絲的名人發一篇貼文,Fan-out on Write 需要寫入 1,000 萬份快取。這會造成巨大的寫入延遲和資源消耗。實務上對名人帳號使用 Fan-out on Read,普通帳號使用 Fan-out on Write — 混合策略。

數字感受:假設 Redis 單次寫入延遲 0.5ms,寫入 1,000 萬份快取需要約 5,000 秒(即使 100 個 Worker 平行處理也要 50 秒)。這 50 秒的延遲意味著名人發文後近一分鐘粉絲才看到,完全不可接受。改用拉模式後,名人發文只需 1 次寫入,粉絲查詢時才拉取,延遲問題消失。

設計流程

1

貼文發布

用戶發布貼文,存入 Post 資料庫

2

Fan-out 決策

判斷發布者粉絲數,選擇推模式或拉模式

3

推模式執行

普通用戶:非同步寫入每個粉絲的 Feed Cache

4

Feed 查詢

用戶請求動態牆:先讀自己的 Feed Cache

5

拉模式補充

再拉取名人帳號的最新貼文,合併排序

6

Ranking 排序

用排名演算法(時間+互動+相關性)決定最終順序

架構設計

Fan-out on Write(推模式)

csharp
public class FeedPublisher
{
    private readonly IMessageQueue _queue;
    private readonly IFollowerService _followers;
    private readonly IFeedCache _feedCache;
 
    public async Task PublishPost(Post post)
    {
        // 1. 儲存貼文
        await _postRepo.SaveAsync(post);
 
        // 2. 取得粉絲清單
        var followers = await _followers.GetFollowerIdsAsync(post.AuthorId);
 
        // 3. 名人帳號(粉絲 > 10 萬)用拉模式,不做 Fan-out
        if (followers.Count > 100_000)
        {
            await _hotAuthorIndex.AddAsync(post.AuthorId, post.Id);
            return;
        }
 
        // 4. 普通帳號:非同步推送到每個粉絲的 Feed Cache
        foreach (var batch in followers.Chunk(1000))
        {
            await _queue.PublishAsync("feed.fanout", new FanoutTask
            {
                PostId = post.Id,
                FollowerIds = batch.ToList()
            });
        }
    }
}
 
// Fan-out Worker
public class FanoutWorker
{
    public async Task Execute(FanoutTask task)
    {
        foreach (var followerId in task.FollowerIds)
        {
            // 寫入每個粉絲的 Feed Cache(Redis Sorted Set)
            await _feedCache.AddToFeedAsync(followerId, task.PostId, timestamp);
            // 保留最新 500 篇,淘汰舊的
            await _feedCache.TrimFeedAsync(followerId, 500);
        }
    }
}

Feed 查詢(混合策略,TypeScript)

typescript
async function getFeed(userId: string, page: number): Promise<FeedItem[]> {
  // 1. 從 Feed Cache 取得推模式的貼文(普通帳號發的)
  const cachedPostIds = await redis.zrevrange(
    `feed:${userId}`, page * 20, (page + 1) * 20 - 1
  );
 
  // 2. 取得此用戶追蹤的名人清單
  const hotAuthors = await getFollowedHotAuthors(userId);
 
  // 3. 拉取名人的最新貼文
  const hotPosts = await Promise.all(
    hotAuthors.map(authorId =>
      redis.zrevrange(`posts:${authorId}`, 0, 5)
    )
  );
 
  // 4. 合併、排序、去重
  const allPostIds = [...new Set([...cachedPostIds, ...hotPosts.flat()])];
  const posts = await batchGetPosts(allPostIds);
 
  // 5. Ranking(簡化版:時間衰減 + 互動分數)
  return posts
    .map(post => ({
      ...post,
      score: calcScore(post.timestamp, post.likes, post.comments)
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 20);
}

架構圖

User (Publisher)
publish post
Post Database
trigger
Fan-out Service
fan-out tasks
Message Queue
consume
Fan-out Workers
write to each follower
Feed Cache (Redis)
User (Reader)
get feed
Feed Service
read cached feed
Ranking Service

上圖展示了推拉混合架構的資料流。左半部是「寫入路徑」:用戶發布貼文後,Fan-out Service 判斷是否需要推送,透過 Message Queue 將任務分配給多個 Worker 平行寫入粉絲的 Feed Cache。右半部是「讀取路徑」:用戶查看動態牆時,Feed Service 同時從 Feed Cache 讀取推模式的貼文,並即時拉取名人帳號的最新貼文,最後交給 Ranking Service 合併排序。

實戰補充

💡Ranking 排序演算法

現代社群平台不再只用時間排序,而是綜合多個訊號:

  1. 時間衰減(Time Decay):越新的貼文分數越高。常見公式:score = baseScore * e^(-lambda * hoursOld),其中 lambda 控制衰減速度,通常 0.1-0.3 表示 3-10 小時後分數降到一半
  2. 互動分數(Engagement Score):讚數、留言數、分享數的加權。例如 Facebook 曾用 like*1 + comment*6 + share*12 的加權,分享權重最高因為代表最強的認同
  3. 用戶偏好(Affinity Score):你常互動的朋友的貼文權重更高。根據過去 30 天內你對該作者的點擊、留言、按讚次數計算親密度
  4. 內容類型(Content Type Boost):影片通常比純文字權重高 1.5-2 倍,因為平台希望提升影片觀看時間
  5. 負面訊號(Negative Signal):被隱藏或檢舉的貼文降權。用戶點擊「不想看到」會同時降低該作者未來貼文的權重

面試常見問題與參考答案

Q1:如果一個用戶追蹤了 5,000 人,拉模式的延遲會不會太高?

A1:會。拉模式需要查詢 5,000 個作者的最新貼文並合併排序,即使用 Redis 批次查詢,延遲也可能超過 500ms。解決方案有三:

  1. 對「活躍作者」(過去 7 天有發文的)做預計算,減少即時查詢量
  2. 分頁載入(只取 Top 20),避免一次查全部
  3. 背景預熱:用戶上線時非同步預拉取名人貼文到暫存 Cache,打開動態牆時直接讀取

Q2:Feed Cache 的 TTL 設多久比較合理?

A2:依用戶活躍度分層設定。每日活躍用戶(DAU)的 Feed Cache TTL 設 7 天,每週活躍用戶設 3 天,超過 30 天沒登入的用戶直接清除 Cache(下次登入時即時重建)。這樣可以在儲存成本和讀取速度之間取得平衡。以 3 億 MAU 計算,約 1 億 DAU 各存 500 篇貼文 ID(每篇 8 bytes),Feed Cache 約需 400 GB Redis 記憶體。

Q3:如何處理「刪除貼文」在所有粉絲 Feed 中的同步?

A3:不需要主動從每個粉絲的 Feed Cache 中移除。採用「惰性刪除」:在 Post DB 標記貼文為已刪除,Feed Service 在查詢時過濾掉已刪除的貼文。這比遍歷所有粉絲的 Cache 逐一刪除高效得多,代價是 Cache 中會有少量已刪除的貼文 ID 佔用空間,但會被 Trim 機制自然淘汰。

理解測驗

🤔 Fan-out on Write 和 Fan-out on Read 的核心差異是什麼?

🤔 為什麼名人帳號不適合用 Fan-out on Write?

🤔 Feed Cache 使用 Redis Sorted Set 的好處是什麼?

重點整理

💡一句話記住

動態消息 = 普通用戶推模式 + 名人拉模式 + Ranking 排序,Fan-out 用 Message Queue 非同步處理。

記憶口訣:「推普拉名,排序合併」(推送普通帳號、拉取名人帳號、排序後合併回傳)。

| 概念 | 說明 | |------|------| | Fan-out on Write | 發布時推送到每個粉絲的 Cache,讀取 O(1) 但寫入量與粉絲數成正比 | | Fan-out on Read | 用戶查看時才即時聚合,寫入 O(1) 但讀取需查詢所有追蹤者的貼文列表 | | 混合策略 | 粉絲 ≤ 10 萬用推模式,> 10 萬用拉模式。業界標準做法(Facebook、Twitter) | | Feed Cache | Redis Sorted Set,以時間戳為 score 儲存貼文 ID,ZREVRANGE 取最新 N 篇 | | Ranking | score = timeDecay * engagement * affinity,綜合時間衰減、互動數、用戶親密度 | | 惰性刪除 | 刪除貼文不主動清理 Cache,查詢時過濾已刪除的貼文 ID,避免大量寫入 |

你可能也想看

設計聊天系統設計通知系統

按 ← → 鍵切換課程