Diseño de news feed tipo Facebook: fan-out on write u on read

1. Descripción general

Un news feed es la página de inicio donde ves los últimos posts de las personas que sigues. La pregunta fundamental al diseñarlo es: ¿construir el feed al escribir (fan-out on write) o al leer (fan-out on read)?

Este artículo recorre un estudio de caso completo. Partiremos de los requisitos y estimaciones de escala, luego construiremos el modelo de datos y la API. Después compararemos dos estrategias de fan-out y elegiremos un modelo híbrido. Finalmente abordaremos los puntos difíciles: celebrities, consistencia y picos de tráfico. Después de leerlo, podrás construir tu propio feed funcional con Spring Boot, PostgreSQL, Redis y Kafka.

2. Requisitos del problema

2.1. Requisitos funcionales

  • Los usuarios publican posts (texto, opcionalmente con imágenes).
  • Los usuarios pueden seguir/dejar de seguir a otros.
  • Los usuarios ven el feed: posts de quienes siguen, los más recientes primero, con paginación (infinite scroll).

2.2. Escala y restricciones

Asumimos los siguientes números:

DAU (usuarios activos diarios)      : 300 millones
Abrir feed por persona / día        : 10 veces  -> 3 mil millones de lecturas/día ≈ 35K QPS, pico ~100K QPS
Posts nuevos por persona / día      : 0.5       -> 150 millones de posts/día ≈ 1.7K QPS, pico ~5K QPS
Seguidores promedio                 : 300
Cuenta más grande                   : > 50 millones de seguidores

De esto se derivan algunas restricciones:

  • Lectura mucho más que escritura (aproximadamente 20 veces). La lectura debe ser rápida, p99 bajo 200 ms.
  • Aceptamos eventual consistency: está bien si los posts nuevos aparecen en el feed de los seguidores después de algunos segundos.
  • La distribución de seguidores está muy sesgada. La mayoría tiene algunos cientos de seguidores, pero unos pocos tienen decenas de millones.

3. Modelo de datos

La fuente de verdad reside en 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,           -- ID de Snowflake
    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);
  • El índice idx_posts_author_time sirve para la consulta “obtener los N posts más recientes del autor X”.
  • La clave primaria de follows responde a “a quién sigo”. El índice idx_follows_followee responde a “quién me sigue”, y el fan-out necesita exactamente esta consulta.

El feed precompilado reside en Redis. Cada usuario tiene un sorted set:

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

Nota: No uses Snowflake ID como score. El score en Redis es un double, que solo puede representar correctamente números enteros hasta 2^53. Un ID de 64-bit de Snowflake se redondeará, y dos posts diferentes podrían tener el mismo score. Usa epoch millis como score y mantén el ID en el miembro.

El feed solo almacena IDs, no el contenido de los posts. El contenido se “hidrata” desde la caché de posts al leer. De esta forma, cuando editas o eliminas un post, solo necesitas cambiar un 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) {}

Ejemplo de una respuesta:

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

Usamos cursor-based pagination (createdAtMillis:postId), no offset. El feed cambia continuamente. Con offset, cuando aparece un nuevo post al inicio, la siguiente página repetirá posts ya vistos.

5. Fan-out on write (push)

La idea: cuando el autor publica un post, el sistema escribe inmediatamente el ID del post en el feed de cada seguidor. Al leer, solo se necesita un comando ZREVRANGEBYSCORE.

Añade las dependencias:

<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 guarda el post en la BD y luego emite un evento PostCreated a Kafka. Un consumer separado realiza el 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: dejar que el flujo de lectura lo extraiga (ver sección 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)); // solo mantener 500 posts más recientes
            }
            return null;
        });
    }
}

Explicación:

  • findActiveFollowerIds itera a través de los seguidores usando keyset pagination (follower_id > lastId), 1.000 a la vez. Solo obtiene usuarios activos en los últimos 30 días.
  • executePipelined agrupa 2.000 comandos en un round-trip, en lugar de 2.000 round-trips separados.
  • zRemRange(key, 0, -501) recorta la cola, manteniendo el feed a máximo 500 elementos. La memoria así no crece indefinidamente con el tiempo.
  • ZADD con el mismo miembro y score es idempotente. Si Kafka reentrega el evento (at-least-once), el feed no tendrá duplicados.

La consulta SQL correspondiente:

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;

Ventajas: lectura ultra rápida, O(log N) en una sola clave. Desventajas: escritura amplificada. 150 millones de posts × 300 seguidores ≈ 45 mil millones de ZADD/día. Una celebrity con 50 millones de seguidores publicando un post genera 50 millones de operaciones de escritura.

6. Fan-out on read (pull)

Idea opuesta: no precomputes nada. Al leer, el sistema obtiene la lista de personas que sigues y luego consulta sus posts más recientes:

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;

Ventajas: escritura barata (solo un INSERT), los posts nuevos aparecen inmediatamente, no consume RAM para el feed. Desventajas: cada lectura toca decenas de autores, y esto ocurre a 100K QPS. Cuando posts está sharded por author_id, una lectura debe hacer scatter-gather a través de múltiples shards. Es muy difícil mantener p99 bajo 200 ms.

Resumen:

                    Fan-out on write          Fan-out on read
Costo de escritura  Alto (× seguidores)      Bajo
Costo de lectura    Bajo (1 clave Redis)     Alto (× seguidos)
Latencia en mostrar Segundos (async)         Inmediato
Celebrity           Desastre                 Bien
Usuario sigue muchos Bien                    Lento

7. Modelo híbrido

Facebook, Twitter y la mayoría de sistemas grandes siguen el enfoque híbrido:

  • Autor típico (< 10K seguidores): push al feed de los seguidores.
  • Celebrity (≥ 10K seguidores): no hacer push. Al leer, el sistema pull los posts de celebrities que el usuario sigue, luego los mezcla con el feed ya enviado por push.

Cada usuario solo sigue algunas decenas de celebrities, así que la parte de pull es pequeña. Los posts recientes de una celebrity los leen millones de personas, por lo que el cache es muy eficiente.

La parte central es la lógica de mezcla. La separamos en una capa Java pura para facilitar las pruebas:

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) { // ¿es e más antiguo que 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) mantiene solo posts más antiguos que el cursor. Cuando dos posts tienen el mismo millisegundo, se usa postId para definir el orden.
  • toMap(..., (a, b) -> a) deduplicación. Un post puede aparecer en ambas fuentes, por ejemplo si el autor acaba de cruzar el umbral de celebrity.
  • Luego la lista se ordena con lo más reciente primero y se corta según limit.

FeedService une las piezas:

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

La consulta a Redis usa max incluyendo el límite, porque pueden haber posts con el mismo millisegundo que el cursor. FeedMerger filtrará exactamente nuevamente.

8. Pruebas de la lógica de mezcla

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

El post 3 aparece en ambas fuentes pero solo se muestra una vez. La segunda página comienza justo después del cursor, sin repeticiones, sin omisiones.

9. Puntos difíciles y cómo resolverlos

9.1. Hot key de una celebrity

Cuando todos leen la misma celeb:{id}:recent, la carga se concentra en un nodo Redis. Hay tres formas de manejarlo:

  • Añadir caché local (Caffeine) en cada instancia con TTL de 2–5 segundos. 100K QPS se reduce entonces a solo cientos de requests/segundo hacia Redis.
  • Habilitar Redis read replica para estas claves.
  • Cuando una celebrity publica un post, escribir proactivamente en el timeline cache, sin esperar a un cache miss. Esto evita el efecto de rebaño (thundering herd).

9.2. Consistencia al eliminar posts, unfollow, nuevo follow

  • Eliminar post: no necesitas escanear millones de feeds. Al hidratar, postCache omite posts con deleted = true. Un job en background limpiará los IDs gradualmente después.
  • Unfollow: filtra al leer usando el conjunto de followees del usuario, al mismo tiempo que emite un evento para eliminar asincrónicamente los posts de esa persona de feed:{userId}.
  • Nuevo follow: ejecuta backfill, extrae aproximadamente 20 posts recientes de la persona seguida y luego ZADD al feed.

Como el feed solo contiene IDs, cualquier inconsistencia está en la “lista de referencia”. El contenido siempre es correcto según la fuente de verdad.

9.3. Pérdida de eventos entre BD y Kafka

Si haces INSERT de un post y luego llamas a kafkaTemplate.send(), el envío podría fallar después de que la transacción se haya confirmado. El post existiría pero nunca sería fan-out.

Nota: Usa transactional outbox. Escribe el post y una fila en la tabla outbox en la misma transacción. Un relay (o CDC como Debezium) lee outbox y envía a Kafka. El consumer debe ser idempotente, y ZADD ya satisface esto de por sí.

9.4. Picos de tráfico

Eventos grandes como partidos de fútbol o Año Nuevo pueden multiplicar el volumen de posts por 10. Las medidas:

  • Kafka como búfer. El consumer procesa según su capacidad, si hay lag el feed se retrasa algunos segundos más. Esto es aceptable según las restricciones de la sección 2.
  • Partición por authorId para mantener el orden de posts del mismo autor. Escala el consumer aumentando el número de particiones.
  • Saltar usuarios inactivos. No reciben push. Cuando regresan y hay cache miss, el feed se reconstruye con fan-out on read y se escribe en Redis.
  • Reducir dinámicamente el umbral de celebrity. Cuando el lag excede un umbral, reduce CELEBRITY_THRESHOLD para cambiar más autores a modo pull.

9.5. Estimación de memoria Redis

~200 millones de usuarios activos × 500 entrada × ~60 bytes ≈ 6 TB

Este número requiere Redis Cluster. Puedes reducir FEED_MAX a 200–300, porque muy pocas personas desplazan más de unos pocos cientos de posts. La parte de desplazamiento más profundo retrocede a pull.

10. Cuándo no usar este modelo

  • Aplicación pequeña (decenas de miles de usuarios): fan-out on read con una consulta SQL indexada es suficiente. Añadir Kafka y Redis solo aumenta la complejidad.
  • Feed ordenado por algoritmo (ranking con ML): el feed push solo actúa como “conjunto de candidatos”. El paso de ranking se ejecuta por separado al leer, y entonces el score temporal ya no es el orden final.
  • Requisitos de consistencia fuerte (los posts deben aparecer inmediatamente para todos los seguidores): el fan-out asincrónico no puede garantizar esto. Debes usar pull, o insertar el post del propio autor en la respuesta inmediatamente después de publicar (read-your-writes).

11. Conclusión

Fan-out on write optimiza para lectura pero amplifica la escritura. Fan-out on read hace lo opuesto. Con una distribución de seguidores tan sesgada como en redes sociales, la respuesta práctica es el modelo híbrido: push para autores típicos, pull para celebrities, luego mezcla al leer.

Puntos clave a recordar al implementar:

  • El feed solo almacena IDs, el score es epoch millis, no Snowflake ID.
  • Usa cursor (createdAt, postId) para paginación estable.
  • El consumer debe ser idempotente, combinado con outbox para no perder eventos.
  • Maneja eliminación de posts y unfollow filtrando al leer.
  • Usa Kafka junto con umbral de celebrity dinámico para absorber picos de tráfico.
system-designrediskafkaspring-bootcase-study