LESSON 30分

ストーリー

高橋アーキテクト
Twitterを開くと、フォローしている人の投稿が時系列で並ぶ。InstagramやFacebookも同じ。この『フィード』を数億人に数百ミリ秒で表示する仕組みを考えよう
あなた
フォロワーが100万人いるインフルエンサーが投稿したら…100万人分のフィードを更新するんですか?
高橋アーキテクト
それが設計のカギだ。Fan-out問題と呼ばれる

要件の整理

const NEWS_FEED_REQUIREMENTS = {
  functional: {
    publish: "テキスト・画像・動画の投稿",
    feed: "フォローしている人の投稿を時系列で表示",
    ranking: "ランキング/レコメンデーションによるフィード最適化",
    pagination: "無限スクロール対応",
  },
  scale: {
    dau: 300_000_000,           // 3億DAU
    avgFollowing: 200,          // 平均フォロー数
    postsPerDay: 500_000_000,   // 5億投稿/日
    feedFetchPerDay: 3_000_000_000, // 30億フィード取得/日
    feedFetchQPS: 35_000,       // 平均3.5万QPS
    peakFeedFetchQPS: 100_000,  // ピーク10万QPS
  },
};

Fan-outパターンの深掘り

Fan-out on Write (Push Model)

// 投稿時にフォロワー全員のフィードキャッシュに書き込む
class FanoutOnWrite {
  async publish(post: Post): Promise<void> {
    // 1. 投稿を保存
    await this.postStore.save(post);

    // 2. フォロワー一覧を取得
    const followers = await this.followService.getFollowers(post.authorId);

    // 3. 各フォロワーのフィードキャッシュに追加
    const pipeline = this.redis.pipeline();
    for (const followerId of followers) {
      pipeline.lpush(`feed:${followerId}`, JSON.stringify({
        postId: post.id,
        authorId: post.authorId,
        timestamp: post.createdAt,
      }));
      // フィードは最新800件まで保持
      pipeline.ltrim(`feed:${followerId}`, 0, 799);
    }
    await pipeline.exec();
  }

  // フィード取得は超高速(キャッシュから直接読む)
  async getFeed(userId: string, page: number): Promise<FeedItem[]> {
    const start = page * 20;
    const items = await this.redis.lrange(`feed:${userId}`, start, start + 19);
    return items.map(item => JSON.parse(item));
  }
}

Fan-out on Read (Pull Model)

// フィード取得時にフォロー先の投稿を集約する
class FanoutOnRead {
  async getFeed(userId: string, page: number): Promise<FeedItem[]> {
    // 1. フォローしている人の一覧を取得
    const following = await this.followService.getFollowing(userId);

    // 2. 各ユーザーの最新投稿を取得してマージ
    const posts = await Promise.all(
      following.map(authorId =>
        this.postStore.getRecent(authorId, 20)
      )
    );

    // 3. タイムスタンプでソートしてページング
    return posts
      .flat()
      .sort((a, b) => b.timestamp - a.timestamp)
      .slice(page * 20, (page + 1) * 20);
  }
  // 問題: フォロー数200なら200回のDB問い合わせが必要
}

ハイブリッドアプローチ(実運用推奨)

// 一般ユーザーはPush、セレブリティはPull
class HybridFanout {
  private readonly CELEBRITY_THRESHOLD = 10_000; // フォロワー1万以上

  async publish(post: Post): Promise<void> {
    const followerCount = await this.followService.getFollowerCount(post.authorId);

    if (followerCount < this.CELEBRITY_THRESHOLD) {
      // 一般ユーザー: Push(Fan-out on Write)
      await this.fanoutOnWrite(post);
    } else {
      // セレブリティ: 投稿の保存のみ(Pullで取得される)
      await this.postStore.save(post);
      await this.celebrityPostIndex.add(post);
    }
  }

  async getFeed(userId: string, page: number): Promise<FeedItem[]> {
    // 1. プッシュ済みフィード(一般ユーザーの投稿)
    const pushedFeed = await this.redis.lrange(`feed:${userId}`, 0, 799);

    // 2. フォロー中のセレブリティの投稿を取得
    const celebrities = await this.followService.getFollowingCelebrities(userId);
    const celebrityPosts = await this.celebrityPostIndex.getRecent(celebrities);

    // 3. マージしてタイムスタンプソート
    return this.mergeAndSort(pushedFeed, celebrityPosts, page);
  }
}

フィードのランキングとパーソナライズ

// フィードランキングのスコアリング
interface FeedRanking {
  // エンゲージメント予測
  engagementScore: number;    // いいね・コメントの予測確率

  // 新鮮さ
  recencyScore: number;       // 新しいほど高スコア

  // 関係性
  relationshipScore: number;  // 投稿者との交流頻度

  // コンテンツの多様性
  diversityScore: number;     // 同じ投稿者が連続しないよう調整
}

// 最終スコア = 重み付き合計
const calculateFinalScore = (r: FeedRanking): number =>
  r.engagementScore * 0.4 +
  r.recencyScore * 0.3 +
  r.relationshipScore * 0.2 +
  r.diversityScore * 0.1;

まとめ

ポイント内容
Fan-out on Write投稿時に全フォロワーに配信。読み取り高速だが書き込みコスト大
Fan-out on Read取得時に集約。書き込み安いが読み取りが遅い
ハイブリッド一般ユーザーはPush、セレブリティはPullが現実的
ランキングエンゲージメント、新鮮さ、関係性を組み合わせてスコアリング

チェックリスト

  • Fan-out on Write/Readの違いとトレードオフを説明できた
  • セレブリティ問題の対処法を理解した
  • ハイブリッドアプローチの設計を把握した
  • フィードランキングの基本概念を理解した

次のステップへ

次は「通知システムの設計」を学びます。プッシュ通知、Email、SMS等マルチチャネルで確実にユーザーに情報を届ける仕組みを設計しましょう。


推定読了時間: 30分