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

Публичное собеседование по System Design: проектирование сервиса для сокращения ссылок

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

Сегодня мы разберём публичное собеседование по system design, в ходе которого кандидат Анатолий под руководством интервьюера Максима проектировал сервис сокращения ссылок — от формулирования требований и оценки нагрузки до построения масштабируемой архитектуры с использованием Redis, Kafka и разделения бэкендов на чтение и запись. Несмотря на некоторые затруднения с выбором стратегии генерации коротких ссылок и организации stateless-подхода, кандидат продемонстрировал хорошее понимание принципов масштабирования и умение работать с обратной связью, а финальная схема оказалась гибкой и готовой к добавлению новых функций, таких как защита ссылок паролем или аналитика в реальном времени.

Вопрос 1. Какие два основных действия должен выполнять сервис сокращения ссылок?

Таймкод: 00:06:51

Ответ собеседника: Правильный. Принимать длинный URL и преобразовывать его в короткий URL; по короткому URL разворачивать обратно полный URL.

Правильный ответ:

Сервис сокращения ссылок выполняет два фундаментальных действия:

1. Сокращение (Shortening / Encode)

Принимает на вход длинный URL и генерирует для него уникальный короткий идентификатор. Суть — создать маппинг в хранилище: короткий ключ → оригинальный URL. Результатом является короткая ссылка вида https://short.ly/abc123.

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

  • Генерация уникального ключа — можно использовать автоинкремент из БД с последующим кодированием в base62, хеширование URL (SHA-256 с усечением), либо предгенерированные случайные строки.
  • Дедупликация — если один и тот же длинный URL приходит повторно, можно возвращать уже существующий короткий ключ, чтобы экономить место.
  • Валидация входного URL — проверка формата, доступности, фильтрация вредоносных ссылок.
type ShortenRequest struct {
OriginalURL string `json:"original_url"`
}

type ShortenResponse struct {
ShortURL string `json:"short_url"`
}

func (s *Service) Shorten(ctx context.Context, req ShortenRequest) (*ShortenResponse, error) {
if !isValidURL(req.OriginalURL) {
return nil, ErrInvalidURL
}

shortKey := s.keyGenerator.Generate()
err := s.repo.Save(ctx, shortKey, req.OriginalURL)
if err != nil {
return nil, err
}

return &ShortenResponse{
ShortURL: s.baseURL + "/" + shortKey,
}, nil
}

2. Перенаправление (Redirection / Decode)

Принимает короткий ключ и выполняет HTTP-редирект на оригинальный URL. Это основной путь трафика, поэтому он должен быть максимально быстрым.

Ключевые аспекты:

  • Кэширование — маппинг ключ→URL должен храниться в быстром кэше (Redis/Memcached), чтобы минимизировать обращения к БД.
  • HTTP-редирект — используется статус 301 (постоянный, кешируется браузером) или 302 (временный, позволяет собирать аналитику).
  • Обработка отсутствующих ключей — возвращать 404 с понятной страницей ошибки.
func (s *Service) Resolve(ctx context.Context, shortKey string) (string, error) {
// Сначала проверяем кэш
originalURL, err := s.cache.Get(ctx, shortKey)
if err == nil {
return originalURL, nil
}

// При промахе кэша идём в БД
originalURL, err = s.repo.FindByKey(ctx, shortKey)
if err != nil {
return "", ErrNotFound
}

// Обновляем кэш
s.cache.Set(ctx, shortKey, originalURL, defaultTTL)
return originalURL, nil
}
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_key VARCHAR(10) UNIQUE NOT NULL,
original_url TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
click_count BIGINT DEFAULT 0
);

CREATE INDEX idx_urls_short_key ON urls(short_key);

Эти два действия — ядро любого сервиса сокращения ссылок. Всё остальное (аналитика, кастомные алиасы, истечение срока действия) строится поверх этой базовой пары операций.

Вопрос 2. Будут ли ссылки генерироваться автоматически или пользователь может задавать собственную короткую ссылку?

Таймкод: 00:09:45

Ответ собеседника: Правильный. По умолчанию ссылки генерируются автоматически; возможность задавать собственную короткую ссылку — платная функция для будущего развития.

Правильный ответ:

Сервис сокращения ссылок обычно поддерживает оба режима, и обоснование каждого из них следующее:

1. Автоматическая генерация (по умолчанию)

Это основной режим для массового пользователя. Сервис генерирует уникальный ключ из пула доступных значений. Подходы к генерации:

  • Автоинкремент + base62-кодирование — последовательное число из БД преобразуется в короткую строку (например, 1000000 → a8ZE).
  • Случайная генерация — генерация случайной строки фиксированной длины с проверкой уникальности.
  • Хеширование — хеш оригинального URL с усечением.
// base62-кодирование автоинкрементного ID
const base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

func EncodeBase62(n uint64) string {
if n == 0 {
return string(base62Chars[0])
}
var result []byte
for n > 0 {
result = append(result, base62Chars[n%62])
n /= 62
}
// Реверсируем результат
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return string(result)
}

2. Кастомные алиасы (Custom Aliases)

Пользователь задаёт собственный короткий ключ, например https://short.ly/my-brand. Это мощный инструмент для маркетинга и брендинга, поэтому часто является платной фичей.

Ключевые технические аспекты:

  • Валидация формата — допустимые символы, минимальная/максимальная длина, зарезервированные слова (api, admin, static).
  • Проверка уникальности — атомарная операция: либо INSERT ... ON CONFLICT, либо предварительная проверка с блокировкой.
  • Монетизация — кастомные алиасы часто доступны только в платных тарифах, так как они занимают ценное «пространство» коротких ключей и требуют дополнительной модерации.
-- Атомарная вставка кастомного алиаса
INSERT INTO urls (short_key, original_url, is_custom)
VALUES ($1, $2, true)
ON CONFLICT (short_key) DO NOTHING
RETURNING id;

Рекомендация по архитектуре:

Разделить пространство ключей: автоматические ключи — фиксированной длины (например, 6 символов), кастомные — переменной длины с минимальным порогом (например, от 3 до 30 символов). Это предотвращает коллизии между двумя режимами и упрощает валидацию на уровне маршрутизации.

func (s *Service) ShortenWithAlias(ctx context.Context, originalURL, customAlias string) error {
if !isValidCustomAlias(customAlias) {
return ErrInvalidAlias
}
// Проверяем, не занят ли алиас и не является ли он зарезервированным
if s.reservedWords[customAlias] {
return ErrReservedAlias
}
return s.repo.SaveWithAlias(ctx, customAlias, originalURL)
}

Таким образом, оба режима имеют право на существование. Автоматическая генерация — для массового бесплатного использования, кастомные алиасы — как премиальная функция для бизнес-клиентов.

Вопрос 3. Как долго должны храниться ссылки в системе?

Таймкод: 00:10:16

Ответ собеседника: Правильный. Активные ссылки хранятся четыре года; неактивные удаляются через один-два месяца после последнего перехода.

Правильный ответ:

Политика хранения ссылок зависит от бизнес-требований, регуляторных норм и экономической целесохранности хранения данных. Типичный подход выглядит следующим образом:

1. Активные ссылки (с регулярными переходами)

Для используемых срок хранения обычно составляет от 1 до 5 лет, что обосновано:

  • Юридические требования — GDPR, 152-ФЗ и другие регуляторные акты могут требовать хранения данных об обработке персональных данных определённый срок.
  • Бизнес-ценность — аналитика переходов накапливается годами и используется для маркетинговых отчётов.
  • Ожидания пользователя — пользователь ожидает, что ссылка будет работать «вечно», особенно если она размещена в печатных материалах или презентациях.

2. Неактивные ссылки (без переходов)

Ссылки без переходов в течение определённого периода считаются «мёртвыми» и подлежат очистке:

  • Срок хранения после последнего перехода — обычно 30–90 дней. Это даёт окно для восстановления, если ссылка была временно неактивна.
  • Экономия ресурсов — миллионы мёртвых ссылок занимают место в БД и кэше.

3. Механизм очистки (TTL и пакетная очистка)

-- Таблица с полем последнего доступа
ALTER TABLE urls ADD COLUMN last_accessed_at TIMESTAMPTZ;

-- Индекс для быстрого поиска мёртвых ссылок
CREATE INDEX idx_urls_last_accessed ON urls(last_accessed_at)
WHERE last_accessed_at < NOW() - INTERVAL '90 days';

-- Пакетная очистка (запускается по расписанию)
DELETE FROM urls
WHERE last_accessed_at < NOW() - INTERVAL '90 days'
AND is_custom = false;
// Обновление времени последнего доступа при каждом редиректе
func (s *Service) Resolve(ctx context.Context, shortKey string) (string, error) {
originalURL, err := s.repo.FindByKey(ctx, shortKey)
if err != nil {
return "", ErrNotFound
}

// Асинхронно обновляем last_accessed_at, не блокируя редирект
go s.repo.UpdateLastAccessed(shortKey, time.Now())

return originalURL, nil
}

4. Дифференцированный подход по тарифам

Тип пользователяСрок хранения активных ссылокСрок хранения неактивных
Бесплатный2 года30 дней
Платный5 лет90 дней
КорпоративныйБессрочно180 дней

5. Soft-delete vs Hard-delete

Рекомендуется использовать soft-delete (пометка записи как удалённой) перед физическим удалением. Это позволяет:

  • Восстановить ссылку в течение окна мягкого удаления.
  • Собирать статистику по удалённым ссылкам.
  • Соблюдать регуляторные требования к хранению данных.
ALTER TABLE urls ADD COLUMN deleted_at TIMESTAMPTZ;

-- Вместо DELETE — пометка
UPDATE urls SET deleted_at = NOW()
WHERE short_key = $1;

-- Физическое удаление после истечения окна восстановления
DELETE FROM urls
WHERE deleted_at < NOW() - INTERVAL '30 days';

Таким образом, политика хранения должна быть гибкой, дифференцированной по типу пользователя и учитывать как бизнес-требования, так и технические ограничения инфраструктуры.

Вопрос 4. Из каких символов будет формироваться короткий URL и какова его максимальная длина?

Таймкод: 00:11:10

Ответ собеседника: Неполный. Короткий URL формируется из латинских букв; максимальная длина зависит от количества пользователей, но конкретное значение не назван.

Правильный ответ:

1. Алфавит для формирования короткого ключа

Стандартный подход — использовать base62 алфавит:

  • Цифры: 0-9 (10 символов)
  • Латинские буквы верхнего регистра: A-Z (26 символов)
  • Латинские буквы нижнего регистра: a-z (26 символов)

Итого: 62 символа.

const base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

Почему именно base62:

  • URL-safe — все символы безопасны для использования в URL без кодирования (в отличие от base64, где присутствуют +, /, =).
  • Компактность — обеспечивает максимальную плотность информации на один символ среди URL-safe алфавитов.
  • Читаемость — легко диктовать по телефону, копировать вручную.

Альтернативы:

АлфавитРазмерПлюсыМинусы
Base10 (только цифры)10Простота, удобство для мобильныхБыстро исчерпывается пространство
Base36 (0-9, a-z)36Без учёта регистраМеньше уникальных комбинаций
Base62 (0-9, A-Z, a-z)62Оптимальный балансРегистрозависимость может путать
Base58 (без 0, O, I, l)58Исключены неоднозначные символыЧуть менее компактный

2. Максимальная длина ключа

Длина ключа определяет ёмкость пространства имён:

Длина ключаКоличество уникальных комбинаций (base62)
462⁴ ≈ 14,7 млн
562⁵ ≈ 916 млн
662⁶ ≈ 56,8 млрд
762⁷ ≈ 3,5 трлн
862⁸ ≈ 218 трлн

Рекомендация: 6–8 символов для автоматически генерируемых ключей.

Обоснование:

  • 6 символов (~57 млрд) — достаточно для старта и среднего масштаба. Даже при 10 млн ссылок в день пространство исчерпается через ~15 лет.
  • 7 символов (~3,5 трлн) — запас на десятилетия при любом реалистичном сценарии роста.
  • 8 символов — избыточно для большинства случаев, но даёт абсолютный запас.
// Пример: генерация ключа фиксированной длины
func GenerateKey(length int) string {
b := make([]byte, length)
for i := range b {
b[i] = base62Chars[rand.Intn(len(base62Chars))]
}
return string(b)
}

3. Кастомные алиасы

Для пользовательских ключей длина обычно варьируется:

  • Минимум: 3 символа — чтобы избежать захвата всего короткого пространства и оставить место для системных нужд.
  • Максимум: 30 символов — длинные кастомные алиасы не имеют смысла для «короткой» ссылки, но могут использоваться для читаемости.
func isValidCustomAlias(alias string) bool {
if len(alias) < 3 || len(alias) > 30 {
return false
}
for _, c := range alias {
if !strings.ContainsRune(base62Chars, c) {
return false
}
}
return true
}

4. Итоговая рекомендация

  • Автоматические ключи: 6 символов, base62 алфавит.
  • Кастомные алиасы: от 3 до 30 символов, base62 алфавит.
  • При исчерпании пространства 6-символьных ключей — переход на 7 символов с сохранением обратной совместимости (старые 6-символьные ссылки продолжают работать).

Таким образом, 6 символов в base62 — это оптимальный выбор для автоматической генерации, обеспечивающий баланс между компактностью и ёмкостью пространства имён.

Вопрос 5. Оценка нагрузки на сервис сокращения ссылок: сколько пользователей, сколько ссылок создаёт один пользователь и сколько переходов по ссылке в месяц?

Таймкод: 00:12:37

Ответ собеседника: Правильный. 100 тыс. пользователей в первый месяц, рост по 200 тыс./мес. В среднем один пользователь создаёт 5 ссылок в месяц. По одной ссылке — около 1000 переходов в месяц. Итого: 500 тыс. новых ссылок и ~500 млн переходов в месяц.

Правильный ответ:

Корректная оценка нагрузки — это фундамент для проектирования архитектуры. Давайте разберём расчёты детально и проверим их реалистичность.

1. Расчёт по заданным метрикам

МетрикаМесяц 1Месяц 6Месяц 12
Новых пользователей100 000200 000200 000
Накопленных пользователей100 0001 100 0002 300 000
Новых ссылок (5 на пользователя)500 0001 000 0001 000 000
Накопленных ссылок500 0004 500 00010 500 000
Переходов в месяц (1000 на ссылку)500 млн4,5 млрд10,5 млрд

2. Расчёт RPS (Requests Per Second)

Операция записи (создание ссылок):

500 000 ссылок / месяц ÷ (30 × 24 × 3600) ≈ 0.2 RPS (средняя)
С учётом пикового коэффициента ×10 → ~2 RPS

Операция чтения (переходы по ссылкам):

500 000 000 переходов / месяц ÷ (30 × 24 × 3600) ≈ 193 RPS (средняя)
С учётом пикового коэффициента ×10 → ~1930 RPS

Соотношение чтение/запись: ~1000:1 — типичная нагрузка, ориентированная на чтение.

3. Требования к хранилищу

-- Размер одной записи
-- short_key: ~8 байт (VARCHAR(10))
-- original_url: ~200 байт (средняя длина URL)
-- created_at: 8 байт (TIMESTAMPTZ)
-- last_accessed_at: 8 байт
-- click_count: 8 байт (BIGINT)
-- Итого: ~240 байт на запись + оверхед индексов

-- Через 12 месяцев:
-- 10 500 000 записей × 240 байт ≈ 2.5 ГБ данных + индексы ≈ 4-5 ГБ

4. Требования к кэшу

При 1930 RPS и среднем времени ответа кэша 1 мс:

Размер кэша = RPS × TTL × размер записи
Если кэшируем на 1 час: 1930 × 3600 × 250 байт ≈ 1.7 ГБ
С учётом того, что 80% переходов приходятся на 20% ссылок (закон Парето):
Достаточно кэша ~500 МБ - 1 ГБ для горячих данных.

5. Архитектурные выводы из расчётов

  • База данных: PostgreSQL с одним мастером для записи и 1-2 репликами для чтения справится с нагрузкой первого года.
  • Кэш: Redis с 1-2 ГБ памяти покроет горячий пул ссылок.
  • Балансировка: Nginx или L7-балансировщик для распределения 1930+ RPS.
  • CDN: для статических ресурсов (страница 404, лендинг).
// Оценка пропускной способности канала
// 500M редиректов × 300 байт (HTTP 302 response) = ~150 ГБ/месяц исходящего трафика
// Это ~0.05 Гбит/с средняя, ~0.5 Гбит/с пиковая — легко покрывается стандартным каналом

6. Масштабирование на будущее

При росте до 10 000+ RPS:

  • Шардирование БД по хешу короткого ключа.
  • Redis Cluster для горизонтального масштабирования кэша.
  • Rate limiting на создание ссылок для предотвращения злоупотреблений.
// Пример rate limiter
func (s *Service) Shorten(ctx context.Context, req ShortenRequest) (*ShortenResponse, error) {
userID := getUserID(ctx)
allowed, err := s.rateLimiter.Allow(ctx, "shorten:"+userID, 10, time.Minute)
if err != nil || !allowed {
return nil, ErrRateLimited
}
return s.doShorten(ctx, req)
}

Таким образом, оценка собеседника корректна и реалистична. Нагрузка первого года — умеренная и покрывается простой архитектурой без сложных оптимизаций.

Вопрос 6. Какой алгоритм генерации короткого URL предлагается использовать и сколько возможных комбинаций можно получить при длине 5–6 символов из 62 допустимых символов?

Таймкод: 00:16:17

Ответ собеседника: Правильный. Предлагается UUID для генерации ссылок; при длине 5–6 символов из 62 символов получается порядка миллиарда комбинаций (60⁵ ≈ 777 млн, 60⁶ ≈ 46 млрд). Подход корректный — сначала оценить объём данных, затем подобрать длину.

Правильный ответ:

1. Уточнение расчёта ёмкости пространства

Собеседник использовал основание 60, хотя правильный алфавит — base62 (0-9, A-Z, a-z):

ДлинаРасчётКоличество комбинаций
562⁵916 132 832 (~916 млн)
662⁶56 800 235 584 (~56,8 млрд)
762⁷3 521 614 606 208 (~3,5 трлн)

При 500 000 новых ссылок в месяц:

  • 5 символов: 916 млн ÷ 500 тыс. ≈ 1832 месяца (~152 года) до исчерпания.
  • 6 символов: практически неисчерпаемо при текущей нагрузке.

2. Алгоритмы генерации коротких ключей

А. Автоинкремент + base62-кодирование (рекомендуется)

Самый надёжный подход — использовать последовательный счётчик и кодировать его в base62:

func (g *KeyGenerator) Next() string {
id := g.sequencer.Next() // атомарный инкремент
return EncodeBase62(id)
}

// Пример: ID 1000000 → "4c92"

Плюсы: гарантированная уникальность, отсутствие коллизий, простота. Минусы: предсказуемость ключей (можно перебрать чужие ссылки).

Б. Хеширование URL (MD5/SHA-256 с усечением)

func HashKey(url string) string {
h := sha256.Sum256([]byte(url + salt))
// Берём первые 6 байт и кодируем в base62
num := binary.BigEndian.Uint64(h[:8])
return EncodeBase62(num)[:6]
}

Плюсы: дедупликация (одинаковый URL → одинаковый ключ). Минусы: возможны коллизии, требуется проверка и retry.

В. Случайная генерация с проверкой уникальности

func (g *KeyGenerator) RandomKey(length int) (string, error) {
for attempt := 0; attempt < maxRetries; attempt++ {
key := GenerateKey(length)
exists, err := g.repo.Exists(key)
if err != nil {
return "", err
}
if !exists {
return key, nil
}
}
return "", ErrKeySpaceExhausted
}

Плюсы: непредсказуемость ключей. Минусы: деградация при заполнении пространства, необходимость retry-логики.

Г. UUID (упомянут собеседником)

UUID v4 генерирует 128-битное значение. Проблема: UUID слишком длинный для короткой ссылки (550e8400-e29b-41d4-a716-446655440000 — 36 символов). Можно использовать только как внутренний идентификатор, а для пользователя генерировать отдельный короткий ключ:

type URLRecord struct {
ID uuid.UUID `json:"id"` // внутренний ID
ShortKey string `json:"short_key"` // 6-символьный публичный ключ
OriginalURL string `json:"original_url"`
}

3. Рекомендуемая стратегия

Для продакшена оптимальна комбинация автоинкремента и base62:

type KeyGenerator struct {
seq *atomic.Uint64
}

func NewKeyGenerator(startFrom uint64) *KeyGenerator {
return &KeyGenerator{seq: atomic.NewUint64(startFrom)}
}

func (g *KeyGenerator) Next() string {
id := g.seq.Add(1)
return EncodeBase62(id)
}

Для распределённой системы — Snowflake-подобные ID (время + ID узла + последовательность):

type SnowflakeGenerator struct {
nodeID int64
sequence int64
lastTime int64
mu sync.Mutex
}

func (g *SnowflakeGenerator) Next() int64 {
g.mu.Lock()
defer g.mu.Unlock()

now := time.Now().UnixMilli()
if now == g.lastTime {
g.sequence++
} else {
g.sequence = 0
g.lastTime = now
}
// 41 бит времени + 10 бит nodeID + 12 бит sequence
return (g.lastTime << 22) | (g.nodeID << 12) | g.sequence
}

4. Итог

При длине 6 символов и алфавите base62 получается 56,8 млрд уникачальных комбинаций, что с запасом покрывает нагрузку в 500 тыс. ссылок в месяц на протяжении тысячи лет. Автоинкремент + base62 — оптимальный выбор для большинства сценариев.

Вопрос 7. Какой тип базы данных выбрать для хранения ссылок — реляционную или NoSQL, и почему?

Таймкод: 00:23:44

Ответ собеседника: Неполный. Предложена реляционная СУБД из-за структурированности данных и простоты работы; упомянуты репликация и шардирование. Однако нет окончательного ответа и обоснования для сценария с высокой нагрузкой на чтение.

Правильный ответ:

Выбор базы данных для сервиса сокращения ссылок зависит от паттернов доступа. В нашем случае ключевая особенность: соотношение чтение/запись ≈ 1000:1. Это определяет архитектуру.

1. Анализ паттернов доступа

ОперацияЧастотаТребования
Создание ссылки (WRITE)~2 RPSНизкая, нужна консистентность
Переход по ссылке (READ)~2000 RPSВысокая, нужна минимальная задержка
Аналитика (WRITE)~2000 RPSАсинхронная, толерантна к задержке

2. Рекомендуемая архитектура: гибридный подход

PostgreSQL как основное хранилище + Redis как кэш-слой.

PostgreSQL выбран потому, что:

  • ACID-транзакции критичны при создании ссылок — нужна гарантия уникальности короткого ключа.
  • Структура данных простая и стабильная: две таблицы с чёткими связями.
  • Индексы обеспечивают O(log n) поиск по короткому ключу.
  • Репликация (streaming replication) позволяет масштабировать чтение.
  • Зрелость экосистемы: миграции, бэкапы, мониторинг — всё отработано.
-- Схема данных
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_key VARCHAR(10) NOT NULL,
original_url TEXT NOT NULL,
user_id BIGINT REFERENCES users(id),
is_custom BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
click_count BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_accessed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);

CREATE UNIQUE INDEX idx_urls_short_key ON urls(short_key) WHERE deleted_at IS NULL;
CREATE INDEX idx_urls_user_id ON urls(user_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_urls_expires ON urls(expires_at) WHERE expires_at IS NOT NULL;

Redis выбран как кэш-слой потому, что:

  • Подмиллисекундная задержка — критична для редиректов, где каждый миллисекунд влияет на пользовательский опыт.
  • Высокая пропускная способность — сотни тысяч операций в секунду на одном узле.
  • Встроенный TTL — автоматическое истечение кэша.
type CachedRepository struct {
db *sql.DB
cache *redis.Client
ttl time.Duration
}

func (r *CachedRepository) FindByKey(ctx context.Context, key string) (string, error) {
// 1. Проверяем кэш
val, err := r.cache.Get(ctx, "url:"+key).Result()
if err == nil {
return val, nil
}

// 2. При промахе — идём в БД
var originalURL string
err = r.db.QueryRowContext(ctx,
"SELECT original_url FROM urls WHERE short_key = $1 AND is_active = true AND deleted_at IS NULL",
key,
).Scan(&originalURL)

if err != nil {
return "", ErrNotFound
}

// 3. Записываем в кэш
r.cache.Set(ctx, "url:"+key, originalURL, r.ttl)
return originalURL, nil
}

3. Почему не чистый NoSQL?

КритерийPostgreSQL + RedisЧистый NoSQL (DynamoDB, MongoDB)
Консистентность при записиACID, уникальные ограниченияEventual consistency (DynamoDB) или нужна доп. логика
Производительность чтенияЧерез Redis — под毫秒Высокая нативно
Сложность разработкиНизкая (SQL знаком всем)Средняя
Стоимость при стартеНизкая (VPS + Redis)Выше (DynamoDB по запросам)
ШардированиеНужно настраивать при ростеВстроено (DynamoDB)

4. Когда переходить на NoSQL?

При масштабировании за пределы текущей нагрузки:

  • DynamoDB — если нужна автоматическая масштабируемость без управления шардами. Ключевое преимущество: неограниченная ёмкость и автоматическое шардирование.
  • Cassandra — если нужна мультирегиональность и высокая скорость записи.
  • MongoDB — если нужна гибкая схема и сложные агрегации для аналитики.
-- Пример схемы для DynamoDB (если переходим на NoSQL)
-- Partition Key: short_key (String)
-- Attributes: original_url, user_id, created_at, click_count, ttl
-- TTL атрибут: автоматическое удаление просроченных ссылок

5. Итоговая рекомендация

Для описанного сценария (500 млн переходов/мес, 500 тыс. новых ссылок/мес):

PostgreSQL + Redis — оптимальный выбор. PostgreSQL обеспечивает надёжное хранение и консистентность при записи, Redis обеспечивает производительность при чтении. Эта комберация покрывает нагрузку с запасом и не требует сложной инфраструктуры на старте. Переход на NoSQL оправдан только при масштабировании на порядки выше текущих метрик.

Вопрос 8. Как организовать масштабирование базы данных при росте нагрузки — master-slave репликация или другой подход?

Таймкод: 00:26:17

Ответ собеседника: Правильный. Предложен один master для записи и несколько реплик для чтения; упомянута маршрутизация запросов через прокси.

Правильный ответ:

При соотношении чтение/запись ≈ 1000:1 классический подход master-slave репликация — это правильная отправная точка. Давайте разберём архитектуру детально.

1. Архитектура Master-Slave репликации

┌─────────────┐
│ PgBouncer │
│ (pooling) │
└──────┬──────┘

┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Master │ │ Replica │ │ Replica │
│ (WRITE) │ │ (READ) │ │ (READ) │
└──────────┘ └──────────┘ └──────────┘
│ ▲ ▲
└────────────┴────────────┘
Streaming Replication

2. Маршрутизация запросов

А. На уровне приложения:

type DBCluster struct {
master *sql.DB
replicas []*sql.DB
counter uint64
}

func (c *DBCluster) WriteDB() *sql.DB {
return c.master
}

func (c *DBCluster) ReadDB() *sql.DB {
// Round-robin между репликами
idx := atomic.AddUint64(&c.counter, 1) % uint64(len(c.replicas))
return c.replicas[idx]
}

func (s *Service) Resolve(ctx context.Context, key string) (string, error) {
// Чтение — идём на реплику
db := s.dbCluster.ReadDB()
var url string
err := db.QueryRowContext(ctx,
"SELECT original_url FROM urls WHERE short_key = $1", key,
).Scan(&url)
return url, err
}

func (s *Service) Shorten(ctx context.Context, originalURL string) (string, error) {
// Запись — идём на мастер
db := s.dbCluster.WriteDB()
_, err := db.ExecContext(ctx,
"INSERT INTO urls (short_key, original_url) VALUES ($1, $2)",
generateKey(), originalURL,
)
return shortKey, err
}

Б. Через прокси (PgPool-II / PgBouncer):

PgPool-II автоматически маршрутизирует запросы:

# pgpool.conf
master_slave_mode = on
master_slave_sub_mode = 'stream'

# SELECT → реплики, INSERT/UPDATE/DELETE → мастер

3. Проблема репликационного лага

При asynchronous replication реплика может отставать от мастера на 100-500 мс. Это критично, если пользователь только что создал ссылку и сразу пытается ей воспользоваться.

Решения:

// Решение 1: Кратковременная запись в кэш при создании
func (s *Service) Shorten(ctx context.Context, originalURL string) (string, error) {
key := s.keyGen.Next()

// Записываем в мастер
_, err := s.dbCluster.WriteDB().ExecContext(ctx,
"INSERT INTO urls (short_key, original_url) VALUES ($1, $2)",
key, originalURL,
)
if err != nil {
return "", err
}

// Сразу пишем в кэш, чтобы реплика не нужна
s.cache.Set(ctx, "url:"+key, originalURL, 24*time.Hour)

return key, nil
}

// Решение 2: Чтение с мастера при промахе кэша (stale read prevention)
func (s *Service) Resolve(ctx context.Context, key string) (string, error) {
// Сначала кэш
if url, err := s.cache.Get(ctx, "url:"+key); err == nil {
return url, nil
}

// Промах кэша — читаем с мастера, чтобы избежать лага
var url string
err := s.dbCluster.WriteDB().QueryRowContext(ctx,
"SELECT original_url FROM urls WHERE short_key = $1", key,
).Scan(&url)

if err == nil {
s.cache.Set(ctx, "url:"+key, url, 24*time.Hour)
}
return url, err
}

4. Масштабирование при дальнейшем росте

Когда одна реплика не справляется с чтением:

А. Увеличение числа реплик:

Master → Replica 1 → Replica 2 → Replica 3
(cascade replication)

Б. Шардирование (когда даже реплики не справляются):

// Шардирование по хешу короткого ключа
func getShard(key string, shardCount int) int {
h := fnv.New32a()
h.Write([]byte(key))
return int(h.Sum32()) % shardCount
}

type ShardedCluster struct {
shards []*DBCluster // каждый шард — свой master + реплики
}

func (c *ShardedCluster) Resolve(ctx context.Context, key string) (string, error) {
shardIdx := getShard(key, len(c.shards))
db := c.shards[shardIdx].ReadDB()
// ... запрос к нужному шарду
}

В. Партицирование таблицы (PostgreSQL native partitioning):

-- Партицирование по хешу short_key
CREATE TABLE urls (
id BIGSERIAL,
short_key VARCHAR(10) NOT NULL,
original_url TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY HASH (short_key);

CREATE TABLE urls_p0 PARTITION OF urls FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE urls_p1 PARTITION OF urls FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE urls_p2 PARTITION OF urls FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE urls_p3 PARTITION OF urls FOR VALUES WITH (MODULUS 4, REMAINDER 3);

5. Итоговая стратегия масштабирования

ЭтапНагрузкаАрхитектура
Старт< 2000 RPSPostgreSQL + Redis
Рост2000-10000 RPSMaster + 2-3 реплики + Redis
Масштаб10000-50000 RPSШардирование + Redis Cluster
Глобальный> 50000 RPSМультирегиональность + DynamoDB/Cassandra

Для текущих метрик (~2000 RPS на чтение) достаточно Master + 1-2 реплики + Redis. Это простая, надёжная и хорошо отработанная архитектура.

Вопрос 9. Как использовать Redis (кэш) для ускорения работы сервиса и разгрузки базы данных?

Таймкод: 00:28:07

Ответ собеседника: Правильный. Кэширование горячих ссылок в Redis; backend сначала проверяет кэш, при промахе обращается к БД.

Правильный ответ:

Redis — ключевой компонент архитектуры сервиса сокращения ссылок. При соотношении чтение/запись ≈ 1000:1 кэш берёт на себя основной трафик. Давайте разберём стратегии кэширования детально.

1. Стратегия Cache-Aside (рекомендуемая)

Это наиболее распространённый паттерн, где приложение само управляет кэшем:

type URLService struct {
db *sql.DB
cache *redis.Client
ttl time.Duration
}

func (s *URLService) Resolve(ctx context.Context, shortKey string) (string, error) {
cacheKey := "url:" + shortKey

// 1. Проверяем кэш
originalURL, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
// Cache hit — обновляем статистику асинхронно
go s.incrementClickCount(shortKey)
return originalURL, nil
}

// 2. Cache miss — идём в БД
originalURL, err = s.findInDB(ctx, shortKey)
if err != nil {
return "", ErrNotFound
}

// 3. Записываем в кэш
s.cache.Set(ctx, cacheKey, originalURL, s.ttl)

return originalURL, nil
}

func (s *URLService) findInDB(ctx context.Context, shortKey string) (string, error) {
var url string
err := s.db.QueryRowContext(ctx,
"SELECT original_url FROM urls WHERE short_key = $1 AND is_active = true AND deleted_at IS NULL",
shortKey,
).Scan(&url)
return url, err
}

2. Политика вытеснения (Eviction Policy)

Выбор правильной политики критичен для эффективности кэша:

# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
ПолитикаОписаниеКогда использовать
allkeys-lruВытесняет наименее используемые ключиРекомендуется — закон Парето: 20% ссылок дают 80% трафика
volatile-lruВытесняет LRU среди ключей с TTLЕсли часть данных должна храниться бессрочно
allkeys-lfuВытесняет наименее часто используемыеЕсли нужна точность частоты, а не давности
noevictionНе вытесняет, возвращает ошибку при нехватке памятиТолько если кэш гарантированно помещается в память

3. Размер кэша и TTL

Расчёт для нашего сценария:

// При 500M переходов в месяц и законе Парето:
// Горячих ссылок (топ 20%): ~1M уникальных ключей
// Размер записи: ~250 байт (ключ + URL + оверхед Redis)
// Итого: 1M × 250 байт ≈ 250 МБ

const (
DefaultTTL = 24 * time.Hour
HotLinkTTL = 7 * 24 * time.Hour // для часто используемых
ColdLinkTTL = 1 * time.Hour // для редко используемых
)
// Адаптивный TTL на основе частоты обращений
func (s *URLService) getTTL(shortKey string) time.Duration {
count, _ := s.cache.Incr(ctx, "count:"+shortKey).Result()
if count > 100 {
return HotLinkTTL
}
return ColdLinkTTL
}

4. Защита от типичных проблем кэширования

А. Cache Stampede (лавина при истечении TTL):

func (s *URLService) ResolveWithLock(ctx context.Context, shortKey string) (string, error) {
cacheKey := "url:" + shortKey

// Проверяем кэш
if url, err := s.cache.Get(ctx, cacheKey).Result(); err == nil {
return url, nil
}

// Пытаемся получить блокировку на пересоздание кэша
lockKey := "lock:" + shortKey
acquired, err := s.cache.SetNX(ctx, lockKey, "1", 5*time.Second).Result()
if err != nil || !acquired {
// Другой процесс уже восстанавливает кэш — ждём и повторяем
time.Sleep(50 * time.Millisecond)
return s.ResolveWithLock(ctx, shortKey)
}

defer s.cache.Del(ctx, lockKey)

// Восстанавливаем кэш
url, err := s.findInDB(ctx, shortKey)
if err != nil {
return "", err
}
s.cache.Set(ctx, cacheKey, url, s.ttl)
return url, nil
}

Б. Cache Penetration (запросы несуществующих ключей):

// Кэшируем пустые значения для несуществующих ключей
const emptyPlaceholder = "__NOT_FOUND__"

func (s *URLService) Resolve(ctx context.Context, shortKey string) (string, error) {
cacheKey := "url:" + shortKey

val, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
if val == emptyPlaceholder {
return "", ErrNotFound
}
return val, nil
}

url, err := s.findInDB(ctx, shortKey)
if err == ErrNotFound {
// Кэшируем «не найдено» на короткое время
s.cache.Set(ctx, cacheKey, emptyPlaceholder, 5*time.Minute)
return "", ErrNotFound
}

s.cache.Set(ctx, cacheKey, url, s.ttl)
return url, nil
}

В. Cache Avalanche (массовое истечение TTL):

// Добавляем случайный разброс к TTL
func jitterTTL(baseTTL time.Duration) time.Duration {
jitter := time.Duration(rand.Int63n(int64(baseTTL) / 10)) // ±10%
return baseTTL + jitter - baseTTL/20
}

5. Инвалидация кэша при создании ссылки

func (s *URLService) Shorten(ctx context.Context, originalURL string) (string, error) {
shortKey := s.keyGen.Next()

_, err := s.db.ExecContext(ctx,
"INSERT INTO urls (short_key, original_url) VALUES ($1, $2)",
shortKey, originalURL,
)
if err != nil {
return "", err
}

// Сразу пишем в кэш, чтобы избежать репликационного лага
cacheKey := "url:" + shortKey
s.cache.Set(ctx, cacheKey, originalURL, s.ttl)

return shortKey, nil
}

6. Мониторинг эффективности кэша

type CacheMetrics struct {
hits atomic.Int64
misses atomic.Int64
}

func (m *CacheMetrics) HitRate() float64 {
h := m.hits.Load()
total := h + m.misses.Load()
if total == 0 {
return 0
}
return float64(h) / float64(total)
}

// Целевой hit rate: > 95% для горячих данных
// При нашей нагрузке (закон Парето) с кэшем 2 ГБ ожидаемый hit rate: 95-98%

7. Итоговая архитектура с кэшем

Клиент → Nginx → Backend → Redis (L1 cache)
│ miss

PostgreSQL (replica)
│ miss

PostgreSQL (master)

При 95% hit rate в Redis:

  • Нагрузка на PostgreSQL: ~100 RPS вместо ~2000 RPS (20x снижение).
  • Средняя задержка: ~1 мс (Redis) вместо ~10 мс (PostgreSQL).

Redis в данной архитектуре — не просто оптимизация, а критический компонент, без которого база данных не выдержит пиковой нагрузки.

Вопрос 10. Как реализовать балансировку нагрузки между несколькими экземплярами backend-сервисов?

Таймкод: 00:31:43

Ответ собеседника: Правильный. Использование балансировщика нагрузки (Nginx) перед backend-сервисами с распределением запросов между экземплярами.

Правильный ответ:

Балансировка нагрузки — обязательный элемент горизонтального масштабирования. Давайте разберём архитектуру, алгоритмы и практические аспекты.

1. Архитектура балансировки

Internet

┌──────┴──────┐
│ CloudFlare │ ← DNS + DDoS protection
│ / CDN │
└──────┬──────┘

┌──────┴──────┐
│ Nginx / │ ← L7 Load Balancer
│ HAProxy │
└──────┬──────┘

┌──────────────┼──────────────┐
│ │ │
┌──────┴──────┐ ┌────┴────┐ ┌──────┴──────┐
│ Backend 1 │ │Backend 2│ │ Backend 3 │
│ :8080 │ │ :8080 │ │ :8080 │
└─────────────┘ └─────────┘ └─────────────┘

2. Алгоритмы балансировки

А. Round Robin (по умолному):

upstream backend {
server backend1:8080;
server backend2:8080;
server backend3:8080;
}

server {
listen 80;
location / {
proxy_pass http://backend;
}
}

Простейший подход — запросы распределяются по очереди. Подходит, когда все backend-ы одинаковы по мощности.

Б. Least Connections (наименьшее число соединений):

upstream backend {
least_conn;
server backend1:8080;
server backend2:8080;
server backend3:8080;
}

Запрос направляется на backend с наименьшим числом активных соединений. Рекомендуется для нашего сценария, так как время обработки запросов может варьироваться (cache hit vs cache miss).

В. Weighted Round Robin (взвешенный):

upstream backend {
server backend1:8080 weight=3; # мощный сервер
server backend2:8080 weight=1; # слабый сервер
server backend3:8080 weight=1;
}

Г. IP Hash (привязка клиента к backend):

upstream backend {
ip_hash;
server backend1:8080;
server backend2:8080;
server backend3:8080;
}

Один и тот же клиент всегда попадает на один backend. Полезно для кэширования на уровне приложения.

3. Health Checks (проверка здоровья)

upstream backend {
least_conn;
server backend1:8080 max_fails=3 fail_timeout=30s;
server backend2:8080 max_fails=3 fail_timeout=30s;
server backend3:8080 max_fails=3 fail_timeout=30s;
}

Nginx автоматически убирает нездоровые бэкенды из пула:

// Health check endpoint в Go-приложении
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
// Проверяем зависимости
if err := s.db.Ping(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if err := s.cache.Ping(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}

4. Балансировка на уровне DNS

Для мультирегионального развертывания:

# DNS round-robin
short.ly IN A 1.2.3.4 # регион EU
short.ly IN A 5.6.7.8 # регион US
short.ly IN A 9.10.11.12 # регион ASIA

Или использование GeoDNS (Route53, CloudFlare) для маршрутизации клиента к ближайшему дата-центру.

5. Service Mesh (для микросервисной архитектуры)

При росте числа сервисов — использование Istio/Linkerd:

# Istio VirtualService
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: url-service
spec:
hosts:
- url-service
http:
- route:
- destination:
host: url-service
subset: v1
weight: 90
- destination:
host: url-service
subset: v2
weight: 10 # canary deployment

6. Graceful Shutdown (корректное завершение)

При обновлении или масштабировании backend должен корректно завершать работу:

func (s *Server) Run() error {
srv := &http.Server{
Addr: ":8080",
Handler: s.router,
}

go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()

// Ожидаем сигнал завершения
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

// Даём время завершить текущие запросы
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

return srv.Shutdown(ctx)
}

7. Рекомендуемая конфигурация для нашего сценария

upstream backend {
least_conn;

server backend1:8080 max_fails=3 fail_timeout=30s;
server backend2:8080 max_fails=3 fail_timeout=30s;
server backend3:8080 max_fails=3 fail_timeout=30s;

keepalive 32; # постоянные соединения к бэкендам
}

server {
listen 80;
server_name short.ly;

# Редирект на HTTPS
return 301 https://$host$request_uri;
}

server {
listen 443 ssl http2;
server_name short.ly;

ssl_certificate /etc/ssl/certs/short.ly.pem;
ssl_certificate_key /etc/ssl/private/short.ly.key;

location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# Таймауты
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
proxy_send_timeout 10s;
}

location /health {
proxy_pass http://backend/health;
access_log off;
}
}

8. Итог

Для нашего сценария (~2000 RPS) достаточно Nginx с least_conn алгоритмом и health checks. Это простое, надёжное и проверенное решение. При росте — добавление второго Nginx (keepalived для failover) и переход на cloud-native балансировщиков (AWS ALB/NLB, GCP Load Balancer).

Вопрос 11. Сервис преимущественно на чтение или на запись, и как это влияет на архитектуре?

Таймкод: 00:34:09

Ответ собеседника: Правильный. Сервис преимущественно на чтение (≈1000:1); это обосновывает master-slave репликацию и кэширование для оптимизации чтения.

Правильный ответ:

Это один из ключевых вопросов проектирования, который определяет всю архитектуру. Давайте разберём влияние паттерна нагрузки на каждый компонент системы.

1. Коллекция соотношения чтение/запись

На основе заданных метрик:

Запись (создание ссылок): 500 000/мес ≈ 0.2 RPS (средняя)
Чтение (переходы): 500 000 000/мес ≈ 193 RPS (средняя)

Соотношение: ~1000:1 (чтение:запись)

С учётом пикового коэффициента ×10:

  • Пик записи: ~2 RPS
  • Пик чтения: ~2000 RPS

2. Влияние на выбор базы данных

АспектRead-heavy (наш случай)Write-heavy
РепликацияMaster + много репликMaster + 1-2 реплики
Критичность кэшаКритическаяВажна, но не критична
Выбор СУБДPostgreSQL + RedisCassandra, ScyllaDB
ИндексыМожно позволить много индексовМинимум индексов (замедляют запись)

3. Влияние на архитектуру кэширования

При read-heavy нагрузке кэш становится критическим компонентом:

// При 1000:1 кэш должен покрывать >95% запросов
// Иначе база данных не справится с пиками

type CacheStrategy struct {
hotDataTTL time.Duration // для часто запрашиваемых
warmDataTTL time.Duration // для умеренно запрашиваемых
coldDataTTL time.Duration // для редко запрашиваемых
}

func NewReadHeavyStrategy() CacheStrategy {
return CacheStrategy{
hotDataTTL: 7 * 24 * time.Hour, // горячие ссылки — надолго
warmDataTTL: 24 * time.Hour, // тёплые — на день
coldDataTTL: 1 * time.Hour, // холодные — на час
}
}

4. Влияние на репликацию

-- При read-heavy нагрузке реплика для чтения обязательна
-- Конфигурация PostgreSQL для read-heavy:

-- postgresql.conf на реплике
shared_buffers = 4GB # больше памяти для кэша страниц
effective_cache_size = 12GB # ОС + PostgreSQL кэш
random_page_cost = 1.1 # SSD — быстрый произвольный доступ
work_mem = 64MB # больше памяти для сортировок
max_parallel_workers_per_gather = 4 # параллельные запросы

5. Влияние на масштабирование

Read-heavy масштабирование:
├── Вертикальное: больше RAM для кэша
├── Горизонтальное: добавляем реплики для чтения
└── Кэш-слой: Redis Cluster

Write-heavy масштабирование:
├── Шардирование по ключу
├── Партицирование таблиц
└── Асинхронная запись (очереди)

6. Влияние на мониторинг

При read-heavy нагрузке ключевые метрики:

type ReadHeavyMetrics struct {
CacheHitRate float64 // целевой: >95%
CacheMissRate float64 // целевой: <5%
ReplicaLag time.Duration // целевой: <100ms
ReadQPS int64 // мониторинг тренда
WriteQPS int64 // должен быть стабильно низким
P99ReadLatency time.Duration // целевой: <5ms
P99WriteLatency time.Duration // целевой: <50ms
}

7. Влияние на отказоустойчивость

// При read-heavy потеря кэша катастрофична — нужен circuit breaker
type CircuitBreaker struct {
failureThreshold int
successThreshold int
timeout time.Duration
}

func (s *Service) ResolveWithCircuitBreaker(ctx context.Context, key string) (string, error) {
// Пробуем кэш
if url, err := s.cache.Get(ctx, key).Result(); err == nil {
return url, nil
}

// Кэш недоступен — проверяем circuit breaker
if s.cb.State() == Open {
// Circuit breaker открыт — идём напрямую в БД
return s.fallbackToDB(ctx, key)
}

// Пробуем восстановить кэш
url, err := s.findInDB(ctx, key)
if err != nil {
s.cb.RecordFailure()
return "", err
}

s.cb.RecordSuccess()
return url, nil
}

8. Итог

Сервис сокращения ссылок — классический read-heavy сервис с соотношением ~1000:1. Это определяет:

  • Кэш — критический компонент, без которого система не масштабируется.
  • Master-slave репликация с несколькими репликами для чтения.
  • Оптимизация чтения — индексы, денормализация, materialized views.
  • Масштабирование — горизонтальное через добавление реплик и кэш-узлов.
  • Мониторинг — фокус на cache hit rate и задержках чтения.

Вся архитектура строится вокруг одной цели: максимально быстро отдать оригинальный URL по короткому ключу.

Вопрос 12. Как добавить в систему аналитику в реальном времени без нагрузки на основную БД и backend?

Таймкод: 00:35:02

Ответ собеседника: Неполный. Предложен отдельный сервис отчётов, читающий из MongoDB. Указано использование Kafka для асинхронной передачи событий, но кандидат не был знаком с этим паттерном.

Правильный ответ:

Аналитика — критически важная функция, но она не должна влиять на основной поток редиров. Ключевой принцип: fire-and-forget — основной сервис не должен ждать обработки аналитических данных.

1. Архитектура аналитики с брокером сообщений

┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────────┐
│ Backend │───▶│ Kafka │───▶│ Analytics │───▶│ ClickHouse │
│ │ │ (Topic: │ │ Consumer │ │ / BigQuery │
│ Публикует│ │ clicks) │ │ Group │ │ │
│ событие │ └──────────┘ └──────────────┘ └─────────────┘
└──────────┘ │

┌──────────────┐
│ Dashboard │
│ (Grafana) │
└──────────────┘

2. Публикация событий из Backend

type ClickEvent struct {
ShortKey string `json:"short_key"`
OriginalURL string `json:"original_url"`
UserAgent string `json:"user_agent"`
IP string `json:"ip"`
Referer string `json:"referer"`
Timestamp time.Time `json:"timestamp"`
}

type AnalyticsProducer struct {
writer *kafka.Writer
}

func NewAnalyticsProducer(brokers []string) *AnalyticsProducer {
return &AnalyticsProducer{
writer: &kafka.Writer{
Addr: kafka.TCP(brokers...),
Topic: "clicks",
Balancer: &kafka.Hash{},
BatchSize: 100,
BatchTimeout: 10 * time.Millisecond,
Async: true, // fire-and-forget
},
}
}

func (p *AnalyticsProducer) TrackClick(ctx context.Context, event ClickEvent) error {
data, err := json.Marshal(event)
if err != nil {
return err
}

return p.writer.WriteMessages(ctx, kafka.Message{
Key: []byte(event.ShortKey), // партиционирование по ключу
Value: data,
})
}

3. Интеграция в основной флоу редиректа

func (s *Server) redirectHandler(w http.ResponseWriter, r *http.Request) {
shortKey := chi.URLParam(r, "shortKey")

// 1. Резолвим URL (основной флоу — должен быть максимально быстрым)
originalURL, err := s.urlService.Resolve(r.Context(), shortKey)
if err != nil {
http.NotFound(w, r)
return
}

// 2. Отправляем редирект немедленно
http.Redirect(w, r, originalURL, http.StatusFound)

// 3. Публикуем аналитику асинхронно (не блокируем ответ)
go s.analytics.TrackClick(context.Background(), ClickEvent{
ShortKey: shortKey,
OriginalURL: originalURL,
UserAgent: r.UserAgent(),
IP: getClientIP(r),
Referer: r.Referer(),
Timestamp: time.Now(),
})
}

4. Consumer для обработки аналитики

type AnalyticsConsumer struct {
reader *kafka.Reader
clickRepo *ClickHouseRepository
}

func NewAnalyticsConsumer(brokers []string, clickRepo *ClickHouseRepository) *AnalyticsConsumer {
return &AnalyticsConsumer{
reader: kafka.NewReader(kafka.ReaderConfig{
Brokers: brokers,
Topic: "clicks",
GroupID: "analytics-consumer-group",
}),
clickRepo: clickRepo,
}
}

func (c *AnalyticsConsumer) Run(ctx context.Context) error {
batch := make([]ClickEvent, 0, 1000)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if len(batch) > 0 {
if err := c.clickRepo.BatchInsert(ctx, batch); err != nil {
log.Printf("batch insert error: %v", err)
}
batch = batch[:0]
}
default:
msg, err := c.reader.ReadMessage(ctx)
if err != nil {
continue
}

var event ClickEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
continue
}

batch = append(batch, event)

if len(batch) >= 1000 {
if err := c.clickRepo.BatchInsert(ctx, batch); err != nil {
log.Printf("batch insert error: %v", err)
}
batch = batch[:0]
}
}
}
}

5. ClickHouse для хранения аналитики

ClickHouse — колоночная СУБД, идеально подходящая для аналитических запросов:

-- Таблица кликов
CREATE TABLE clicks (
short_key String,
original_url String,
user_agent String,
ip IPv4,
referer String,
country LowCardinality(String),
timestamp DateTime
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (short_key, timestamp)
TTL timestamp + INTERVAL 2 YEAR;

-- Materialized view для агрегированной статистики
CREATE MATERIALIZED VIEW url_stats_mv
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (short_key, date)
AS SELECT
short_key,
toDate(timestamp) AS date,
count() AS clicks,
uniq(ip) AS unique_visitors
FROM clicks
GROUP BY short_key, date;

-- Пример аналитического запроса
SELECT
short_key,
sum(clicks) AS total_clicks,
sum(unique_visitors) AS total_unique
FROM url_stats_mv
WHERE date >= today() - 30
GROUP BY short_key
ORDER BY total_clicks DESC
LIMIT 100;

6. Альтернатива Kafka: упрощённый вариант через Redis Streams

Если Kafka — избыточно для текущего масштаба:

// Producer через Redis Streams
func (s *Service) TrackClickRedis(ctx context.Context, event ClickEvent) error {
data, _ := json.Marshal(event)
return s.cache.XAdd(ctx, &redis.XAddArgs{
Stream: "clicks",
Values: map[string]interface{}{
"data": string(data),
},
}).Err()
}

// Consumer через Redis Streams
func (c *Consumer) consumeFromRedis(ctx context.Context) {
for {
streams, err := c.cache.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: "analytics",
Consumer: "worker-1",
Streams: []string{"clicks", ">"},
Count: 100,
}).Result()

if err != nil {
continue
}

for _, msg := range streams[0].Messages {
var event ClickEvent
json.Unmarshal([]byte(msg.Values["data"].(string)), &event)
c.process(event)
c.cache.XAck(ctx, "clicks", "analytics", msg.ID)
}
}
}

7. Итоговая архитектура аналитики

Клиент → Backend → Redis (редирект, < 5ms)

├──▶ Kafka (асинхронно, fire-and-forget)
│ │
│ ▼
│ Consumer Group
│ │
│ ▼
│ ClickHouse
│ │
│ ▼
│ Grafana Dashboard

└──▶ PostgreSQL (обновление click_count, асинхронно)

Ключевые принципы:

  • Основной флоу не блокируется — редирект происходит до публикации аналитики.
  • Асинхронная публикацияgo func() или Kafka async producer.
  • Пакетная запись — накопление событий и пакетная вставка в ClickHouse (1000 событий за раз).
  • Отдельное хранилище — ClickHouse для аналитики, PostgreSQL для основных данных. Никакой нагрузки на основную БД.
  • Отказоустойчивость — потеря нескольких аналитических событий допустима; потеря редиректа — нет.

Вопрос 13. Как разделить backend на две зоны ответственности — запись и чтение — для масштабирования?

Таймкод: 00:47:31

Ответ собеседника: Правильный. Разделение на write backend (генерация ссылок) и read backend (редиректы); read backend масштабируется горизонтально, write backend — один инстанс.

Правильный answer:

Разделение на read и write сервисы — это паттерн CQRS (Command Query Responsibility Segregation), адаптированный под специфику сервиса сокращения ссылок. Давайте разберём архитектуру детально.

1. Архитектура разделения

┌─────────────┐
│ Nginx / │
│ API Gateway │
└──────┬──────┘

┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Write Service │ │ Read Service │ │ Analytics │
│ (API: POST) │ │ (API: GET) │ │ Service │
│ │ │ │ │ │
│ 1 инстанс │ │ N инстансов │ │ 1-2 инстанса │
└───────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ PostgreSQL │ │ Redis Cluster │ │ ClickHouse │
│ (Master) │ │ (Read-through) │ │ │
└────────────────┘ └────────────────┘ └────────────────┘
│ ▲
│ replication │
└───────────────────┘

2. Write Service (Command Side)

Отвечает за создание, обновление и удаление ссылок:

type WriteService struct {
db *sql.DB
cache *redis.Client
keyGen *KeyGenerator
producer *kafka.Writer
}

func (s *WriteService) Shorten(ctx context.Context, req ShortenRequest) (*ShortenResponse, error) {
// 1. Валидация
if !isValidURL(req.OriginalURL) {
return nil, ErrInvalidURL
}

// 2. Генерация ключа
shortKey := s.keyGen.Next()

// 3. Запись в БД
_, err := s.db.ExecContext(ctx,
"INSERT INTO urls (short_key, original_url, user_id) VALUES ($1, $2, $3)",
shortKey, req.OriginalURL, req.UserID,
)
if err != nil {
return nil, err
}

// 4. Инвалидация/прогрев кэша
s.cache.Set(ctx, "url:"+shortKey, req.OriginalURL, 24*time.Hour)

// 5. Публикация события для аналитики
s.producer.WriteMessages(ctx, kafka.Message{
Topic: "url_created",
Value: mustJSON(map[string]string{
"short_key": shortKey,
"original_url": req.OriginalURL,
}),
})

return &ShortenResponse{ShortURL: s.baseURL + "/" + shortKey}, nil
}

func (s *WriteService) Delete(ctx context.Context, shortKey string, userID int64) error {
_, err := s.db.ExecContext(ctx,
"UPDATE urls SET deleted_at = NOW() WHERE short_key = $1 AND user_id = $2",
shortKey, userID,
)
if err != nil {
return err
}

// Инвалидация кэша
s.cache.Del(ctx, "url:"+shortKey)
return nil
}

3. Read Service (Query Side)

Отвечает только за чтение и редиректы:

type ReadService struct {
cache *redis.Client
db *sql.DB // read replica
producer *kafka.Writer
}

func (s *ReadService) Resolve(ctx context.Context, shortKey string) (string, error) {
// 1. Пытаемся из кэша
originalURL, err := s.cache.Get(ctx, "url:"+shortKey).Result()
if err == nil {
// Асинхронно публикуем событие клика
s.producer.WriteMessages(ctx, kafka.Message{
Topic: "clicks",
Key: []byte(shortKey),
})
return originalURL, nil
}

// 2. Промах кэша — идём в read replica
var url string
err = s.db.QueryRowContext(ctx,
"SELECT original_url FROM urls WHERE short_key = $1 AND is_active = true AND deleted_at IS NULL",
shortKey,
).Scan(&url)

if err != nil {
return "", ErrNotFound
}

// 3. Прогреваем кэш
s.cache.Set(ctx, "url:"+shortKey, url, 24*time.Hour)

return url, nil
}

4. API Gateway для маршрутизации

func NewRouter(writeSvc *WriteService, readSvc *ReadService) *chi.Mux {
r := chi.NewRouter()

// Write endpoints → Write Service
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware)
r.Post("/api/v1/shorten", writeSvc.ShortenHandler)
r.Delete("/api/v1/urls/{shortKey}", writeSvc.DeleteHandler)
r.Put("/api/v1/urls/{shortKey}", writeSvc.UpdateHandler)
})

// Read endpoints → Read Service
r.Get("/{shortKey}", readSvc.RedirectHandler)
r.Get("/api/v1/urls/{shortKey}/stats", readSvc.StatsHandler)

return r
}

Или на уровне Nginx:

# Write endpoints → write backend
location /api/v1/shorten {
proxy_pass http://write_backend;
}

location /api/v1/urls {
proxy_pass http://write_backend;
}

# Read endpoints → read backend (масштабируемый)
location ~ ^/[a-zA-Z0-9]{3,30}$ {
proxy_pass http://read_backend;
}

5. Масштабирование каждого сервиса

# docker-compose.yml (иллюстрация)
services:
write-service:
build: ./write-service
deploy:
replicas: 1 # один инстанс — запись идёт в один master
environment:
- DB_HOST=postgres-master
- REDIS_HOST=redis

read-service:
build: ./read-service
deploy:
replicas: 5 # пять инстансов — чтение масштабируется
environment:
- DB_HOST=postgres-replica
- REDIS_HOST=redis

6. Преимущества разделения

АспектМонолитный backendРазделённый (CQRS)
МасштабированиеМасштабируем всё вместеМасштабируем только чтение
НадёжностьОшибка записи ломает чтениеНезависимые отказы
ОптимизацияКомпромиссКаждый сервис оптимизирован под свою нагрузку
ДеплойПолный редеплойНезависимые релизы
СложностьНизкаяСредняя

7. Обработка гонки при создании ссылки

При разделении сервисов важно обеспечить консистентность:

// Write Service — атомарная вставка с обработкой конфликтов
func (s *WriteService) Shorten(ctx context.Context, req ShortenRequest) (*ShortenResponse, error) {
shortKey := s.keyGen.Next()

// INSERT с ON CONFLICT для идемпотентности
_, err := s.db.ExecContext(ctx,
`INSERT INTO urls (short_key, original_url, user_id)
VALUES ($1, $2, $3)
ON CONFLICT (short_key) DO NOTHING`,
shortKey, req.OriginalURL, req.UserID,
)
if err != nil {
return nil, err
}

// Прогрев кэша — Read Service сразу увидит новую ссылку
s.cache.Set(ctx, "url:"+shortKey, req.OriginalURL, 24*time.Hour)

return &ShortenResponse{ShortURL: s.baseURL + "/" + shortKey}, nil
}

8. Итог

Разделение на read и write сервисы даёт:

  • Независимое масштабирование: 5+ инстансов read, 1 инстанс write.
  • Независимый деплой: обновление read сервиса не затрагивает write.
  • Оптимизацию: read сервис оптимизирован под быстрое чтение, write — под консистентную запись.
  • Отказоустойчивость: проблемы write сервиса не влияют на редиректы (кэш продолжает работать).

Это естественное продолжение паттерна master-slave репликации на уровне приложения.

Вопрос 14. Как генерировать уникальные короткие URL на нескольких инстансах backend без конфликтов?

Таймкод: 00:49:53

Ответ собеседника: Неполный. Предложено разделение пространства ID на диапазоны с координацией через ZooKeeper. Однако это создаёт stateful-систему. Кандидат не предложил решение через распределённый счётчик.

Правильный ответ:

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

1. Подход 1: Централизованный счётчик через Redis INCR

Простейшее решение — атомарный инкремент в Redis:

type RedisKeyGenerator struct {
client *redis.Client
}

func (g *RedisKeyGenerator) Next(ctx context.Context) (string, error) {
// INCR атомарен — гарантирует уникальность даже при множестве инстансов
id, err := g.client.Incr(ctx, "url_counter").Result()
if err != nil {
return "", err
}
return EncodeBase62(uint64(id)), nil
}

Плюсы: простота, гарантированная уникальность, монотонная последовательность. Минусы: единая точка отказа (Redis), дополнительная зависимость.

Решение проблемы SPOF — Redis Sentinel или Redis Cluster:

// Redis Cluster — INCR работает на мастере шарда
// При падении мастера — failover на реплику
client := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{
"redis-node-1:6379",
"redis-node-2:6379",
"redis-node-3:6379",
},
})

2. Подход 2: Snowflake-подобные ID (рекомендуется)

Каждый инстанс генерирует ID самостоятельно, без координации:

┌──────────────────────────────────────────────────┐
│ Snowflake ID (64 бит) │
├──────────┬────────────┬──────────────┬───────────┤
│ 1 бит │ 41 бит │ 10 бит │ 12 бит │
│ зарезерв.│ timestamp │ node_id │ sequence │
└──────────┴────────────┴──────────────┴───────────┘
type SnowflakeGenerator struct {
nodeID int64
sequence int64
lastTime int64
mu sync.Mutex

epoch int64 // кастомная эпоха (например, 2024-01-01)
}

const (
nodeBits = 10
sequenceBits = 12
nodeMax = (1 << nodeBits) - 1 // 1023
sequenceMask = (1 << sequenceBits) - 1 // 4095
)

func NewSnowflakeGenerator(nodeID int64) *SnowflakeGenerator {
if nodeID < 0 || nodeID > nodeMax {
panic("node ID out of range")
}
return &SnowflakeGenerator{
nodeID: nodeID,
epoch: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli(),
}
}

func (g *SnowflakeGenerator) NextID() int64 {
g.mu.Lock()
defer g.mu.Unlock()

now := time.Now().UnixMilli() - g.epoch

if now == g.lastTime {
g.sequence = (g.sequence + 1) & sequenceMask
if g.sequence == 0 {
// Переполнение sequence — ждём следующую миллисекунду
for now <= g.lastTime {
now = time.Now().UnixMilli() - g.epoch
}
}
} else {
g.sequence = 0
}

g.lastTime = now

return (now << (nodeBits + sequenceBits)) |
(g.nodeID << sequenceBits) |
g.sequence
}

func (g *SnowflakeGenerator) NextKey() string {
id := g.NextID()
return EncodeBase62(uint64(id))
}

Плюсы: stateless (после инициализации), не требует координации, высокая производительность. Минусы: требует уникальный nodeID для каждого инстанса, ID не являются последовательными глобально.

3. Подход 3: UUID v4

import "github.com/google/uuid"

func GenerateUUIDKey() string {
id := uuid.New()
// UUID → base62 для компактности
return EncodeBase62(id.ID())
}

Плюсы: полностью stateless, нулевая вероятность коллизий. Минусы: длинный ключ (36 символов в стандартном виде), не является «коротким».

4. Подход 4: Pre-generated keys (предварительная генерация)

Генерируем пул ключей заранее и раздаём инстансам:

type KeyPool struct {
db *sql.DB
}

func (p *KeyPool) GenerateKeys(ctx context.Context, count int) error {
keys := make([]string, count)
for i := 0; i < count; i++ {
keys[i] = GenerateRandomKey(6)
}

// Пакетная вставка в пул доступных ключей
valueStrings := make([]string, 0, count)
valueArgs := make([]interface{}, 0, count)
for i, key := range keys {
valueStrings = append(valueStrings, fmt.Sprintf("($%d)", i+1))
valueArgs = append(valueArgs, key)
}

stmt := fmt.Sprintf(
"INSERT INTO key_pool (short_key, status) VALUES %s ON CONFLICT DO NOTHING",
strings.Join(valueStrings, ","),
)
_, err := p.db.ExecContext(ctx, stmt, valueArgs...)
return err
}

func (p *KeyPool) AcquireKey(ctx context.Context) (string, error) {
var key string
err := p.db.QueryRowContext(ctx,
`UPDATE key_pool SET status = 'used', acquired_at = NOW()
WHERE id = (
SELECT id FROM key_pool
WHERE status = 'available'
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING short_key`,
).Scan(&key)
return key, err
}
CREATE TABLE key_pool (
id BIGSERIAL PRIMARY KEY,
short_key VARCHAR(10) UNIQUE NOT NULL,
status VARCHAR(20) DEFAULT 'available',
acquired_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_key_pool_available ON key_pool(status) WHERE status = 'available';

5. Подход 5: Выделение диапазонов (Range-based)

Улучшенная версия идеи собеседника — через атомарный инкремент:

type RangeAllocator struct {
db *sql.DB
rangeSize int64
current int64
end int64
mu sync.Mutex
}

func (a *RangeAllocator) Next(ctx context.Context) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()

if a.current >= a.end {
// Исчерпали диапазон — запрашиваем новый
err := a.db.QueryRowContext(ctx,
"UPDATE id_allocator SET current_value = current_value + $1 RETURNING current_value",
a.rangeSize,
).Scan(&a.end)
if err != nil {
return "", err
}
a.current = a.end - a.rangeSize
}

a.current++
return EncodeBase62(uint64(a.current)), nil
}
CREATE TABLE id_allocator (
id INT PRIMARY KEY DEFAULT 1,
current_value BIGINT NOT NULL DEFAULT 0
);
INSERT INTO id_allocator (current_value) VALUES (0);

6. Сравнение подходов

ПодходКоординацияПроизводительностьМасштабируемостьСложность
Redis INCRМинимальнаяВысокаяСредняя (SPOF)Низкая
SnowflakeНетОчень высокаяВысокаяСредняя
UUID v4НетОчень высокаяВысокаяНизкая
Pre-generated keysМинимальнаяВысокаяВысокаяСредняя
Range-basedМинимальнаяВысокаяВысокаяСредняя

7. Рекомендация

Для нашего сценария оптимален Snowflake:

  • Stateless после инициализации (nodeID из конфига или service discovery).
  • Нет единой точки отказа.
  • Производительность — миллионы ID в секунду на одном инстансе.
  • ID содержат временную метку — можно сортировать по времени создания.
// Инициализация при старте сервиса
func initKeyGenerator() *SnowflakeGenerator {
nodeID := getNodeID() // из env, config, service discovery
return NewSnowflakeGenerator(nodeID)
}

// Пример: nodeID из переменной окружения
func getNodeID() int64 {
id, _ := strconv.ParseInt(os.Getenv("NODE_ID"), 10, 64)
return id
}

Для write сервиса (один инстанс) подойдёт и Redis INCR — проще реализовать, а SPOF Redis решается через Sentinel/Cluster.

Вопрос 15. Как организовать политику кэширования в Redis — какие ссылки кэшировать и как определять горячие?

Таймкод: 01:05:03

Ответ собеседника: Неполный. Предложено кэшировать топ-10% ссылок на основе счётчика использований в MongoDB. Более эффективный подход — кэширование при каждом чтении с LRU-политикой.

Правильный ответ:

Правильная политика кэширования — это баланс между простотой реализации и эффективностью использования памяти. Давайте разберём оптимальный подход.

1. Основной принцип: кэшируй всё при чтении, вытесняй по LRU

Самый простой и эффективный подход — кэшировать каждую ссылку при первом обращении и позволить Redis автоматически вытеснять неактивные ключи:

func (s *ReadService) Resolve(ctx context.Context, shortKey string) (string, error) {
cacheKey := "url:" + shortKey

// 1. Проверяем кэш
originalURL, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
return originalURL, nil
}

// 2. Промах — идём в БД
originalURL, err = s.findInDB(ctx, shortKey)
if err != nil {
return "", ErrNotFound
}

// 3. Кэшируем с TTL
s.cache.Set(ctx, cacheKey, originalURL, s.ttl)

return originalURL, nil
}

Конфигурация Redis:

# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru

При этом Redis автоматически вытесняет наименее недавно используанные ключи, когда память заканчивается. Горячие ссылки остаются в кэше автоматически.

2. Почему это работает: закон Парето

В сервисах сокращения ссылок распределение трафика крайне неравномерно:

20% ссылок → 80% переходов (горячие)
80% ссылок → 20% переходов (холодные)

При 2 ГБ кэша и 250 байт на запись:

2 ГБ ÷ 250 байт ≈ 8 миллионов записей

При 10 миллионах ссылок через 12 месяцев — кэш покроет ~80% ссылок, которые генерируют ~95% трафика.

3. Адаптивный TTL для оптимизации

func (s *ReadService) ResolveWithAdaptiveTTL(ctx context.Context, shortKey string) (string, error) {
cacheKey := "url:" + shortKey

// Проверяем кэш
originalURL, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
// Обновляем TTL при каждом обращении (продлеваем жизнь горячим ключам)
s.cache.Expire(ctx, cacheKey, s.getAdaptiveTTL(shortKey))
return originalURL, nil
}

originalURL, err = s.findInDB(ctx, shortKey)
if err != nil {
return "", ErrNotFound
}

// Начальный TTL — короткий
s.cache.Set(ctx, cacheKey, originalURL, 1*time.Hour)
return originalURL, nil
}

func (s *ReadService) getAdaptiveTTL(shortKey string) time.Duration {
// Считаем обращения за последний час
count, _ := s.cache.Incr(ctx, "freq:"+shortKey).Result()
s.cache.Expire(ctx, "freq:"+shortKey, time.Hour)

switch {
case count > 1000:
return 7 * 24 * time.Hour // очень горячая
case count > 100:
return 24 * time.Hour // горячая
case count > 10:
return 4 * time.Hour // тёплая
default:
return 1 * time.Hour // холодная
}
}

4. Защита от пустых ключей (cache penetration)

const emptyPlaceholder = "__NOT_FOUND__"

func (s *ReadService) Resolve(ctx context.Context, shortKey string) (string, error) {
cacheKey := "url:" + shortKey

val, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
if val == emptyPlaceholder {
return "", ErrNotFound
}
return val, nil
}

originalURL, err := s.findInDB(ctx, shortKey)
if err != nil {
// Кэшируем «не найдено» на короткое время
s.cache.Set(ctx, cacheKey, emptyPlaceholder, 5*time.Minute)
return "", ErrNotFound
}

s.cache.Set(ctx, cacheKey, originalURL, s.ttl)
return originalURL, nil
}

5. Мониторинг эффективности кэша

type CacheMetrics struct {
hits atomic.Int64
misses atomic.Int64
}

func (s *ReadService) ResolveWithMetrics(ctx context.Context, shortKey string) (string, error) {
cacheKey := "url:" + shortKey

val, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
s.metrics.hits.Add(1)
return val, nil
}

s.metrics.misses.Add(1)

originalURL, err := s.findInDB(ctx, shortKey)
if err != nil {
return "", ErrNotFound
}

s.cache.Set(ctx, cacheKey, originalURL, s.ttl)
return originalURL, nil
}

// Целевые метрики:
// Hit rate: > 95%
// P99 latency (cache hit): < 1ms
// P99 latency (cache miss): < 10ms

6. Предварительный прогрев кэша (Cache Warming)

При деплое или перезапуске кэш пуст. Чтобы избежать лавины запросов к БД:

func (s *Service) WarmupCache(ctx context.Context) error {
// Загружаем топ-10000 горячих ссылок
rows, err := s.db.QueryContext(ctx,
`SELECT short_key, original_url
FROM urls
ORDER BY click_count DESC
LIMIT 10000`,
)
if err != nil {
return err
}
defer rows.Close()

pipe := s.cache.Pipeline()
for rows.Next() {
var key, url string
rows.Scan(&key, &url)
pipe.Set(ctx, "url:"+key, url, 24*time.Hour)
}
_, err = pipe.Exec(ctx)
return err
}

7. Итоговая стратегия

Кэширование: cache-aside (lazy loading)
Политика вытеснения: allkeys-lru
TTL: адаптивный (1ч для холодных, 24ч для горячих, 7д для очень горячих)
Защита от penetration: кэширование пустых значений
Прогрев: при старте — топ-10000 горячих ссылок
Размер кэша: 2 ГБ (~8 млн записей)
Ожидаемый hit rate: 95-98%

Ключевая идея: не нужно заранее определять, какие ссылки горячие. LRU-политика в Redis делает это автоматически — часто запрашиваемые ссылки естественным образом остаются в кэше, а редко запрашиваемые вытесняются. Это проще, надёжнее и эффективнее, чем ручной расчёт топа.

Вопрос 16. Как реализовать функциональность защиты ссылки паролем (password-protected URLs)?

Таймкод: 01:14:20

Ответ собеседника: Правильный. Пароль задаётся при создании, шифруется и хранится в БД; при запросе — страница ввода пароля, backend проверяет и выполняет редирект; упомянут rate limiting от перебора.

Правильный ответ:

Защита ссылки паролем — распространённая фича, требующая внимательного подхода к безопасности. Давайте разберём полную реализацию.

1. Модель данных

ALTER TABLE urls ADD COLUMN password_hash VARCHAR(255);
ALTER TABLE urls ADD COLUMN is_password_protected BOOLEAN DEFAULT FALSE;

-- Таблица для отслеживания неудачных попыток
CREATE TABLE password_attempts (
id BIGSERIAL PRIMARY KEY,
short_key VARCHAR(10) NOT NULL,
ip INET NOT NULL,
attempted_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_password_attempts
ON password_attempts(short_key, ip, attempted_at);

2. Создание защищённой ссылки

type ShortenRequest struct {
OriginalURL string `json:"original_url"`
Password string `json:"password,omitempty"` // опциональный пароль
}

type ShortenResponse struct {
ShortURL string `json:"short_url"`
IsProtected bool `json:"is_protected"`
}

func (s *WriteService) Shorten(ctx context.Context, req ShortenRequest) (*ShortenResponse, error) {
if !isValidURL(req.OriginalURL) {
return nil, ErrInvalidURL
}

shortKey := s.keyGen.Next()
isProtected := req.Password != ""

var passwordHash string
if isProtected {
// Валидация пароля
if len(req.Password) < 4 || len(req.Password) > 72 {
return nil, ErrInvalidPassword
}
// Хеширование пароля с помощью bcrypt
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
passwordHash = string(hash)
}

_, err := s.db.ExecContext(ctx,
`INSERT INTO urls (short_key, original_url, user_id, password_hash, is_password_protected)
VALUES ($1, $2, $3, $4, $5)`,
shortKey, req.OriginalURL, req.UserID, passwordHash, isProtected,
)
if err != nil {
return nil, err
}

return &ShortenResponse{
ShortURL: s.baseURL + "/" + shortKey,
IsProtected: isProtected,
}, nil
}

3. Проверка пароля при редиректе

type PasswordVerifyRequest struct {
ShortKey string `json:"short_key"`
Password string `json:"password"`
}

func (s *ReadService) Resolve(ctx context.Context, shortKey string, password string) (string, error) {
cacheKey := "url:" + shortKey

// Получаем информацию о ссылке
urlInfo, err := s.getURLInfo(ctx, shortKey)
if err != nil {
return "", ErrNotFound
}

// Если ссылка защищена паролем
if urlInfo.IsPasswordProtected {
// Проверяем rate limit
if s.isRateLimited(ctx, shortKey, getClientIP(ctx)) {
return "", ErrTooManyAttempts
}

// Проверяем пароль
if password == "" {
return "", ErrPasswordRequired
}

if err := bcrypt.CompareHashAndPassword(
[]byte(urlInfo.PasswordHash),
[]byte(password),
); err != nil {
// Записываем неудачную попытку
s.recordFailedAttempt(ctx, shortKey, getClientIP(ctx))
return "", ErrInvalidPassword
}
}

// Пароль верен или не требуется — возвращаем URL
return urlInfo.OriginalURL, nil
}

func (s *ReadService) getURLInfo(ctx context.Context, shortKey string) (*URLInfo, error) {
// Пробуем из кэша
cacheKey := "urlinfo:" + shortKey
val, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
var info URLInfo
json.Unmarshal([]byte(val), &info)
return &info, nil
}

// Из БД
var info URLInfo
err = s.db.QueryRowContext(ctx,
`SELECT short_key, original_url, password_hash, is_password_protected
FROM urls WHERE short_key = $1 AND is_active = true AND deleted_at IS NULL`,
shortKey,
).Scan(&info.ShortKey, &info.OriginalURL, &info.PasswordHash, &info.IsPasswordProtected)

if err != nil {
return nil, err
}

// Кэшируем
data, _ := json.Marshal(info)
s.cache.Set(ctx, cacheKey, data, 1*time.Hour)

return &info, nil
}

4. Rate Limiting от перебора

func (s *ReadService) isRateLimited(ctx context.Context, shortKey, ip string) bool {
// Проверяем количество неудачных попыток за последние 15 минут
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM password_attempts
WHERE short_key = $1 AND ip = $2 AND attempted_at > NOW() - INTERVAL '15 minutes'`,
shortKey, ip,
).Scan(&count)

if err != nil {
return false
}

return count >= 10 // максимум 10 попыток за 15 минут
}

func (s *ReadService) recordFailedAttempt(ctx context.Context, shortKey, ip string) {
s.db.ExecContext(ctx,
`INSERT INTO password_attempts (short_key, ip) VALUES ($1, $2)`,
shortKey, ip,
)
}

// Альтернатива: rate limiter через Redis (быстрее)
func (s *ReadService) isRateLimitedRedis(ctx context.Context, shortKey, ip string) bool {
key := "pw_attempts:" + shortKey + ":" + ip
count, _ := s.cache.Incr(ctx, key).Result()
if count == 1 {
s.cache.Expire(ctx, key, 15*time.Minute)
}
return count > 10
}

5. HTTP Flow

func (s *ReadService) redirectHandler(w http.ResponseWriter, r *http.Request) {
shortKey := chi.URLParam(r, "shortKey")

urlInfo, err := s.getURLInfo(r.Context(), shortKey)
if err != nil {
http.NotFound(w, r)
return
}

// Если ссылка защищена паролем
if urlInfo.IsPasswordProtected {
// GET-запрос без пароля — показываем форму
if r.Method == http.MethodGet {
s.renderPasswordForm(w, r, shortKey)
return
}

// POST-запрос с паролем — проверяем
if r.Method == http.MethodPost {
password := r.FormValue("password")

if s.isRateLimitedRedis(r.Context(), shortKey, getClientIP(r)) {
http.Error(w, "Too many attempts. Try again later.", http.StatusTooManyRequests)
return
}

if err := bcrypt.CompareHashAndPassword(
[]byte(urlInfo.PasswordHash),
[]byte(password),
); err != nil {
s.recordFailedAttempt(r.Context(), shortKey, getClientIP(r))
s.renderPasswordFormWithError(w, r, shortKey, "Invalid password")
return
}

// Пароль верен — редирект
http.Redirect(w, r, urlInfo.OriginalURL, http.StatusFound)
return
}
}

// Ссылка без пароля — обычный редирект
http.Redirect(w, r, urlInfo.OriginalURL, http.StatusFound)
}

6. Соображения безопасности

  • bcrypt для хеширования паролей — устойчив к brute-force благодаря адаптивной стоимости.
  • Rate limiting по IP и по ключу — защита от перебора.
  • HTTPS обязателен — пароль передаётся в открытом виде без шифрования.
  • Не логировать пароли — в логах не должно быть паролей в открытом виде.
  • Ограничение длины пароля — bcrypt ограничен 72 байтами.
// Очистка старых записей о попытках (cron job)
func (s *Service) cleanupOldAttempts(ctx context.Context) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM password_attempts WHERE attempted_at < NOW() - INTERVAL '1 hour'`,
)
return err
}

7. Итог

Защита паролем реализуется через:

  • Хеширование пароля с bcrypt при создании.
  • Проверку пароля при редиректе с показом формы.
  • Rate limiting для защиты от перебора.
  • Кэширование информации о ссылке (включая флаг is_protected) для быстрой проверки.

Это стандартный подход, используемый в большинстве сервисов сокращения ссылок.

Вопрос 17. В каких случаях лучше использовать RabbitMQ, а в каких — Kafka? Можно ли использовать RabbitMQ вместо Kafka в данной архитектуре?

Таймкод: 01:26:19

Ответ собеседника: Неполный. Кандидат не имеет опыта работы с RabbitMQ и не смог ответить на вопрос.

Правильный ответ:

RabbitMQ и Kafka — это принципиально разные инструменты, решающие разные задачи. Понимание их различий критично для правильного выбора архитектуры.

1. Фундаментальные различия

ХарактеристикаRabbitMQKafka
ТипБрокер сообщений (Message Broker)Распределённый лог событий (Event Streaming Platform)
МодельPush (consumer получает сообщения)Pull (consumer запрашивает сообщения)
Хранение сообщенийДо доставки и подтвержденияНастраиваемый retention (дни/недели/месяцы)
Порядок сообщенийВ пределах одной очередиВ пределах партиции
Throughput~10-50K msg/s~1M+ msg/s
LatencyМиллисекундыМиллисекунды (при правильной настройке)
Replay сообщенийНет (сообщение удаляется после доставки)Да (можно перечитать лог с любого offset)
Модель потребленияКонкурентные consumer'ыConsumer groups

2. Когда использовать RabbitMQ

А. Простые очереди задач (Task Queues)

// Отправка задачи в очередь
func (s *Service) SendEmailTask(ctx context.Context, email EmailTask) error {
body, _ := json.Marshal(email)
return s.amqpChannel.Publish(
"", // exchange
"email_queue", // routing key
false, false,
amqp.Publishing{
ContentType: "application/json",
Body: body,
},
)
}

// Worker обрабатывает задачи
func (w *EmailWorker) Run() {
msgs, _ := w.channel.Consume("email_queue", "", true, false, false, false, nil)
for msg := range msgs {
var email EmailTask
json.Unmarshal(msg.Body, &email)
w.sendEmail(email)
}
}

Б. RPC-паттерн (запрос-ответ)

// Синхронный вызов удалённого сервиса
func (s *Service) CallRemoteService(ctx context.Context, req Request) (*Response, error) {
correlationID := uuid.New().String()
replyQueue := s.declareReplyQueue()

s.channel.Publish("", "rpc_queue", false, false, amqp.Publishing{
CorrelationId: correlationID,
ReplyTo: replyQueue.Name,
Body: mustJSON(req),
})

// Ждём ответа
msgs, _ := s.channel.Consume(replyQueue.Name, "", true, false, false, false, nil)
for msg := range msgs {
if msg.CorrelationId == correlationID {
var resp Response
json.Unmarshal(msg.Body, &resp)
return &resp, nil
}
}
return nil, ErrTimeout
}

В. Маршрутизация сообщений (Routing, Topics)

// Гибкая маршрутизация через exchanges
// Direct exchange: точное совпадение routing key
// Topic exchange: паттерны (e.g., "order.*", "*.created")
// Fanout exchange: broadcast всем привязанным очередям

3. Когда использовать Kafka

А. Event Sourcing / Event Streaming

// Kafka хранит полную историю событий
// Можно перечитать события с любого момента

// Producer
func (p *AnalyticsProducer) TrackEvent(ctx context.Context, event ClickEvent) error {
data, _ := json.Marshal(event)
return p.writer.WriteMessages(ctx, kafka.Message{
Topic: "clicks",
Key: []byte(event.ShortKey), // партиционирование
Value: data,
Time: event.Timestamp,
})
}

// Consumer с сохранением offset
func (c *AnalyticsConsumer) Run(ctx context.Context) {
for {
msg, err := c.reader.ReadMessage(ctx)
if err != nil {
continue
}

var event ClickEvent
json.Unmarshal(msg.Value, &event)
c.process(event)
// offset сохраняется автоматически или вручную
}
}

Б. Множество consumer group'

// Одно событие → несколько независимых обработчиков
// clicks topic → Analytics Consumer Group (запись в ClickHouse)
// clicks topic → Fraud Detection Consumer Group (проверка на ботов)
// clicks topic → Real-time Stats Consumer Group (обновление счётчиков)

reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: brokers,
Topic: "clicks",
GroupID: "analytics-consumer-group", // каждая группа читает независимо
})

В. Replay событий

// При добавлении нового сервиса — можно перечитать все исторические события
reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: brokers,
Topic: "clicks",
Partition: 0,
MinBytes: 1e3,
MaxBytes: 10e6,
})

// Читаем с самого начала
reader.SetOffset(kafka.FirstOffset)

4. Сравнение для нашего сценария

ТребованиеRabbitMQKafka
Аналитика (один consumer)✅ Подходит✅ Подходит
Множество consumer group'⚠️ Сложнее✅ Нативно
Replay событий❌ Нет✅ Да
Простота настройки✅ Проще⚠️ Сложнее
Высокий throughput⚠️ Средний✅ Высокий
Хранение событий⚠️ Короткое✅ Длительное

5. Можно ли использовать RabbitMQ вместо Kafka?

Да, для нашего сценария RabbitMQ достаточен, если:

  • Нужен только один consumer group (аналитика).
  • Не требуется replay собыёв.
  • Нагрузка умеренная (~2000 событий/сек).
// Реализация через RabbitMQ
type RabbitMQProducer struct {
channel *amqp.Channel
}

func (p *RabbitMQProducer) TrackClick(ctx context.Context, event ClickEvent) error {
body, _ := json.Marshal(event)
return p.channel.Publish(
"analytics", // exchange
"clicks", // routing key
false, false,
amqp.Publishing{
ContentType: "application/json",
Body: body,
DeliveryMode: amqp.Persistent, // переживёт рестарт брокера
},
)
}

type RabbitMQConsumer struct {
channel *amqp.Channel
repo *ClickHouseRepository
}

func (c *RabbitMQConsumer) Run(ctx context.Context) {
msgs, _ := c.channel.Consume(
"analytics_clicks_queue", // queue
"", // consumer tag
false, // auto-ack (false = manual ack)
false, false, false, nil,
)

batch := make([]ClickEvent, 0, 1000)
ticker := time.NewTicker(5 * time.Second)

for {
select {
case msg := <-msgs:
var event ClickEvent
if json.Unmarshal(msg.Body, &event) == nil {
batch = append(batch, event)
}
msg.Ack(false)

if len(batch) >= 1000 {
c.repo.BatchInsert(ctx, batch)
batch = batch[:0]
}

case <-ticker.C:
if len(batch) > 0 {
c.repo.BatchInsert(ctx, batch)
batch = batch[:0]
}
}
}
}

6. Когда переключаться с RabbitMQ на Kafka?

  • При добавлении новых consumer group' (fraud detection, ML pipeline, real-time dashboards).
  • При необходимости хранить события для аудита или replay.
  • При росте throughput выше 50K msg/s.
  • При необходимости интеграции с множеством downstream-систем.

7. Итог

Для текущего масштаба сервиса сокращения ссылок RabbitMQ — оптимальный выбор: проще в настройке, проще в эксплуатации, достаточный по производительности. Kafka стоит рассматривать при росте числа интеграций и необходимости event replay.

Вопрос 18. Почему была выбрана MongoDB, а не реляционная СУБД? Рассматривались ли column-family базы данных, такие как Cassandra? Подходит ли PostgreSQL для данной задачи?

Таймкод: 01:28:51

Ответ собеседника: Неполный. Кандидат изначально предлагал реляционную СУБД, затем согласился с MongoDB. Не смог обосновать выбор между различными типами СУБД.

Правильный ответ:

Это один из важнейших вопросов проектирования. Выбор СУБД определяет масштабируемость, надёжность и стоимость владения системой. Давайте разберём все варианты.

1. Анализ структуры данных

Данные сервиса сокращения ссылок просты и хорошо структурированы:

User: id, email, name, created_at, plan
URL: id, short_key, original_url, user_id, is_custom, is_active,
click_count, created_at, last_accessed_at, expires_at, deleted_at
Click: id, short_key, ip, user_agent, referer, timestamp

Это классические реляционные данные с чёткими связями (URL принадлежит User, Click ссылается на URL).

2. PostgreSQL — оптимальный выбор для основного хранилища

Почему PostgreSQL подходит:

  • ACID-транзакции — критичны для обеспечения уникальности коротких ключей.
  • Структура данных реляционна — две основные сущности с чёткими связями.
  • Зрелость — документация, экосистема, инструменты.
  • Расширяемость — JSONB для полей, которые могут меняться.
-- Основная таблица
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_key VARCHAR(10) NOT NULL,
original_url TEXT NOT NULL,
user_id BIGINT REFERENCES users(id),
is_custom BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
is_password_protected BOOLEAN DEFAULT FALSE,
password_hash VARCHAR(255),
click_count BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_accessed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,

-- Метаданные в JSONB для гибкости
metadata JSONB DEFAULT '{}'
);

CREATE UNIQUE INDEX idx_urls_short_key ON urls(short_key) WHERE deleted_at IS NULL;
CREATE INDEX idx_urls_user_id ON urls(user_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_urls_expires ON urls(expires_at) WHERE expires_at IS NOT NULL AND deleted_at IS NULL;

-- Партицирование по времени для аналитики
CREATE TABLE clicks (
id BIGSERIAL,
short_key VARCHAR(10) NOT NULL,
ip INET,
user_agent TEXT,
referer TEXT,
timestamp TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY RANGE (timestamp);

CREATE TABLE clicks_2024_01 PARTITION OF clicks
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

Производительность PostgreSQL при наших объёмах:

500K новых ссылок/мес → ~0.2 RPS записи
500M переходов/мес → ~193 RPS чтения

PostgreSQL способен обработать:
- Чтение: 10,000+ RPS (с индексами и достаточным RAM)
- Запись: 1,000+ RPS

Вывод: запас ×50 для чтения и ×5000 для записи.

3. MongoDB — возможный выбор, но с оговорками

Когда MongoDB оправдан:

  • Гибкая схема (частые изменения структуры данных).
  • Документо-ориентированная модель (вложенные объекты).
  • Горизонтальное масштабирование «из коробки».

Проблемы MongoDB для нашего сценария:

  • Нет ACID-транзакций (до версии 4.0, и они ограничены) — сложнее обеспечить уникальность ключей.
  • Нет JOIN — придётся делать несколько запросов или денормализовать.
  • Избыточность — для простой структуры данных документная модель не даёт преимуществ.
// MongoDB документ
{
"_id": ObjectId("..."),
"short_key": "abc123",
"original_url": "https://example.com/very/long/path",
"user_id": ObjectId("..."),
"is_custom": false,
"is_active": true,
"click_count": 1500,
"created_at": ISODate("2024-01-15T10:30:00Z"),
"last_accessed_at": ISODate("2024-01-20T15:45:00Z")
}

4. Cassandra — column-family СУБД

Когда Cassandra оправдана:

  • Очень высокая нагрузка на запись (миллионы записей в секунду).
  • Мультирегиональность (active-active между дата-центрами).
  • Линейное горизонтальное масштабирование.
  • Данные временных рядов (метрики, логи).

Проблемы Cassandra для нашего сценария:

  • Избыточность — при 500K записей/мес Cassandra не раскрывает свой потенциал.
  • Сложность моделирования — данные моделируются под запросы, а не под сущности.
  • Нет ad-hoc запросов — каждый запрос должен быть известен заранее и поддерживаться соответствующей таблицей.
  • Операционная сложность — требует больше ресурсов для эксплуатации.
-- Cassandra: таблица под конкретный запрос
CREATE TABLE urls_by_short_key (
short_key text PRIMARY KEY,
original_url text,
user_id uuid,
is_custom boolean,
click_count bigint,
created_at timestamp
);

-- Для запроса "все ссылки пользователя" нужна отдельная таблица
CREATE TABLE urls_by_user (
user_id uuid,
created_at timestamp,
short_key text,
original_url text,
PRIMARY KEY (user_id, created_at)
) WITH CLUSTERING ORDER BY (created_at DESC);

5. Сравнительная таблица

КритерийPostgreSQLMongoDBCassandra
Структура данныхРеляционнаяДокументнаяColumn-family
ACIDПолныйОграниченныйEventual consistency
Масштабирование чтенияРепликиReplica setsЛинейное
Масштабирование записиШардированиеШардированиеЛинейное
Сложность эксплуатацииНизкаяСредняяВысокая
Подходит для нашего сценарияДаВозможноИзбыточно

6. Рекомендация для нашего сценария

PostgreSQL + Redis — оптимальная комбинация:

  • PostgreSQL справляется с нагрузкой с огромным запасом.
  • Простота разработки и эксплуатации.
  • Полная ACID-совместимость.
  • Зрелая экосистема.

Когда пересматривать выбор:

МасштабРекомендация
< 100M ссылок, < 10K RPSPostgreSQL + Redis
100M-1B ссылок, 10K-50K RPSPostgreSQL с партицированием + Redis Cluster
> 1B ссылок, > 50K RPSPostgreSQL с шардированием (Citus) или Cassandra

7. Рекомендация по литературе

Как верно отметил интервьюер, книга Мартина Клепмана «Designing Data-Intensive Applications» — обязательное чтение для понимания фундаментальных принципов выбора СУБД. Книга покрывает:

  • Модели данных и языки запросов.
  • Механизмы хранения и индексации.
  • Репликация и партицирование.
  • Транзакции и согласованность.
  • Пакетная и потоковая обработка.

Для текущего сценария PostgreSQL — правильный выбор. MongoDB могла бы работать, но не даёт преимуществ при реляционной структуре данных. Cassandra избыточна при данных объёмах.