Projetando um news feed estilo Facebook: fan-out on write ou on read

1. Visão geral

News feed é a página inicial onde você vê os posts mais recentes de pessoas que você segue. A pergunta central ao projetá-la é: montar o feed na hora da escrita (fan-out on write) ou na hora da leitura (fan-out on read)?

Este artigo passa por um case study completo. Começaremos apresentando os requisitos e estimando a escala, depois construiremos o modelo de dados e a API. Depois compararemos as duas estratégias de fan-out e escolheremos um modelo híbrido. Por fim, lidaremos com pontos difíceis como celebridades, consistência e picos de tráfego. Após ler, você será capaz de construir um feed funcional usando Spring Boot, PostgreSQL, Redis e Kafka.

2. Requisitos do problema

2.1. Requisitos funcionais

  • Usuários postam (texto, possivelmente com imagens).
  • Usuários seguem/param de seguir outros.
  • Usuários veem o feed: posts de pessoas que seguem, mais recentes primeiro, com paginação (infinite scroll).

2.2. Escala e restrições

Vamos assumir os seguintes números:

DAU (usuários ativos diários)       : 300 milhões
Abrir feed por pessoa / dia         : 10 vezes  -> 3 bilhões de leituras/dia ≈ 35K QPS, pico ~100K QPS
Posts novos por pessoa / dia        : 0.5       -> 150 milhões posts/dia ≈ 1.7K QPS, pico ~5K QPS
Seguidores em média                 : 300
Maior conta                          : > 50 milhões de seguidores

Disso extraímos algumas restrições:

  • Leitura é muito mais que escrita (cerca de 20 vezes). Leitura de feed deve ser rápida, p99 abaixo de 200ms.
  • Aceitamos eventual consistency: um novo post aparecendo no feed do seguidor alguns segundos depois é aceitável.
  • Distribuição de seguidores é altamente enviesada. A maioria tem centenas de seguidores, mas alguns têm dezenas de milhões.

3. Modelo de dados

A fonte única de verdade fica no 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);
  • O índice idx_posts_author_time serve a query “pega N posts mais recentes do autor X”.
  • A chave primária de follows responde “quem eu sigo”. O índice idx_follows_followee responde “quem me segue”, e o fan-out precisa exatamente desta query.

O feed pré-montado fica no Redis. Cada usuário tem um sorted set:

Key    : feed:{userId}
Member : postId  (como string)
Score  : createdAt em epoch millis

Cuidado: Não use Snowflake ID como score. Score do Redis é um double, representando inteiros exatos até 2^53. Um Snowflake de 64 bits sofrerá arredondamento, e dois posts diferentes podem ter o mesmo score. Use epoch millis como score e mantenha o ID no member.

O feed armazena apenas IDs, não o conteúdo do post. O conteúdo é “hydratado” do cache de posts na leitura. Dessa forma, quando edita ou deleta um post, precisa modificar em um único lugar.

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

Exemplo de resposta:

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

Usamos cursor-based pagination (createdAtMillis:postId), não offset. O feed muda continuamente. Com offset, quando um novo post entra no topo, a próxima página repetirá um post que já viu.

5. Fan-out on write (push)

Ideia: quando o autor posta, o sistema escreve imediatamente o ID do post no feed de cada um de seus seguidores. Na leitura, é apenas um comando ZREVRANGEBYSCORE.

Adicione a dependência:

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

O PostService salva o post no DB e depois emite um evento PostCreated para o Kafka. Um consumer separado faz o 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; // celebridade: deixa o caminho de leitura puxar (veja seção 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)); // manter apenas 500 posts mais recentes
            }
            return null;
        });
    }
}

Explicação:

  • findActiveFollowerIds itera sobre os seguidores usando keyset pagination (follower_id > lastId), 1.000 de cada vez. Lida apenas com pessoas ativas nos últimos 30 dias.
  • executePipelined agrupa 2.000 comandos em uma única round-trip, em vez de 2.000 round-trips separadas.
  • zRemRange(key, 0, -501) poda a cauda, mantendo no máximo 500 elementos no feed. A memória não cresce com o tempo dessa forma.
  • ZADD com o mesmo member e score é idempotente. Se o Kafka reentrega o evento (at-least-once), o feed não terá duplicatas.

A query SQL correspondente:

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;

Vantagens: leitura é extremamente rápida, O(log N) em uma única key. Desvantagens: escrita é amplificada. 150 milhões de posts × 300 seguidores ≈ 45 bilhões de ZADDs/dia. Uma celebridade com 50 milhões de seguidores postando uma vez gera 50 milhões de comandos de escrita.

6. Fan-out on read (pull)

Ideia oposta: não pré-monta nada. Na leitura, o sistema pega a lista de pessoas seguidas e consulta seus posts mais recentes:

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;

Vantagens: escrita é barata (apenas um INSERT), novos posts aparecem imediatamente, sem consumir RAM para feeds. Desvantagens: cada leitura deve tocar centenas de autores, e isso acontece em 100K QPS. Quando posts é sharded por author_id, uma leitura deve scatter-gather por múltiplos shards. Será muito difícil manter p99 abaixo de 200ms.

Resumo:

                    Fan-out on write          Fan-out on read
Custo de escrita    Alto (× número de seg.)   Baixo
Custo de leitura    Baixo (1 key Redis)       Alto (× número de seguidos)
Latência de exib.   Alguns segundos (async)   Imediata
Celebridade         Desastre                  Ok
Pessoa que segue... Ok                        Lento

7. Modelo híbrido (hybrid)

Facebook, Twitter e a maioria dos grandes sistemas seguem a abordagem híbrida:

  • Autores comuns (< 10K seguidores): push para o feed de seguidores.
  • Celebridades (≥ 10K seguidores): não push. Na leitura, o sistema puxa posts recentes de celebridades que o usuário segue, depois mescla com o feed que foi feito push.

Cada usuário segue apenas algumas dezenas de celebridades, então a parte de pull é pequena. Posts recentes de celebridades são lidos por milhões de pessoas, então o cache é muito eficaz.

O núcleo é a lógica de mesclagem. Separamos em uma classe Java pura para facilitar testes:

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 é mais antigo que o 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) mantém apenas posts mais antigos que o cursor. Quando dois posts têm o mesmo milissegundo, o postId é usado para definir a ordem.
  • toMap(..., (a, b) -> a) remove duplicatas. Um post pode estar em ambas as fontes, por exemplo se o autor acabou de passar o limiar de celebridade.
  • Em seguida, a lista é ordenada com mais recente primeiro e cortada por limit.

FeedService junta as peças:

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, ignora posts deletados
    String next = page.isEmpty() ? null
        : page.getLast().createdAtMillis() + ":" + page.getLast().postId();
    return new FeedPage(items, next);
}

A query Redis usa max inclusive dos limites, pois pode haver posts com mesmo milissegundo do cursor. FeedMerger filtrará exatamente novamente.

8. Testando a lógica de mesclagem

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

O post 3 está em ambas as fontes mas aparece apenas uma vez. A segunda página começa corretamente após o cursor, sem repetição, sem omissão.

9. Pontos difíceis e como resolvê-los

9.1. Hot key de celebridade

Quando todos leem celeb:{id}:recent, concentra a carga em um único nó Redis. Há três maneiras de lidar:

  • Adicione local cache (Caffeine) em cada instância com TTL de 2–5 segundos. 100K QPS então gera apenas centenas de requisições/segundo para Redis.
  • Ative Redis read replicas para essas keys.
  • Quando celebridade posta, escreva proativamente no cache de timeline, não espere por cache miss. Assim evita thundering herd.

9.2. Consistência ao deletar post, unfollows, novos follows

  • Deletar post: não precisa varrer milhões de feeds. Ao hydratizar, postCache ignora posts com deleted = true. Um job em background removerá os IDs gradualmente depois.
  • Unfollow: filtre ao ler usando o conjunto de seguidos do usuário, e ao mesmo tempo emita um evento para deletar de forma assíncrona os posts dessa pessoa de feed:{userId}.
  • Novo follow: execute um backfill, puxe os ~20 posts mais recentes de quem começou a seguir e faça ZADD no feed.

Como o feed contém apenas IDs, qualquer inconsistência fica na “lista de referência”. O conteúdo sempre está correto segundo a fonte de verdade.

9.3. Perder evento entre DB e Kafka

Se você faz INSERT no post e depois chama kafkaTemplate.send(), o send pode falhar após a transaction fazer commit. O post existirá mas nunca será feito fan-out.

Dica: Use transactional outbox. Escreva o post e uma linha em uma tabela outbox na mesma transaction. Um relay (ou CDC como Debezium) lê a outbox e faz push para Kafka. Do lado do consumer, deve ser idempotente, e ZADD já cumpre isso.

9.4. Picos de tráfego

Eventos grandes como partidas de futebol ou Ano Novo podem aumentar posts 10x. Medidas:

  • Kafka como buffer. O consumer processa conforme sua capacidade, lag aumenta, o feed fica alguns segundos atrasado. Isso é aceitável dentro da restrição da seção 2.
  • Particione por authorId para manter a ordem de posts do mesmo autor. Escale o consumer aumentando o número de partições.
  • Ignore usuários inativos. Não recebem push. Ao retornar e sofrer cache miss, o feed é reconstruído com fan-out on read e escrito no Redis.
  • Reduza dinamicamente o limiar de celebridade. Quando lag ultrapassa um limiar, diminua CELEBRITY_THRESHOLD para mover mais autores para o modo pull.

9.5. Estimando memória Redis

~200 milhões de usuários ativos × 500 entrada × ~60 bytes ≈ 6 TB

Esse número exige Redis Cluster. Você pode reduzir FEED_MAX para 200–300, pois muito poucos rolam além de alguns centenas de posts. Rolagem mais profunda faz fallback para pull.

10. Quando não usar este modelo

  • Aplicação pequena (dezenas de milhares de usuários): fan-out on read com uma query SQL com índice é suficiente. Adicionar Kafka e Redis só aumenta complexidade.
  • Feed ordenado por algoritmo (ranking com ML): feed push funciona apenas como “conjunto de candidatos”. Ranking roda separadamente na leitura, e aí ordem por tempo já não é a ordem final.
  • Requisito de consistência forte (post deve aparecer para todos os seguidores imediatamente): fan-out assíncrono não garante isso. Você deve fazer pull, ou incluir o post do próprio autor na resposta imediatamente após postar (read-your-writes).

11. Conclusão

Fan-out on write otimiza leitura mas amplifica escrita. Fan-out on read faz o oposto. Com distribuição de seguidores enviesada como em redes sociais, a resposta prática é modelo híbrido: push para autores comuns, pull para celebridades, depois mescle ao ler.

Pontos que lembrar ao implementar:

  • Feed armazena apenas ID, score é epoch millis, não Snowflake ID.
  • Use cursor (createdAt, postId) para paginação estável.
  • Consumer deve ser idempotente, combine com outbox para não perder eventos.
  • Trate deleção de post e unfollow filtrando na leitura.
  • Use Kafka com limiar de celebridade dinâmico para absorver picos.
system-designrediskafkaspring-bootcase-study