МОК-интервью по System Design / Проектируем ленту Twitter
Сегодня мы разберём систем-дизайн интервью по бэкенду, в котором кандидат вместе с интервьюером проектирует архитектуру Twitter-подобного сервиса — от сбора требований и оценки нагрузки до детального проектирования сервисов, выбора баз данных и решения ключевых проблем, таких как «проблема знаменитостей» и обеспечение eventual consistency. Это отличный пример того, как на собеседовании проверяется не только умение проектировать масштабируемые системы, но и способность рассуждать, находить компромиссы и обосновывать архитектурные решения.
Вопрос 1. Какие основные функциональные требования предъявляются к системе, аналогичной Twitter/X?
Таймкод: 00:00:16
Ответ собеседника: Правильный. Система ограничена основной функциональностью: публикация твитов, просмотр ленты на основе подписок и пользовательская лента с собственными постами. Твит содержит текст до 280 символов и не более одной картинки. Лента строится в обратном хронологическом порядке без рекомендаций. Доступны реакции (лайк, дизлайк, комментарий). Подписки реализуются через follow/unfollow с возможностью просмотра подписчиков, ограничение — 1 млн подписчиков на пользователя.
Правильный ответ:
Ответ кандидата достаточно полно покрывает ключевые функциональные требования. Дополним и структурируем их более детально для полноты картины.
1. Публикация контента (Tweet/Post)
Пользователь может создавать твит, который включает текст ограниченной длины (например, 280 символов) и, опционально, медиавложение (одно изображение). Система должна валидировать длину текса, проверять наличие запрещённого контента и сохранять твит с привязкой к автору и временной меткой.
2. Лента новостей (Home Timeline)
Главная лента пользователя агрегирует твиты от тех, на кого он подписан (follow). Порядок — обратный хронологический (от новых к старым). На данном этапе алгоритмические рекомендации не используются, что упрощает архитектуру — это классическая модель fan-out on read.
3. Профиль пользователя (User Profile / User Timeline)
Каждый пользователь имеет личную страницу, на которой отображаются только его собственные твиты в хронологическом порядке. Это упрощает реализацию по сравнению с домашней лентой.
4. Подписки (Follow / Unfollow)
Пользователь может подписываться на других пользователей и отписываться. Система должна поддерживать асимметричную модель подписок (как в Twitter, а не взаимную, как в Facebook). Ограничение на количество подписок (1 млн) — важное ограничение, влияющее на выбор архитектуры хранения и доставки контента. Также необходим просмотр списка подписчиков и подписок.
5. Реакции (Engagement)
Поддерживаются три типа взаимодействия: лайк (like), дизлайк (dislike) и комментарий (reply). Каждая реакция должна быть привязана к конкретному твиту и пользователю, с возможностью отмены. Комментарий по сути является ответным твитом с ссылкой на родительский пост.
Дополнительные аспекты, которые стоит учитывать при проектировании:
- Идентификация твита — каждый твит должен иметь уникальный глобальный идентификатор (например, Snowflake ID или UUID), что критично для последующего шардирования.
- Ограничение на частоту публикаций (Rate Limiting) — для предотвращения спама и нагрузки на систему.
- Медиа-хранилище — изображения должны храниться отдельно от основного хранилища данных (например, в object storage типа S3), а в базе данных сохраняется только ссылка (URL).
- Удаление и редактирование — на начальном этапе редактирование твита может не поддерживаться, но удаление должно корректно обрабатываться во всех лентах.
Данный набор требований является типичным для интервью по проектированию систем (System Design) и покрывает основные use cases, позволяющие оценить навыки кандидата в области архитектурных решений.
Вопрос 2. Какие нефункциональные требования предъявляются к системе: масштаб, производительность, доступность, география и хранение данных?
Таймкод: 00:02:46
Ответ собеседника: Правильный. Целевое количество пользователей — 100 млн в день. Средняя активность — 1 твит на пользователя в 5 дней, просмотр ленты — 5 раз в день. Загрузка ленты занимает 1–2 секунды, доставка новых твитов — до 5 секунд (eventual consistency). Доступность — 99,99% (~1 час простоя в год). Один регион — СНГ. Данные хранятся бессрочно, старые — на дешёвом оборудовании.
Правильный ответ:
Ответ кандидата покрывает все ключевые аспекты нефункциональных требований на высоком уровне. Развёрнем каждый пункт для полноты понимания и добавим важные расчёты, которые ожидаются на интервью.
1. Масштаб системы (Scale)
- DAU (Daily Active Users): 100 млн пользователей в день. Это задаёт верхнюю планку для всех расчётов нагрузки.
- Публикация твитов: 1 твит на пользователя каждые 5 дней = 20 млн твитов в день. В пересчёте на пиковые нагрузки (с учётом неравномерности, обычно коэффициент 2–3x) это даёт ~500–1000 твитов в секунду в среднем и до 2000–3000 твитов/сек в пике.
- Просмотр ленты: 5 раз в день на пользователя = 500 млн запросов ленты в день. Это ~5800 запросов/сек в среднем и до 15000–20000 запросов/сек в пике. Это самая нагруженная операция в системе.
- Реакции: если предположить, что 10% просмотренных твитов получают реакцию, это ещё ~50 млн реакций в день.
2. Производительность (Latency)
- Загрузка ленты: 1–2 секунды — это разумный SLA для пользовательского опыта. При этом важно различать p50, p95 и p99 латентность. Для 99.99% доступности нужно закладывать p99 < 2 секунды.
- Доставка твита в ленту: до 5 секунд — это eventual consistency, что допустимо для социальных сетей. Пользователь может написать твит, и его подписчики увидят его с задержкой до 5 секунд. Это позволяет использовать асинхронную архитектуру fan-out.
- Публикация твита: должна быть быстрой, < 500 мс, чтобы пользователь получал мгновенную обратную связь.
3. Доступность (Availability)
- 99.99% uptime = ~52 минуты простоя в год (кандидат округлил до часа — допустимо). Это требует:
- Резервирования всех критических компонентов (multi-AZ, multi-datacenter).
- Автоматического failover для баз данных и сервисов.
- Graceful degradation при частичных сбоях (например, если сервис реакций недоступен, лента всё равно должна отображаться).
4. Географическое распределение (Geography)
- Один регион — СНГ. Это существенно упрощает архитектуру: не нужно решать проблемы кросс-регионной репликации данных, согласованности между дата-центрами и синхронизации в реальном времени. Задержки внутри одного региона будут минимальными (< 50 мс между дата-центрами).
- Достаточно развернуть систему в 2–3 зонах доступности (Availability Zones) одного региона для отказоустойчивости.
5. Хранение данных (Storage)
- Бессрочное хранение — это важное бизнес-решение, которое сильно влияет на стоимость инфраструктуры.
- Оценка объёма данных: 20 млн твитов в день × ~500 байт (текст + метаданные) = ~10 ГБ в день только текст. С учётом медиа (одно изображение ~200 КБ, допустим 10% твитов с картинкой) = ~400 ГБ медиа в день. За год это ~150 ТБ медиа и ~4 ТБ текстовых данных. За 5 лет — ~750 ТБ медиа.
- Tiered Storage (многоуровневое хранение): кандидат верно отметил, что старые данные можно перемещать на более дешёвое хранилище. Типичная стратегия:
- Горячие данные (последние 7 день) — SSD, быстрый доступ.
- Тёплые данные (7–90 дней) — стандартное хранилище.
- Холодные данные (старше 90 дней) — object storage (S3, GCS) с редким доступом.
6. Дополнительные нефункциональные требования, которые стоит упомянуть:
- Consistency model: eventual consistency для ленты и реакций, strong consistency для подписок (пользователь должен сразу видеть, на кого подписался).
- Durability: данные не должны теряться — репликация с коэффициентом минимум 3x.
- Rate limiting: защита от злоупотреблений (массовые подписки, спам).
- Observability: метрики, логирование, трейсинг для мониторинга 99.99% доступности.
Данные требования формируют основу для всех последующих архитектурных решений: выбора баз данных, стратегии fan-out, кэширования и шардирования.
Вопрос 3. Какие ключевые выводы следуют из собранных требований, которые необходимо учитывать при проектировании архитектуры?
Таймкод: 00:06:05
Ответ собеседника: Правильный. Система имеет сильный перекос в сторону чтения (read-heavy). Проблема знаменитостей — пользователи с миллионом подписчиков создают нагрузку при публикации. Допустима eventual consistency — твиты доставляются в ленту с задержкой до ~5 секунд.
Правильный ответ:
Кандидата выделил три наиболее критичных вывода. Дополним и структурируем их, а также добавим другие важные следствия из требований.
1. Read-Heavy нагрузка
Соотношение чтений к записям составляет примерно 25:1 (500 млн просмотров ленты против 20 млн твитов в день). Это означает, что архитектура должна быть оптимизирована преимущественно под чтение. Ключевые решения:
- Агрессивное кэширование лент.
- Denormalization данных для быстрого чтения.
- Fan-out стратегия при записи (предварительное вычисление лент) предпочтительнее, чем агрегация при чтении для большинства пользователей.
- CDN для медиаконтента.
2. Проблема знаменитостей (Celebrity Problem / Fan-out Problem)
Пользователь с 1 млн подписчиков при публикации одного твита генерирует 1 млн записей в ленты подписчиков. При скорости публикации ~1000 твитов/сек даже небольшое количество таких пользователей может перегрузить систему.
Типичные подходы к решению:
А. Гибридная стратегия fan-out
Для обычных пользователей (< 10 000 подписчиков) — fan-out on write (при публикации твита сразу записываем его в ленты всех подписчиков). Для «знаменитостей» (> 10 000 подписчиков) — fan-out on read (при запросе ленты агрегируем твиты знаменитостей в реальном времени).
func (s *TimelineService) PublishTweet(ctx context.Context, tweet *Tweet) error {
// Сохраняем твит
if err := s.tweetRepo.Save(ctx, tweet); err != nil {
return err
}
followers, err := s.followRepo.GetFollowers(ctx, tweet.AuthorID)
if err != nil {
return err
}
// Порог определяется эмпирически
const celebrityThreshold = 10000
if len(followers) > celebrityThreshold {
// Знаменитость — не делаем fan-out, подписчики подтянут при чтении
s.metrics.IncCelebrityTweetFanoutSkipped()
return nil
}
// Обычный пользователь — fan-out on write
return s.timelineFanout.FanoutToFollowers(ctx, tweet, followers)
}
Б. Pull-based подход для знаменитостей
При построении ленты загружаем твиты обычных подписок из предварительно сформированной ленты, а твиты знаменитостей подтягиваем отдельным запросом и объединяем.
3. Eventual Consistency допустима
Задержка до 5 секунд для доставки твита в ленту подписчиков — это бизнес-допущение, которое существенно упрощает архитектуру:
- Можно использовать асинхронную обработку через очереди сообщений (Kafka, RabbitMQ).
- Не требуется синхронная запись в ленты всех подписчиков в момент публикации.
- Система становится более устойчивой к пиковым нагрузкам — очередь выступает буфером.
// Асинхронная обработка fan-out через очередь
func (s *TimelineService) handleFanoutMessage(ctx context.Context, msg *FanoutMessage) error {
followers, err := s.followRepo.GetFollowersChunked(ctx, msg.AuthorID, msg.Cursor, 1000)
if err != nil {
return fmt.Errorf("get followers chunk: %w", err)
}
for _, followerID := range followers {
if err := s.timelineRepo.AddTweet(ctx, followerID, msg.TweetID); err != nil {
// Повторная попытка или dead letter queue
s.metrics.IncFanoutErrors()
return err
}
}
// Если есть ещё подписчики — публикуем следующий chunk
if len(followers) == 1000 {
nextMsg := &FanoutMessage{
AuthorID: msg.AuthorID,
TweetID: msg.TweetID,
Cursor: followers[len(followers)-1],
}
return s.queue.Publish(ctx, nextMsg)
}
return nil
}
4. Дополнительные выводы из требований
- Один регион (СНГ) — упрощает выбор стратегии репликации. Не нужна кросс-регионная консистентность, достаточно multi-AZ репликации внутри одного региона.
- Бессрочное хранение — требует стратегии архивации и миграции данных между уровнями хранения (hot → warm → cold). Нужно предусмотреть TTL-подобные механизмы для автоматического перемещения старых данных.
- 100 млн DAU — это достаточно большой масштаб, при котором необходимо шардирование баз данных и горизонтальное масштабирование всех сервисов. Однако это не масштаб глобальной платформы (Twitter ~400 млн DAU), что позволяет обойтись без некоторых экстремальных оптимизаций.
- Ограничение подписчиков (1 млн) — задаёт верхнюю границу fan-out, что делает задачу вычислимой. Без этого ограничения fan-out для вирусных аккаунтов мог бы быть неуправляемым.
- Медиаконтент — изображения составляют основной объём хранимых данных (>95%), поэтому object storage с CDN критически важен для производительности и стоимости.
Эти выводы непосредственно влияют на выбор архитектурных паттернов: стратегия fan-out, выбор баз данных для разных типов данных, дизайн кэш-слоя и подход к шардированию.
Вопрос 4. Какие числовые метрики (RPS, трафик) можно рассчитать для системы на основе требований?
Таймкод: 00:07:11
Ответ собеседника: Правильный. Создание твитов: 20 млн/день, ~231 твит/с в среднем, пиковое ×2–3. Чтение: 500 млн открытий ленты/день, ~289 запросов/с. Подтверждается read-heavy перекос.
Правильный ответ:
Кандидат верно рассчитал основные метрики. Расширим расчёт, добавим пиковые нагрузки, трафик и объёмы хранения — это стандартный набор расчётов, который ожидается на System Design интервью.
1. Запись твитов (Tweet Creation)
- Среднее в день: 100 млн пользователей × (1 твит / 5 дней) = 20 млн твитов/день.
- Средний RPS: 20 000 000 / 86 400 ≈ 231 твит/сек.
- Пиковый RPS (коэффициент 2–3x): 460–700 твитов/сек.
2. Чтение ленты (Timeline Reads)
- Среднее в день: 100 млн × 5 просмотров = 500 млн запросов/день.
- Средний RPS: 500 000 000 / 86 400 ≈ 5 787 запросов/сек.
- Пиковый RPS: 11 500–17 000 запросов/сек.
Кандидат упомянул «20 твитов на страницу» — это корректное уточнение, но RPS запросов ленты считается по количеству запросов страницы, а не по количеству твитов. Каждый запрос ленты возвращает ~20 твитов.
3. Реакции (Engagement)
Предположим, что 5% просмотренных твитов получают реакцию:
- Допустим, пользователь видит 20 твитов за просмотр, 5 просмотров в день = 100 твитов в день.
- Реакции в день: 100 млн × 100 × 0.05 = 500 млн реакций/день.
- Средний RPS: 500 000 000 / 86 400 ≈ 5 787 реакций/сек.
- Пиковый RPS: 11 500–17 000 реакций/сек.
4. Подписки (Follow/Unfollow)
Предположим, что 1% пользователей подписываются на кого-то каждый день:
- Операции в день: 100 млн × 0.01 = 1 млн подписок/день.
- Средний RPS: 1 000 000 / 86 400 ≈ 12 операций/сек.
- Это относительно небольшая нагрузка.
5. Общий RPS
- Средний общий RPS: ~231 + 5 787 + 5 787 + 12 ≈ 11 817 запросов/сек.
- Пиковый общий RPS: ~23 000–35 000 запросов/сек.
- Соотношение read/write: ~50:1 (чтение/запись твитов без реакций, или ~25:1 с реакциями).
6. Трафик (Bandwidth)
- Твит с метаданными: ~500 байт.
- Трафик записи: 231 × 500 ≈ 115 КБ/сек.
- Ответ ленты (20 твитов × 500 байт): ~10 КБ на запрос.
- Трафик чтения: 5 787 × 10 КБ ≈ 58 МБ/сек.
- Медиаконтент (10% твитов с картинкой ~200 КБ): 231 × 0.1 × 200 КБ ≈ 4.6 МБ/сек загрузки + значительно больше на отдачу через CDN.
7. Объём хранения в день
- Текстовые данные: 20 млн × 500 байт = 10 ГБ/день.
- Медиа: 2 млн × 200 КБ = 400 ГБ/день.
- Реакции: 500 млн × 100 байт = 50 ГБ/день.
- Подписки: 1 млн × 50 байт = 50 МБ/день.
- Итого: ~460 ГБ/день или ~170 ТБ/год.
Эти расчёты являются фундаментом для принятия решений о количестве шардов, размере кэш-кластера, пропускной способности сети и выборе типа хранилища.
Вопрос 5. Как рассчитать трафик (ingress/egress) для операций записи и чтения твитов?
Таймкод: 00:08:37
Ответ собеседника: Правильный. Write-трафик: RPS × (0.5 КБ текст + 1 МБ картинка × процент твитов с медиа). Read-трафик: RPS × 0.5 КБ × 20 твитов в пачке. Допоперации (лайки, подписки) опущены как неприоритетные.
Правильный ответ:
Кандидат верно определил формулы. Приведём полный расчёт с конкретными числами, разделив ingress (входящий) и egress (исходящий) трафик — это важно для проектирования сети и выбора CDN.
1. Ingress (входящий трафик — от пользователей в систему)
А. Запись твитов (Tweet Write Ingress)
Формула: RPS_write × (tweet_size + image_size × image_percentage)
С конкретными значениями:
- RPS записи: 231 твит/сек
- Размер твита (текст + метаданные): ~500 байт ≈ 0.5 КБ
- Размер изображения: ~200 КБ (после сжатия/ресайза, не 1 МБ — оригинал может быть больше, но Twitter хранит оптимизированные версии)
- Процент твитов с картинкой: ~10%
Ingress_write = 231 × 0.5 КБ + 231 × 0.1 × 200 КБ
= 115.5 КБ/с + 4 620 КБ/с
≈ 4.7 МБ/сек (среднее)
≈ 9.4–14 МБ/сек (пиковое)
Б. Загрузка медиа — отдельный путь
Изображения обычно загружаются напрямую в object storage (S3) через presigned URL, минуя основной сервер приложения. Поэтому ingress медиа идёт в object storage, а не в API-сервис.
В. Реакции и подписки (прочие write-операции)
Ingress_reactions = 5 787 × 100 байт ≈ 579 КБ/сек
Ingress_follows = 12 × 50 байт ≈ 0.6 КБ/сек (пренебрежимо мало)
2. Egress (исходящий трафик — от системы к пользователям)
А. Чтение ленты (Timeline Read Egress)
Формула: RPS_read × tweet_size × tweets_per_page
Egress_read = 5 787 × 0.5 КБ × 20
= 57 870 КБ/с
≈ 56.5 МБ/сек (среднее)
≈ 113–170 МБ/сек (пиковое)
Б. Отдача медиаконтента (Media Egress)
Это основной egress-трафик. Каждый просмотр ленты с 20 твитами, из которых ~2 содержат картинку:
Egress_media = 5 787 × 2 × 200 КБ
= 2 314 800 КБ/с
≈ 2.2 ГБ/сек (среднее)
≈ 4.4–6.6 ГБ/сек (пиковое)
Именно поэтому медиаконтент обязательно отдаётся через CDN — это снижает egress-нагрузку на основную инфраструктуру и уменьшает задержки для пользователей.
3. Сводная таблица трафика
| Тип операции | Средний | Пиковый |
|---|---|---|
| Ingress (write твиты) | 4.7 МБ/с | 9–14 МБ/с |
| Ingress (write медиа в S3) | 4.6 МБ/с | 9–14 МБ/с |
| Ingress (реакции) | 0.6 МБ/с | 1–2 МБ/с |
| Egress (чтение лент) | 56.5 МБ/с | 113–170 МБ/с |
| Egress (медиа через CDN) | 2.2 ГБ/с | 4.4–6.6 ГБ/с |
4. Важные выводы из расчётов
- Egress >> Ingress — типичная картина для read-heavy систем. Исходящий трафик в десятки раз превышает входящий.
- Медиаконтент доминирует в egress — >95% исходящего трафика. CDN обязателен.
- Ingress от записи твитов относительно небольшой, что позволяет использовать простые очереди для асинхронной обработки.
- Сеть между сервисами (internal traffic) будет значительно больше внешнего трафика из-за fan-out: каждый твит записывается в ленты подписчиков, что генерирует внутренний трафик в десятки раз больше, чем исходный запрос.
Эти расчёты помогают определить количество серверов, пропускную способность сети между дата-центрами и стоимость CDN-сервиса.
Вопрос 6. Как организовать хранение и отдачу медиаконтента (изображений) в системе?
Таймкод: 00:10:31
Ответ собеседника: Правильный. Двухэтапный постинг: сначала загрузка картинки в S3-подобное хранилище с получением media ID, затем создание твита с прикреплённым media ID через REST API. Выделенный сервис для медиаконтента.
Правильный ответ:
Кандидата описал базовый подход верно. Раскроем архитектуру медиахранилища более детально, включая обработку изображений, отдачу через CDN и обработку ошибок.
1. Архитектура загрузки медиа
Двухэтапный процесс — это стандартный паттерн для социальных сетей:
Этап 1: Загрузка изображения
Клиент запрашивает presigned URL у сервиса медиа и загружает изображение напрямую в object storage, минуя основные серверы приложения. Это снижает нагрузку на backend.
type MediaService struct {
storage ObjectStorage // S3-совместимое хранилище
repo MediaRepository
processor ImageProcessor
}
func (s *MediaService) InitiateUpload(ctx context.Context, userID int64, contentType string) (*UploadSession, error) {
mediaID := generateMediaID()
// Генерируем presigned URL для прямой загрузки в S3
presignedURL, err := s.storage.GeneratePresignedURL(ctx, PresignRequest{
Bucket: "tweet-media",
Key: fmt.Sprintf("originals/%d/%s", userID, mediaID),
ContentType: contentType,
Expiry: 15 * time.Minute,
})
if err != nil {
return nil, fmt.Errorf("generate presigned URL: %w", err)
}
session := &UploadSession{
MediaID: mediaID,
PresignedURL: presignedURL,
Status: UploadStatusPending,
CreatedAt: time.Now(),
}
if err := s.repo.SaveUploadSession(ctx, session); err != nil {
return nil, fmt.Errorf("save upload session: %w", err)
}
return session, nil
}
Этап 2: Подтверждение загрузки и создание твита
После загрузки клиент уведомляет сервер, запускается асинхронная обработка изображения.
func (s *MediaService) ConfirmUpload(ctx context.Context, userID int64, mediaID string) (*Media, error) {
// Проверяем, что файл действительно загружен в S3
exists, err := s.storage.ObjectExists(ctx, "tweet-media",
fmt.Sprintf("originals/%d/%s", userID, mediaID))
if err != nil {
return nil, err
}
if !exists {
return nil, ErrMediaNotUploaded
}
media := &Media{
ID: mediaID,
AuthorID: userID,
Status: MediaStatusProcessing,
CreatedAt: time.Now(),
}
if err := s.repo.SaveMedia(ctx, media); err != nil {
return nil, err
}
// Асинхронная обработка через очередь
if err := s.queue.Publish(ctx, &ImageProcessingMessage{
MediaID: mediaID,
UserID: userID,
S3Key: fmt.Sprintf("originals/%d/%s", userID, mediaID),
}); err != nil {
return nil, err
}
return media, nil
}
2. Обработка изображений (Image Processing Pipeline)
Загруженное изображение проходит через пайплайн обработки:
func (p *ImageProcessor) Process(ctx context.Context, msg *ImageProcessingMessage) error {
// 1. Скачиваем оригинал из S3
original, err := p.storage.Download(ctx, "tweet-media", msg.S3Key)
if err != nil {
return fmt.Errorf("download original: %w", err)
}
// 2. Валидация: проверяем формат, размер, наличие вредоносного контента
if err := p.validate(original); err != nil {
return p.markFailed(ctx, msg.MediaID, err)
}
// 3. Генерируем варианты разных размеров
variants := []ImageVariant{
{Name: "thumb", Width: 150, Height: 150}, // Превью в ленте
{Name: "small", Width: 680, Height: 680}, // Мобильная версия
{Name: "large", Width: 1200, Height: 1200}, // Полный размер
}
for _, v := range variants {
resized, err := p.resize(original, v.Width, v.Height)
if err != nil {
return err
}
// Загружаем обработанную версию обратно в S3
key := fmt.Sprintf("processed/%d/%s/%s.jpg", msg.UserID, msg.MediaID, v.Name)
if err := p.storage.Upload(ctx, "tweet-media", key, resized); err != nil {
return err
}
}
// 4. Обновляем статус
return p.repo.UpdateStatus(ctx, msg.MediaID, MediaStatusReady)
}
3. Отдача медиа через CDN
CDN критически важен, так как медиаконтент составляет >95% egress-трафика.
func (s *MediaService) GetMediaURL(ctx context.Context, mediaID string, variant string) (string, error) {
media, err := s.repo.GetMedia(ctx, mediaID)
if err != nil {
return "", err
}
if media.Status != MediaStatusReady {
return "", ErrMediaNotReady
}
// URL через CDN, а не напрямую из S3
// Пример: https://cdn.example.com/media/{user_id}/{media_id}/{variant}.jpg
return fmt.Sprintf("https://cdn.example.com/media/%d/%s/%s.jpg",
media.AuthorID, mediaID, variant), nil
}
4. Организация бакетов в Object Storage
Структура хранения в S3-совместимом хранилище:
tweet-media/
├── originals/{user_id}/{media_id} # Оригинал (для возможной переобработки)
├── processed/{user_id}/{media_id}/
│ ├── thumb.jpg # 150x150 превью
│ ├── small.jpg # 680x680 для мобильных
│ └── large.jpg # 1200x1200 полный размер
5. Lifecycle Policy для экономии затрат
{
"Rules": [
{
"ID": "MoveOldMediaToColdStorage",
"Status": "Enabled",
"Filter": {"Prefix": "originals/"},
"Transitions": [
{
"Days": 90,
"StorageClass": "GLACIER"
}
]
},
{
"ID": "DeleteOldProcessingArtifacts",
"Status": "Enabled",
"Filter": {"Prefix": "processing-temp/"},
"Expiration": {"Days": 7}
}
]
}
6. Ключевые решения
- Presigned URLs — клиент загружает напрямую в S3, backend не является bottleneck для загрузки файлов.
- Асинхронная обработка — пользователь получает мгновенный ответ, обработка происходит в фоне. Если обработка не завершена, показываем placeholder.
- CDN с географическим кешированием — для региона СНГ достаточно точек присутствия в Москве, Санкт-Петербурге и Алматы.
- Несколько размеров — мобильные клиенты загружают thumb/small, десктопные — large. Это экономит трафик.
- Originals хранятся отдельно — для возможности переобработки при изменении требований к размерам или форматам.
Вопрос 7. Какой API предполагается для основных операций системы?
Таймкод: 00:10:58
Ответ собеседния: Правильный. REST API с стандартными CRUD-эндпоинтами для твитов, подписок, лайки, комментариев. Отдельная ручка для загрузки медиаконтента.
Правильный ответ:
Кандидат верно определил тип API. Приведём детальную спецификацию ключевых эндпоинтов с примерами запросов и ответов, а также рассмотрим важные аспекты проектирования API.
1. Общие принципы
- RESTful подход — ресурсно-ориентированный дизайн с использованием HTTP-методов.
- Версионирование —
/api/v1/для возможности обратно несовместимых изменений. - Пагинация — cursor-based пагинация для лент (более надёжная, чем offset-based, при высокой нагрузке).
- Аутентификация — JWT-токены в заголовке
Authorization: Bearer <token>.
2. API для твитов (Tweets)
POST /api/v1/tweets — создать твит
GET /api/v1/tweets/{tweet_id} — получить твит по ID
DELETE /api/v1/tweets/{tweet_id} — удалить твит
Создание твита:
POST /api/v1/tweets
Authorization: Bearer <jwt_token>
Content-Type: application/json
{
"text": "Hello, Twitter clone!",
"media_id": "media_abc123"
}
// Response 201 Created
{
"data": {
"id": "tweet_789xyz",
"author_id": 42,
"text": "Hello, Twitter clone!",
"media": {
"id": "media_abc123",
"url": "https://cdn.example.com/media/42/media_abc123/large.jpg",
"thumb_url": "https://cdn.example.com/media/42/media_abc123/thumb.jpg"
},
"created_at": "2025-01-15T10:30:00Z",
"stats": {
"likes": 0,
"dislikes": 0,
"comments": 0
}
}
}
3. API для ленты (Timeline)
GET /api/v1/timeline/home — домашняя лента (твиты подписок)
GET /api/v1/timeline/user/{user_id} — твиты конкретного пользователя
GET /api/v1/timeline/home?cursor=eyJ0c18iOjE3MDUzMTI2MDB9&limit=20
Authorization: Bearer <jwt_token>
// Response 200 OK
{
"data": [
{
"id": "tweet_789xyz",
"author": {
"id": 42,
"username": "johndoe",
"display_name": "John Doe"
},
"text": "Hello, Twitter clone!",
"media": {
"thumb_url": "https://cdn.example.com/media/42/media_abc123/thumb.jpg"
},
"created_at": "2025-01-15T10:30:00Z",
"stats": {
"likes": 150,
"dislikes": 3,
"comments": 25
},
"user_reaction": "like"
}
],
"pagination": {
"next_cursor": "eyJ0c18iOjE3MDUzMTI1MDB9",
"has_more": true
}
}
Cursor-based пагинация основана на временной метке последнего твита, что обеспечивает стабильные результаты даже при добавлении новых твитов между запросами.
4. API для реакций (Engagement)
POST /api/v1/tweets/{tweet_id}/like — поставить лайк
DELETE /api/v1/tweets/{tweet_id}/like — убрать лайк
POST /api/v1/tweets/{tweet_id}/dislike — поставить дизлайк
DELETE /api/v1/tweets/{tweet_id}/dislike — убрать дизлайк
POST /api/v1/tweets/{tweet_id}/comments — написать комментарий
GET /api/v1/tweets/{tweet_id}/comments — получить комментарии
POST /api/v1/tweets/tweet_789xyz/like
Authorization: Bearer <jwt_token>
// Response 200 OK
{
"data": {
"tweet_id": "tweet_789xyz",
"reaction": "like",
"stats": {
"likes": 151,
"dislikes": 3
}
}
}
5. API для подписок (Follow)
POST /api/v1/users/{user_id}/follow — подписаться
DELETE /api/v1/users/{user_id}/follow — отписаться
GET /api/v1/users/{user_id}/followers — список подписчиков
GET /api/v1/users/{user_id}/following — список подписок
6. API для медиа (Media)
POST /api/v1/media/upload/init — инициировать загрузку (получить presigned URL)
POST /api/v1/media/upload/confirm — подтвердить успешную загрузку
POST /api/v1/media/upload/init
Authorization: Bearer <jwt_token>
Content-Type: application/json
{
"content_type": "image/jpeg",
"file_size": 204800
}
// Response 200 OK
{
"data": {
"media_id": "media_abc123",
"upload_url": "https://storage.example.com/presigned/...",
"upload_method": "PUT",
"expires_at": "2025-01-15T10:45:00Z"
}
}
7. Rate Limiting
Для защиты от злоупотреблений необходим rate limiting:
// Пример конфигурации rate limiter
var rateLimits = map[string]RateLimit{
"POST /api/v1/tweets": {Requests: 30, Window: time.Minute},
"POST /api/v1/tweets/*/like": {Requests: 100, Window: time.Minute},
"POST /api/v1/media/upload/*": {Requests: 10, Window: time.Minute},
"GET /api/v1/timeline/*": {Requests: 60, Window: time.Minute},
}
8. Важные аспекты проектирования
- Idempotency keys — для POST-запросов создания твитов и реакций, чтобы избежать дублирования при повторных запросах.
- Soft delete — твиты удаляются логически (soft delete), а не физически, для аудита и возможности восстановления.
- Field selection — параметр
fieldsдля оптимизации ответов мобильных клиентов:?fields=id,text,author,created_at. - WebSocket/SSE — для real-time обновлений ленты можно дополнительно предусмотреть WebSocket или Server-Sent Events, хотя это выходит за рамки базовых требований.
Вопрос 8. Какие сервисы выделяются в архитектуре и какие у них роли?
Таймкод: 00:11:13
Ответ собеседника: Правильный. Выделены четыре сервиса: Tweet Service (создание твитов), Timeline Service (формирование ленты), Follow Service (подписки), Media Service (медиаконтент).
Правильный ответ:
Кандидат выделил четыре ключевых сервиса. Дополним архитектуру инфраструктурными компонентами и опишем взаимодействие между сервисами.
1. Основные бизнес-сервисы
А. Tweet Service (Сервис твитов)
Отвечает за создание, хранение, получение и удаление твитов. Хранит текст твита, автора, временную метку, ссылку на медиа. При создании твита публикует событие в очередь для асинхронной обработки fan-out.
type TweetService struct {
tweetRepo TweetRepository
eventBus EventBus
mediaClient MediaServiceClient
}
func (s *TweetService) CreateTweet(ctx context.Context, req *CreateTweetRequest) (*Tweet, error) {
// Валидация: длина текста, наличие медиа
if len(req.Text) > 280 {
return nil, ErrTweetTooLong
}
tweet := &Tweet{
ID: generateSnowflakeID(),
AuthorID: req.UserID,
Text: req.Text,
MediaID: req.MediaID,
CreatedAt: time.Now(),
}
if err := s.tweetRepo.Save(ctx, tweet); err != nil {
return nil, fmt.Errorf("save tweet: %w", err)
}
// Публикуем событие для fan-out
if err := s.eventBus.Publish(ctx, &TweetCreatedEvent{
TweetID: tweet.ID,
AuthorID: tweet.AuthorID,
Text: tweet.Text,
}); err != nil {
// Логируем, но не возвращаем ошибку пользователю — твит уже сохранён
s.logger.Error("failed to publish tweet event", "error", err)
}
return tweet, nil
}
Б. Timeline Service (Сервис ленты)
Отвечает за формирование и отдачу ленты пользователя. Хранит предварительно сформированные ленты (home timeline) и обрабатывает fan-out. Использует кэш для снижения нагрузки на базу данных.
type TimelineService struct {
timelineRepo TimelineRepository
cache Cache // Redis
tweetClient TweetServiceClient
}
func (s *TimelineService) GetHomeTimeline(ctx context.Context, userID int64, cursor string, limit int) (*TimelineResponse, error) {
cacheKey := fmt.Sprintf("timeline:%d:%s:%d", userID, cursor, limit)
// Пробуем получить из кэша
cached, err := s.cache.Get(ctx, cacheKey)
if err == nil {
return decodeTimeline(cached), nil
}
// Кэш промах — читаем из базы
tweetIDs, nextCursor, err := s.timelineRepo.GetTimeline(ctx, userID, cursor, limit)
if err != nil {
return nil, err
}
// Подтягиваем полные данные твитов
tweets, err := s.tweetClient.GetTweetsByIDs(ctx, tweetIDs)
if err != nil {
return nil, err
}
resp := &TimelineResponse{Tweets: tweets, NextCursor: nextCursor}
// Кешируем на короткое время (30 сек — из-за eventual consistency)
s.cache.Set(ctx, cacheKey, encodeTimeline(resp), 30*time.Second)
return resp, nil
}
В. Follow Service (Сервис подписок)
Управляет отношениями между пользователями: подписка, отписка, получение списка подписчиков и подписок. Должен обеспечивать strong consistency — пользователь должен сразу видеть результат подписки.
type FollowService struct {
followRepo FollowRepository
cache Cache
}
func (s *FollowService) Follow(ctx context.Context, followerID, targetID int64) error {
if followerID == targetID {
return ErrCannotFollowSelf
}
// Проверяем лимит подписок
followingCount, err := s.followRepo.GetFollowingCount(ctx, followerID)
if err != nil {
return err
}
if followingCount >= 1_000_000 {
return ErrFollowingLimitExceeded
}
follow := &Follow{
FollowerID: followerID,
FolloweeID: targetID,
CreatedAt: time.Now(),
}
if err := s.followRepo.Create(ctx, follow); err != nil {
return err
}
// Инвалидируем кэш подписок
s.cache.Del(ctx,
fmt.Sprintf("following:%d", followerID),
fmt.Sprintf("followers:%d", targetID),
)
return nil
}
Г. Media Service (Сервис медиаконтента)
Управляет загрузкой, обработкой и отдачей медиафайлов. Генерирует presigned URLs для загрузки, запускает обработку изображений, предоставляет CDN-ссылки.
2. Дополнительные сервисы
Д. User Service (Сервис пользователей)
Регистрация, аутентификация, профиль пользователя. Выделен отдельно, так как используется всеми остальными сервисами.
Е. Fan-out Worker (Воркер рассылки)
Не является отдельным API-сервисом, но критически важный компонент. Читает события из очереди и выполняет fan-out твитов в ленты подписчиков.
type FanoutWorker struct {
queue MessageQueue
followClient FollowServiceClient
timelineRepo TimelineRepository
}
func (w *FanoutWorker) ProcessTweetCreated(ctx context.Context, event *TweetCreatedEvent) error {
// Получаем подписчиков пачками
cursor := int64(0)
batchSize := 1000
for {
followers, err := w.followClient.GetFollowersChunked(ctx, event.AuthorID, cursor, batchSize)
if err != nil {
return err
}
if len(followers) == 0 {
break
}
// Записываем твит в ленты подписчиков пачкой
entries := make([]TimelineEntry, 0, len(followers))
for _, followerID := range followers {
entries = append(entries, TimelineEntry{
UserID: followerID,
TweetID: event.TweetID,
AuthorID: event.AuthorID,
CreatedAt: time.Now(),
})
}
if err := w.timelineRepo.BatchInsert(ctx, entries); err != nil {
return err
}
cursor = followers[len(followers)-1]
}
return nil
}
3. Инфраструктурные компоненты
| Компонент | Назначение |
|---|---|
| API Gateway | Маршрутизация запросов, rate limiting, аутентификация |
| Message Queue (Kafka) | Асинхронная передача событий между сервисами |
| Redis Cluster | Кэширование лент, сессий, счётчиков |
| Object Storage (S3) | Хранение оригиналов и обработанных изображений |
| CDN | Отдача медиаконтента с минимальной задержкой |
4. Схема взаимодействия
Client → API Gateway → [Tweet Service, Timeline Service, Follow Service, Media Service]
↓ ↓
Object Storage Message Queue (Kafka)
↓ ↓
CDN Fan-out Worker
↓
Timeline DB + Redis
Такое разделение на сервисы позволяет масштабировать каждый компонент независимо: Timeline Service и Timeline DB будут нагружены значительно сильнее, чем Follow Service, и могут масштабироваться отдельно.
Вопрос 9. Какие поля содержит сущность твита и какая база данных выбрана для хранения?
Таймкод: 00:11:39
Ответ собеседника: Правильный. Поля: user ID, дата создания, tweet ID, текст, media ID (ссылка на S3), количество лайков и дизлайков. Выбрана MongoDB как NoSQL-база, поскольку нет отношений с другими сущностями.
Правильный ответ:
Кандидат верно определил основные поля и обосновал выбор NoSQL. Дополним схему данных и рассмотрим нюансы выбора базы данных.
1. Схема сущности твита
type Tweet struct {
ID int64 `bson:"_id" json:"id"` // Snowflake ID — уникальный, монотонно возрастающий
AuthorID int64 `bson:"author_id" json:"author_id"` // ID автора твита
Text string `bson:"text" json:"text"` // Текст твита, до 280 символов
MediaID *string `bson:"media_id,omitempty" json:"media_id,omitempty"` // Ссылка на медиа (опционально)
CreatedAt time.Time `bson:"created_at" json:"created_at"` // Время создания
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` // Время последнего обновления
DeletedAt *time.Time `bson:"deleted_at,omitempty" json:"-"` // Soft delete
// Денормализованные счётчики — для быстрого отображения без JOIN
LikesCount int64 `bson:"likes_count" json:"stats.likes"`
DislikesCount int64 `bson:"dislikes_count" json:"stats.dislikes"`
CommentsCount int64 `bson:"comments_count" json:"stats.comments"`
// Денормализованные данные автора — чтобы не делать JOIN при отдаче ленты
AuthorUsername string `bson:"author_username" json:"author.username"`
AuthorDisplayName string `bson:"author_display_name" json:"author.display_name"`
AuthorAvatarURL string `bson:"author_avatar_url" json:"author.avatar_url"`
}
2. Обоснование выбора MongoDB
Выбор NoSQL (MongoDB) для хранения твитов обоснован несколькими факторами:
А. Отсутствие сложных отношений
Твит — это в основном самодостаточная сущность. Основные запросы — получить твит по ID или получить твиты пользователя. Нет необходимости в JOIN-ах, которые являются сильной стороной реляционных баз.
Б. Гибкость схемы
NoSQL позволяет легко добавлять новые поля без миграций. Например, добавление поля reply_to_tweet_id для комментариев или location для геометок.
В. Горизонтальное масштабирование
MongoDB поддерживает шардирование из коробки, что критично для 20 млн твитов в день. Шардирование по author_id или по tweet_id позволяет распределить нагрузку.
Г. Производительность записи
MongoDB оптимизирована для высокой throughput записи, что важно при 231 твит/сек в среднем и до 700/сек в пике.
3. Индексы для твитов
// Основной индекс — получение твитов пользователя в хронологическом порядке
db.tweets.createIndex({ author_id: 1, created_at: -1 })
// Для получения твита по ID (уже покрыт _id, но на случай lookup по другим полям)
db.tweets.createIndex({ _id: 1 })
// Для поиска удалённых твитов (soft delete)
db.tweets.createIndex({ deleted_at: 1 }, { sparse: true })
4. Шардирование
// Шардирование по tweet_id (Snowflake ID содержит временную метку)
// Это обеспечивает равномерное распределение и локальность по времени
sh.shardCollection("twitter_clone.tweets", { _id: 1 })
5. Денормализация счётчиков
Счётчики лайков, дизлайков и комментариев хранятся непосредственно в документе твита. Это классическая денормализация для read-heavy систем:
// Инкремент счётчика лайков атомарной операцией
func (r *TweetRepository) IncrementLikes(ctx context.Context, tweetID int64) error {
_, err := r.collection.UpdateOne(ctx,
bson.M{"_id": tweetID},
bson.M{"$inc": bson.M{"likes_count": 1}},
)
return err
}
6. Альтернативный взгляд: почему можно выбрать PostgreSQL
Хотя выбор MongoDB обоснован, стоит отметить, что PostgreSQL также может быть хорошим выбором:
- JSONB колонки в PostgreSQL дают гибкость, сопоставимую с MongoDB.
- Лучшая консистентность — ACID-транзакции для критичных операций.
- Более зрелый инструментарий для мониторинга и бэкапов.
- Партиционирование по времени — нативная поддержка в PostgreSQL, что удобно для бессрочного хранения с архивацией.
Для данного масштаба обе базы справятся с нагрузкой, и выбор между ними часто определяется экспертизой команды.
Вопрос 10. Какие поля содержит сущность подписки и почему выбрана PostgreSQL?
Таймкод: 00:12:56
Ответ собеседника: Правильный. Поля: дата создания, ID подписавшегося, ID того, на кого подписались. Выбрана PostgreSQL — реляционная БД, так как есть отношения между пользователями.
Правильный ответ:
Кандидат верно определил структуру и обосновал выбор реляционной базы. Раскроем схему детальнее и рассмотрим важные нюансы.
1. Схема сущности подписки
type Follow struct {
FollowerID int64 `db:"follower_id" json:"follower_id"` // Кто подписался
FolloweeID int64 `db:"followee_id" json:"followee_id"` // На кого подписались
CreatedAt time.Time `db:"created_at" json:"created_at"` // Дата подписки
}
SQL-схема:
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),
-- Ограничение: нельзя подписаться на себя
CONSTRAINT no_self_follow CHECK (follower_id != followee_id)
);
-- Индекс для получения списка подписчиков пользователя
CREATE INDEX idx_follows_followee_id ON follows (followee_id, created_at DESC);
-- Индекс для проверки существования подписки (покрывается PRIMARY KEY)
-- и для подсчёта количества подписок/подписчиков
2. Обоснование выбора PostgreSQL
Выбор реляционной базы для подписок обоснован несколькими ключевыми факторами:
А. Сильная консистентность (Strong Consistency)
Подписки требуют ACID-гарантий. Когда пользователь нажимает «подписаться», он должен сразу видеть результат. Двухфазные операции (проверить → подписаться) должны быть атомарными, чтобы избежать дублирования подписок при параллельных запросах.
func (r *FollowRepository) Create(ctx context.Context, follow *Follow) error {
// INSERT ... ON CONFLICT обеспечивает идемпотентность
// и атомарность операции без отдельной проверки
query := `
INSERT INTO follows (follower_id, followee_id, created_at)
VALUES ($1, $2, $3)
ON CONFLICT (follower_id, followee_id) DO NOTHING
RETURNING created_at
`
err := r.db.QueryRowContext(ctx, query,
follow.FollowerID, follow.FolloweeID, follow.CreatedAt,
).Scan(&follow.CreatedAt)
if err == sql.ErrNoRows {
return ErrAlreadyFollowing // Уже подписан — не ошибка, но информируем
}
return err
}
Б. Сложные запросы с агрегацией
Для подписок типичны запросы: «сколько у пользователя подписчиков», «на кого подписан пользователь», «есть ли взаимная подписка». Эти запросы естественно выражаются в SQL.
-- Количество подписчиков
SELECT COUNT(*) FROM follows WHERE followee_id = $1;
-- Количество подписок
SELECT COUNT(*) FROM follows WHERE follower_id = $1;
-- Проверка, подписан ли пользователь A на пользователя B
SELECT EXISTS(
SELECT 1 FROM follows WHERE follower_id = $1 AND followee_id = $2
);
-- Взаимные подпики (для отображения «взаимная подписка»)
SELECT EXISTS(
SELECT 1 FROM follows f1
JOIN follows f2 ON f1.follower_id = f2.followee_id
WHERE f1.follower_id = $1 AND f1.followee_id = $2
AND f2.follower_id = $2 AND f2.followee_id = $1
);
В. Целостность данных (Referential Integrity)
PostgreSQL обеспечивает внешние ключи, которые гарантируют, что подписка не может ссылаться на несуществующего пользователя. Хотя в микросервисной архитектуре внешние ключи между сервисами не используются, внутри сервиса подписок можно добавить проверку существования пользователя через кэш.
3. Операции с подписками
type FollowRepository struct {
db *sqlx.DB
cache *redis.Client
}
// Получить подписчиков с пагинацией (cursor-based)
func (r *FollowRepository) GetFollowers(ctx context.Context, userID int64, cursor int64, limit int) ([]int64, error) {
query := `
SELECT follower_id FROM follows
WHERE followee_id = $1 AND follower_id > $2
ORDER BY follower_id ASC
LIMIT $3
`
var followerIDs []int64
err := r.db.SelectContext(ctx, &followerIDs, query, userID, cursor, limit)
return followerIDs, err
}
// Получить подписки с пагинацией
func (r *FollowRepository) GetFollowing(ctx context.Context, userID int64, cursor int64, limit int) ([]int64, error) {
query := `
SELECT followee_id FROM follows
WHERE follower_id = $1 AND followee_id > $2
ORDER BY followee_id ASC
LIMIT $3
`
var followeeIDs []int64
err := r.db.SelectContext(ctx, &followeeIDs, query, userID, cursor, limit)
return followeeIDs, err
}
// Подписчики пачками для fan-out (курсор по ID)
func (r *FollowRepository) GetFollowersChunked(ctx context.Context, userID int64, cursor int64, batchSize int) ([]int64, error) {
return r.GetFollowers(ctx, userID, cursor, batchSize)
}
4. Кэширование счётчиков
Счётчики подписчиков и подписок кэшируются в Redis для быстрого отображения на странице профиля:
func (s *FollowService) GetFollowCounts(ctx context.Context, userID int64) (*FollowCounts, error) {
cacheKey := fmt.Sprintf("follow_counts:%d", userID)
// Пробуем кэш
cached, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
var counts FollowCounts
if json.Unmarshal([]byte(cached), &counts) == nil {
return &counts, nil
}
}
// Кэш промах — читаем из базы
counts, err := s.repo.GetCounts(ctx, userID)
if err != nil {
return nil, err
}
// Кешируем на 5 минут
data, _ := json.Marshal(counts)
s.cache.Set(ctx, cacheKey, data, 5*time.Minute)
return counts, nil
}
5. Масштабирование PostgreSQL
При 100 млн пользователях и среднем количестве подписок ~200 на пользователя, таблица подписок будет содержать ~20 млрд записей. Для масштабирования:
- Партиционирование по follower_id — распределяет данные по шардам.
- Read replicas — для чтения списков подписчиков (read-heavy операция).
- Connection pooling (PgBouncer) — для управления подключениями при высоком RPS.
Выбор PostgreSQL для подписок — это стандартное решение в индустрии, обеспечивающее надёжность, консистентность и гибкость запросов.
Вопрос 11. Как устроен Timeline-сервис и зачем нужен кэш (Redis)?
Таймкод: 00:13:28
Ответ собеседника: Правильный. Timeline-сервис использует Redis для кэширования предподготовленных лент. Хранятся два представления: пользовательская лента и лента подписок. Ключ — user ID, значение — массив первых 20 твитов.
Правильный ответ:
Кандидат верно описал базовую идею. Раскроем архитектуру Timeline-сервиса детальнее, включая стратегию кэширования, инвалидацию кэша и обработку граничных случаев.
1. Роль Redis в Timeline-сервисе
Redis выполняет две ключевые функции:
А. Кэш готовых лент (Timeline Cache)
При RPS чтения ~5800 запросов/сек (пиковый ~17000/сек) прямые запросы к базе данных для каждого запроса ленты создадут неприемлемую нагрузку. Redis позволяет отдавать ленту за < 5 мс вместо 50–200 мс из базы данных.
Б. Хранение предварительно вычисленных лент (Fan-out Storage)
Вместо хранения лент в реляционной базе, Redis используется как основное хранилище для быстрого доступа. Каждый пользователь имеет отсортированный набор (sorted set) твитов в своей ленте.
2. Структура данных в Redis
А. Домашняя лента (Home Timeline)
// Ключ: timeline:{user_id}
// Тип: Sorted Set (ZSET)
// Score: timestamp создания твита (для сортировки)
// Member: tweet_id
func (r *TimelineCache) AddTweetToTimeline(ctx context.Context, userID int64, tweetID int64, tweetTimestamp time.Time) error {
key := fmt.Sprintf("timeline:%d", userID)
score := float64(tweetTimestamp.UnixNano())
// Добавляем твит в sorted set
pipe := r.redis.Pipeline()
pipe.ZAdd(ctx, key, redis.Z{
Score: score,
Member: tweetID,
})
// Обрезаем до 800 последних твитов (для пагинации)
pipe.ZRemRangeByRank(ctx, key, 0, -801)
// Устанавливаем TTL — если пользователь неактивен, данные удалятся
pipe.Expire(ctx, key, 7*24*time.Hour)
_, err := pipe.Exec(ctx)
return err
}
func (r *TimelineCache) GetTimeline(ctx context.Context, userID int64, cursor int64, limit int) ([]int64, error) {
key := fmt.Sprintf("timeline:%d", userID)
// Если ключа нет — нужно перестроить ленту из базы
exists, err := r.redis.Exists(ctx, key).Result()
if err != nil {
return nil, err
}
if exists == 0 {
return nil, ErrTimelineCacheMiss
}
// Читаем твиты в обратном хронологическом порядке
// cursor — это score (timestamp) последнего полученного твита
results, err := r.redis.ZRevRangeByScore(ctx, key, &redis.ZRangeBy{
Min: fmt.Sprintf("(%d", cursor), // exclusive min
Max: "+inf",
Count: int64(limit),
}).Result()
if err != nil {
return nil, err
}
tweetIDs := make([]int64, 0, len(results))
for _, r := range results {
id, _ := strconv.ParseInt(r, 10, 64)
tweetIDs = append(tweetIDs, id)
}
return tweetIDs, nil
}
Б. Пользовательская лента (User Timeline)
// Ключ: user_timeline:{user_id}
// Тип: Sorted Set (ZSET)
// Аналогичная структура, но содержит только твиты самого пользователя
func (r *TimelineCache) AddToUserTimeline(ctx context.Context, userID int64, tweetID int64, tweetTimestamp time.Time) error {
key := fmt.Sprintf("user_timeline:%d", userID)
score := float64(tweetTimestamp.UnixNano())
pipe := r.redis.Pipeline()
pipe.ZAdd(ctx, key, redis.Z{Score: score, Member: tweetID})
pipe.ZRemRangeByRank(ctx, key, 0, -801)
pipe.Expire(ctx, key, 30*24*time.Hour) // Дольше храним — это личные твиты
_, err := pipe.Exec(ctx)
return err
}
3. Полная архитектура Timeline-сервиса
type TimelineService struct {
timelineCache *TimelineCache // Redis
timelineRepo TimelineRepository // PostgreSQL/MongoDB как fallback
tweetClient TweetServiceClient // Получение полных данных твитов
followClient FollowServiceClient // Получение списка подписок
}
func (s *TimelineService) GetHomeTimeline(ctx context.Context, userID int64, cursor int64, limit int) (*TimelineResponse, error) {
// 1. Пробуем получить ID твитов из Redis
tweetIDs, err := s.timelineCache.GetTimeline(ctx, userID, cursor, limit)
if err != nil {
if errors.Is(err, ErrTimelineCacheMiss) {
// 2. Cache miss — перестраиваем ленту из базы
tweetIDs, err = s.rebuildTimelineFromDB(ctx, userID)
if err != nil {
return nil, err
}
} else {
return nil, err
}
}
// 3. Подтягиваем полные данные твитов из Tweet Service
tweets, err := s.tweetClient.GetTweetsByIDs(ctx, tweetIDs)
if err != nil {
return nil, err
}
// 4. Формируем ответ с пагинацией
var nextCursor int64
if len(tweetIDs) > 0 {
nextCursor = tweetIDs[len(tweetIDs)-1]
}
return &TimelineResponse{
Tweets: tweets,
NextCursor: nextCursor,
HasMore: len(tweetIDs) == limit,
}, nil
}
// Перестроение ленты при cache miss
func (s *TimelineService) rebuildTimelineFromDB(ctx context.Context, userID int64) ([]int64, error) {
// Получаем список подписок
following, err := s.followClient.GetFollowing(ctx, userID, 0, 10000)
if err != nil {
return nil, err
}
// Загружаем последние твиты от каждого, кого читает пользователь
var allTweetIDs []int64
for _, followeeID := range following {
tweets, err := s.timelineRepo.GetUserRecentTweets(ctx, followeeID, 20)
if err != nil {
continue // Пропускаем ошибки — graceful degradation
}
allTweetIDs = append(allTweetIDs, tweets...)
}
// Сортируем по времени и берём топ-20
sort.Slice(allTweetIDs, func(i, j int) bool {
return allTweetIDs[i] > allTweetIDs[j]
})
if len(allTweetIDs) > 20 {
allTweetIDs = allTweetIDs[:20]
}
// Записываем в Redis для последующих запросов
go s.warmTimelineCache(context.Background(), userID, allTweetIDs)
return allTweetIDs, nil
}
4. Fan-out и запись в Redis
Fan-out worker при получении события о новом твите записывает его в Redis для каждого подписчика:
type FanoutWorker struct {
timelineCache *TimelineCache
followClient FollowServiceClient
}
func (w *FanoutWorker) ProcessTweetCreated(ctx context.Context, event *TweetCreatedEvent) error {
// Получаем подписчиков пачками
cursor := int64(0)
batchSize := 1000
for {
followers, err := w.followClient.GetFollowersChunked(ctx, event.AuthorID, cursor, batchSize)
if err != nil {
return err
}
if len(followers) == 0 {
break
}
// Записываем твит в ленты всех подписчиков пачкой
pipe := w.timelineCache.Pipeline()
for _, followerID := range followers {
key := fmt.Sprintf("timeline:%d", followerID)
score := float64(event.TweetTimestamp.UnixNano())
pipe.ZAdd(ctx, key, redis.Z{Score: score, Member: event.TweetID})
pipe.ZRemRangeByRank(ctx, key, 0, -801)
}
if _, err := pipe.Exec(ctx); err != nil {
return err
}
cursor = followers[len(followers)-1]
}
return nil
}
5. Почему 800 твитов в кэше?
Хранить все твиты в Redis дорого по памяти. 800 твитов — это эмпирический компромисс:
- При 20 твитов на страницу и cursor-based пагинации 800 твитов = 40 страниц.
- Средний пользователь просматривает 3–5 страниц за сессию, поэтому 800 покрывает большинство сценариев.
- При достижении конца кэша — перестроение из базы данных.
6. Оценка потребления памяти Redis
- На пользователя: 800 tweet_id × 8 байт (int64) ≈ 6.4 КБ на sorted set.
- На 100 млн активных пользователей: 100M × 6.4 КБ ≈ 640 ГБ.
- С учётом оверхеда Redis (~2x): ~1.3 ТБ.
Это значительный объём, но при использовании Redis Cluster с 10–15 шардами по 128 ГБ каждый — это реалистичная конфигурация.
7. Инвалидация кэша
Кэш инвалидируется в следующих случаях:
- Новый твит от подписки — fan-out worker добавляет в ленту.
- Пользователь отписался — лента перестраивается (удаляем кэш, при следующем запросе пересоздаётся).
- Твит удалён — асинхронно удаляем из всех лент подписчиков.
- TTL истёк — автоматическая очистка неактивных пользователей.
Redis является критическим компонентом системы — без него нагрузка на базу данных от 5800 RPS чтения лент была бы неподъёмной.
Вопрос 12. Как новые твиты попадают в кэш Timeline-сервиса и как решается проблема консистентности записи в MongoDB и Kafka?
Таймкод: 00:14:28
Ответ собеседника: Правильный. После записи твита в MongoDB событие отправляется в Kafka. Timeline-сервис подписан на Kafka и обновляет кэш. Проблема двойной записи решается паттерном Transactional Outbox или CDC от MongoDB.
Правильный ответ:
Кандидат верно определил оба основных подхода к решению проблемы двойной записи. Раскроем каждый из них детально — это один из ключевых вопросов на System Design интервью.
1. Проблема двойной записи (Dual Write Problem)
Tweet Service должен выполнить две операции: сохранить твит в MongoDB и отправить событие в Kafka. Эти две системы не поддерживают распределённые транзакции, поэтому возможны сценарии:
- Твит сохранён в MongoDB, но Kafka недоступна → твит не попадёт в ленты подписчиков.
- Kafka доступна, но MongoDB недоступна → событие отправлено, но твита нет в базе.
- Процесс упал между двумя операциями → неконсистентное состояние.
2. Решение 1: Transactional Outbox Pattern
Это наиболее надёжный и часто используемый паттерн. Идея: запись в базу данных и запись в таблицу outbox выполняются в одной транзакции. Отдельный процесс (relay) читает outbox и публикует в Kafka.
type TweetService struct {
db *mongo.Database
outboxRepo OutboxRepository
}
func (s *TweetService) CreateTweet(ctx context.Context, req *CreateTweetRequest) (*Tweet, error) {
tweet := &Tweet{
ID: generateSnowflakeID(),
AuthorID: req.UserID,
Text: req.Text,
MediaID: req.MediaID,
CreatedAt: time.Now(),
}
// Используем транзакцию MongoDB
session, err := s.db.Client().StartSession()
if err != nil {
return nil, err
}
defer session.EndSession(ctx)
err = session.WithTransaction(ctx, func(sessCtx mongo.SessionContext) error {
// 1. Сохраняем твит в коллекцию tweets
_, err := s.db.Collection("tweets").InsertOne(sessCtx, tweet)
if err != nil {
return fmt.Errorf("insert tweet: %w", err)
}
// 2. Записываем событие в outbox-коллекцию (в той же транзакции!)
outboxEvent := &OutboxEvent{
ID: generateUUID(),
Type: "tweet_created",
Payload: mustMarshal(tweet),
CreatedAt: time.Now(),
Status: OutboxStatusPending,
}
_, err = s.db.Collection("outbox").InsertOne(sessCtx, outboxEvent)
if err != nil {
return fmt.Errorf("insert outbox event: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return tweet, nil
}
Relay-процесс (Polling Publisher):
type OutboxRelay struct {
db *mongo.Database
kafka kafka.Writer
batchSize int
interval time.Duration
}
func (r *OutboxRelay) Start(ctx context.Context) {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.publishBatch(ctx)
}
}
}
func (r *OutboxRelay) publishBatch(ctx context.Context) {
// 1. Читаем неотправленные события из outbox
events, err := r.outboxRepo.GetPendingEvents(ctx, r.batchSize)
if err != nil {
log.Error("failed to get pending events", "error", err)
return
}
for _, event := range events {
// 2. Публикуем в Kafka
err := r.kafka.WriteMessages(ctx, kafka.Message{
Key: []byte(event.Payload["author_id"]),
Value: event.Payload,
Topic: "tweet-events",
})
if err != nil {
log.Error("failed to publish to kafka",
"event_id", event.ID, "error", err)
continue
}
// 3. Помечаем как отправленное
if err := r.outboxRepo.MarkAsPublished(ctx, event.ID); err != nil {
log.Error("failed to mark event as published", "error", err)
}
}
}
Схема Transactional Outbox:
Tweet Service
│
├──[Transaction]──► MongoDB: tweets collection
│ MongoDB: outbox collection
│
Outbox Relay (отдельный процесс)
│
├── Читает outbox ──► Публикует в Kafka ──► Помечает как sent
│
Kafka: tweet-events topic
│
▼
Fan-out Worker ──► Обновляет Redis timeline
3. Решение 2: Change Data Capture (CDC)
CDC — это подход, при котором изменения в базе данных автоматически захватываются и публикуются в поток событий. Для MongoDB используется Change Streams.
type CDCRelay struct {
db *mongo.Database
kafka kafka.Writer
}
func (c *CDCRelay) Start(ctx context.Context) {
// Подписываемся на изменения в коллекции tweets
pipeline := mongo.Pipeline{
{{"$match", bson.M{
"operationType": "insert",
"ns.coll": "tweets",
}}},
}
changeStream, err := c.db.Collection("tweets").Watch(ctx, pipeline)
if err != nil {
log.Fatal("failed to open change stream", "error", err)
}
defer changeStream.Close(ctx)
for changeStream.Next(ctx) {
var changeEvent bson.M
if err := changeStream.Decode(&changeEvent); err != nil {
log.Error("failed to decode change event", "error", err)
continue
}
// Извлекаем полный документ из change event
fullDocument := changeEvent["fullDocument"].(bson.M)
// Публикуем в Kafka
payload, _ := json.Marshal(fullDocument)
err := c.kafka.WriteMessages(ctx, kafka.Message{
Key: []byte(fullDocument["author_id"].(string)),
Value: payload,
Topic: "tweet-events",
})
if err != nil {
log.Error("failed to publish to kafka", "error", err)
}
}
}
4. Сравнение подходов
| Критерий | Transactional Outbox | CDC (Change Streams) |
|---|---|---|
| Надёжность | Высокая — атомарность с записью | Средняя — зависит от доступности change stream |
| Сложность | Средняя — нужен relay-процесс | Низкая — не нужно менять код записи |
| Гарантия порядка | Да — события в порядке записи | Да — change stream сохраняет порядок |
| Задержка | Зависит от polling interval (1–5 сек) | Минимальная (< 1 сек) |
| Зависимость от БД | Работает с любой БД | Только MongoDB (или БД с CDC поддержкой) |
| Идемпотентность | Легко реализуется | Требует доп. обработки |
5. Рекомендация
Для production-системы рекомендуется Transactional Outbox как более надёжный подход:
- Гарантирует, что событие не будет потеряно даже при падении процесса.
- Не зависит от специфичных фич базы данных.
- Позволяет контролировать retry-логику и dead letter queue.
CDC может использоваться как дополнительный механизм или для систем, где минимальная задержка критична.
6. Потребитель событий (Fan-out Worker)
type FanoutWorker struct {
consumer kafka.Reader
timelineCache *TimelineCache
followClient FollowServiceClient
}
func (w *FanoutWorker) Start(ctx context.Context) {
for {
msg, err := w.consumer.ReadMessage(ctx)
if err != nil {
log.Error("failed to read message", "error", err)
continue
}
var event TweetCreatedEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
log.Error("failed to unmarshal event", "error", err)
continue
}
// Идемпотентность: проверяем, не обработали ли уже
if w.isAlreadyProcessed(ctx, event.TweetID) {
continue
}
if err := w.fanout(ctx, &event); err != nil {
log.Error("fanout failed", "tweet_id", event.TweetID, "error", err)
// Не коммитим offset — сообщение будет обработано повторно
continue
}
}
}
Оба подхода решают фундаментальную проблему распределённых систем — согласованность данных между несколькими хранилищами без двухфазного коммита.
Вопрос 13. Как Timeline-сервис узнает, кому обновлять ленту, и почему не нужно каждый раз ходить в Follow-сервис?
Таймкод: 00:15:24
Ответ собеседника: Правильный. Благодаря eventual consistency Timeline-сервис забирает события из Kafka батчами и реже обращается к Follow-сервису за списком подписчиков, снижая нагрузку.
Правильный ответ:
Кандидат затронул важный аспект, но ответ требует существенного дополнения. Механизм получения списка подписчиков — это ключевая часть архитектуры fan-out.
1. Как Fan-out Worker получает список подписчиков
Fan-out Worker при получении события TweetCreated из Kafka обращается к Follow-сервису для получения списка подписчиков автора твита:
type FanoutWorker struct {
consumer kafka.Reader
followClient FollowServiceClient
timelineCache *TimelineCache
}
func (w *FanoutWorker) fanout(ctx context.Context, event *TweetCreatedEvent) error {
// Запрашиваем подписчиков пачками
cursor := int64(0)
batchSize := 1000
for {
followers, err := w.followClient.GetFollowersChunked(ctx, event.AuthorID, cursor, batchSize)
if err != nil {
return fmt.Errorf("get followers: %w", err)
}
if len(followers) == 0 {
break
}
// Добавляем твит в ленты всех подписчиков пачкой
if err := w.timelineCache.BatchAddToTimelines(ctx, followers, event.TweetID, event.CreatedAt); err != nil {
return fmt.Errorf("batch add to timelines: %w", err)
}
cursor = followers[len(followers)-1]
}
return nil
}
2. Оптимизация: кэширование списка подписчиков
Обращение к Follow-сервису для каждого твита создаёт значительную нагрузку. Для оптимизации используется кэширование списков подписчиков в Redis:
type FanoutWorker struct {
followClient FollowServiceClient
followerCache *FollowerCache // Redis кэш подписчиков
timelineCache *TimelineCache
}
func (w *FanoutWorker) getFollowersWithCache(ctx context.Context, authorID int64) ([]int64, error) {
cacheKey := fmt.Sprintf("followers:%d", authorID)
// Пробуем получить из кэша
cached, err := w.followerCache.GetAll(ctx, cacheKey)
if err == nil && len(cached) > 0 {
return cached, nil
}
// Cache miss — загружаем из Follow-сервиса
var allFollowers []int64
cursor := int64(0)
for {
followers, err := w.followClient.GetFollowersChunked(ctx, authorID, cursor, 5000)
if err != nil {
return nil, err
}
if len(followers) == 0 {
break
}
allFollowers = append(allFollowers, followers...)
cursor = followers[len(followers)-1]
}
// Кешируем на 5 минут (подписчики меняются редко по сравнению с твитами)
if len(allFollowers) > 0 {
w.followerCache.SetAll(ctx, cacheKey, allFollowers, 5*time.Minute)
}
return allFollowers, nil
}
3. Почему это работает при eventual consistency
Ключевое наблюдение: подписчики меняются гораздо реже, чем публикуются твиты.
- Подписки: ~1 млн операций в день (~12 RPS).
- Твиты: 20 млн в день (~231 RPS).
Соотношение ~20:1 в пользу твитов. Поэтому:
- Кэш подписчиков с TTL 5 минут даст точность 99%+ для большинства пользователей.
- Небольшая задержка в обновлении списка подписчиков (до 5 минут) допустима в рамках eventual consistency — новый подписчик увидит твиты с небольшой задержкой.
- Это снижает нагрузку на Follow-сервис в десятки раз.
4. Проблема знаменитостей и оптимизация
Для пользователей с большим количеством подписчиков (до 1 млн) даже пачная запись в Redis может быть медленной:
func (w *FanoutWorker) fanout(ctx context.Context, event *TweetCreatedEvent) error {
followers, err := w.getFollowersWithCache(ctx, event.AuthorID)
if err != nil {
return err
}
// Для знаменитостей — не делаем fan-out on write
const celebrityThreshold = 10000
if len(followers) > celebrityThreshold {
// Твит будет подгружен при чтении ленты (fan-out on read)
w.metrics.IncCelebrityFanoutSkipped()
return nil
}
// Для обычных пользователей — пачками по 500
batchSize := 500
for i := 0; i < len(followers); i += batchSize {
end := i + batchSize
if end > len(followers) {
end = len(followers)
}
batch := followers[i:end]
if err := w.timelineCache.BatchAddToTimelines(ctx, batch, event.TweetID, event.CreatedAt); err != nil {
return err
}
}
return nil
}
5. Пакетная запись в Redis
Для минимизации количества запросов к Redis используется пакетная запись через pipeline:
func (c *TimelineCache) BatchAddToTimelines(ctx context.Context, userIDs []int64, tweetID int64, timestamp time.Time) error {
pipe := c.redis.Pipeline()
score := float64(timestamp.UnixNano())
for _, userID := range userIDs {
key := fmt.Sprintf("timeline:%d", userID)
pipe.ZAdd(ctx, key, redis.Z{Score: score, Member: tweetID})
pipe.ZRemRangeByRank(ctx, key, 0, -801) // Храним только 800 последних
}
_, err := pipe.Exec(ctx)
return err
}
Один pipeline с 500 операциями выполняется за ~2–5 мс вместо 500 отдельных запросов за ~500 мс.
6. Нагрузка на Follow-сервис
С учётом кэширования:
- Без кэширования: 231 твит/с × 1 запрос к Follow-сервису = 231 RPS к Follow-сервису.
- С кэшированием (TTL 5 мин, уникальных авторов ~50 000 в 5 мин): 50 000 запросов / 300 с = ~17 RPS к Follow-сервису.
Это снижение нагрузки более чем в 10 раз.
7. Итого: путь твита от публикации до ленты
1. Пользователь публикует твит → Tweet Service
2. Tweet Service сохраняет в MongoDB + Outbox
3. Outbox Relay публикует в Kafka
4. Fan-out Worker читает из Kafka
5. Fan-out Worker получает подписчиков (из кэша или Follow-сервиса)
6. Fan-out Worker записывает tweet_id в Redis для каждого подписчика
7. Подписчик запрашивает ленту → Timeline Service читает из Redis
Каждый шаг асинхронен и масштабируем независимо. Eventual consistency в 5 секунд — это суммарная задержка прохождения через все эти этапы.
Вопрос 14. Как решается проблема знаменитостей (celebrity problem) при публикации твитов?
Таймкод: 00:15:56
Ответ собеседника: Правильный. Пользователи с >10 000 подписчиков помечаются как знаменитости. При публикации обновляется только их собственная лента, ленты подписчиков не обновляются. Кэш лент подписчиков инвалидируется, при следующем запросе данные динамически подгружаются из MongoDB.
Правильный ответ:
Кандидат описал подход, но с существенным упрощением. Инвалидация кэша лент подписчиков — это не совсем корректное решение, так как это приведёт к массовому cache stampede. Раскроем правильный подход.
1. Суть проблемы знаменитостей
Пользователь с 1 млн подписчиков при публикации одного твита генерирует 1 млн записей в Redis. При 231 твит/сек даже 100 таких знаменитоков создают нагрузку в 100 млн записей/сек — это неподъёмно для любого кэша.
2. Правильное решение: гибридный fan-out
Используется комбинация двух стратегий:
- Fan-out on write для обычных пользователей (< 10 000 подписчиков) — твит записывается в ленты подписчиков при публикации.
- Fan-out on read для знаменитостей (> 10 000 подписчиков) — твиты подтягиваются динамически при построении ленты.
3. Архитектура fan-out on read
type TimelineService struct {
timelineCache *TimelineCache
tweetClient TweetServiceClient
followClient FollowServiceClient
celebrityCache *CelebrityCache // Кэш списка знаменитостей
}
func (s *TimelineService) GetHomeTimeline(ctx context.Context, userID int64, cursor int64, limit int) (*TimelineResponse, error) {
// 1. Получаем список подписок пользователя
following, err := s.followClient.GetFollowing(ctx, userID, 0, 10000)
if err != nil {
return nil, err
}
// 2. Разделяем подписки на обычных и знаменитостей
var normalFollowees, celebrityFollowees []int64
for _, followeeID := range following {
if s.celebrityCache.IsCelebrity(ctx, followeeID) {
celebrityFollowees = append(celebrityFollowees, followeeID)
} else {
normalFollowees = append(normalFollowees, followeeID)
}
}
// 3. Твиты обычных подписок — из предварительно сформированного кэша
normalTweetIDs, err := s.timelineCache.GetTimeline(ctx, userID, cursor, limit)
if err != nil {
return nil, err
}
// 4. Твиты знаменитостей — загружаем динамически из их user timelines
celebrityTweets, err := s.getCelebTweets(ctx, celebrityFollowees, cursor, limit)
if err != nil {
// Graceful degradation: показываем без твитов знаменитостей
s.logger.Warn("failed to load celebrity tweets", "error", err)
}
// 5. Объединяем и сортируем по времени (merge)
allTweets := s.mergeAndSort(normalTweetIDs, celebrityTweets, limit)
return s.buildResponse(ctx, allTweets, limit)
}
func (s *TimelineService) getCelebTweets(ctx context.Context, celebrityIDs []int64, cursor int64, limit int) ([]*Tweet, error) {
var allTweets []*Tweet
for _, celebID := range celebrityIDs {
// Читаем из user_timeline знаменитости в Redis
tweetIDs, err := s.timelineCache.GetUserTimeline(ctx, celebID, cursor, limit)
if err != nil {
continue
}
tweets, err := s.tweetClient.GetTweetsByIDs(ctx, tweetIDs)
if err != nil {
continue
}
allTweets = append(allTweets, tweets...)
}
return allTweets, nil
}
4. Merge двух потоков
Классический алгоритм merge двух отсортированных массивов:
func (s *TimelineService) mergeAndSort(normalTweetIDs []int64, celebrityTweets []*Tweet, limit int) []*Tweet {
// Загружаем полные данные для обычных твитов
normalTweets, _ := s.tweetClient.GetTweetsByIDs(context.Background(), normalTweetIDs)
// Объединяем и сортируем по created_at (обратный порядок)
allTweets := append(normalTweets, celebrityTweets...)
sort.Slice(allTweets, func(i, j int) bool {
return allTweets[i].CreatedAt.After(allTweets[j].CreatedAt)
})
if len(allTweets) > limit {
allTweets = allTweets[:limit]
}
return allTweets
}
5. Определение знаменитостей
Список знаменитостей кэшируется и обновляется периодически:
type CelebrityCache struct {
redis *redis.Client
followRepo FollowRepository
threshold int
}
func (c *CelebrityCache) Refresh(ctx context.Context) error {
// Запускаем периодически (раз в час) для обновления списка
celebrities, err := c.followRepo.GetUsersWithFollowersAbove(ctx, c.threshold)
if err != nil {
return err
}
pipe := c.redis.Pipeline()
pipe.Del(ctx, "celebrities")
for _, celebID := range celebrities {
pipe.SAdd(ctx, "celebrities", celebID)
}
pipe.Expire(ctx, "celebrities", time.Hour)
_, err = pipe.Exec(ctx)
return err
}
func (c *CelebrityCache) IsCelebrity(ctx context.Context, userID int64) bool {
isMember, err := c.redis.SIsMember(ctx, "celebrities", userID).Result()
return err == nil && isMember
}
6. Почему НЕ стоит инвалидировать кэш подписчиков
Подход, описанный кандидатом (инвалидация кэша при публикации твита знаменитостью), имеет серьёзную проблему — cache stampede:
- Знаменитость с 1 млн подписчиков публикует твит.
- Инвалидируем 1 млн ключей в Redis.
- 1 млн пользователей одновременно получают cache miss.
- 1 млн запросов идут в MongoDB одновременно — база может не выдержать.
Вместо этого, fan-out on read распределяет нагрузку естественным образом: каждый пользователь загружает твиты знаменитостей только при запросе своей ленты, и эти запросы распределены во времени.
7. Оптимизация: ограничение на количество знаменитостей в ленте
Если пользователь подписан на 1000 знаменитостей, загрузка твитов от каждого будет медленной:
const maxCelebsInTimeline = 20
func (s *TimelineService) getCelebTweets(ctx context.Context, celebrityIDs []int64, cursor int64, limit int) ([]*Tweet, error) {
// Ограничиваем количество знаменитостей для динамической подгрузки
if len(celebrityIDs) > maxCelebsInTimeline {
// Берём только самых активных или самых релевантных
celebrityIDs = s.rankCelebsByRelevance(ctx, celebrityIDs, maxCelebsInTimeline)
}
// ...
}
8. Сравнение нагрузки
| Метод | Запись (fan-out) | Чтение (timeline) |
|---|---|---|
| Fan-out on write (все) | 231 × 200 (ср. подписчиков) = 46 200 записей Redis/с | 1 чтение Redis/запрос |
| Гибридный | 231 × 50 (обычные) = 11 550 записей Redis/с | 1 чтение Redis + 2–3 чтения celeb timelines |
| Экономия | ~4x меньше записей | +5–10 мс на чтение |
Гибридный подход значительно снижает нагрузку на запись, с минимальным влиянием на латентность чтения.
Вопрос 15. Как решить проблему мусорных картинок (загружены в storage, но твит не опубликован)?
Таймкод: 00:20:25
Ответ собеседника: Правильный. Предложены два варианта: временное хранилище с периодической очисткой непривязанных картинок, и reference counting с дедупликацией. Выбран первый вариант как более простой.
Правильный ответ:
Кандидат предложил два разумных подхода. Дополним деталями реализации и рассмотрим дополнительные нюансы.
1. Проблема в деталиях
Сценарий возникновения «мусорных» картинок:
- Пользователь начинает создавать твит, загружает картинку в S3.
- Клиент получает media_id.
- Пользователь закрывает вкладку / пропадает соединение / передумал.
- Картинка остаётся в S3 навсегда, занимая место.
При 231 твит/сек и 10% твитов с картинками, даже 10% «брошенных» загрузок = ~2.3 ГБ мусора в день.
2. Решение 1: Временный бакет + TTL (рекомендуемое)
Самый простой и надёжный подход — использовать отдельный бакет для загрузок с lifecycle policy:
type MediaService struct {
tempStorage ObjectStorage // Бакет для загрузок: "media-temp"
permanentStorage ObjectStorage // Бакет для опубликованных: "media-permanent"
}
func (s *MediaService) InitiateUpload(ctx context.Context, userID int64, contentType string) (*UploadSession, error) {
mediaID := generateMediaID()
// Загружаем во временный бакет
presignedURL, err := s.tempStorage.GeneratePresignedURL(ctx, PresignRequest{
Bucket: "media-temp",
Key: fmt.Sprintf("%d/%s", userID, mediaID),
ContentType: contentType,
Expiry: 15 * time.Minute,
})
if err != nil {
return nil, err
}
session := &UploadSession{
MediaID: mediaID,
PresignedURL: presignedURL,
Status: UploadStatusPending,
CreatedAt: time.Now(),
}
// Сохраняем сессию в базу
if err := s.repo.SaveUploadSession(ctx, session); err != nil {
return nil, err
}
return session, nil
}
// Вызывается при подтверждении публикации твита
func (s *MediaService) ConfirmMedia(ctx context.Context, userID int64, mediaID string) error {
tempKey := fmt.Sprintf("%d/%s", userID, mediaID)
permKey := fmt.Sprintf("%d/%s", userID, mediaID)
// Перемещаем из временного в постоянный бакет
if err := s.tempStorage.CopyObject(ctx, "media-temp", tempKey, "media-permanent", permKey); err != nil {
return fmt.Errorf("copy to permanent storage: %w", err)
}
// Удаляем из временного
if err := s.tempStorage.DeleteObject(ctx, "media-temp", tempKey); err != nil {
s.logger.Warn("failed to delete temp object", "key", tempKey, "error", err)
}
// Обновляем статус
return s.repo.UpdateStatus(ctx, mediaID, MediaStatusReady)
}
Lifecycle policy для автоматической очистки:
{
"Rules": [
{
"ID": "CleanupTempUploads",
"Status": "Enabled",
"Filter": {"Prefix": ""},
"Expiration": {"Days": 1},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 1}
}
]
}
Объекты в бакете media-temp автоматически удаляются через 24 часа. Это достаточно для любого сценария «брошенной» загрузки.
3. Решение 2: Периодическая очистка (Garbage Collection)
Более точный подход — периодическая проверка привязки медиа к твитам:
type MediaGarbageCollector struct {
mediaRepo MediaRepository
tweetRepo TweetRepository
storage ObjectStorage
}
func (gc *MediaGarbageCollector) Run(ctx context.Context) error {
// Находим медиа, загруженные более 1 часа назад
// и не привязанные ни к одному твиту
cutoffTime := time.Now().Add(-1 * time.Hour)
orphanedMedia, err := gc.mediaRepo.GetOrphanedMedia(ctx, cutoffTime)
if err != nil {
return err
}
for _, media := range orphanedMedia {
// Проверяем, не появился ли твит с этим медиа
exists, err := gc.tweetRepo.ExistsWithMediaID(ctx, media.ID)
if err != nil {
continue
}
if !exists {
// Безопасно удаляем
key := fmt.Sprintf("%d/%s", media.AuthorID, media.ID)
if err := gc.storage.DeleteObject(ctx, "media-temp", key); err != nil {
gc.logger.Error("failed to delete orphaned media",
"media_id", media.ID, "error", err)
continue
}
gc.mediaRepo.MarkAsDeleted(ctx, media.ID)
gc.metrics.IncOrphanedMediaCleaned()
}
}
return nil
}
4. Решение 3: Reference Counting (дополнительная оптимизация)
Для дедупликации одинаковых изображений:
type MediaDeduplicator struct {
storage ObjectStorage
repo MediaRepository
}
func (d *MediaDeduplicator) StoreWithDedup(ctx context.Context, userID int64, imageData []byte) (string, error) {
// Вычисляем хеш изображения
hash := sha256.Sum256(imageData)
hashStr := hex.EncodeToString(hash[:])
// Проверяем, есть ли уже такой файл
existing, err := d.repo.GetByHash(ctx, hashStr)
if err == nil {
// Файл уже существует — увеличиваем счётчик ссылок
d.repo.IncrementRefCount(ctx, existing.ID)
return existing.ID, nil
}
// Новый файл — загружаем
mediaID := generateMediaID()
key := fmt.Sprintf("%d/%s", userID, mediaID)
if err := d.storage.Upload(ctx, "media-permanent", key, imageData); err != nil {
return "", err
}
media := &Media{
ID: mediaID,
AuthorID: userID,
ContentHash: hashStr,
RefCount: 1,
Status: MediaStatusReady,
}
if err := d.repo.Save(ctx, media); err != nil {
return "", err
}
return mediaID, nil
}
5. Рекомендуемая комбинация
Для production-системы рекомендуется комбинация:
- Временный бакет с TTL 24 часа — как основной механизм защиты от мусора. Простой, надёжный, не требует дополнительного кода.
- Garbage Collector — как дополнительный механизм для более быстрой очистки (1 час вместо 24 часов).
- Reference counting — как оптимизация для дедупликации, если бюджет позволяет.
6. Оценка экономии
- Без очистки: ~2.3 ГБ мусора в день = ~840 ГБ/год.
- С TTL 24 часа: максимум 1 день мусора = ~2.3 ГБ одновременно.
- С GC каждый час: максимум 1 час мусора = ~100 МБ одновременно.
Учитывая стоимость хранения в S3 (~20/месяц — незначительна. Поэтому временный бакет с TTL — оптимальное решение по соотношению простоты и эффективности.
Вопрос 16. Как клиент будет получать картинку, если она может находиться в разных хранилищах (временном и основном)?
Таймкод: 00:21:59
Ответ собеседника: Правильный. Клиент обращается к обоим хранилищам — ищет картинку сначала в основном, затем во временном (или параллельно).
Правильный ответ:
Кандидат предложил подход, но он не совсем корректен с точки зрения архитектуры. Клиент НЕ должен знать о существовании двух хранилищ — это нарушает принцип инкапсуляции. Раскроем правильный подход.
1. Принцип: клиент не знает о хранилищах
Клиент получает единый URL через API и загружает картинку напрямую из CDN. Вся логика определения местоположения файла скрыта на стороне сервера.
// Клиент получает такой URL в ответе API:
// https://cdn.example.com/media/{user_id}/{media_id}/large.jpg
// Клиент не знает и не должен знать, где физически хранится файл
2. Архитектура отдачи медиа
Правильная архитектура выглядит так:
Клиент → CDN → Media Service (Origin) → Object Storage
CDN кэширует файлы и отдаёт их с ближайшей точки присутствия. Если CDN не имеет файла в кэше, запрос идёт к Media Service.
3. Media Service как единая точка доступа
type MediaService struct {
permanentStorage ObjectStorage // "media-permanent" бакет
tempStorage ObjectStorage // "media-temp" бакет
repo MediaRepository
}
func (s *MediaService) GetMediaURL(ctx context.Context, mediaID string, variant string) (string, error) {
media, err := s.repo.GetMedia(ctx, mediaID)
if err != nil {
return "", ErrMediaNotFound
}
// В зависимости от статуса — разный бакет
var bucket string
switch media.Status {
case MediaStatusReady:
bucket = "media-permanent"
case MediaStatusProcessing:
// Можно отдать временную версию или placeholder
bucket = "media-temp"
default:
return "", ErrMediaNotReady
}
// Генерируем presigned URL или возвращаем CDN URL
// Для production — всегда CDN URL
return fmt.Sprintf("https://cdn.example.com/media/%d/%s/%s.jpg",
media.AuthorID, mediaID, variant), nil
}
4. CDN как маршрутизатор
CDN настроен с двумя origin-ами:
# Конфигурация CDN (пример на основе Nginx)
server {
listen 443 ssl;
server_name cdn.example.com;
location ~ ^/media/(\d+)/([a-zA-Z0-9_-]+)/(thumb|small|large)\.jpg$ {
# Сначала проверяем кэш CDN
# Если нет в кэше — идём в origin
# Origin: Media Service, который сам решает, из какого бакета отдать
proxy_pass http://media-service-backend;
proxy_set_header Host $host;
# Кеширование на уровне CDN — 24 часа
expires 24h;
add_header Cache-Control "public, max-age=86400";
}
}
5. Полный цикл запроса изображения
1. Клиент запрашивает ленту → Timeline Service
2. Timeline Service возвращает tweet с media URL:
https://cdn.example.com/media/42/media_abc123/large.jpg
3. Клиент загружает изображение с CDN
4. CDN проверяет свой кэш:
a. Cache HIT → отдаёт сразу (< 10 мс)
b. Cache MISS → запрос к Media Service (Origin)
5. Media Service определяет бакет по статусу медиа
6. Media Service возвращает файл или presigned URL
7. CDN кэширует файл для последующих запросов
6. Обработка неготовых изображений
Если изображение ещё обрабатывается, есть несколько стратегий:
func (s *MediaService) GetMediaURL(ctx context.Context, mediaID string, variant string) (string, error) {
media, err := s.repo.GetMedia(ctx, mediaID)
if err != nil {
return "", ErrMediaNotFound
}
switch media.Status {
case MediaStatusReady:
return s.buildCDNURL(media.AuthorID, mediaID, variant), nil
case MediaStatusProcessing:
// Вариант 1: Отдать thumbnail (он создаётся первым)
if variant == "thumb" {
return s.buildCDNURL(media.AuthorID, mediaID, "thumb"), nil
}
// Вариант 2: Отдать placeholder
return "https://cdn.example.com/placeholder/processing.jpg", nil
case MediaStatusPending:
// Загрузка ещё не завершена
return "https://cdn.example.com/placeholder/pending.jpg", nil
case MediaStatusFailed:
return "https://cdn.example.com/placeholder/error.jpg", nil
default:
return "", ErrMediaNotReady
}
}
7. Почему клиент не должен искать в обоих хранилищах
Подход кандидата (клиент ищет в обоих хранилищах) имеет несколько проблем:
- Нарушение инкапсуляции — клиент знает о внутренней структуре хранения.
- Двойные запросы — клиент делает 2 запроса вместо 1, увеличивая задержку.
- Безопасность — клиент получает доступ к временному хранилищу, которое может содержать непроверенный контент.
- Сложность — при добавлении третьего хранилища (например, холодного) нужно менять клиентский код.
Правильный принцип: клиент получает один URL, сервер решает, откуда отдать. Это стандартный подход в веб-разработке, используемый всеми крупными платформами.
Вопрос 17. Что происходит, когда пользователь пролистывает ленту дальше первых 20 твитов (пагинация)?
Таймкод: 00:22:20
Ответ собеседника: Правильный. При запросе за пределами первых 20 закэшированных твитов система обращается к Tweet-сервису (MongoDB) и подгружает следующие твиты по хронологии, обновляя кэш. Такие ситуации редки.
Правильный ответ:
Кандидат описал базовый сценарий верно, но упустил важные детали реализации пагинации. Раскроем механизм полностью.
1. Cursor-based пагинация
Вместо offset-based пагинации (которая нестабильна при изменении данных) используется cursor-based пагинация на основе временной метки последнего полученного твита:
type TimelineRequest struct {
Cursor int64 `query:"cursor"` // timestamp последнего твита в наносекундах
Limit int `query:"limit"` // количество твитов (по умолчанию 20)
}
type TimelineResponse struct {
Tweets []*Tweet `json:"tweets"`
NextCursor int64 `json:"next_cursor"` // cursor для следующей страницы
HasMore bool `json:"has_more"`
}
2. Полный цикл пагинации
func (s *TimelineService) GetHomeTimeline(ctx context.Context, userID int64, cursor int64, limit int) (*TimelineResponse, error) {
if limit <= 0 || limit > 100 {
limit = 20 // значение по умолчанию и максимум
}
// 1. Пробуем получить из Redis (основной путь — cache hit)
tweetIDs, err := s.timelineCache.GetTimeline(ctx, userID, cursor, limit)
if err != nil {
if errors.Is(err, ErrTimelineCacheMiss) {
// 2. Cache miss — перестраиваем ленту из базы
tweetIDs, err = s.rebuildTimelineFromDB(ctx, userID)
if err != nil {
return nil, err
}
} else {
return nil, err
}
}
// 3. Подтягиваем полные данные твитов
tweets, err := s.tweetClient.GetTweetsByIDs(ctx, tweetIDs)
if err != nil {
return nil, err
}
// 4. Формируем cursor для следующей страницы
var nextCursor int64
if len(tweetIDs) > 0 {
// Cursor — это score (timestamp) последнего твита
nextCursor, _ = s.timelineCache.GetScore(ctx, userID, tweetIDs[len(tweetIDs)-1])
}
return &TimelineResponse{
Tweets: tweets,
NextCursor: nextCursor,
HasMore: len(tweetIDs) == limit,
}, nil
}
3. Чтение из Redis с cursor
func (c *TimelineCache) GetTimeline(ctx context.Context, userID int64, cursor int64, limit int) ([]int64, error) {
key := fmt.Sprintf("timeline:%d", userID)
// Проверяем существование ключа
exists, err := c.redis.Exists(ctx, key).Result()
if err != nil || exists == 0 {
return nil, ErrTimelineCacheMiss
}
var minScore string
if cursor == 0 {
// Первая страница — начинаем с самых новых
minScore = "-inf"
} else {
// Следующие страницы — начинаем после cursor (exclusive)
minScore = fmt.Sprintf("(%d", cursor)
}
// ZRevRangeByScore — читаем в обратном порядке (новые → старые)
results, err := c.redis.ZRevRangeByScore(ctx, key, &redis.ZRangeBy{
Min: minScore,
Max: "+inf",
Offset: 0,
Count: int64(limit),
}).Result()
if err != nil {
return nil, err
}
if len(results) == 0 {
return nil, ErrNoMoreTweets
}
tweetIDs := make([]int64, 0, len(results))
for _, r := range results {
id, _ := strconv.ParseInt(r, 10, 64)
tweetIDs = append(tweetIDs, id)
}
return tweetIDs, nil
}
4. Что происходит при достижении конца кэша
Redis хранит 800 последних твитов. Если пользователь пролистал дальше:
func (s *TimelineService) handleEndOfCache(ctx context.Context, userID int64, cursor int64, limit int) (*TimelineResponse, error) {
// Пользователь пролистал все 800 закэшированных твитов
// Загружаем из базы данных более старые твиты
// Получаем список подписок
following, err := s.followClient.GetFollowing(ctx, userID, 0, 10000)
if err != nil {
return nil, err
}
// Загружаем твиты старше cursor от всех подписок
oldTweets, err := s.tweetRepo.GetTweetsFromUsersOlderThan(ctx, following, cursor, limit)
if err != nil {
return nil, err
}
// Обновляем кэш этими твитами (чтобы следующий запрос был быстрее)
go s.warmTimelineCache(context.Background(), userID, tweetIDsFromTweets(oldTweets))
return &TimelineResponse{
Tweets: oldTweets,
NextCursor: calculateNextCursor(oldTweets),
HasMore: len(oldTweets) == limit,
}, nil
}
5. SQL-запрос для загрузки старых твитов
-- Загрузка твитов от подписок старше определённого cursor
SELECT t.*
FROM tweets t
WHERE t.author_id = ANY($1) -- список подписок
AND t.created_at < to_timestamp($2) -- cursor (timestamp)
AND t.deleted_at IS NULL
ORDER BY t.created_at DESC
LIMIT $3;
6. Статистика пагинации
Кандидат верно отметил, что большинство пользователей не пролистывают далеко. Типичное распределение:
- 1 страница (20 твитов): ~70% сессий.
- 2–5 страниц: ~25% сессий.
- >5 страниц: ~5% сессий.
- >40 страниц (800 твитов): <0.1% сессий.
Поэтому 800 твитов в кэше покрывает >99.9% запросов без обращения к базе данных.
7. Prefetching для плавного скролла
Для улучшения пользовательского опыта можно реализовать предзагрузку:
// Когда пользователь запросил страницу N, предзагружаем страницу N+1
func (s *TimelineService) prefetchNextPage(ctx context.Context, userID int64, nextCursor int64, limit int) {
// Запускаем в фоне, не блокируя текущий ответ
go func() {
bgCtx := context.Background()
tweetIDs, err := s.timelineCache.GetTimeline(bgCtx, userID, nextCursor, limit)
if err != nil {
return
}
// Предзагружаем полные данные твитов в кэш второго уровня
s.tweetClient.PrefetchTweets(bgCtx, tweetIDs)
}()
}
8. Итого: путь запроса при пагинации
Страница 1 (cursor=0):
→ Redis ZRevRangeByScore(key, "-inf", "+inf", 0, 20)
→ Возвращаем 20 твитов, next_cursor = timestamp последнего
Страница 2 (cursor=timestamp_20):
→ Redis ZRevRangeByScore(key, "(timestamp_20", "+inf", 0, 20)
→ Возвращаем следующие 20 твитов
...
Страница 40 (cursor=timestamp_780):
→ Redis возвращает оставшиеся твиты (если есть)
→ Если твитов нет — fallback к базе данных
→ Загружаем из MongoDB твиты старше cursor
Cursor-based пагинация обеспечивает стабильные результаты даже при добавлении новых твитов между запросами, в отличие от offset-based пагинации, где новые твиты сдвигают смещение и могут приводить к дублированию или пропуску записей.
Вопрос 18. Как обеспечить отказоустойчивость и масштабируемость системы?
Таймкод: 00:23:56
Ответ собеседника: Правильный. Сервисы stateless для горизонтального масштабирования, репликация баз данных и очередей, шардирование баз, CDN для медиаконтента.
Правильный ответ:
Кандидат обозначил основные направления. Раскроем каждый аспект детально с конкретными механизмами и числами.
1. Отказоустойчивость сервисов (Stateless Services)
Все бизнес-сервисы (Tweet, Timeline, Follow, Media) являются stateless — не хранят состояние между запросами. Это позволяет:
// Пример: любой инстанс Timeline Service может обработать любой запрос
type TimelineService struct {
timelineCache *TimelineCache // внешний Redis
timelineRepo TimelineRepository // внешняя БД
tweetClient TweetServiceClient // клиент к другому сервису
followClient FollowServiceClient // клиент к другому сервису
// Никакого локального состояния!
}
Горизонтальное масштабирование через orchestrator:
# docker-compose / kubernetes пример
services:
timeline-service:
replicas: 10 # Легко масштабируется
resources:
limits:
memory: "512Mi"
cpu: "500m"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 3s
retries: 3
При RPS ~17000 пиковый и одном инстансе, обрабатывающем ~500 RPS, нужно ~34 инстанса. С запасом — 40–50 инстансов.
2. Отказоустойчивость Redis
Redis Cluster с репликацией:
// Конфигурация Redis Cluster
func NewRedisCluster() *redis.ClusterClient {
return redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{
"redis-node-1:6379",
"redis-node-2:6379",
"redis-node-3:6379",
"redis-node-4:6379",
"redis-node-5:6379",
"redis-node-6:6379",
},
// Каждый мастер имеет 1-2 реплики
// При падении мастера реплика автоматически становится мастером
ReadOnly: true, // Чтение с реполик для распределения нагрузки
RouteRandomly: true,
})
}
Стратегия при недоступности Redis:
func (s *TimelineService) GetTimelineWithFallback(ctx context.Context, userID int64, cursor int64, limit int) (*TimelineResponse, error) {
// 1. Пробуем Redis
tweetIDs, err := s.timelineCache.GetTimeline(ctx, userID, cursor, limit)
if err == nil {
return s.buildResponse(ctx, tweetIDs)
}
// 2. Redis недоступен — fallback к базе данных
s.logger.Warn("Redis unavailable, falling back to database", "error", err)
s.metrics.IncRedisFallback()
tweetIDs, err = s.timelineRepo.GetTimelineFromDB(ctx, userID, cursor, limit)
if err != nil {
return nil, err
}
// 3. Фоновое восстановление кэша
go func() {
bgCtx := context.Background()
if err := s.timelineCache.WarmCache(bgCtx, userID, tweetIDs); err != nil {
s.logger.Warn("failed to warm cache", "error", err)
}
}()
return s.buildResponse(ctx, tweetIDs)
}
3. Отказоустойчивость баз данных
MongoDB (твиты):
Replica Set конфигурация:
- Primary: 1 нода (запись + чтение)
- Secondary: 2 ноды (только чтение)
- Arbiter: 1 нода (для голосования при failover)
При падении Primary:
- Автоматический failover за 10–30 секунд
- Одна из Secondary становится Primary
- Приложение автоматически переключается
PostgreSQL (подписки):
Конфигурация:
- Primary: 1 нода (запись)
- Sync Replica: 1 нода (синхронная репликация для критичных данных)
- Async Replicas: 2-3 ноды (чтение)
Connection pooling через PgBouncer:
- Управляет подключениями при failover
- Прозрачно перенаправляет на новый Primary
4. Отказоустойчивость Kafka
Конфигурация кластера:
- 3 брокера (минимум для production)
- Replication factor: 3 (каждое сообщение хранится на 3 брокерах)
- Min.insync.replicas: 2 (запись подтверждается при репликации на 2 брокера)
- acks=all (продюсер ждёт подтверждения от всех in-sync реплик)
При падении 1 брокера:
- Система продолжает работать
- Лидерство партиций перераспределяется
- Данные не теряются
5. Шардирование для масштабируемости
Шардирование MongoDB (твиты):
// Шардирование по tweet_id (Snowflake ID)
// Обеспечивает равномерное распределение
sh.shardCollection("twitter_clone.tweets", { _id: "hashed" })
// Для 4 шардов:
// Shard 1: tweet_id hash range [0, 25%)
// Shard 2: tweet_id hash range [25%, 50%)
// Shard 3: tweet_id hash range [50%, 75%)
// Shard 4: tweet_id hash range [75%, 100%)
Шардирование PostgreSQL (подписки):
-- Шардирование по follower_id
-- Шард 1: follower_id % 4 == 0
-- Шард 2: follower_id % 4 == 1
-- Шард 3: follower_id % 4 == 2
-- Шард 4: follower_id % 4 == 3
-- Или использование Citus для автоматического шардирования
SELECT create_distributed_table('follows', 'follower_id');
Шардирование Redis:
Redis Cluster: 6 нод (3 мастера + 3 реплики)
- 16384 хеш-слота распределены между 3 мастерами
- Каждый мастер ~5460 слотов
- При необходимости масштабирования — добавление новых мастеров с миграцией слотов
6. Graceful Degradation
При частичных сбоях система должна продолжать работать с пониженной функциональностью:
func (s *TimelineService) GetTimelineGraceful(ctx context.Context, userID int64, cursor int64, limit int) (*TimelineResponse, error) {
// Пробуем основной путь
resp, err := s.GetHomeTimeline(ctx, userID, cursor, limit)
if err == nil {
return resp, nil
}
// Fallback 1: Без твитов знаменитостей (если celebrity service недоступен)
if errors.Is(err, ErrCelebrityServiceUnavailable) {
s.logger.Warn("Celebrity service down, showing timeline without celebrity tweets")
return s.GetTimelineWithoutCelebrities(ctx, userID, cursor, limit)
}
// Fallback 2: Только кэшированные данные (если БД недоступна)
if errors.Is(err, ErrDatabaseUnavailable) {
s.logger.Warn("Database down, serving from cache only")
return s.GetTimelineFromCacheOnly(ctx, userID, cursor, limit)
}
// Fallback 3: Пустой ответ с сообщением (лучше, чем ошибка 500)
return &TimelineResponse{
Tweets: []*Tweet{},
NextCursor: 0,
HasMore: false,
Message: "Some features may be temporarily unavailable",
}, nil
}
7. Мониторинг и алертинг
Для обеспечения 99.99% доступности необходим комплексный мониторинг:
// Метрики, которые нужно отслеживать
var (
requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Request duration in seconds",
},
[]string{"method", "endpoint", "status"},
)
requestTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of requests",
},
[]string{"method", "endpoint", "status"},
)
cacheHitRate = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "cache_hit_rate",
Help: "Cache hit rate",
},
[]string{"cache_name"},
)
dbConnectionPool = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "db_connection_pool_size",
Help: "Database connection pool stats",
},
[]string{"db_name", "state"}, // state: active, idle, waiting
)
)
8. Целевые метрики для 99.99% доступности
| Метрика | Целевое значение |
|---|---|
| Uptime | 99.99% (~52 мин простоя/год) |
| P50 latency (чтение ленты) | < 50 мс |
| P99 latency (чтение ленты) | < 2 сек |
| P99 latency (публикация твита) | < 500 мс |
| Cache hit rate (timeline) | > 95% |
| Error rate | < 0.1% |
| Kafka consumer lag | < 1000 сообщений |
Комбинация stateless-сервисов, реплицированных баз данных, шардирования и graceful degradation обеспечивает как горизонтальное масштабирование под растущую нагрузку, так и отказоустойчивость при сбоях отдельных компонентов.
Вопрос 19. Как реплицировать и шардировать базы данных при росте нагрузки?
Таймкод: 00:24:47
Ответ собеседника: Неполный. Кандидат не уверен в деталях шардирования и репликации, предполагает, что это решается через конфигурацию.
Правильный ответ:
Это важная тема для System Design интервью. Раскроем механизмы репликации и шардирования для обеих баз данных в нашей архитектуре.
1. Репликация MongoDB (твиты)
Replica Set — базовый механизм репликации MongoDB:
Replica Set конфигурация:
┌─────────────────────────────────────────────┐
│ Primary Node │
│ - Принимает все операции записи │
│ - Принимает операции чтения (по умолчанию) │
│ - Ведёт oplog (operation log) │
└──────────────────┬──────────────────────────┘
│ async replication
┌──────────┴──────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Secondary 1 │ │ Secondary 2 │
│ - Только чтение│ │ - Только чтение│
│ - Копирует │ │ - Копирует │
│ oplog │ │ oplog │
└───────────────┘ └───────────────┘
Конфигурация через MongoDB Shell:
// Инициализация Replica Set
rs.initiate({
_id: "tweetReplicaSet",
members: [
{ _id: 0, host: "mongo-primary:27017", priority: 2 },
{ _id: 1, host: "mongo-secondary-1:27017", priority: 1 },
{ _id: 2, host: "mongo-secondary-2:27017", priority: 1 },
{ _id: 3, host: "mongo-arbiter:27017", arbiterOnly: true }
]
})
// Настройка read preference для чтения с реплик
// Это распределяет нагрузку чтения
Настройка в Go-приложении:
func NewMongoClient(ctx context.Context) (*mongo.Client, error) {
opts := options.Client().
ApplyURI("mongodb://mongo-primary:27017,mongo-secondary-1:27017,mongo-secondary-2:27017").
SetReplicaSet("tweetReplicaSet").
SetReadPreference(readpref.SecondaryPreferred()). // Читаем с реплик
SetReadConcern(readconcern.Majority()). // Читаем только подтверждённые данные
SetWriteConcern(writeconcern.New(writeconcern.WMajority())) // Пишем с подтверждением от большинства
client, err := mongo.Connect(ctx, opts)
if err != nil {
return nil, err
}
return client, nil
}
Write Concern и Read Concern — баланс между консистентностью и производительностью:
// Для критичных операций (публикация твита) — строгая консистентность
tweetCollection := client.Database("twitter").Collection("tweets",
options.Collection().
SetWriteConcern(writeconcern.New(writeconcern.WMajority())).
SetReadConcern(readconcern.Majority()),
)
// Для менее критичных операций (счётчики) — можно ослабить
statsCollection := client.Database("twitter").Collection("stats",
options.Collection().
SetWriteConcern(writeconcern.New(writeconcern.W1())). // Подтверждение от 1 ноды
SetReadPreference(readpref.SecondaryPreferred()),
)
2. Шардирование MongoDB
Когда один Replica Set не справляется с нагрузкой, используется шардирование:
┌─────────────────┐
│ mongos (router)│
│ - Маршрутизация│
│ запросов │
└────────┬────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Shard 1 │ │ Shard 2 │ │ Shard 3 │
│ (Replica │ │ (Replica │ │ (Replica │
│ Set) │ │ Set) │ │ Set) │
│ │ │ │ │ │
│ tweet_id │ │ tweet_id │ │ tweet_id │
│ hash [0,33%)│ │ hash [33,66%)│ │ hash [66,100%)│
└─────────────┘ └─────────────┘ └─────────────┘
Настройка шардирования:
// 1. Включаем шардирование для базы
sh.enableSharding("twitter")
// 2. Создаём индекс по shard key
db.tweets.createIndex({ "_id": "hashed" })
// 3. Шардируем коллекцию
sh.shardCollection("twitter.tweets", { "_id": "hashed" })
// 4. Проверяем распределение
sh.status()
Выбор shard key — критически важное решение:
// Вариант 1: Hashed по tweet_id (Snowflake ID)
// Плюсы: равномерное распределение, простота
// Минусы: range запросы идут на все шарды
sh.shardCollection("twitter.tweets", { "_id": "hashed" })
// Вариант 2: Композитный ключ {author_id, created_at}
// Плюсы: эффективные запросы по автору
// Минусы: hot spots для активных авторов
// sh.shardCollection("twitter.tweets", { "author_id": 1, "created_at": -1 })
// Для нашей системы рекомендуется Hashed по _id:
// - Равномерное распределение записи
// - Основной запрос — получение твита по ID
// - Запросы по автору идут scatter-gather, но это приемлемо
3. Репликация PostgreSQL (подписки)
Streaming Replication:
┌─────────────────────────────────────────┐
│ Primary PostgreSQL │
│ - Запись + чтение │
│ - Ведёт WAL (Write-Ahead Log) │
└──────────────────┬──────────────────────┘
│ streaming replication
┌──────────┴──────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Sync Replica │ │ Async Replica │
│ - Только чтение│ │ - Только чтение│
│ - Синхронная │ │ - Асинхронная │
│ репликация │ │ репликация │
└───────────────┘ └───────────────┘
Настройка в postgresql.conf:
# Primary конфигурация
wal_level = replica
max_wal_senders = 5
wal_keep_size = 1024 # MB
# Синхронная репликация (для критичных данных)
synchronous_remote_apply = on
synchronous_standby_names = 'replica1'
Настройка в pg_hba.conf:
# Разрешаем репликацию
host replication replicator 10.0.0.0/24 md5
Настройка пула соединений через PgBouncer:
[databases]
follows_primary = host=pg-primary port=5432 dbname=follows
follows_replica1 = host=pg-replica1 port=5432 dbname=follows
follows_replica2 = host=pg-replica2 port=5432 dbname=follows
[pgbouncer]
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 20
4. Шардирование PostgreSQL
Для PostgreSQL нет встроенного шардирования, поэтому используются внешние инструменты:
Вариант 1: Citus (расширение PostgreSQL)
-- Установка расширения
CREATE EXTENSION citus;
-- Создаём distributed таблицу
SELECT create_distributed_table('follows', 'follower_id');
-- Настройка количества шардов
SET citus.shard_count = 32;
-- Citus автоматически распределяет данные по worker-нодам
-- и маршрутизирует запросы
Вариант 2: Прикладное шардирование (Application-level sharding)
// ShardRouter определяет, на какой шард направить запрос
type ShardRouter struct {
shards []*sqlx.DB
count int
}
func NewShardRouter(shardURLs []string) (*ShardRouter, error) {
shards := make([]*sqlx.DB, len(shardURLs))
for i, url := range shardURLs {
db, err := sqlx.Connect("postgres", url)
if err != nil {
return nil, err
}
shards[i] = db
}
return &ShardRouter{shards: shards, count: len(shards)}, nil
}
func (r *ShardRouter) GetShard(userID int64) *sqlx.DB {
shardIndex := userID % int64(r.count)
return r.shards[shardIndex]
}
func (r *ShardRouter) GetAllShards() []*sqlx.DB {
return r.shards
}
// Использование в репозитории
type FollowRepository struct {
router *ShardRouter
}
func (r *FollowRepository) Create(ctx context.Context, follow *Follow) error {
// Шардируем по follower_id
db := r.router.GetShard(follow.FollowerID)
_, err := db.ExecContext(ctx,
"INSERT INTO follows (follower_id, followee_id, created_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
follow.FollowerID, follow.FolloweeID, follow.CreatedAt,
)
return err
}
func (r *FollowRepository) GetFollowers(ctx context.Context, userID int64, cursor int64, limit int) ([]int64, error) {
// Шардируем по followee_id — нужен scatter-gather по всем шардам
var allFollowers []int64
for _, db := range r.router.GetAllShards() {
var followers []int64
err := db.SelectContext(ctx, &followers,
"SELECT follower_id FROM follows WHERE followee_id = $1 AND follower_id > $2 ORDER BY follower_id LIMIT $3",
userID, cursor, limit,
)
if err != nil {
continue
}
allFollowers = append(allFollowers, followers...)
}
return allFollowers, nil
}
5. Сравнение стратегий шардирования
| Критерий | MongoDB (native) | PostgreSQL + Citus | PostgreSQL (manual) |
|---|---|---|---|
| Сложность настройки | Низкая | Средняя | Высокая |
| Автоматическая ребалансировка | Да | Да | Нет |
| Cross-shard запросы | Поддерживаются | Поддерживаются | Ручная реализация |
| Гибкость shard key | Высокая | Средняя | Высокая |
| Операционные затраты | Низкие | Средние | Высокие |
6. Рекомендация для нашей системы
Для твитов (MongoDB): начать с Replica Set (1 мастер + 2 реплики). При росте нагрузки — включить шардирование с 4–8 шардами.
Для подписок (PostgreSQL): начать с Primary + 2 Read Replicas. При росте — использовать Citus с шардированием по follower_id (32–64 шарда).
Оба подхода позволяют наращивать мощность постепенно по мере роста нагрузки, без необходимости переделки архитектуры.
