> Как масштабировать сервис коротких ссылок и настроить балансировку нагрузки (Go)

Уровень: senior · Роль: backend · Язык: Go · Категория: Технические вопросы

Компании: amoCRM

Стек: Go

> Пример ответа

Короткий ответ

Масштабирование сервиса коротких ссылок сводится к горизонтальному масштабированию stateless API-слоя и масштабированию storage. Балансировка нагрузки - через L4/L7 балансировщик (nginx, HAProxy, ALB) с least-connections или round-robin. Критично: генерация коротких кодов должна быть распределённой (snowflake, base62 от ID, или pre-generated пулы), чтобы избежать гонок на запись. Для чтения - кэш (Redis) перед БД, для записи - шардирование по хэшу кода или диапазону ID. Сессии и sticky sessions не нужны - сервис должен быть полностью stateless.

Подробное объяснение

Основная проблема сервиса коротких ссылок - это не столько объём данных, сколько соотношение read/write. Обычно чтение на порядки превышает запись (переходы по ссылкам). Поэтому архитектура строится вокруг кэширования и горизонтального масштабирования read-пути.

Ключевые компоненты:

  1. API-слой (Go-сервисы): полностью stateless, хранят только конфигурацию и подключения к внешним системам. Масштабируются простым добавлением инстансов за балансировщиком.

  2. Балансировка нагрузки:

    • L7 (HTTP) - если нужно маршрутизировать по пути или заголовкам, терминировать TLS.
    • L4 (TCP) - если нужна минимальная задержка и нет потребности в HTTP-логике.
    • Алгоритм: least-connections предпочтительнее round-robin, так как запросы могут быть неравномерными по стоимости (redirect дешевле, чем создание ссылки).
  3. Генерация коротких кодов - самое узкое место при масштабировании записи:

    • Вариант A: централизованный ID-генератор (snowflake, Twitter-style) - выдаёт уникальные 64-битные ID, которые затем кодируются в base62. Плюс: нет коллизий, просто шардировать. Минус: зависимость от генератора.
    • Вариант B: pre-generated пулы кодов - сервис заранее генерирует миллионы кодов и хранит их в Redis/БД как "свободные". При создании ссылки берёт код из пула. Плюс: нет гонок на генерацию. Минус: нужно пополнять пул.
    • Вариант C: хэш от URL + проверка коллизий - плохо для масштабирования, так как требует проверки в БД при каждой записи.
  4. Хранение:

    • Для чтения: Redis-кэш с TTL (например, 24 часа) перед PostgreSQL/MySQL. При попадании в кэш - отдаём redirect без обращения к БД.
    • Для записи: шардирование по хэшу короткого кода (consistent hashing) или по диапазону ID. Шардирование по коду проще, так как код известен при чтении.
    • Для аналитики (количество переходов) - отдельный поток, не блокирующий основной read-путь (асинхронная запись в ClickHouse или Kafka + consumer).
  5. Особенности Go:

    • Использовать net/http с настройкой ReadTimeout, WriteTimeout, MaxHeaderBytes.
    • Пул соединений к Redis/БД - настраивать MaxOpenConns, MaxIdleConns.
    • Graceful shutdown для обновлений без потери трафика.

На практике

Реальный сценарий для senior-позиции: сервис обрабатывает 10k RPS на чтение и 100 RPS на запись. База - PostgreSQL, кэш - Redis.

  1. Ставим 3-5 инстансов Go-сервиса за nginx (least_connections). Каждый инстанс подключается к Redis-кластеру (3 мастера + реплики) и PostgreSQL (шардирована на 4 шарда по хэшу кода).

  2. При GET /{code}: сначала Redis GET code → если есть, 302 redirect. Если нет - запрос в нужный шард PostgreSQL, запись в Redis с TTL, ответ.

  3. При POST /: генерируем код через snowflake-подобный генератор (или берём из пула в Redis), пишем в шард, отвечаем.

  4. Для аналитики переходов - асинхронно отправляем событие в Kafka (или просто инкрементим счётчик в Redis с периодическим сбросом в БД).

  5. Мониторинг: latency p99, hit rate кэша, количество ошибок 5xx, saturation CPU/connections. Автоскейлинг по CPU или RPS.

Пример кода

GO
// Основной обработчик redirect с кэшем
func (s *Service) redirectHandler(w http.ResponseWriter, r *http.Request) {
code := strings.TrimPrefix(r.URL.Path, "/")
if code == "" {
http.Error(w, "empty code", http.StatusBadRequest)
return
}
// 1. Пытаемся получить из кэша
url, err := s.cache.Get(r.Context(), code)
if err == nil {
http.Redirect(w, r, url, http.StatusFound)
s.metrics.RedirectCacheHit()
return
}
// 2. Кэш промахнулся - идём в шардированную БД
shard := s.shardForCode(code) // consistent hash
url, err = s.db[shard].GetURL(r.Context(), code)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
s.metrics.RedirectMiss()
return
}
// 3. Пишем в кэш с TTL
s.cache.Set(r.Context(), code, url, 24*time.Hour)
http.Redirect(w, r, url, http.StatusFound)
}
// Генерация кода через snowflake-подобный генератор
func (s *Service) createShortURL(r *http.Request) {
var req CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
id := s.idGen.NextID() // 64-bit уникальный ID
code := base62.Encode(id) // например, "aB3dE9"
shard := s.shardForCode(code)
if err := s.db[shard].InsertURL(r.Context(), code, req.URL); err != nil {
http.Error(w, "internal", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"short_url": "https://s.example/" + code})
}

Как отвечать на собеседовании

Начните с главного: разделите read и write пути. Скажите, что сервис должен быть stateless, а балансировка - на уровне L7 с least-connections. Затем переходите к генерации кодов - это ключевая проблема масштабирования записи. Упомяните snowflake или pre-generated пулы. Потом расскажите про кэширование и шардирование. Если спросят про конкретные цифры - прикиньте RPS и latency. В конце упомяните мониторинг и автоскейлинг.

Что проверяет интервьюер

  • Понимание разницы между масштабированием чтения и записи.
  • Знание паттернов распределённой генерации уникальных ID.
  • Умение проектировать кэш-слой и выбирать стратегию инвалидации.
  • Понимание trade-off между consistency и availability (например, что делать при недоступности Redis).
  • Практический опыт с балансировщиками и настройкой Go-сервисов под нагрузку.

Типичные ошибки

  • Предложение использовать автоинкремент в БД для генерации кодов - не масштабируется на запись.
  • Игнорирование кэша - без него read-путь упирается в БД.
  • Использование sticky sessions - сервис должен быть stateless, иначе теряется смысл горизонтального масштабирования.
  • Отсутствие graceful degradation: если Redis упал, сервис должен продолжать работать через БД (с повышенной latency), а не падать целиком.
  • Неучёт аналитики переходов: если писать каждый переход синхронно в БД, write-путь забьётся.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью