MidПрактика10 min

Fan-out: write vs read

Push (fan-out on write) vs Pull (fan-out on read), гибрид для celebrities, storage costs

Проблема: home timeline

Соцсеть: пользователь А публикует пост. У него 1000 подписчиков. Каждый из них должен увидеть пост в своей ленте. Как это сделать?

Простой подход -- при открытии ленты пользователем B делать запрос:

SELECT p.* FROM posts p
JOIN follows f ON f.followee_id = p.author_id
WHERE f.follower_id = :userB
ORDER BY p.created_at DESC
LIMIT 50;

Если B подписан на 1000 людей, за день они опубликовали 5000 постов -- БД должна отсортировать 5000 записей из возможно миллионов. Выполняется каждый раз при открытии ленты. При 100M активных пользователей это убьёт БД.

Есть две противоположные стратегии: push (fan-out on write) и pull (fan-out on read).

Fan-out on write (Push model)

При публикации поста сразу копируем его в inbox каждого подписчика.

User A posts --> Post saved --> fan-out worker
                                      │
                                      v
                    ┌─────────────────┼─────────────────┐
                    v                 v                 v
              inbox:U1          inbox:U2          inbox:UN
              [post_id]         [post_id]         [post_id]

Чтение ленты -- это просто чтение готового списка из inbox.

Плюсы

  • Быстрое чтение: O(1) запрос Redis sorted set или Cassandra по user_id
  • Кэш дружелюбно: inbox в Redis, 50 элементов
  • Низкая latency: timeline открывается за 5-20 ms

Минусы

  • Write amplification: 1 пост = N записей (N подписчиков)
  • Storage взрывается: 100M юзеров × 500 постов в ленте = 50B записей inbox
  • Celebrity problem: у Илона Маска 200M подписчиков -- 200M записей на 1 твит. 10 минут fan-out, спайк нагрузки.
  • Обновления сложные: если пост удалён -- надо вычистить из всех inbox'ов

Fan-out on read (Pull model)

Никаких inbox. При открытии ленты -- запрос постов авторов, на которых подписан пользователь.

User B opens feed
  │
  v
  Get follows(B) = [A1, A2, ... A1000]
  │
  v
  Query: posts WHERE author IN follows ORDER BY created_at LIMIT 50
  │
  v
  Merge, sort, return

Плюсы

  • Запись дешёвая: 1 пост = 1 запись в posts
  • Малый storage: только авторитетные данные
  • Удаление простое: пост ушёл из таблицы -- нет его в ленте

Минусы

  • Медленное чтение: JOIN + сортировка на каждый открытый feed
  • Плохо масштабируется: если подписок 1000+, запрос медленный даже с индексами
  • Кэширование сложное: каждый пользователь видит уникальный микс

Сравнение

Критерий Push (fan-out on write) Pull (fan-out on read)
Latency чтения 10-20 ms 100-500 ms
Latency записи 100 ms - 10 s (для celebrities) 10 ms
Storage N × users × posts users × posts
Delete поста Сложно (N копий) Просто
Celebrity (1M+ followers) Катастрофа Норма
Новый подписчик видит историю Надо backfill Сразу работает
Подходит для Активные пользователи, низкие followers Редкие пользователи, celebrities

Hybrid model

Реальные системы (Twitter, Instagram) используют гибрид:

Правило: у обычных пользователей (меньше N подписчиков) -- push. У celebrities (больше N) -- pull. Лента = объединение inbox'а + последние посты celebrities, на которых я подписан.

User B opens feed:
  1. Get inbox(B) from Redis -> [posts from regular users]
  2. Get celebrity follows(B) = [Elon, Lex, Cristiano]
  3. Get latest posts for each celebrity (cached per celebrity, shared)
  4. Merge + sort by created_at
  5. Return top 50

Порог N обычно 10K-100K фолловеров. Ниже -- push, выше -- pull. Для celebrities достаточно кэшировать их последние посты один раз -- все фолловеры читают из одного кэша.

Реализация

<?php

declare(strict_types=1);

namespace App\Timeline;

use Predis\ClientInterface as Redis;

/**
 * Threshold above which a user is treated as a "celebrity"
 * and we switch from push to pull for their posts.
 */
final class TimelineService
{
    private const CELEBRITY_THRESHOLD = 10_000;
    private const INBOX_LIMIT = 1000;
    private const FEED_PAGE_SIZE = 50;

    public function __construct(
        private readonly Redis $redis,
        private readonly PostRepository $posts,
        private readonly FollowRepository $follows,
    ) {}

    /**
     * On new post: push to follower inboxes, but skip if author is a celebrity.
     * Celebrity posts are pulled at read time from a shared cache.
     */
    public function onPostPublished(string $authorId, string $postId, int $createdAt): void
    {
        $followerCount = $this->follows->countFollowersOf($authorId);

        if ($followerCount > self::CELEBRITY_THRESHOLD) {
            // Push to a per-author "latest posts" cache, read by fans on feed load
            $this->cacheCelebrityPost($authorId, $postId, $createdAt);
            return;
        }

        // Regular user: fan-out on write to all followers
        $this->fanoutToFollowers($authorId, $postId, $createdAt);
    }

    private function cacheCelebrityPost(string $authorId, string $postId, int $ts): void
    {
        $key = "celeb:posts:$authorId";
        $this->redis->zadd($key, [$postId => $ts]);
        // Keep last 200 posts for celebrity
        $this->redis->zremrangebyrank($key, 0, -201);
    }

    /**
     * Fan-out to followers. For >10K followers this is batched.
     * In production run as a background job with chunking.
     */
    private function fanoutToFollowers(string $authorId, string $postId, int $ts): void
    {
        foreach ($this->follows->streamFollowers($authorId, chunkSize: 1000) as $chunk) {
            $pipe = $this->redis->pipeline();
            foreach ($chunk as $followerId) {
                $key = "inbox:$followerId";
                $pipe->zadd($key, [$postId => $ts]);
                $pipe->zremrangebyrank($key, 0, -self::INBOX_LIMIT - 1);
            }
            $pipe->execute();
        }
    }

    /**
     * Read feed: inbox (pushed posts) + celebrities pulled live.
     * Merge and return top N by timestamp.
     *
     * @return string[] post IDs
     */
    public function homeTimeline(string $userId): array
    {
        // 1. Pushed posts from regular users
        $inboxKey = "inbox:$userId";
        $pushed = $this->redis->zrevrange($inboxKey, 0, self::FEED_PAGE_SIZE - 1, 'WITHSCORES');

        // 2. Pull latest posts from celebrities user follows
        $celebs = $this->follows->getCelebrityFollows($userId);
        $celebPosts = [];
        foreach ($celebs as $celebId) {
            $key = "celeb:posts:$celebId";
            $items = $this->redis->zrevrange($key, 0, 20, 'WITHSCORES');
            foreach ($items as $postId => $score) {
                $celebPosts[$postId] = (int) $score;
            }
        }

        // 3. Merge: convert pushed to [id => score] and union
        $merged = [];
        foreach ($pushed as $postId => $score) {
            $merged[$postId] = (int) $score;
        }
        $merged += $celebPosts;

        // Sort by score desc, take top N
        arsort($merged);
        return array_slice(array_keys($merged), 0, self::FEED_PAGE_SIZE);
    }
}
## Storage costs: оценка

Предположим:

  • 100M активных пользователей
  • В среднем 200 подписок, 500 подписчиков
  • 10 постов в день на пользователя
  • Inbox хранит последние 1000 постов
  • Post ID = 16 байт

Push-only: 100M × 1000 × 16 байт = 1.6 TB inbox storage. Запись: 100M × 10 × 500 = 500B операций в день = 5.8M ops/sec.

Pull-only: только posts = 100M × 10 × 365 (год) × 100 байт = 36.5 TB. Но нет inbox'ов. Чтение: N×M joins постоянно.

Hybrid (2% celebrities, 98% обычных): push для 98% = ~1.5 TB inbox, pull для 2% = кэш топ-1000 celebrities в Redis ~ 1 GB. Лучший компромисс.

Backfill при подписке

Проблема push: подписался на нового пользователя -- старые посты не появляются. Два решения:

  1. Ленивый backfill: при первом открытии ленты подтянуть последние N постов новых follows и вставить в inbox
  2. Нет backfill: показывать только новые посты. Twitter делает так

Для celebrities backfill не нужен -- они уже pull.

Пагинация

Feed пагинируется по score (timestamp), не OFFSET:

GET /timeline?before=1700000000
  -> ZRANGEBYSCORE inbox:user (before -inf LIMIT 50)

Курсор-пагинация, стабильная при появлении новых постов.

Pitfalls

  • Fan-out для celebrity -- 10 минут write + спайк. Обязательно hybrid.
  • Inbox не триммится -- растёт бесконечно, Redis OOM. ZREMRANGEBYRANK после каждого добавления.
  • Удаление поста -- в push надо проходить по inbox'ам. Альтернатива: post_id + lazy check "deleted?" при чтении.
  • Блокировки -- заблокировали автора, а его посты в inbox'е. Фильтрация на чтении.
  • Inbox per-device vs per-user -- читал с телефона, хочу продолжить на ноуте. Last read marker в отдельном ключе.
  • Ordering at write time -- timestamp clock skew между серверами. Используйте monotonic IDs (Snowflake).

Варианты для других доменов

  • Notifications: push прямо во время события -- обычно ок, уведомлений у каждого мало
  • Chat rooms: всегда pull из одного топика -- пользователей в комнате мало
  • Youtube subscriptions: pull + кэш по каналам -- каналов мало
  • News feed (Google News): pull с ранжированием ML -- порядок не хронологический

Выводы

  • Push (fan-out on write): быстрое чтение, дорогая запись, killer для celebrities
  • Pull (fan-out on read): дешёвая запись, медленное чтение
  • Hybrid (99% реального применения): push для обычных, pull для celebrities с общим кэшем
  • Порог ~10K-100K фолловеров
  • Storage амортизируется: inbox limit (например 1000 постов) обязателен
  • Пагинация курсором по timestamp, не OFFSET
  • Для других доменов (chat, news) выбор зависит от fan-out ratio