ストーリー
要件の整理
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分