Facebookのようなニュースフィード設計:fan-out on writeか on readか

1. 概要

ニュースフィードは、フォローしている人から最新の投稿を見るホームページです。設計する際の中心的な問題は:フィードをwrite時に組み立てるか(fan-out on write)、read時に組み立てるか(fan-out on read)?

この記事は完全なケーススタディを通じて説明します。要件とスケールを提示し、データモデルとAPIを構築します。その後、2つのfan-out戦略を比較し、ハイブリッドモデルを選択します。最後に、celebrity、一貫性、バースト負荷などの難しい点を扱います。読み終わったら、Spring Boot、PostgreSQL、Redis、Kafkaを使って、実際に動作するフィードシステムを自分で構築できます。

2. 要件仕様

2.1. 機能要件

  • ユーザーが投稿を作成する(テキスト、画像付きも可)。
  • ユーザーが他のユーザーをフォロー/アンフォローする。
  • ユーザーがフィードを閲覧する:フォローしている人の投稿、最新の順、ページング付き(無限スクロール)。

2.2. スケールと制約

以下の数値を仮定します:

DAU(日次アクティブユーザー)      : 3億
ユーザーあたり1日のフィード閲覧   : 10回  → 30億読み取り/日 ≈ 35K QPS、peak ~100K QPS
ユーザーあたり1日の新規投稿        : 0.5   → 1億5000万投稿/日 ≈ 1.7K QPS、peak ~5K QPS
平均フォロワー数                   : 300
最大フォロワーアカウント           : > 5000万フォロワー

これらから、以下の制約が生じます:

  • 読み取りは書き込みより非常に多い(約20倍)。フィード読み取りは高速である必要があり、p99は200 ms以下。
  • 結果整合性を受け入れる:新しい投稿がフォロワーのフィードに数秒後に表示されれば良い。
  • フォロワーの分布は非常に偏っている。大多数が数百人のフォロワーを持ち、少数の者が数千万人のフォロワーを持つ。

3. データモデル

真実のソース(source of truth)はPostgreSQLに置かれています:

CREATE TABLE users (
    id              BIGINT PRIMARY KEY,
    name            TEXT   NOT NULL,
    follower_count  BIGINT NOT NULL DEFAULT 0
);

CREATE TABLE posts (
    id          BIGINT PRIMARY KEY,           -- Snowflake ID
    author_id   BIGINT      NOT NULL,
    content     TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted     BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_posts_author_time ON posts (author_id, created_at DESC);

CREATE TABLE follows (
    follower_id BIGINT NOT NULL,
    followee_id BIGINT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX idx_follows_followee ON follows (followee_id, follower_id);
  • インデックスidx_posts_author_timeは、「著者Xの最新N件の投稿を取得する」というクエリに対応します。
  • followsの主キーは「私は誰をフォローしているか」という質問に答えます。インデックスidx_follows_followeeは「誰が私をフォローしているか」という質問に答え、fan-outに必要です。

あらかじめ組み立てられたフィードはRedisに置かれています。各ユーザーはsorted setを持ちます:

Key    : feed:{userId}
Member : postId  (文字列形式)
Score  : createdAt (エポックミリ秒)

注意:Snowflake IDをscoreとして使用しないでください。Redisのscoreはdoubleで、2^53までの整数のみ正確に表現します。64ビットのSnowflake IDは丸められ、異なる2つの投稿が同じscoreを持つ可能性があります。scoreとしてエポックミリ秒を使用し、IDはmemberに保持してください。

フィードはIDのみを保存し、投稿の内容は保存しません。内容は読み取り時に投稿キャッシュから「hydrate」されます。こうすることで、投稿を編集または削除する場合、1か所だけ修正すればよいです。

4. API

@RestController
@RequestMapping("/api")
public class FeedController {

    private final PostService postService;
    private final FeedService feedService;

    public FeedController(PostService postService, FeedService feedService) {
        this.postService = postService;
        this.feedService = feedService;
    }

    @PostMapping("/posts")
    public ResponseEntity<PostDto> create(@RequestHeader("X-User-Id") long userId,
                                          @RequestBody CreatePostRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(postService.create(userId, req.content()));
    }

    @GetMapping("/feed")
    public FeedPage feed(@RequestHeader("X-User-Id") long userId,
                         @RequestParam(required = false) String cursor,
                         @RequestParam(defaultValue = "20") int limit) {
        return feedService.getFeed(userId, Cursor.parse(cursor), Math.min(limit, 50));
    }
}

public record CreatePostRequest(String content) {}
public record FeedPage(List<PostDto> items, String nextCursor) {}

例えば、レスポンス:

GET /api/feed?limit=2
{
  "items": [ { "id": 7253..., "authorId": 42, "content": "Hello" }, { ... } ],
  "nextCursor": "1760245800123:7253..."
}

カーソルベースのページング(createdAtMillis:postId)を使用し、offsetは使用しません。フィードは絶えず変わります。offsetでは、新しい投稿が先頭に挿入されると、次のページで既に見た投稿が重複します。

5. Fan-out on write(push)

考え方:著者が投稿するとき、システムは直ちに投稿IDを各フォロワーのフィードに書き込みます。読み取り時は、1つのZREVRANGEBYSCOREコマンドのみ必要です。

依存性を追加:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

PostServiceは投稿をDBに保存し、その後PostCreatedイベントをKafkaに発行します。専用のconsumerがfan-outを実行:

public record PostCreatedEvent(long postId, long authorId, long createdAtMillis, long followerCount) {}

@Component
public class FanoutConsumer {

    static final int FEED_MAX = 500;
    static final long CELEBRITY_THRESHOLD = 10_000;
    private static final int BATCH = 1_000;

    private final FollowRepository follows;
    private final StringRedisTemplate redis;

    public FanoutConsumer(FollowRepository follows, StringRedisTemplate redis) {
        this.follows = follows;
        this.redis = redis;
    }

    @KafkaListener(topics = "post-created", groupId = "feed-fanout")
    public void onPostCreated(PostCreatedEvent e) {
        if (e.followerCount() >= CELEBRITY_THRESHOLD) {
            return; // celebrity: 読み取り経路が自分でプル(セクション7参照)
        }
        long lastId = 0;
        List<Long> batch;
        do {
            batch = follows.findActiveFollowerIds(e.authorId(), lastId, BATCH);
            pushToFeeds(batch, e);
            if (!batch.isEmpty()) lastId = batch.getLast();
        } while (batch.size() == BATCH);
    }

    private void pushToFeeds(List<Long> followerIds, PostCreatedEvent e) {
        redis.executePipelined((RedisCallback<Object>) conn -> {
            StringRedisConnection c = (StringRedisConnection) conn;
            for (Long followerId : followerIds) {
                String key = "feed:" + followerId;
                c.zAdd(key, e.createdAtMillis(), String.valueOf(e.postId()));
                c.zRemRange(key, 0, -(FEED_MAX + 1)); // 最新の500件のみ保持
            }
            return null;
        });
    }
}

説明:

  • findActiveFollowerIdsはkeyset pagination(follower_id > lastId)でフォロワーを走査し、毎回1,000人を取得します。これは過去30日間にアクティブなユーザーのみを取得します。
  • executePipelinedは2,000個のコマンドを1つのラウンドトリップに結合し、2,000個の個別ラウンドトリップの代わりになります。
  • zRemRange(key, 0, -501)は末尾を切り、フィードを最大500要素に保つ。これでメモリが時間とともに膨張することはありません。
  • 同じmemberとscoreを持つZADDはべき等です。Kafkaが再びイベントを配信する場合(at-least-once)、フィードは重複しません。

対応するSQL:

SELECT f.follower_id
FROM follows f
JOIN user_activity a ON a.user_id = f.follower_id
WHERE f.followee_id = :authorId
  AND f.follower_id > :lastId
  AND a.last_seen_at > now() - interval '30 days'
ORDER BY f.follower_id
LIMIT :batch;

利点:読み取りが非常に高速、1つのキーでO(log N)。 欠点:書き込みが増幅される。1億5000万の投稿 × 300フォロワー ≈ 450億のZADD/日。5000万人のフォロワーを持つcelebrityが1つの投稿をすると、5000万の書き込みコマンドが生じます。

6. Fan-out on read(pull)

その逆の考え方:事前に何も組み立てません。読み取り時に、システムはフォローしているユーザーのリストを取得し、彼らの最新の投稿をクエリします:

SELECT p.id, p.created_at
FROM posts p
WHERE p.author_id IN (SELECT followee_id FROM follows WHERE follower_id = :userId)
  AND p.created_at < :before
  AND p.deleted = false
ORDER BY p.created_at DESC
LIMIT 20;

利点:書き込みが安い(1つのINSERTのみ)、新しい投稿がすぐに表示される、フィードにRAMを使用しない。 欠点:各読み取りが数百人の著者にアクセスする必要があり、これは100K QPSで起こります。postsがauthor_idでシャード化されている場合、1つの読み取りは複数のシャード間でscatter-gatherを実行する必要があります。p99を200 ms以下に保つことは非常に難しくなります。

要約:

                    Fan-out on write          Fan-out on read
書き込みコスト       高(× フォロワー数)       低
読み取りコスト       低(1つのRedisキー)       高(× フォロイー数)
表示遅延             数秒(非同期)              即時
Celebrity           悪い                     ○
多数フォロー         ○                       遅い

7. ハイブリッドモデル(hybrid)

Facebook、Twitter、そしてほとんどの大規模システムはハイブリッド方式に従っています:

  • 通常の著者(< 10K フォロワー):フォロワーのフィードにpushします。
  • Celebrity(≥ 10K フォロワー):pushしません。読み取り時に、システムはユーザーがフォローしているcelebrityの投稿をpullし、すでにpushされたフィードと混合します。

各ユーザーはわずか数十人のcelebrityをフォローしているため、pull部分は小さい。最近のcelebrityの投稿は数百万人に読まれるため、キャッシュは非常に効果的です。

コアの部分は混合ロジックです。テストを容易にするために、純粋なJavaレイヤーに分割します:

public record FeedEntry(long postId, long createdAtMillis) {}

public record Cursor(long createdAtMillis, long postId) {
    static final Cursor START = new Cursor(Long.MAX_VALUE, Long.MAX_VALUE);

    public static Cursor parse(String raw) {
        if (raw == null || raw.isBlank()) return START;
        String[] p = raw.split(":");
        return new Cursor(Long.parseLong(p[0]), Long.parseLong(p[1]));
    }

    boolean isAfter(FeedEntry e) { // eはカーソルより古いか?
        return e.createdAtMillis() < createdAtMillis
            || (e.createdAtMillis() == createdAtMillis && e.postId() < postId);
    }
}

public final class FeedMerger {

    private static final Comparator<FeedEntry> NEWEST_FIRST =
        Comparator.comparingLong(FeedEntry::createdAtMillis)
                  .thenComparingLong(FeedEntry::postId)
                  .reversed();

    public static List<FeedEntry> merge(List<FeedEntry> pushed, List<FeedEntry> pulled,
                                        Cursor cursor, int limit) {
        return Stream.concat(pushed.stream(), pulled.stream())
            .filter(cursor::isAfter)
            .collect(Collectors.toMap(FeedEntry::postId, e -> e, (a, b) -> a))
            .values().stream()
            .sorted(NEWEST_FIRST)
            .limit(limit)
            .toList();
    }
}

説明:

  • filter(cursor::isAfter)はカーソルより古い投稿のみを保持します。同じミリ秒に2つの投稿がある場合、postIdを使用して順序を決定します。
  • toMap(..., (a, b) -> a)は重複を除去します。投稿は両方のソースに存在する可能性があります。例えば、著者がちょうどcelebrityのしきい値を超えた場合など。
  • その後、リストは最新の順にソートされ、limitに従ってカットされます。

FeedServiceがピースを組み合わせます:

public FeedPage getFeed(long userId, Cursor cursor, int limit) {
    double max = cursor == Cursor.START ? Double.POSITIVE_INFINITY : cursor.createdAtMillis();

    List<FeedEntry> pushed = redis.opsForZSet()
        .reverseRangeByScoreWithScores("feed:" + userId, 0, max, 0, limit + 1)
        .stream()
        .map(t -> new FeedEntry(Long.parseLong(t.getValue()), t.getScore().longValue()))
        .toList();

    List<Long> celebs = followRepository.findCelebrityFolloweeIds(userId);
    List<FeedEntry> pulled = celebrityTimelineCache.recentPosts(celebs, cursor, limit);

    List<FeedEntry> page = FeedMerger.merge(pushed, pulled, cursor, limit);
    List<PostDto> items = postCache.hydrate(page); // multi-get、削除済みの投稿をスキップ
    String next = page.isEmpty() ? null
        : page.getLast().createdAtMillis() + ":" + page.getLast().postId();
    return new FeedPage(items, next);
}

Redisクエリはmaxを含む境界を使用します。カーソルと同じミリ秒の投稿がある可能性があるため。FeedMergerは正確にもう一度フィルタリングします。

8. 混合ロジックのテスト

class FeedMergerTest {

    @Test
    void mergesSortsDedupesAndRespectsCursor() {
        List<FeedEntry> pushed = List.of(
            new FeedEntry(5, 5_000), new FeedEntry(3, 3_000), new FeedEntry(1, 1_000));
        List<FeedEntry> pulled = List.of(
            new FeedEntry(4, 4_000), new FeedEntry(3, 3_000), new FeedEntry(2, 2_000));

        List<FeedEntry> firstPage = FeedMerger.merge(pushed, pulled, Cursor.START, 3);

        assertThat(firstPage).extracting(FeedEntry::postId).containsExactly(5L, 4L, 3L);

        Cursor next = new Cursor(3_000, 3);
        List<FeedEntry> secondPage = FeedMerger.merge(pushed, pulled, next, 3);

        assertThat(secondPage).extracting(FeedEntry::postId).containsExactly(2L, 1L);
    }
}

投稿3は両方のソースに存在しますが、1回だけ表示されます。2ページ目はカーソルの直後に始まり、重複なし、漏れなし。

9. 難しい点と解決方法

9.1. Celebrityのホットキー

すべてのユーザーがceleb:{id}:recentを同時に読むと、負荷が1つのRedisノードに集中します。3つの対処方法があります:

  • 各インスタンスにlocal cache(Caffeine)をTTL 2~5秒で追加する。100K QPSはその場合、毎秒数百のリクエストに削減されます。
  • これらのキーに対してRedisの読み取りレプリカを有効にする。
  • Celebrityが投稿するとき、キャッシュミスを待たずにタイムラインキャッシュに事前に書き込みます。この方法はthundering herdを回避します。

9.2. 削除、unfollow、新しいフォロー時の一貫性

  • 削除:数百万のフィードをスキャンする必要はありません。Hydrate時に、postCacheはdeleted = trueの投稿をスキップします。バックグラウンドジョブが後でIDを徐々にクリーンアップします。
  • Unfollow:ユーザーのフォロイーセットを使って読み取り時にフィルタリングし、その人の投稿を非同期でfeed:{userId}から削除するイベントを発行します。
  • 新しいフォロー:バックフィルを実行し、新しくフォローした人の最新約20件の投稿をプルして、フィードにZADDします。

フィードはIDのみを含むため、すべての不一致は「参照リスト」にあります。内容は常にソース オブ トゥルースに従って正確です。

9.3. DBとKafka間でのイベント喪失

投稿をINSERTした後にkafkaTemplate.send()を呼び出す場合、トランザクションがコミット後に送信が失敗する可能性があります。投稿は存在しますが、fan-outされることはありません。

注意:transactional outboxを使用してください。同じトランザクション内で投稿と1行をoutboxテーブルに書き込みます。relay(またはDebeziumのようなCDC)がoutboxを読み、Kafkaにプッシュします。Consumer側はべき等である必要があり、ZADDはこれを既に満たしています。

9.4. バースト負荷

サッカーやお正月のような大きなイベントは投稿量を10倍に増やす可能性があります。対策:

  • Kafkaがバッファとして機能します。 Consumerは容量に応じて処理し、lagが増加するとフィードの遅延が数秒増加します。これはセクション2の制約に従って受け入れられます。
  • authorIdでパーティション化して、同じ著者の投稿の順序を保つ。 パーティション数を増やしてconsumerをスケーリング。
  • 非アクティブなユーザーをスキップ。 彼らはpushされません。戻ってきてキャッシュミスが発生した場合、フィードはfan-out on readを使用して再構築され、Redisに書き込まれます。
  • Celebrityのしきい値を動的に下げます。 Lagが上限を超える場合、CELEBRITY_THRESHOLDを減らして、より多くの著者をpullモードに切り替えます。

9.5. Redisメモリの見積もり

~2億人のアクティブユーザー × 500エントリ × ~60バイト ≈ 6 TB

この数値はRedis Clusterが必要です。FEED_MAXを200~300に減らすことができます。数百を超えてスクロールする人はほとんどいません。それ以上のスクロールはpullへのフォールバックです。

10. このモデルを使わない場合

  • 小規模アプリケーション(数万ユーザー):インデックス付きの1つのSQLクエリによるfan-out on readで十分。KafkaとRedisを追加すると、複雑さが増すだけです。
  • アルゴリズムでランク付けされたフィード(MLによるランキング):フィードpushは「候補セット」としてのみ機能します。ランキング段階は読み取り時に個別に実行され、その時点で時間スコアはもう最終順序ではありません。
  • 強い一貫性の要件(投稿がすべてのフォロワーに直ちに表示される必要がある):非同期fan-outはこれを保証しません。pullするか、投稿直後に著者自身の投稿を応答に挿入する必要があります(read-your-writes)。

11. 結論

Fan-out on writeは読み取りに対して最適ですが、書き込みを増幅します。Fan-out on readはその逆を行います。ソーシャルメディアのような偏ったフォロワー分布では、実践的な答えはハイブリッドモデル:通常の著者にはpush、celebrityにはpull、読み取り時に混合します。

実装時に覚えておくべき点:

  • フィードはIDのみを保存し、scoreはSnowflake IDではなくエポックミリ秒。
  • 安定したページング用にカーソル(createdAt, postId)を使用します。
  • Consumerはべき等である必要があり、イベントの損失を防ぐためにoutboxと組み合わせます。
  • 読み取り時にフィルタリングして投稿削除とunfollowを処理します。
  • Kafkaと動的なcelebrityしきい値を使用してバースト負荷を吸収します。
system-designrediskafkaspring-bootcase-study