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

Mock-собеседование по System Design от Team Lead из Ozon

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

Сегодня мы разберём процесс прохождения mock-собеседования по системному дизайну, в ходе которого кандидат с опытом работы в крупных технологических компаниях проектирует сервис сокращения ссылок. Интервьюер последовательно оценивает подход кандидата к сбору требований, оценке объёмов данных и нагрузки, а также к проектированию высокоуровневой архитектуры, акцентируя внимание на важности чёткого формулирования API и предотвращения повторений. В результате кандидат демонстрирует уверенное владение теорией и способность проектировать масштабируемые системы, но получает ценный фидбек по улучшению навыков коммуникации и управления временем в условиях реального интервью.

Вопрос 1. Расскажите о себе, вашем опыте и цели участия в этом интервью.

Таймкод: 00:00:05

Ответ собеседника: Правильный. Около 10 лет опыта в разработке, 5 лет работал во ВКонтакте, последние 2 года работает в бигтех-компании в Токио. Большая часть опыта связана с инфраструктурой: облачные платформы, надёжность, отказоустойчивость, масштабирование, виртуальные машины, стек PaaS, контейнеризация. Цель интервью — получить опыт прохождения System Design собеседования в формате живого общения, так как ранее был только один подобный опыт два года назад.

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

Ответ собеседника является хорошим вступлением для System Design интервью. Он лаконично описывает релевантный опыт, который напрямую связан с темой собеседования (инфраструктура, надёжность, масштабирование), и честно обозначает цель. Это помогает интервьюеру понять контекст и уровень кандидата.

Для подобного вопроса на реальном интервью рекомендуется структурировать ответ следующим образом:

1. Текущая роль и зона ответственности Кратко опишите, чем вы занимаетесь сейчас, какие системы поддерживаете и какие задачи решаете. Например: «В текущей роли я отвечаю за платформу виртуализации, которая обслуживает X миллионов запросов в сутки и управляет Y тысячами виртуальных машин».

2. Ключевые проекты и достижения Упомяните 2–3 наиболее значимых проекта, которые демонстрируют вашу экспертизу в проектировании систем. Важно не просто перечислить, а показать масштаб и вашу роль: «Спроектировал и внедрил систему автоматического восстановления после сбоев, что сократило MTTR с 30 минут до 2 минут».

3. Технический стек и область экспертизы Обозначьте технологии и концепции, в которых вы сильны. Это помогает интервьюеру выбрать направление для System Design вопроса.

4. Мотивация Кратко объясните, почему вы рассматриваете эту возможность и что хотите получить от перехода.

Такой формат позволяет за 1–2 минуты дать интервьюеру достаточно информации для выбора подходящего вопроса и одновременно продемонстрировать структурованное мышление — качество, критически важное для System Design.

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

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

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

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

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

Основные различия между форматами:

Реальный формат максимально приближен к настоящему собеседованию. Интервьюер не даёт подсказок, не направляет и не останавливает кандидата. Обратная связь предоставляется только в конце. Этот формат полезен для оценки текущего уровня и выявления слабых мест в условиях, приближённых к боевым.

Обучающий формат предполагает диалог. Интервьюер может задавать наводящие вопросы, указывать на упущенные аспекты, предлагать рассмотреть альтернативные подходы прямо в процессе обсуждения. Этот формат лучше подходит для развития навыков проектирования и расширения кругозора.

Рекомендация для подготовки: Если опыта System Design интервью недостаточно, начинать с реального формата — хорошая стратегия. Он даёт честную картину текущего уровня. Если кандидат зашёл в тупик или упустил критически важный аспект, переключение в обучающий формат позволяет извлечь максимум пользы из сессии, не теряя времени на бесплодные попытки.

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

Вопрос 3. Какие базовые и расширенные функциональные требования должен выполнять сервис сокращения ссылок?

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

Ответ собеседника: Правильный. Сервис должен принимать длинную ссылку и возвращать короткую (без редиректа, как API-сервис для других систем). Ссылки должны иметь срок действия, истекающий по таймауту. Пользователь должен иметь возможность задать уникаческое кастомное имя для ссылки. Необходим rate limiting для защиты от DDoS и злоупотреблений. Дополнительно нужно записывать аналитические данные — переходы по ссылкам с разбивкой по дням и часам.

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

Ответ собеседника покрывает основные аспекты хорошо. Для полноты картины можно расширить список требований и структурировать их по категориям.

Базовые функциональные требования (Core):

Генерация короткого URL — принимает длинный URL, возвращает уникальный короткий идентификатор. Редирект — по запросу короткого URL возвращает HTTP 301/302 с оригинальным адресом. Истечение срока действия (TTL) — ссылка автоматически деактивируется после заданного периода. Кастомные алиасы — пользователь может указать собственное короткое имя вместо сгенерированного.

Расширенные функциональные требования (Extended):

Аналитика переходов — подсчёт кликов, рефереров, User-Agent, геолокация, временные метки. Это требование собеседник упомянул верно. Rate limiting — ограничение количества запросов на создание и редирект для защиты от злоупотреблений. Управление ссылками — возможность деактивации, удаления, изменения TTL автором ссылки. QR-коды — генерация QR-кодов для коротких ссылок.

Нефункциональные требования (критически важно на интервью):

Производительность — редирект должен работать с минимальной задержкой (целевое p99 < 10 мс), так как каждая миллисекунда влияет на конверсию. Масштабируемость — система должна выдерживать миллионы редиректов в секунду и тысячи созданий ссылок в секунду. Доступность — целевой SLA 99.99%, так как недоступность сервиса означает неработающие ссылки для всех пользователей. Надёжность — данные не должны теряться; каждая созданная ссылка должна гарантированно разрешаться при условии, что не истекла.

Важный нюанс при проектировании:

Стоит уточнить у интервьюера, является ли сервис публичным (как bit.ly) или внутренним. Это влияет на требования к аутентификации, квотированию и модели угроз. Также важно уточнить ожидаемый объём данных и трафика — это определит выбор архитектурных решений.

Вопрос 4. Будет ли в системе аутентификация и нужно ли её проектировать?

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

Ответ собеседника: Правильный. Аутентификация будет присутствовать в системе — она будет отображена на схеме, но проектировать её не нужно, считаем что она уже существует.

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

Ответ корректный и демонстрирует важный навык — умение расставлять приоритеты и фокусироваться на основном объекте проектирования.

Почему это правильный подход:

На System Design интервью невозможно детально спроектировать каждую подсистему. Аутентификация — это хорошо изученная область с устоявшимися паттернами (OAuth 2.0, JWT, session-based auth), и её детальное проектирование не добавляет ценности к обсуждению сервиса сокращения ссылок. Однако полностью игнорировать её тоже нельзя.

Как правильно работать с аутентификацией на интервью:

Обозначить на архитектурной схеме — показать, что аутентификационный шлюз (API Gateway / Auth Service) существует на входе в систему. Это демонстрирует понимание реальной архитектуры.

Определить контракт — уточнить, в каком формате информация о пользователе передаётся дальше по конвейеру. Например, после аутентификации API Gateway добавляет заголовок X-User-ID с идентификатором пользователя, и все внутренние сервисы доверяют этому заголовку.

Учесть в требованиях — аутентификация влияет на rate limiting (лимиты на пользователя), аналитику (привязка ссылок к авторам), управление ссылками (только автор может удалить свою ссылку).

Пример формулировки для интервью:

«Аутентификацию считаем существующей. API Gateway проверяет JWT-токен и пробрасывает user ID в заголовках. Нам важно, что сервис создания ссылок получает идентификатор пользователя для привязки ссылок и применения квот».

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

Вопрос 5. Каковы оценки нагрузки на систему: количество создаваемых ссылок, соотношение запись/чтение, срок хранения и требования к доступности и задержке?

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

Ответ собеседника: Правильный. 100 млн ссылок в месяц с соотношением запись/чтение 1:100. Дефолтный срок хранения — 1 год (в процессе обсуждения увеличен до 10 лет). Требования к доступности — минимальное время простоя, задержка чтения — порядка 1 миллисекунды. Хранилище пользователей считается отдельно, проектируется только хранилище ссылок.

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

Ответ хороший, собеседник корректно принял параметры и зафиксировал расширение требований. Разберём эти числа подробнее, поскольку они определяют все последующие архитектурные решения.

Анализ нагрузки:

Запись (создание ссылок): 100 млн/месяц ≈ 38 ссылок/сек (38 RPS). Это относительно небольшая нагрузка, которую выдержит даже одна реплика базы данных.

Чтение (редиректы): при соотношении 1:100 получаем 3 800 RPS. Это уже требует внимательного проектирования — нужен кэш, чтобы не грузить базу на каждый редирект.

Хранение данных при 10 годах: 100 млн × 12 × 10 = 12 млрд записей. Примерный размер одной записи (short_code, original_url, user_id, created_at, expires_at) — около 300–500 байт. Итого: 12 млрд × 500 байт ≈ 6 ТБ. Это объём, который требует шардирования базы данных.

Задержка 1 мс — амбициозная, но достижимая цель. Это означает, что основной путь редиректа должен проходить через кэш (Redis/Memcached), а не через базу данных. Чтение из Redis обычно занимает 0.1–0.5 мс в пределах одного дата-центра.

Ключевые выводы из этих требований:

Система является read-heavy — на одну запись приходится 100 чтений. Это означает, что оптимизация чтения является приоритетом. Кэширование — не опция, а необходимость. Долгий срок хранения (10 лет) означает, что нельзя полагаться на удаление просроченных записей как на основной механизм управления объёмом. Нужна стратегия архивации или tiered storage.

Практическая формулировка для интервью:

«При 100 млн ссылок в месяц и соотношении 1:100 мы имеем ~40 RPS на запись и ~4000 RPS на чтение. При хранении 10 лет накопится ~12 млрд записей объёмом ~6 ТБ. Задержка в 1 мс на редирект требует, чтобы горячий кэш обслуживал подавляющее большинство запросов чтения».

Вопрос 6. Какие аналитические данные нужно собирать и как долго их хранить?

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

Ответ собеседника: Правильный. Базовая аналитика: для каждой короткой ссылки фиксируется количество переходов с разбивкой по дням и часам. Это второстепенная функция, расширение основного сервиса.

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

Ответ корректный для базового уровня. На интервью стоит показать более глубокое понимание аналитической составляющей, поскольку она существенно влияет на архитектуру.

Какие данные собирать:

Счётчик кликов — агрегированные данные с разбивкой по временным интервалам (час/день/неделя/месяц). Это то, что упомянул собеседник.

Метаданные перехода — для более детальной аналитики: User-Agent (тип устройства, браузер), реферер (откуда пришёл пользователь), IP-адрес (для геолокации), временная метка с точностью до секунды.

Уникальные посетители — приблизительная оценка через хеширование IP + User-Agent или использование куки. Это позволяет отличать уникальных пользователей от повторных кликов.

Как хранить:

Агрегированная статистика (счётчики по часам/дням) может храниться в реляционной базе или колоночном хранилище. Срок хранения — 1–2 года для детальных данных, обобщённые данные (помесячные) — бессрочно.

Сырые логи переходов — это отдельный массив данных, который при 4000 RPS генерирует ~350 млн записей в сутки. Хранить сырые логи в основной базе нецелесообразно. Правильный подход — отправлять события в потоковую систему (Kafka) с последующей обработкой и сохранением в колоночное хранилище (ClickHouse, Apache Druid) или объектное хранилище (S3) с возможностью аналитических запросов.

Влияние на архитектуру:

Аналитика должна собираться асинхронно, чтобы не увеличивать латентность редиректа. После выполнения редиректа сервис отправляет событие в очередь (Kafka/Redis Streams), а отдельный consumer агрегирует данные. Это принципиально важно для выполнения требования в 1 мс на редирект.

Формулировка для интервью:

«Аналитику собираем асинхронно через очередь сообщений, чтобы не влиять на латентность редиректа. Агрегированные счётчики храним в основной базе для быстрого доступа, сырые события — в аналитическом хранилище с возможностью ad-hoc запросов».

Вопрос 7. Какова схема данных для хранения одной записи ссылки и сколько памяти она занимает?

Таймкод: 00:13:04

Ответ собеседника: Правильный. Одна запись включает длинную ссылку (до 256 байт), короткую ссылку (8 символов из букв и цифр), ID пользователя (~10 байт), дату создания (~6 байт) и срок действия. Итого примерно 300 байт на одну запись. При хранении 10 лет (100 млн × 12 × 10 = ~12 млрд записей) общий объём составляет примерно 36 ТБ.

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

Ответ в целом верный, но содержит неточность в итоговом объёме. Разберём подробно.

Схема данных одной записи:

ПолеТипРазмер
id (PK)BIGINT8 байт
short_codeVARCHAR(8)8 байт (62^8 комбинаций)
original_urlVARCHAR(2048)~256 байт средняя длина
user_idBIGINT8 байт
created_atTIMESTAMP8 байт
expires_atTIMESTAMP8 байт
is_activeBOOLEAN1 байт
Итого на запись~300 байт

Собеседник верно оценил размер одной записи в ~300 байт. Однако расчёт общего объёма содержит ошибку: 12 млрд × 300 байт = 3.6 ТБ, а не 36 ТБ. Вероятно, это была оговорка в устной речи.

Расчёты для интервью:

Объём данных: 12 млрд записей × 300 байт ≈ 3.6 ТБ (без индексов). С индексами (особенно индекс по short_code) объём вырастет до ~5–6 ТБ.

Количество комбинаций короткого кода: При 8 символах из алфавита [a-zA-Z-0-9] (62 символа) получаем 62^8 ≈ 218 триллионов комбинаций. Это более чем достаточно для 12 млрд записей, коллизии крайне маловероятны.

Практическая значимость этих цифр:

3.6 ТБ — это объём, который не помещается на одну ноду базы данных с комфортным запасом. Необходимо шардирование. Обычно шардируют по short_code с помощью консистентного хеширования или по диапазонам. При 4000 RPS на чтение и горячем кэше с hit rate 95%+ нагрузка на базу составит ~200 RPS, что вполне управляемо для шардированного кластера.

SQL-схема:

CREATE TABLE links (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(8) NOT NULL,
original_url VARCHAR(2048) NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,

UNIQUE INDEX idx_short_code (short_code),
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at)
);

Индекс на short_code — критически важный, так как по нему идёт каждое чтение (редирект). Индекс на expires_at нужен для фоновой задачи очистки просроченных ссылок.

Вопрос 8. Какой длины должна быть короткая ссылка и как обосновать этот выбор математически?

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

Ответ собеседника: Неполный. Предложено использовать 8 символов из английских букв (больших и маленьких) и цифр. Кандидат помнил, что 8 символов дают триллионы комбинаций, но не смог точно вспомнить формулу расчёта. Не был выполнен точный подсчёт: при 62 возможных символах (a-z, A-Z, 0-9) в длине 8 это 62^8 ≈ 218 триллионов комбинаций, что с запасом покрывает 12 млрд ссылок за 10 лет. Кандидат осознал, что нужно было посчитать математически, а не гадать.

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

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

Математическое обоснование длины короткого кода:

Алфавит: [a-z] + [A-Z] + [0-9] = 26 + 26 + 10 = 62 символа

Количество комбинаций для длины N: 62^N

ДлинаКомбинацииДостаточно?
562^5 ≈ 916 млнНет (меньше 100 млн/мес)
662^6 ≈ 56.8 млрдГраничный случай
762^7 ≈ 3.5 трлнДа
862^8 ≈ 218 трлнДа, с огромным запасом

Выбор длины 6 vs 8:

При длине 6 символов получаем ~57 млрд комбинаций. При 12 млрд записей за 10 лет коэффициент заполнения составит ~21%. Это критический порог: при использовании случайной генерации вероятность коллизий начинает расти значительно (см. парадокс дней рождения). Генератору придётся делать множественные попытки при создании новых ссылок, что увеличивает латентность записи.

При длине 8 символов и 12 млрд записей коэффициент заполнения составляет ~0.006%. Коллизии практически исключены, генератор почти всегда будет успешен с первой попытки.

Дополнительные факторы при выборе длины:

Человекочитаемость — 6 символов легче запомнить и продиктовать по телефону, чем 8. Однако для API-сервиса (как указано в требованиях) это менее критично.

Длина URL — при базовом домене вида sho.rt/XXXXXX 6 символов дают короткий URL, что ценно для публикации в соцсетях с ограничением символов.

Рекомендация: для публичного сервиса с ручным вводом — 6 символов с проверкой коллизий. Для API-сервиса с автоматическим созданием — 8 символов для гарантированной надёжности.

Парадокс дней рождения для оценки коллизий:

Вероятность хотя бы одной коллизии при N записях из S возможных значений:

P(коллизия) ≈ 1 - e^(-N² / 2S)

Для длины 6: P ≈ 1 - e^(-(12×10⁹)² / (2 × 56.8×10⁹)) — крайне высокая вероятность.

Для длины 8: P ≈ 1 - e^(-(12×10⁹)² / (2 × 218×10¹²)) ≈ 1 - e^(-0.33) ≈ 0.28 — приемлемо, но лучше использовать 8 символов для большего запаса.

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

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

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

Ответ собеседника: Правильный. 100 млн записей в месяц — это примерно 40 запросов на запись в секунду. При соотношении 1:100 это даёт 4000 запросов на чтение в секунду. Трафик на запись: 40 × 300 байт ≈ 12 КБ/с. Трафик на чтение: 4000 × 300 байт ≈ 1.2 МБ/с — оба значения некритичны.

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

Расчёты собеседника верны. Разберём подробнее, почему эти цифры важны и на что стоит обратить внимание.

Расчёт нагрузки:

Запись: 100 000 000 / (30 × 24 × 3600) ≈ 38.6 RPS → округляем до ~40 RPS

Чтение: 40 × 100 = 4 000 RPS

Трафик на запись: 40 × 300 байт = 12 000 байт/с ≈ 12 КБ/с — действительно незначительный.

Трафик на чтение: 4 000 × 300 байт = 1 200 000 байт/с ≈ 1.2 МБ/с — также некритичный для современного сетевого оборудования.

Почему эти цифры важны для проектирования:

Запись 40 RPS — это настолько малая нагрузка, что одна реплика PostgreSQL или MySQL справится с ней без проблем. Даже без шардирования база данных будет загружена на доли процента. Это означает, что оптимизация записи не является приоритетом.

Чтение 4 000 RPS — умеренная нагрузка. Одна реплика PostgreSQL может выдержать до 10 000–50 000 простых запросов в секунду (точечные чтения по первичному ключу). Однако требование задержки в 1 мс делает кэширование обязательным, так как даже быстрая база данных обычно отвечает за 1–5 мс.

Важные нюансы, которые стоит учитывать:

Пиковая нагрузка — средние 4 000 RPS не означают, что нагрузка равномерна. Пиковая нагрузка может быть в 3–5 раза выше (12 000–20 000 RPS), особенно если ссылки распространяются через социальные сети или мессенджеры. Система должна быть спроектирована с запасом.

Рост нагрузки — если сервис успешен, нагрузка может расти на 50–100% в год. Архитектура должна допускать горизонтальное масштабирование без перепроектирования.

Вывод для архитектуры:

При таких объёмах нагрузки система не требует экзотических решений. Один инстанс базы данных с репликой для чтения, слой кэширования (Redis), и несколько инстансов приложения за балансировщиком — этого достаточно. Основной фокус проектирования должен быть на достижении целевой задержки и обеспечении высокой доступности, а не на обработке огромных объёмов трафика.

Вопрос 10. Как будет выглядеть High Level Design системы сокращения ссылок?

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

Ответ собеседника: Правильный. Клиенты (отдельно для создания и для чтения ссылок) → балансировщик / API Gateway (выполняет балансировку нагрузки и аутентификацию) → разделение на два отдельных сервиса: сервис POST-запросов (создание ссылок) и сервис GET-запросов (получение ссылок). Это разделение позволяет эластично масштабировать сервис чтения с большим количеством реплик, так как нагрузка на чтение в 100 раз превышает нагрузку на запись.

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

Ответ собеседника демонстрирует правильное поразделение ответственности и понимание паттерна CQRS. Разберём архитектуру более детально.

Полная High Level архитектура:

1. Клиенты Веб-приложения, мобильные приложения, сторонние системы через API. Клиенты взаимодействуют с системой через единый entry point.

2. API Gateway / Load Balancer Единая точка входа, которая выполняет: аутентификацию и авторизацию запросов, rate limiting на уровне пользователя и IP, маршрутизацию запросов к соответствующим сервисам, SSL-терминацию, логирование и мониторинг.

3. Link Creation Service (POST) Обрабатывает создание новых коротких ссылок. Основные обязанности: валидация входного URL, генерация уникального короткого кода, сохранение в базу данных, инвалидация (или предварительное заполнение) кэша. Масштабируется независимо, но требует меньше реплик из-за низкой нагрузки (40 RPS).

4. Link Resolution Service (GET) Обрабатывает редиректы по коротким ссылкам. Основные обязанности: проверка кэша (Redis), при промахе — обращение к базе данных, возврат HTTP 301/302 с оригинальным URL, асинхронная отправка события аналитики в очередь. Масштабируется горизонтально с большим количеством реплик.

5. Хранилища данных База данных (PostgreSQL/MySQL) — основное хранилище ссылок. Redis — кэш для горячих ссылок. Kafka/очередь — буфер для событий аналитики.

6. Аналитический конSUMER Отдельный сервис, который читает события из очереди и агрегирует статистику переходов.

Ключевое архитектурное решение — разделение на два сервиса:

Это применение паттерна CQRS (Command Query Responsibility Segregation). При соотношении запись/чтение 1:100 объединение обоих путей в одном сервисе привело к тому, что редкие, но сложные операции записи (генерация кода, проверка коллизий) конкурировали бы за ресурсы с высокочастотными операциями чтения. Разделение позволяет масштабировать каждый сервис независимо и оптимизировать его под свой паттерн нагрузки.

Почему это важно на интервью:

Разделение read и write путей — это один из первых вопросов, который ожидает услышать интервьюер. Кандидаты, которые этого не делают и рисуют один монолитный сервис, теряют баллы. Собеседник уверенно пришёл к этому решению, что является сильной стороной ответа.

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

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

Ответ собеседника: Правильный. Предложено использовать SQL-базу данных с шардированием и репликацией. 36 ТБ за 10 лет — объём, требующий шардировадания. Шардирование по дате не подходит, так как все обращения идут к последнему шарду. Шардирование по long link не подходит, так как при GET-запросе у нас нет long link — запрос идёт только по short link. Итого шардирование по short link (хеш от short link) — единственно логичный вариант. Проблема hot spot не актуальна, так как нагрузка распределяется по коротким ссылкам, а не по пользователям.

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

Ответ собеседника демонстрирует отличное понимание принципов шардирования. Разберём каждый аспект подробнее.

Выбор базы данных:

Реляционная СУБД (PostgreSQL или MySQL) — правильный выбор для основного хранилища. Причины: данные имеют чёткую структуру, требуется уникальность short_code (UNIQUE constraint), нужны транзакции при создании ссылки, зрелые инструменты репликации и шардирования.

Для кэш-слоя — Redis, что собеседник упомянет далее. Для аналитики — отдельное хранилище (ClickHouse или колоночная СУБД).

Почему шардирование необходимо:

При 3.6 ТБ данных (с учётом собеседника — 36 ТБ, хотя точный расчёт даёт ~3.6 ТБ) и 12 млрд записей одна нода базы данных будет испытывать проблемы с производительностью индексов. B-дерево индекса по short_code на 12 млрд записей станет очень глубоким, что увеличит время поиска. Шардирование распределяет данные и индексы по нескольким нодам.

Анализ ключей шардирования:

По дате создания — плохой выбор. Все запросы на редирект обращаются к недавно созданным ссылкам (они наиболее активны), что создаёт hot spot на последнем шарде. Старые шарды простаивают.

По original_url — невозможный выбор. При GET-запросе клиент отправляет только short_code, а не оригинальный URL. Шардирование по полю, которого нет в запросе, потребовало бы scatter-gather запроса ко всем шардам.

По short_code (хеш) — оптимальный выбор. Каждый GET-запрос содержит short_code, поэтому можно однозначно определить целевой шард. Хеш-функция обеспечивает равномерное распределение данных и нагрузки.

По user_id — возможный выбор, но создаёт hot spot для пользователей с большим количеством ссылок. Также усложняет запросы аналитики по конкретной ссылке.

Репликация:

Каждый шард должен иметь 1–2 реплики для отказоустойчивости и распределения нагрузки чтения. При 4 000 RPS на чтение и горячем кэше с hit rate 95% на базу придётся ~200 RPS, что легко обрабатывается одной репликой на шард.

Конкретная реализация:

-- Определение шарда в приложении
shard_id = hash(short_code) % num_shards

-- Пример: 16 шардов, каждый с 1 мастером и 2 репликами
-- Шард 0: shard-0-master, shard-0-replica-1, shard-0-replica-2
-- Шард 1: shard-1-master, shard-1-replica-1, shard-1-replica-2
-- ...

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

Вопрос 12. Какие API-эндпоинты будут у сервиса — что передавать и получать в запросах POST и GET?

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

Ответ собеседника: Правильный. GET-запрос: передаётся короткая ссылка, в ответ возвращается длинная ссылка. Дополнительные поля (кто создал, когда) пользователю не отдаются — только сама ссылка. POST-запрос: передаётся длинная ссылка (и опционально кастомное имя), в ответ возвращается короткая ссылка.

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

Ответ корректный для базовых операций. Для полноты на интервью стоит описать все эндпоинты и детализировать контракты.

Основные эндпоинты:

POST /api/v1/links — создание короткой ссылки

// Request
POST /api/v1/links
Authorization: Bearer <jwt_token>
Content-Type: application/json

{
"original_url": "https://example.com/very/long/path?param=value",
"custom_alias": "mylink", // опционально
"expires_at": "2025-12-31T23:59:59Z" // опционально, по умолчанию +10 лет
}

// Response 201 Created
{
"short_code": "aB3xK9mN",
"short_url": "https://sho.rt/aB3xK9mN",
"original_url": "https://example.com/very/long/path?param=value",
"created_at": "2025-01-15T10:30:00Z",
"expires_at": "2025-12-31T23:59:59Z"
}

// Response 409 Conflict (custom_alias уже занят)
{
"error": "CUSTOM_ALIAS_EXISTS",
"message": "Custom alias 'mylink' is already taken"
}

GET /{short_code} — редипо к оригинальному URL

GET /aB3xK9mN

// Response 301 Moved Permanently
Location: https://example.com/very/long/path?param=value

// Response 410 Gone (ссылка истекла)
{
"error": "LINK_EXPIRED",
"message": "This link has expired"
}

// Response 404 Not Found
{
"error": "LINK_NOT_FOUND",
"message": "Short code not found"
}

Дополнительные эндпоинты для полноты API:

GET /api/v1/links/{short_code}/stats — получение статистики по ссылке (для владельца)

Response 200 OK
{
"short_code": "aB3xK9mN",
"total_clicks": 15234,
"clicks_by_day": [
{"date": "2025-01-15", "clicks": 523},
{"date": "2025-01-14", "clicks": 1204}
],
"top_referrers": [
{"referrer": "twitter.com", "clicks": 8100},
{"referrer": "facebook.com", "clicks": 3200}
]
}

DELETE /api/v1/links/{short_code} — деактивация ссылки (только владелец)

Response 204 No Content

GET /api/v1/links — список ссылок пользователя с пагинацией

GET /api/v1/links?page=1&limit=20

Response 200 OK
{
"links": [...],
"total": 156,
"page": 1,
"limit": 20
}

Важные решения по дизайну API:

301 vs 302 редирект: 301 (Moved Permanently) предпочтительнее, так как браузеры и поисковые системы кэшируют его, снижая нагрузку на сервис. Однако это означает, что изменение оригинального URL для существующего short_code не будет немедленно подхвачено кэширующими клиентами. Для сервиса сокращения ссылок 301 — стандартный выбор.

Почему не отдавать метаданные при GET-запросе: Собеседник верно отметил, что GET /{short_code} должен возвращать только редирект. Это публичный эндпоинт без аутентификации, и раскрытие информации о создателе и дате создания было бы утечкой данных. Метаданные доступны только через защищённый API.

Rate limiting на уровне API: POST /api/v1/links — строгий лимит (например, 100 запросов в минуту на пользователя), чтобы предотвратить массовое создание ссылок. GET /{short_code} — более мягкий лимит, но с защитой от DDoS на уровне API Gateway.

Вопрос 13. Нужен ли кэш в системе, где его разместить и какую стратегию кэширования использовать?

Таймкод: 00:41:50

Ответ собеседника: Правильный. Кэш предлагается разместить между GET-сервисом и базой данных (in-memory кэш, например Redis). Кэш используется только для чтения: при GET-запросе сначала проверяется кэш, при промахе — обращение в БД и запись в кэш. Стратегия кэширования — LRU (Least Recently Used) как наиболее распространённая. Сложная инвалидация не требуется, так как ссылки не изменяются после создания (только истекают по сроку действия).

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

Ответ собеседника покрывает основные аспекты хорошо. Разберём детали и добавим нюансы.

Зачем нужен кэш:

Целевая задержка на редирав — 1 мс. Чтение из Redis занимает 0.1–0.5 мс, чтение из PostgreSQL — 1–5 мс (даже при простом точечном запросе по первичному ключу). Без кэша достижение целевой задержки невозможно. Кроме того, кэш защищает базу данных от нагрузки: при hit rate 95% на базу придётся всего ~200 RPS вместо 4 000 RPS.

Размещение кэша:

Кэш размещается как отдельный слой между Link Resolution Service и базой данных. Redis Cluster — стандартный выбор. Альтернатива — локальный in-process кэш (например, встроенный в приложение через groupcache или bigcache в Go), который ещё быстрее (микросекунды), но сложнее в управлении при множестве реплик.

Стратегия кэширования — Cache-Aside (Lazy Loading):

Это именно то, что описал собеседник:

func (s *LinkService) Resolve(shortCode string) (string, error) {
// 1. Проверяем кэш
originalURL, err := s.cache.Get(shortCode)
if err == nil {
return originalURL, nil // cache hit
}

// 2. При промахе — обращаемся к базе
link, err := s.db.GetLink(shortCode)
if err != nil {
return "", err
}

// 3. Записываем в кэш с TTL = оставшийся срок действия ссылки
ttl := time.Until(link.ExpiresAt)
s.cache.Set(shortCode, originalURL, ttl)

return link.OriginalURL, nil
}

Стратегия вытеснения — LRU:

LRU (Least Recently Used) — оптимальный выбор для этого сценария. Ссылки, которые недавно запрашивались, с большей вероятностью будут запрошены снова. Redis нативно поддерживает LRU через настройку maxmemory-policy=allkeys-lru.

Размер кэша:

Горячий кэш должен вмещать наиболее активных ссылок. Если 20% ссылок генерируют 80% трафика (правило Парето), то для покрытия 95% запросов достаточно кэша, вмещающего ~10–15% от общего числа активных ссылок. При 12 млрд записей за 10 лет, но только ~1 млрд активных (не истёкших), кэш на 100–200 млн записей × 300 байт ≈ 30–60 ГБ. Это помещается в один кластер Redis из 3–6 нод.

Инвалидация кэша:

Собеседник верно отметил, что сложная инвалидация не нужна. Ссылка после создания не изменяется — она либо активна, либо истекла. Два механизма обработки истечения:

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

Активный: Фоновый процесс периодически сканирует базу на предмет истёкших ссылок и удаляет их из кэша. Это необязательно, но экономит память кэша.

Write-Through vs Cache-Aside:

Cache-Aside предпочтительнее Write-Through в данном случае, потому что запись происходит редко (40 RPS), и нет смысла заполнять кэш при создании ссылки, которая, возможно, никогда не будет запрошена. Кэш заполняется только при первом запросе на чтение.

Вопрос 14. Как генерировать уникальные короткие ссылки и гарантировать их уникальность при шардировании?

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

Ответ собеседника: Правильный. Использовать хэш-функцию (например MD5) от длинной ссылки нельзя напрямую, так как при обрезке до 8 символов будут частые коллизии. Предложено: использовать отдельный сервис-пул заготовленных коротких ссылок. Фоновый сервис генерирует и хранит запас коротких ссылок (например, 200 млн). POST-сервис при создании ссылки берёт из этого пула свободную ссылку, ставит на неё distributed lock (через ZooKeeper), записывает в соответствующий шард БД, затем удаляет из пула. При падении POST-сервиса блокировка освобождается по таймауту. Также нужен сервис очистки просроченных ссылок (через 10 лет), который удаляет записи из БД и возвращает короткие ссылки обратно в пул.

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

Собеседник предложил интересный подход с пулом предгенерированных ссылок, который имеет свои преимущества. Разберём все основные подходы к генерации коротких кодов.

Подход 1: Пул предгенерированных кодов (предложен собеседником)

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

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

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

Реализация пула может быть основана на Redis SET с атомарной операцией SPOP:

func (p *CodePool) AcquireCode() (string, error) {
// Атомарное извлечение случайного элемента из множества
code, err := p.redis.SPOP("available_codes").Result()
if err == redis.Nil {
return "", ErrPoolExhausted
}
return code, err
}

Фоновый генератор пополняет пул, когда его размер падает ниже порога:

func (g *CodeGenerator) Run() {
for {
currentSize, _ := g.redis.SCard("available_codes").Result()
if currentSize < PoolLowWatermark {
g.refillPool(PoolHighWatermark - currentSize)
}
time.Sleep(RefillCheckInterval)
}
}

Подход 2: Хэширование с разрешением коллизий

Генерируем хэш от original_url + timestamp + random_salt, берём первые 8 символов. При коллизии — повторяем с другим salt.

func GenerateShortCode(url string) string {
for attempts := 0; attempts < MaxAttempts; attempts++ {
salt := generateRandomString(8)
hash := md5.Sum([]byte(url + salt + time.Now().String()))
code := base62Encode(hash[:])[:8]

if !db.Exists(code) {
return code
}
}
return ErrGenerationFailed
}

Преимущества: простота реализации, нет необходимости в дополнительной инфраструктуре.

Недостатки: при высоком коэффициенте заполнения число коллизий растёт, увеличивая латентность. При 8 символах и 12 млрд записей вероятность коллизии при каждой генерации ~0.006%, что приемлемо.

Подход 3: Инкрементальный счётчик (auto-increment)

Используем распределённый генератор последовательных идентификаторов (Snowflake ID, UUID v7) и кодируем число в base62.

func GenerateShortCode() string {
id := snowflake.Generate() // гарантированно уникальный ID
return base62Encode(id)[:8]
}

Преимущества: гарантированная уникальность без проверок, максимальная производительность.

Недостатки: последовательные коды предсказуемы — злоумышленник может перебирать ссылки. Для публичного сервиса это проблема безопасности. Можно использовать перестановку или XOR с секретным ключом для маскировки последовательности.

Рекомендация для интервью:

Подход с пулом — корректный, но избыточный для данной нагрузки. При 40 RPS на запись и вероятности коллизии 0.006% ожидаемое число коллизий — менее 1 в час. Простой подход с хэшированием и retry-loop полностью покрывает потребности и значительно проще в реализации. Однако предложение собеседника демонстрирует глубокое понимание проблемы и способность мыслить нестандартно, что является плюсом.

Гарантия уникальности при шардировании:

Независимо от подбора генерации, окончательная гарантия уникальности обеспечивается UNIQUE constraint на уровне базы данных. При вставке с дубликатом БД вернёт ошибку, и сервис повторит попытку с новым кодом. Это страховочный механизм, который должен быть в любой реализации.

Вопрос 15. Как организовать аналитику — сбор данных о переходах по ссылкам?

Таймкод: 00:58:24

Ответ собеседника: Правильный. Аналитика размещается на GET-сервисе. Предложено использовать распределённый лог/очередь (например, Kafka) для асинхронной записи событий переходов по ссылкам. Это позволяет не блокировать основной поток ответов. Учитываются только успешные ответы (200), невалидные ссылки в аналитику не попадают. Негативные ответы (несуществующие ссылки) кэшируются для защиты от злоупотреблений.

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

Ответ собеседника демонстрирует правильное понимание асинхронного сбора аналитики. Разберём архитектуру подробнее.

Принцип работы:

Аналитика не должна влиять на латентность редиректа. Событие о переходе отправляется асинхронно, а ответ пользователю возвращается немедленно.

Схема потока данных:

GET /{short_code} → Link Resolution Service
├── Найти URL (кэш или БД)
├── Вернуть 302 редирект клиенту (< 1 мс)
└── Асинхронно: отправить событие в Kafka

Kafka Topic: link_clicks

Consumer Group: Analytics Workers

Агрегация → ClickHouse / PostgreSQL

Структура события в Kafka:

{
"short_code": "aB3xK9mN",
"timestamp": "2025-01-15T10:30:00.123Z",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17...)",
"referrer": "https://twitter.com/someuser/status/12345",
"ip_hash": "a1b2c3d4...",
"country": "JP",
"device_type": "mobile"
}

Реализация отправки события на стороне GET-сервиса:

func (s *RedirectService) HandleRedirect(w http.ResponseWriter, r *http.Request) {
shortCode := r.PathValue("short_code")

originalURL, err := s.resolveURL(shortCode)
if err != nil {
http.NotFound(w, r)
return
}

// Возвращаем редирект немедленно
http.Redirect(w, r, originalURL, http.StatusFound)

// Асинхронно отправляем событие аналитики
go s.analyticsCollector.CollectClick(ClickEvent{
ShortCode: shortCode,
Timestamp: time.Now(),
UserAgent: r.UserAgent(),
Referrer: r.Referer(),
IP: hashIP(r.RemoteAddr),
})
}

Обработка событий:

Consumer-сервис читает события из Kafka и агрегирует их. Агрегация может выполняться в двух режимах:

Real-time агрегация — подсчёт счётчиков в Redis с периодической фиксацией в базу:

func (c *ClickConsumer) ProcessBatch(events []ClickEvent) {
pipe := c.redis.Pipeline()
for _, event := range events {
// Увеличиваем общий счётчик
pipe.HIncrBy("stats:"+event.ShortCode, "total", 1)
// Увеличиваем счётчик по часу
hourKey := event.Timestamp.Format("2006-01-02-15")
pipe.HIncrBy("stats:"+event.ShortCode, "hour:"+hourKey, 1)
// Увеличиваем счётчик по дню
dayKey := event.Timestamp.Format("2006-01-02")
pipe.HIncrBy("stats:"+event.ShortCode, "day:"+dayKey, 1)
}
pipe.Exec()
}

Batch агрегация — накопление событий и периодическая запись в колоночное хранилище (ClickHouse) для сложных аналитических запросов.

Кэширование негативных ответов:

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

// Кэш несуществующих ссылок с TTL 5 минут
func (s *RedirectService) resolveURL(shortCode string) (string, error) {
// Проверяем кэш негативных ответов
if s.negativeCache.Exists(shortCode) {
return "", ErrLinkNotFound
}

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

// Обращаемся к базе
link, err := s.db.GetLink(shortCode)
if err == ErrNotFound {
s.negativeCache.Set(shortCode, true, 5*time.Minute)
return "", ErrLinkNotFound
}

return link.OriginalURL, nil
}

Масштабирование аналитики:

При 4 000 RPS генерируется ~4 000 событий аналитики в секунду, что составляет ~350 млн событий в сутки. Kafka легко обрабатывает такие объёмы. Рекомендуется использовать партиционирование Kafka по short_code, чтобы события одной ссылки обрабатывались одним consumer'ом и порядок событий сохранялся.

Вопрос 16. Как реализовать rate limiting (ограничение запросов) в системе?

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

Ответ собеседника: Правильный. Rate limiting размещается на API Gateway. Для POST-запросов — ограничение количества создаваемых ссылок пользователем за промежуток времени (тарифные планы: чем больше создаёт, тем больше платит). Для GET-запросов — лимитинг на уровне IP Gateway по количеству запросов в секунду с использованием стратегии sliding window. Аутентификация выполняется только для POST-запросов.

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

Ответ собеседника корректный и покрывает основные аспекты. Разберём детали реализации.

Два уровня rate limiting:

POST /api/v1/links (создание ссылок) — аутентифицированный лимитинг:

Лимит привязан к user_id и определяется тарифным планом пользователя. Например:

  • Free: 10 ссылок в минуту, 1000 в месяц
  • Pro: 100 ссылок в минуту, 100 000 в месяц
  • Enterprise: неограниченно

GET /{short_code} (редиректы) — анонимный лимитинг:

Лимит привязан к IP-адресу. Это защищает от DDoS и перебора ссылок. Например: 1000 запросов в минуту с одного IP.

Алгоритмы rate limiting:

Sliding Window Log — наиболее точный. Храним временные метки всех запросов в пределах окна. При новом запросе удаляем устаревшие записи и проверяем количество оставшихся.

func (l *SlidingWindowLimiter) Allow(userID string) bool {
now := time.Now()
windowStart := now.Add(-l.window)

// Удаляем записи за пределами окна
l.redis.ZRemRangeByScore(userID, "0", strconv.FormatInt(windowStart.UnixMilli(), 10))

// Считаем количество запросов в окне
count, _ := l.redis.ZCard(userID).Result()

if count >= l.maxRequests {
return false
}

// Добавляем текущий запрос
l.redis.ZAdd(userID, redis.Z{
Score: float64(now.UnixMilli()),
Member: now.UnixMilli() + "-" + uuid.New().String(),
})
l.redis.Expire(userID, l.window)

return true
}

Sliding Window Counter — более экономичный по памяти. Используем взвешенное среднее между предыдущим и текущим окном.

func (l *SlidingWindowCounter) Allow(userID string) bool {
now := time.Now()
currentWindow := now.Unix() / int64(l.window.Seconds())
previousWindow := currentWindow - 1

// Доля текущего окна, прошедшая от начала
ratio := float64(now.Unix()%int64(l.window.Seconds())) / float64(l.window.Seconds())

pipe := l.redis.Pipeline()
prevCmd := pipe.Get(fmt.Sprintf("%s:%d", userID, previousWindow))
pipe.Incr(fmt.Sprintf("%s:%d", userID, currentWindow))
pipe.Expire(fmt.Sprintf("%s:%d", userID, currentWindow), l.window*2)
pipe.Exec()

prevCount, _ := prevCmd.Int64()
estimatedCount := float64(prevCount)*(1-ratio) + float64(1)

return estimatedCount <= float64(l.maxRequests)
}

Token Bucket — позволяет обрабатывать кратковременные всплески трафика. Токены добавляются с постоянной скоростью, и каждый запрос потребляет один токен.

func (l *TokenBucketLimiter) Allow(userID string) bool {
script := `
local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local last_tokens = tonumber(redis.call('GET', tokens_key)) or capacity
local last_refreshed = tonumber(redis.call('GET', timestamp_key)) or now

local delta = math.max(0, now - last_refreshed)
local new_tokens = math.min(capacity, last_tokens + delta * rate)

if new_tokens >= 1 then
redis.call('SET', tokens_key, new_tokens - 1)
redis.call('SET', timestamp_key, now)
redis.call('EXPIRE', tokens_key, math.ceil(capacity / rate))
redis.call('EXPIRE', timestamp_key, math.ceil(capacity / rate))
return 1
end

return 0
`

result, _ := l.redis.Eval(script,
[]string{userID + ":tokens", userID + ":timestamp"},
l.rate, l.cap, time.Now().Unix(),
).Int()

return result == 1
}

Размещение rate limiter:

Собеседник верно указал API Gateway как место для rate limiting. Это логично, потому что Gateway — единая точка входа, и отклонение запросов на этом уровне экономит ресурсы всей системы. Для аутентифицированных запросов (POST) Gateway проверяет JWT и определяет user_id до передачи запроса в сервис. Для анонимных запросов (GET) используется IP-адрес.

Ответ при превышении лимита:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705312200

{
"error": "RATE_LIMIT_EXCEEDED",
"message": "You have exceeded the rate limit. Please retry after 60 seconds."
}

Вопрос 17. Каковы точки отказа системы и как обеспечить отказоустойчивость?

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

Ответ собеседника: Правильный. Точки отказа и решения: 1) DNS — DNS-запись с множеством адресов инстансов балансировщика, сервис health-check для обновления DNS. 2) API Gateway — репликация и автоматическое переключение. 3) Сервис аутентификации — при недоступности retry с jitter или ответ 500; нагрузка только от POST. 4) POST-сервис — stateless, легко масштабируется; при падении distributed lock освобождается по таймауту. 5) База данных — репликация шардов, при выходе мастера реплика становится мастером. 6) Шардирование — фиксированное количество шардов изначально (рассчитано на 10 лет), при сбое реплика заменяет мастера без пересчёта количества шардов. 7) GET-сервис — stateless, масштабируется горизонтально. Операция создания ссылки идемпотентна: один и тот же long URL может иметь несколько разных tiny URL (для разных рекламных кампаний).

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

Ответ собеседника комплексный и демонстрирует хорошее понимание отказоустойчивости. Разберём каждую точку и дополним.

Полный анализ точек отказа:

DNS: Использование Anycast DNS или DNS с несколькими A-записями и health-check (Route 53, Cloudflare). TTL должен быть достаточно низким (30–60 секунд) для быстрого переключения, но не слишком низким, чтобы не перегружать DNS-серверы.

API Gateway: Деплоится как кластер из нескольких нод за L4/L7 балансировором. Каждая нода stateless, при падении одной трафик перераспределяется на оставшиеся. Health-check балансировщика определяет недоступные ноды за 5–10 секунд.

Сервис аутентификации: Критичен только для POST-запросов (40 RPS). Для GET-запросов аутентификация не требуется, поэтому недоступность сервиса аутентификации не влияет на основной трафик. При недоступности — fail-fast с ответом 503 и заголовком Retry-After. Клиенты используют exponential backoff с jitter:

func (c *AuthClient) AuthenticateWithRetry(token string) (*AuthResult, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
result, err := c.authService.Authenticate(token)
if err == nil {
return result, nil
}
lastErr = err

if !isRetryable(err) {
return nil, err
}

// Exponential backoff with jitter
backoff := time.Duration(math.Pow(2, float64(attempt))) * 100 * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
time.Sleep(backoff + jitter)
}
return nil, lastErr
}

База данных: Каждый шард имеет конфигурацию master + 2 реплики. При отказе мастера автоматический failover через Patroni (для PostgreSQL) или Orchestrator (для MySQL). Время переключения обычно 10–30 секунд. В этот момент записи на данный шард недоступны, но чтение с реплик продолжается.

Redis (кэш): Redis Cluster с 3 мастерами и 3 репликами. При падении одного мастера его реплика автоматически становится мастером. Промахи кэша направляются в базу данных. Критически важно: система должна корректно работать при полном отказе кэша (cache stampede protection).

Kafka: Репликация с фактором 3, min.insync.replicas=2. Потеря одного брокера не влияет на доступность. При потере двух брокеров партиции, чьи лидеры были на них, переизбирают лидеров из оставшихся реплик.

Идемпотентность POST-запроса:

Собеседник упомянул идемпотентность, но трактовал её не совсем точно. Идемпотентность означает, что повторный вызов с теми же данными даёт тот же результат. Если один и тот же original_url создаёт разные short_code, операция не является идемпотентной в строгом смысле.

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

POST /api/v1/links
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
"original_url": "https://example.com"
}

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

func (s *LinkService) CreateLink(req CreateLinkRequest, idempotencyKey string) (*Link, error) {
// Проверяем, был ли уже обработан этот запрос
if cached, err := s.idempotencyCache.Get(idempotencyKey); err == nil {
return cached, nil
}

// Создаём ссылку
link, err := s.createLink(req)
if err != nil {
return nil, err
}

// Сохраняем результат на 24 часа
s.idempotencyCache.Set(idempotencyKey, link, 24*time.Hour)

return link, nil
}

Деградация функциональности:

При частичном отказе система должна деградировать грациозно:

  • Отказ аналитики → редиректы работают, статистика не собирается
  • Отказ кэша → редиректы работают, но медленнее (обращение к БД)
  • Отказ POST-сервиса → создание ссылок недоступно, редиректы работают
  • Отказ одного шарда БД → ссылки на этом шарде недоступны, остальные работают

Такой подход к деградации обеспечивает максимальную доступность наиболее критичной функции — редиректов.