Google system design interview: Design Spotify (with ex-Google EM)
Сегодня мы разберём пример высококачественного собеседования по системному дизайну, на котором кандидат с опытом инженерного менеджера в Google демонстрирует структурированный подход к проектированию Spotify. В ходе интервью кандидёт последовательно сужает задачу, проводит оценку масштаба системы, разделяет данные на аудио и метаданные, предлагает многоуровневое кэширование и умную балансировку нагрузки. Это отличная иллюстрация того, как опытный инженер применяет лучшие практики проектирования распределённых систем в условиях ограниченного времени собеседования.
Вопрос 1. Как бы вы спроектировали Spotify?
Таймкод: 00:01:15
Ответ собеседника: Правильный. Кандидат начал с уточнения и сужения области проектирования до двух основных use cases: поиск и воспроизведение музыки. Оценил масштаб системы: 1 млрд пользователей, 100 млн песен, ~5 МБ на песню, итого ~500 ТБ (0.5 ПБ) аудиоданных с 3x репликацией = ~1.5 ПБ, метаданные песен ~10-100 ГБ, пользовательские метаданные ~1 ТБ. Предложил высокоуровневую архитектуру: мобильное приложение → балансировщик нагрузки → кластер веб-серверов → две отдельные базы данных. Разделил хранение на: (1) Amazon S3 для аудиофайлов (blob-хранилище, неизменяемые данные, линейное масштабирование, оптимально для потоковой передачи) и (2) Amazon RDS (реляционная БД) для метаданных песен и пользователей (частые запросы, поиск по жанрам/артистам, обновления позиции воспроизведения). Описал поток поиска: запрос → балансировщик → веб-сервер → RDS → возврат списка песен с метаданными. Описал поток воспроизведения: выбор песи → запрос к веб-серверу → получение ссылки на MP3 из RDS → чтение аудио из S3 в память сервера (5 МБ — умещается в RAM) → потоковая передача чанками через websocket обратно в приложение. Выявил проблему «горячих» песен (например, новый релиз BTS с 10 млн запросов в минуту) и предложил многоуровневое кэширование: (1) CDN (CloudFront) на границе сети для популярных аудиофайлов с механизмом прогрева на основе частоты запросов, (2) in-memory кэш на веб-серверах для часто запрашиваемых песен, (3) локальное кэширование на устройстве пользователя. Для балансировки нагрузки предложил использовать метрики на основе сетевой пропускной способности и текущих активных стримов, а не только CPU или round-robin. На глобальном уровне предложил географически-осведомлённую стратегию размещения реплик данных (региональный контент хранится ближе к пользователям региона).
Правильный ответ:
Отличный ответ кандидата — он продемонстрировал системный подход к проектированию. Ниже приведено развёрнутое дополнение, которое углубляет и расширяет предложенную архитектуру, покрывая аспекты, которые стоит упомянуть на интервью для демонстрации экспертного уровня.
1. Уточнение требований (Functional и Non-Functional)
Прежде чем проектировать, необходимо чётко разделить функциональные и нефункциональные требования.
Фунциональные требования:
- Поиск треков по названию, артисту, альбому, жанру
- Потоковое воспроизведение аудио (стриминг)
- Управление плейлистами (создание, редактирование, шаринг)
- Оффлайн-прослушивание (скачивание треков на устройство)
- Рекомендации и персонализация
Нефункциональные требования:
- Низкая задержка при старте воспроизведения (< 200 мс)
- Высокая доступность (99.99% uptime)
- Горизонтальная масштабируемость
- Глобальное покрытие с минимальной латентностью
- Эффективное использование пропускной способности (адаптивный битрейт)
2. Оценка масштаба (Back-of-the-Envelope Calculations)
Кандидат верно оценил объёмы. Дополним расчёты:
- 1 млрд пользователей, из них ~200 млн активных ежедневно (DAU)
- 100 млн треков × 5 МБ (средний MP3, 128 kbps, 3 минуты) = 500 ТБ аудио
- С учётом 3x репликации и нескольких форматов (AAC, OGG, FLAC) — реальный объём ближе к 3-5 ПБ
- Метаданные: 100 млн треков × 1 КБ = ~100 ГБ (название, артист, альбом, жанр, длительность)
- Пользовательские данные: 1 млрд × 1 КБ = ~1 ТБ (профиль, настройки, история)
- Плейлисты: ~10 млрд плейлистов × 100 треков × 16 байт (UUID) = ~1.6 ТБ
- QPS на пике: ~200 млн DAU × 10 запросов/день ÷ 86400 сек × 3 (пиковый коэффициент) ≈ 70 000 QPS на чтение
3. Высокоуровневая архитектура
Кандидат предложил корректную базовую схему. Расширим её до полноценной микросервисной архитектуры.
Клиентский слой:
- Мобильные приложения (iOS, Android) и веб-клиент
- Локальный кэш для оффлайн-прослушивания
- Адаптивный стриминг (HLS/DASH) с переключением битрейта
Edge-слой:
- CDN (CloudFront/Akamai) для раздачи аудиофайлов и статического контента
- DNS-балансировка с географической маршрутизацией (Route 53 latency-based routing)
- API Gateway для rate limiting, аутентификации, маршрутизации запросов
Сервисный слой (микросервисы):
- Search Service — полнотекстовый поиск по трекам, артистам, альбомам. Использует Elasticsearch или OpenSearch с индексами, шардированными по языку/региону.
- Playback Service — управление сессиями воспроизведения, получение URL для стриминга, отслеживание позиции воспроизведения.
- User Service — управление профилями, подписками (free vs premium), аутентификация.
- Playlist Service — CRUD для плейлистов, шаринг, коллаборативные плейлисты.
- Recommendation Service — генерация персонализированных рекомендаций на основе истории прослушиваний, collaborative filtering, ML-модели.
- Catalog Service — управление метаданными треков, альбомов, артистов.
- Ingestion Service — приём и обработка нового контента от лейблов (транскодирование, извлечение метаданных, генерация waveform).
Слой данных:
- Object Storage (S3/GCS) — хранение аудиофайлов в нескольких форматах и битрейтах. Каждый трек хранится в оригинальном качестве + транскодированные версии (64, 128, 256, 320 kbps).
- Реляционная БД (PostgreSQL/RDS) — метаданные треков, пользовательские данные, плейлисты. Шардирование по user_id для пользовательских данных.
- Elasticsearch — поисковые индексы с поддержкой fuzzy matching, автодополнения, фильтрации по жанрам/годам.
- Redis Cluster — кэширование горячих треков, сессий пользователей, результатов поиска.
- Cassandra/DynamoDB — история прослушиваний (write-heavy workload, time-series данные).
- Kafka — event streaming для асинхронной обработки событий прослушивания, обновления рекомендаций, аналитики.
4. Поток воспроизведения (детальный)
Кандидат описал базовый поток. В реальности он сложнее:
- Пользователь выбирает трек в приложении
- Приложение запрашивает Playback Service URL для стриминга
- Playback Service проверяет подписку, получает из кэша (Redis) или БД информацию о треке
- Генерируется подписанный URL (presigned URL) к CDN-ребру с TTL ~5 минут
- Приложение начинает стриминг через HLS (HTTP Live Streaming) — аудио разбивается на чанки по 10 секунд
- Адаптивный битрейт: приложение мониторит пропускную способность сети и переключается между качествами (64/128/256 kbps)
- Каждые 30 секунд приложение отправляет heartbeat в Playback Service для отслеживания позиции
- При переключении устройств — синхронизация позиции через WebSocket
Важно: в реальности аудио НЕ проходит через веб-сервер — это создаст огромную нагрузку на сеть. Вместо этого используется CDN с подписанными URL, и аудио идёт напрямую от CDN к клиенту.
5. Стратегия кэширования (многоуровневая)
Кандидат верно определил проблему «горячих» треков. Дополним стратегию:
- L1 — CDN Edge Cache: 95%+ запросов на популярные треки обслуживаются из CDN. TTL от 1 часа до 24 часов. Прогрев кэша при выходе новых релизов.
- L2 — Redis Cluster: кэш метаданных треков, результатов поиска, сессий пользователей. TTL от 5 минут до 1 часа.
- L3 — In-memory cache на серверах: LRU-кэш для горячих данных внутри процесса (например, с помощью Go-библиотеки
ristrettoилиbigcache). - L4 — Клиентский кэш: локальное хранение на устройстве для оффлайн-прослушивания и быстрого доступа к недавним трекам.
Для прогрева кэша при выходе нового релиз: за час до релиза система автоматически загружает треки в CDN-кэш всех регионов на основе предзаказов и ожидаемого спроса.
6. Поиск
Кандидат упомянул поиск через RDS, но для полноценного поиска нужна специализированная система:
- Elasticsearch с индексами по трекам, артистам, альбомам
- Поддержка fuzzy search (опечатки), синонимов, транслитерации
- Автодополнение (typeahead) через отдельный индекс n-грамм
- Фильтрация по жанру, году, длительности, популярности
- Персонализация результатов поиска на основе истории пользователя
7. Масштабирование и отказоустойчивость
- Горизонтальное масштабирование всех stateless-сервисов за счёт auto-scaling groups
- Шардирование БД по user_id (consistent hashing) для равномерного распределения нагрузки
- Multi-AZ и Multi-Region развертывание — данные реплицируются между регионами
- Circuit Breaker между сервисами (например, через
sony/gobreakerв Go) для предотвращения каскадных отказов - Graceful degradation — при падении Recommendation Service показываются популярные треки вместо персонализированных
- Rate limiting на уровне API Gateway для защиты от DDoS
8. Мониторинг и наблюдаемость
- Метрики: latency p50/p95/p99, error rate, throughput, cache hit ratio
- Логирование: структурированные логи через ELK-стек
- Трейсинг: distributed tracing через Jaeger/Zipkin для отслеживания запросов между сервисами
- Алертинг: PagerDop для критических инцидентов
9. Пример кода на Go — Playback Service
package main
import (
"context"
"fmt"
"time"
"github.com/go-redis/redis/v8"
"github.com/google/uuid"
)
type PlaybackService struct {
trackRepo TrackRepository
cache *redis.Client
cdnBaseURL string
signingKey []byte
}
type TrackMetadata struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Duration int `json:"duration"` // в секундах
S3Key string `json:"s3_key"`
}
type StreamURL struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
Quality string `json:"quality"`
}
func (s *PlaybackService) GetStreamURL(ctx context.Context, userID, trackID, quality string) (*StreamURL, error) {
// 1. Проверяем подписку пользователя
if err := s.checkSubscription(ctx, userID); err != nil {
return nil, fmt.Errorf("subscription check failed: %w", err)
}
// 2. Получаем метаданные трека из кэша
cacheKey := fmt.Sprintf("track:%s", trackID)
cached, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
// Десериализуем из кэша
track := deserializeTrack(cached)
return s.generateSignedURL(track, quality), nil
}
// 3. Если нет в кэше — идём в БД
track, err := s.trackRepo.GetByID(ctx, trackID)
if err != nil {
return nil, fmt.Errorf("track not found: %w", err)
}
// 4. Сохраняем в кэш на 1 час
s.cache.Set(ctx, cacheKey, serializeTrack(track), time.Hour)
// 5. Генерируем подписанный URL к CDN
return s.generateSignedURL(track, quality), nil
}
func (s *PlaybackService) generateSignedURL(track *TrackMetadata, quality string) *StreamURL {
// Формируем путь к файлу нужного качества
path := fmt.Sprintf("/audio/%s/%s_%s.mp3", quality, track.S3Key, quality)
// Подписываем URL с TTL 5 минут
expiresAt := time.Now().Add(5 * time.Minute)
signature := s.signURL(path, expiresAt)
return &StreamURL{
URL: fmt.Sprintf("%s%s?sig=%s&exp=%d", s.cdnBaseURL, path, signature, expiresAt.Unix()),
ExpiresAt: expiresAt,
Quality: quality,
}
}
func (s *PlaybackService) signURL(path string, expires time.Time) string {
// HMAC-SHA256 подпись URL
// Реализация опущена для краткости
return "signature"
}
func (s *PlaybackService) checkSubscription(ctx context.Context, userID string) error {
// Проверка статуса подписки
return nil
}
// Заглушки для сериализации
func serializeTrack(t *TrackMetadata) string { return "" }
func deserializeTrack(s string) *TrackMetadata { return nil }
type TrackRepository interface {
GetByID(ctx context.Context, id string) (*TrackMetadata, error)
}
10. Ключевые trade-offs
- Консистентность vs доступность: для позиции воспроизведения допустима eventual consistency (AP в CAP-теореме), для подписок — строгая консистентность (CP)
- Latency vs throughput: чанки HLS по 10 секунд — компромисс между задержкой старта и эффективностью кэширования
- Storage cost vs performance: хранение нескольких форматов увеличивает затраты на хранение, но улучшает пользовательский опыт
- Freshness vs cache hit ratio: агрессивное кэширование улучшает производительность, но может показывать устаревшие данные
Этот ответ демонстрирует глубокое понимание распределённых систем, практический опыт проектирования высоконагруженных сервисов и способность принимать обоснованные архитектурные решения.
Вопрос 2. Как бы вы реализовали балансировку нагрузки для этого приложения?
Таймкод: 00:35:03
Ответ собеседника: Правильный. Кандидат отметил, что стандартный round-robin не подходит для стримингового приложения. Предложил сделать балансировщик нагрузки «умнее», используя несколько метрики для распределения трафика: сетевая пропускная способность (network bandwidth) — чтобы не перегружать серверы, которые уже на пределе I/O, количество текущих активных стримов (outstanding streams), использование памяти (для кэширования песен). Объяснил, что при стриминге сервер может быть не загружен по CPU, но упираться в лимит сетевого канала, и отправка дополнительных запросов на такой сервер приведёт к ухудшению качества (пропуски кадров). Честно признал, что балансировка нагрузки — не его сильная область, но продемонстрировал понимание принципов.
Правильный ответ:
Кандидат верно определил ключевую проблему — классический round-robin не подходит для I/O-bound нагрузки. Ниже приведено развёрнутое описание многоуровневой стратегии балансировки для стримингового приложения.
1. Уровни балансировки
Балансировка нагрузки в системе типа Spotify работает на нескольких уровнях, каждый из которых решает свою задачу.
Уровень 1 — DNS-балансировка (Global Server Load Balancing)
На самом высоком уровне DNS направляет пользователя в ближайший дата-центр на основе географического положения и задержек. Используется latency-based routing (Route 53, Cloudflare Load Balancing).
- Пользователь из Берлина попадает в европейский кластер (Frankfurt)
- Пользователь из Сан-Паулу — в южноамериканский (São Paulo)
- При отказе региона — failover в ближайший здоровый регион
Уровень 2 — L4-балансировка (Transport Layer)
На входе в каждый дата-центр стоит L4-балансировщик (HAProxy, Nginx, AWS NLB), который распределяет TCP-соединения между группами сервисов.
Ключевые алгоритмы для стриминга:
- Weighted Least Connections — отправляет запрос на сервер с наименьшим количеством активных соединений, взвешенным по мощности сервера. Это базовый разумный выбор.
- Weighted Least Bandwidth — направляет трафик на сервер с наименьшим исходящим трафиком (в байтах/сек). Идеально для стриминга, где сетевой I/O — главное узкое место.
Уровень 3 — L7-балансировка (Application Layer)
API Gateway или L7-балансировщик (AWS ALB, Envoy) принимает решения на основе HTTP-заголовков, пути запроса, параметров.
- Маршрутизация по сервисам:
/api/search→ Search Service,/api/play→ Playback Service - Canary deployments: 5% трафика на новую версию
- Rate limiting per user
- Circuit breaking: если сервис отвечает с ошибками — убираем из пула
2. Метрики для принятия решений
Кандидат верно указал на важность правильных метрик. Для стримингового приложения критичны:
- Active streams (количество активных стримов) — главная метрика. Каждый стрим потребляет ~128-320 Kbps исходящего трафика.
- Network bandwidth utilization (использование сетевого канала) — сервер с 1 Gbps интерфейсом может обслужить ~3000 стримов по 320 Kbps.
- Memory usage (использование памяти) — важно для in-memory кэша. Если память заполнена — cache eviction, рост latency.
- CPU usage — вторичная метрика для стриминга, но важна для поиска и рекомендаций.
- Error rate — если сервер начинает отдавать 500-е — убираем из пула.
- P99 latency — если сервер тормозит, даже если не перегружен по количеству.
3. Health Checks
Балансировщик должен проверять здоровье серверов на нескольких уровнях:
- Liveness probe — жив ли процесс? (HTTP 200 на
/healthz) - Readiness probe — готов ли принимать трафик? (проверка соединений с БД, кэшем)
- Deep health check — проверка полного цикла: можем ли мы выполнить поиск и начать стрим?
4. Sticky Sessions (сеансовая привязка)
Для стриминга sticky sessions обычно НТН нужны, потому что:
- Аудио идёт через CDN по подписанному URL, а не через балансировщик
- Каждый чанк HLS — отдельный HTTP-запрос, который может прийти на любой сервер
- Состояние сессии хранится в Redis, а не на сервере
Однако для WebSocket-соединений (синхронизация позиции, real-time обновления) sticky sessions полезны.
5. Реализация кастомного балансировщика на Go
Вот пример простого балансировщика с учётом метрик стриминга:
package main
import (
"context"
"net/http"
"net/http/httputil"
"sync"
"sync/atomic"
"time"
)
// ServerInstance представляет бэкенд-сервер
type ServerInstance struct {
Address string
Weight int32
ActiveStreams int64 // текущие активные стримы
BandwidthUsed uint64 // использованная пропускная способность в байтах/сек
MaxBandwidth uint64 // максимальная пропускная способность
Healthy int32 // 1 = healthy, 0 = unhealthy
ReverseProxy *httputil.ReverseProxy
}
// LoadBalancer реализует взвешенный least-bandwidth алгоритм
type LoadBalancer struct {
servers []*ServerInstance
mu sync.RWMutex
}
func NewLoadBalancer() *LoadBalancer {
return &LoadBalancer{
servers: make([]*ServerInstance, 0),
}
}
func (lb *LoadBalancer) AddServer(server *ServerInstance) {
lb.mu.Lock()
defer lb.mu.Unlock()
lb.servers = append(lb.servers, server)
}
// SelectServer выбирает сервер на основе комбинированной метрики
func (lb *LoadBalancer) SelectServer() *ServerInstance {
lb.mu.RLock()
defer lb.mu.RUnlock()
var best *ServerInstance
var bestScore float64 = -1
for _, s := range lb.servers {
if atomic.LoadInt32(&s.Healthy) == 0 {
continue
}
// Вычисляем score: чем больше свободных ресурсов — тем лучше
streams := atomic.LoadInt64(&s.ActiveStreams)
bandwidth := atomic.LoadUint64(&s.BandwidthUsed)
weight := atomic.LoadInt32(&s.Weight)
maxBW := s.MaxBandwidth
if maxBW == 0 {
continue
}
// Нормализованная загрузка (0 = свободен, 1 = загружен)
bandwidthRatio := float64(bandwidth) / float64(maxBW)
streamRatio := float64(streams) / float64(1000*weight) // предполагаем 1000 стримов на единицу веса
// Комбинированная загрузка (больше веса у полосы пропускания)
load := 0.7*bandwidthRatio + 0.3*streamRatio
// Score = вес × (1 - загрузка)
score := float64(weight) * (1.0 - load)
if score > bestScore {
bestScore = score
best = s
}
}
return best
}
func (lb *LoadBalancer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
server := lb.SelectServer()
if server == nil {
http.Error(w, "No healthy servers available", http.StatusServiceUnavailable)
return
}
// Инкрементируем счётчик активных стримов
atomic.AddInt64(&server.ActiveStreams, 1)
defer atomic.AddInt64(&server.ActiveStreams, -1)
// Проксируем запрос
server.ReverseProxy.ServeHTTP(w, r)
}
// HealthChecker периодически проверяет здоровье серверов
type HealthChecker struct {
lb *LoadBalancer
interval time.Duration
client *http.Client
}
func NewHealthChecker(lb *LoadBalancer, interval time.Duration) *HealthChecker {
return &HealthChecker{
lb: lb,
interval: interval,
client: &http.Client{
Timeout: 3 * time.Second,
},
}
}
func (hc *HealthChecker) Start(ctx context.Context) {
ticker := time.NewTicker(hc.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
hc.checkAll()
}
}
}
func (hc *HealthChecker) checkAll() {
hc.lb.mu.RLock()
servers := make([]*ServerInstance, len(hc.lb.servers))
copy(servers, hc.lb.servers)
hc.lb.mu.RUnlock()
for _, s := range servers {
resp, err := hc.client.Get(s.Address + "/healthz")
if err != nil || resp.StatusCode != http.StatusOK {
atomic.StoreInt32(&s.Healthy, 0)
} else {
atomic.StoreInt32(&s.Healthy, 1)
resp.Body.Close()
}
}
}
// MetricsCollector собирает метрики с серверов
type MetricsCollector struct {
lb *LoadBalancer
interval time.Duration
}
func (mc *MetricsCollector) Start(ctx context.Context) {
ticker := time.NewTicker(mc.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
mc.collect()
}
}
}
func (mc *MetricsCollector) collect() {
mc.lb.mu.RLock()
defer mc.lb.mu.RUnlock()
for _, s := range mc.lb.servers {
// В реальности — HTTP-запрос к /metrics сервера
// и парсинг ответа для получения bandwidth, memory, etc.
_ = s
}
}
func main() {
lb := NewLoadBalancer()
// Добавляем серверы с разными весами
servers := []*ServerInstance{
{
Address: "http://backend1:8080",
Weight: 3,
MaxBandwidth: 1_000_000_000, // 1 Gbps
Healthy: 1,
},
{
Address: "http://backend2:8080",
Weight: 2,
MaxBandwidth: 500_000_000, // 500 Mbps
Healthy: 1,
},
}
for _, s := range servers {
proxy := httputil.NewSingleHostReverseProxy(parseURL(s.Address))
s.ReverseProxy = proxy
lb.AddServer(s)
}
// Запускаем health checker
hc := NewHealthChecker(lb, 10*time.Second)
go hc.Start(context.Background())
// Запускаем metrics collector
mc := &MetricsCollector{lb: lb, interval: 5 * time.Second}
go mc.Start(context.Background())
// Запускаем балансировщик
http.Handle("/", lb)
http.ListenAndServe(":80", nil)
}
func parseURL(addr string) *url.URL {
u, _ := url.Parse(addr)
return u
}
6. Продвинутые техники
Adaptive Load Balancing — балансировщик динамически корректирует веса серверов на основе реальной производительности. Если сервер с weight=3 справляется хуже, чем с weight=2 — понижаем его вес.
Predictive Scaling — на основе исторических данных (вечерние пики, выходы альбомов) система заранее масштабирует количество серверов до нагрузки, а не реагирует постфактум.
Consistent Hashing для кэширования — запросы на один и тот же трек направляются на один и тот же сервер, чтобы максимизировать cache hit ratio. При добавлении/удалении серверов мигрируется только часть кэша.
Backpressure — если все серверы перегружены, балансировщик возвращает 503 с заголовком Retry-After, а клиент повторяет запрос через указанное время.
7. Сравнение алгоритмов
| Алгоритм | Плюсы | Минусы | Когда использовать |
|---|---|---|---|
| Round Robin | Простой, равномерный | Не учитывает загрузку | Одинаковые серверы, лёгкая нагрузка |
| Weighted RR | Учитывает мощность сервера | Не учитывает текущую загрузку | Серверы разной мощности, стабильная нагрузка |
| Least Connections | Адаптивный | Не учитывает вес запроса | Разнородные по длительности запросы |
| Least Bandwidth | Идеален для стриминга | Требует сбора метрик | Стриминг, file serving |
| Adaptive (комбинированный | Максимально точный | Сложная реализация | Продакшн высоконагруженные системы |
8. Рекомендации для продакшна
В реальном продакшне обычно не пишут балансировщик с нуля, а используют:
- AWS ALB/NLB — для облачных развёртываний
- Envoy Proxy — для service mesh в Kubernetes
- HAProxy — зрелый и проверенный вариант
- Traefik — для контейоризированных окружений с автодискавери
Кастомная балансировка оправдана только при специфических требованиях, которые не покрываются стандартными решениями — например, когда нужен учёт специфических метрик приложения в реальном времени.
