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_timeserve a query “pega N posts mais recentes do autor X”. - A chave primária de
followsresponde “quem eu sigo”. O índiceidx_follows_followeeresponde “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:
findActiveFollowerIdsitera sobre os seguidores usando keyset pagination (follower_id > lastId), 1.000 de cada vez. Lida apenas com pessoas ativas nos últimos 30 dias.executePipelinedagrupa 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.ZADDcom 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, opostIdé 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,
postCacheignora posts comdeleted = 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
ZADDno 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
outboxna mesma transaction. Um relay (ou CDC como Debezium) lê aoutboxe faz push para Kafka. Do lado do consumer, deve ser idempotente, eZADDjá 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
authorIdpara 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_THRESHOLDpara 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.