Перейти к основному содержимому

Google system design interview: Design Spotify (with ex-Google EM)

· 15 мин. чтения

Сегодня мы разберём пример высококачественного собеседования по системному дизайну, на котором кандидат с опытом инженерного менеджера в 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. Поток воспроизведения (детальный)

Кандидат описал базовый поток. В реальности он сложнее:

  1. Пользователь выбирает трек в приложении
  2. Приложение запрашивает Playback Service URL для стриминга
  3. Playback Service проверяет подписку, получает из кэша (Redis) или БД информацию о треке
  4. Генерируется подписанный URL (presigned URL) к CDN-ребру с TTL ~5 минут
  5. Приложение начинает стриминг через HLS (HTTP Live Streaming) — аудио разбивается на чанки по 10 секунд
  6. Адаптивный битрейт: приложение мониторит пропускную способность сети и переключается между качествами (64/128/256 kbps)
  7. Каждые 30 секунд приложение отправляет heartbeat в Playback Service для отслеживания позиции
  8. При переключении устройств — синхронизация позиции через 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 — для контейоризированных окружений с автодискавери

Кастомная балансировка оправдана только при специфических требованиях, которые не покрываются стандартными решениями — например, когда нужен учёт специфических метрик приложения в реальном времени.