News-Feed-Design wie Facebook: Fan-out on Write oder on Read

1. Übersicht

News Feed ist die Startseite, auf der du die neuesten Beiträge von Personen siehst, denen du folgst. Die zentrale Frage beim Design ist: Sollte der Feed beim Schreiben (fan-out on write) oder beim Lesen (fan-out on read) zusammengestellt werden?

Dieser Artikel behandelt eine vollständige Case Study. Wir stellen zunächst die Anforderungen und schätzen die Skalierung ab, dann modellieren wir die Daten und das API. Danach vergleichen wir die beiden Fan-out-Strategien und wählen ein Hybrid-Modell. Zum Schluss behandeln wir schwierige Punkte wie Celebrities, Konsistenz und Lastspitzen. Nach dem Lesen kannst du einen funktionierenden Feed mit Spring Boot, PostgreSQL, Redis und Kafka selbst bauen.

2. Anforderungen an das Problem

2.1. Funktionale Anforderungen

  • Nutzer posten Beiträge (Text, optional mit Bildern).
  • Nutzer können anderen Nutzern folgen/entfolgen.
  • Nutzer sehen ihren Feed: Beiträge von Personen, denen sie folgen, neueste zuerst, mit Pagination (infinite scroll).

2.2. Skalierung und Einschränkungen

Wir gehen von folgenden Zahlen aus:

DAU (Daily Active Users)         : 300 Millionen
Feed-Abrufe pro Person pro Tag   : 10      -> 3 Mrd. Lesezugriffe/Tag ≈ 35K QPS, Peak ~100K QPS
Neue Beiträge pro Person pro Tag : 0,5     -> 150 Mio. Beiträge/Tag ≈ 1,7K QPS, Peak ~5K QPS
Durchschn. Follower pro Person   : 300
Größtes Konto                    : > 50 Mio. Follower

Daraus ergeben sich einige Einschränkungen:

  • Lesezugriffe überwiegen Schreibzugriffe deutlich (etwa 20:1). Das Lesen des Feeds muss schnell sein, p99 unter 200 ms.
  • Eventual Consistency ist akzeptabel: Ein neuer Beitrag erscheint im Feed eines Followers nach einigen Sekunden – das ist ok.
  • Die Verteilung der Follower ist stark schief. Die meisten haben ein paar hundert Follower, einige wenige haben Zehner von Millionen.

3. Datenmodell

Die Quelle der Wahrheit liegt in 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);
  • Der Index idx_posts_author_time beantwortet die Frage “Hole die N neuesten Beiträge von Autor X”.
  • Der Primary Key von follows beantwortet “Wem folge ich”. Der Index idx_follows_followee beantwortet “Wer folgt mir”, und dieser ist für den Fan-out essentiell.

Der vorgenerierte Feed liegt in Redis. Jeder Nutzer hat einen Sorted Set:

Key    : feed:{userId}
Member : postId  (als String)
Score  : createdAt in Epoch-Millisekunden

Achtung: Verwende nicht die Snowflake ID als Score. Redis-Scores sind vom Typ double und können Integer-Zahlen nur bis 2^53 exakt darstellen. Snowflake 64-Bit-IDs werden gerundet, und zwei verschiedene Beiträge könnten den gleichen Score bekommen. Nutze Epoch-Millisekunden als Score und behalte die ID im Member-Feld.

Der Feed speichert nur IDs, nicht den Inhalt der Beiträge. Der Inhalt wird beim Lesen aus dem Beitrags-Cache “hydratisiert”. Dadurch muss bei Edits oder Löschungen nur eine Stelle geändert werden.

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) {}

Ein Beispiel einer Antwort:

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

Wir nutzen cursor-basierte Pagination (createdAtMillis:postId), nicht offset. Der Feed ändert sich ständig. Bei Offset würde ein neuer Beitrag an der Spitze dazu führen, dass die nächste Seite bereits gelesen Beiträge wiederholt.

5. Fan-out on Write (Push)

Die Idee: Wenn der Autor einen Beitrag postet, schreibt das System sofort die Beitrags-ID in den Feed jedes Followers. Beim Lesen reicht ein einfacher ZREVRANGEBYSCORE-Befehl.

Abhängigkeiten hinzufügen:

<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>

Der PostService speichert den Beitrag in der DB und sendet dann ein PostCreated-Event auf Kafka. Ein separater Consumer führt den Fan-out durch:

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: wird von der Leseoperation selbst gepullt (siehe Abschnitt 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)); // behalte nur die 500 neuesten Beiträge
            }
            return null;
        });
    }
}

Erklärung:

  • findActiveFollowerIds iteriert Follower mit Keyset-Pagination (follower_id > lastId), in Batches à 1.000 Personen. Es bezieht sich nur auf Nutzer, die in den letzten 30 Tagen aktiv waren.
  • executePipelined kombiniert 2.000 Befehle in einen Round-Trip, statt 2.000 einzelne Round-Trips.
  • zRemRange(key, 0, -501) schneidet den Feed zu, behält maximal 500 Einträge. Der Speicherverbrauch wächst dadurch nicht über die Zeit.
  • ZADD mit gleichem Member und Score ist idempotent. Wenn Kafka ein Event erneut zuliefert (at-least-once), wird der Feed nicht duplizieren.

Die entsprechende 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;

Vorteile: Lesezugriffe sind extrem schnell, O(log N) auf einem Key. Nachteile: Schreibvorgänge werden vervielfacht. 150 Millionen Beiträge × 300 Follower ≈ 45 Milliarden ZADDs pro Tag. Ein Celebrity mit 50 Millionen Followern, der einen Beitrag postet, erzeugt 50 Millionen Write-Befehle.

6. Fan-out on Read (Pull)

Die umgekehrte Idee: Es wird nichts vorberechnet. Beim Lesen werden zunächst alle Personen gesucht, denen man folgt, dann werden die neuesten Beiträge von diesen abgerufen:

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;

Vorteile: Schreiben ist günstig (nur ein INSERT), neue Beiträge erscheinen sofort, keine RAM-Verschwendung für den Feed. Nachteile: Jeder Lesezugriff muss hunderte Autoren treffen, und das geschieht bei 100K QPS. Wenn posts nach author_id gesharded ist, braucht jeder Lesezugriff Scatter-Gather über mehrere Shards. p99 unter 200 ms zu halten wird sehr schwierig.

Zusammenfassung:

                    Fan-out on Write          Fan-out on Read
Schreib-Kosten      Hoch (× Follower-Zahl)   Niedrig
Lese-Kosten         Niedrig (1 Redis-Key)    Hoch (× Followee-Zahl)
Anzeige-Latenz      Einige Sekunden (async)  Instant
Celebrity           Katastrophal             OK
Viel-Follower       OK                       Langsam

7. Hybrid-Modell

Facebook, Twitter und die meisten großen Systeme gehen nach einem hybriden Ansatz vor:

  • Normale Autoren (< 10K Follower): Push in die Feeds der Follower.
  • Celebrities (≥ 10K Follower): Nicht pushen. Beim Lesen werden die Beiträge der Celebrities, denen der Nutzer folgt, gepullt und dann mit dem gepushten Feed gemischt.

Da jeder Nutzer nur einigen wenigen Celebrities folgt, ist der Pull-Anteil klein. Und da die neuesten Beiträge einer Celebrity von Millionen gelesen werden, ist das Caching sehr effektiv.

Der Kern ist die Merge-Logik. Wir isolieren sie in einer reinen Java-Klasse für bessere Testbarkeit:

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) { // ist e älter als der Cursor?
        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) behält nur Beiträge, die älter als der Cursor sind. Wenn zwei Beiträge die gleiche Millisekunde haben, wird postId verwendet, um die Reihenfolge zu definieren.
  • toMap(..., (a, b) -> a) entfernt Duplikate. Ein Beitrag könnte in beiden Quellen vorhanden sein, z.B. wenn ein Autor gerade über die Celebrity-Schwelle gewandert ist.
  • Danach wird die Liste nach neuesten zuerst sortiert und auf limit gekürzt.

FeedService klebt alles zusammen:

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, gelöschte Beiträge rausfiltern
    String next = page.isEmpty() ? null
        : page.getLast().createdAtMillis() + ":" + page.getLast().postId();
    return new FeedPage(items, next);
}

Die Redis-Abfrage nutzt max inklusive der Grenze, da möglicherweise noch Beiträge mit der gleichen Millisekunde wie der Cursor vorhanden sind. FeedMerger wird präzise ein zweites Mal filtern.

8. Test der Merge-Logik

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);
    }
}

Beitrag 3 ist in beiden Quellen, erscheint aber nur einmal. Die zweite Seite beginnt direkt nach dem Cursor, keine Wiederholung, keine Lücken.

9. Schwierige Punkte und Lösungen

9.1. Hot Key von Celebrities

Wenn alle Nutzer celeb:{id}:recent lesen, konzentriert sich die Last auf einen Redis-Node. Es gibt drei Wege das zu behandeln:

  • Füge einen lokalen Cache (Caffeine) auf jedem Instance mit TTL 2–5 Sekunden hinzu. 100K QPS fallen dann auf nur hunderte Request/Sekunde zu Redis.
  • Aktiviere Redis Read Replicas für diese Keys.
  • Wenn eine Celebrity einen Beitrag postet, schreibe proaktiv in den Timeline-Cache, statt auf einen Cache-Miss zu warten. So vermeidest du Thundering Herd.

9.2. Konsistenz beim Löschen, Entfolgen und neuem Folgen

  • Beitrag löschen: Keine Notwendigkeit, Millionen von Feeds abzuscannen. Beim Hydratisieren wird postCache Beiträge mit deleted = true übersprungen. Ein Background-Job wird später die IDs aufräumen.
  • Entfolgen: Filter beim Lesen mit der Menge deiner aktuellen Followees. Sende gleichzeitig ein Event, um die Beiträge dieser Person asynchron aus feed:{userId} zu löschen.
  • Neuem Folgen: Führe Backfill durch, hole etwa 20 neueste Beiträge der neu gefolgten Person und ZADD sie in deinen Feed.

Da der Feed nur IDs enthält, liegt jede Inkonsistenz nur in der “Referenzliste”. Der Inhalt folgt immer der Quelle der Wahrheit.

9.3. Verlorene Events zwischen DB und Kafka

Wenn du einen Beitrag mit INSERT speicherst und dann kafkaTemplate.send() aufrufst, kann der Send scheitern, nachdem die Transaktion bereits committed hat. Der Beitrag existiert dann, wird aber nie gepusht.

Achtung: Nutze transactional outbox. Speichere sowohl den Beitrag als auch eine Zeile in eine outbox-Tabelle in der gleichen Transaktion. Ein Relay (oder CDC wie Debezium) liest die outbox und schiebt auf Kafka. Die Consumer-Seite muss idempotent sein, und ZADD erfüllt das bereits.

9.4. Lastspitzen

Großereignisse wie Fußballspiele oder Silvester können den Beitrags-Aufwand um das 10-fache erhöhen. Maßnahmen:

  • Kafka als Puffer. Consumer verarbeiten in ihrem Tempo, Lag wächst dann und der Feed wird um ein paar Sekunden verzögert. Laut den Anforderungen von Abschnitt 2 ist das akzeptabel.
  • Partition nach authorId, um die Reihenfolge von Beiträgen des gleichen Autors zu bewahren. Skaliere Consumer, indem du Partitionen hinzufügst.
  • Übernimm keine inaktiven Nutzer. Sie werden nicht gepusht. Wenn sie zurückkehren und einen Cache-Miss treffen, wird ihr Feed mittels Fan-out on Read wiederaufgebaut und dann in Redis geschrieben.
  • Schwelle für Celebrity dynamisch anpassen. Wenn der Lag über einen Schwellenwert geht, senke die CELEBRITY_THRESHOLD, um mehr Autoren zum Pull-Modus zu wechseln.

9.5. Redis-Speicherverbrauch schätzen

~200 Millionen aktive Nutzer × 500 Einträge × ~60 Byte ≈ 6 TB

Diese Zahl erfordert Redis Cluster. Du kannst FEED_MAX auf 200–300 senken, da sehr wenige Menschen über ein paar hundert Beiträge hinaus scrollen. Tieferes Scrolling fallback auf Pull.

10. Wann man dieses Modell nicht verwenden sollte

  • Kleine Anwendungen (einige Zehntausend Nutzer): Fan-out on read mit einer indexierten SQL-Abfrage ist ausreichend. Kafka und Redis zu addieren erhöht nur die Komplexität.
  • Feed mit Algorithmen-Ranking (ML-basiertes Ranking): Gepushter Feed spielt nur die Rolle eines “Kandidaten-Pools”. Der Ranking-Schritt läuft separat beim Lesen ab, und Zeitstempel ist dann nicht die finale Ordnung.
  • Starke Konsistenz-Anforderungen (Beitrag muss sofort für alle Follower sichtbar sein): Asynchroner Fan-out garantiert das nicht. Du musst pullen, oder den Beitrag des Autors direkt in der Antwort nach dem Posten einfügen (read-your-writes).

11. Fazit

Fan-out on Write optimiert das Lesen, vervielfacht aber das Schreiben. Fan-out on Read macht das Gegenteil. Bei der schiefen Verteilung der Follower wie in sozialen Netzwerken ist die praktische Antwort ein Hybrid-Modell: Push für normale Autoren, Pull für Celebrities, dann beim Lesen mergen.

Was du dir beim Implementieren merken solltest:

  • Der Feed speichert nur IDs, der Score ist Epoch-Millisekunden, nicht die Snowflake-ID.
  • Nutze Cursor (createdAt, postId) für stabile Pagination.
  • Consumer müssen idempotent sein, kombiniert mit Outbox-Pattern, um keine Events zu verlieren.
  • Behandle Beitrags-Löschung und Entfolgen durch Filtern beim Lesen.
  • Nutze Kafka mit dynamischer Celebrity-Schwelle, um Lastspitzen zu puffern.
system-designrediskafkaspring-bootcase-study