Проблема: 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);
}
}
Предположим:
- 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: подписался на нового пользователя -- старые посты не появляются. Два решения:
- Ленивый backfill: при первом открытии ленты подтянуть последние N постов новых follows и вставить в inbox
- Нет 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