voidlearn
 

11.5Секция системного дизайна

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

Фреймворк: как вести диалог 45 минут

Дальше идут восемь шагов, и это инструкция, а не список тем. У каждого шага есть вопрос, на который он отвечает, и причина, по которой без ответа дальше идти нельзя. Шаг, который ни на какой вопрос не отвечает, пропусти и скажи об этом вслух; это сильнее, чем добросовестно проговорить пункт ради пункта.

Держи в голове примерный бюджет: 5–8 минут на требования, 5 на оценку нагрузки, 5 на API и модель данных, 15–20 на архитектуру и потоки, 10 на узкие места, отказоустойчивость и добивающие вопросы. Хуже всего провалить первые десять минут: неверно понятую задачу не спасут остальные тридцать пять.

Шаг 1. Уточнение требований (5–8 минут, не пропускать никогда)

Вопрос этого шага: что мы вообще строим и чего в этой версии точно не делаем? Интервьюер нарочно даёт неполное условие. Не додумывай за него молча, спрашивай. Заодно раздели вопросы на функциональные и нефункциональные и проговори, что именно ты исключаешь из скоупа.

ФункциональныеНефункциональные
Кто пользователи и какие сценарии главные? Какие два-три сценария мы точно поддерживаем?Сколько DAU/MAU? Какой рост ожидается за год?
Что не делаем в этой версии (модерация, аналитика, биллинг, мобильные пуши)?Соотношение чтение/запись? Есть ли пики (вечер, чёрная пятница, матч)?
Нужна ли авторизация, роли, мультиарендность?Целевая латентность p99 для чтения и записи?
Есть ли редактирование и удаление, или только создание?Целевая доступность: 99.9 % (43 мин/мес) или 99.99 % (4.3 мин/мес)?
Нужна ли история/версионирование?Насколько допустима eventual consistency? Где именно недопустима?
Каков жизненный цикл данных: хранить вечно или N месяцев?Есть ли требования по географии (один регион или несколько), по закону (ПДн, аудит)?
Один вопрос, который стоит десяти

«Что здесь абсолютно недопустимо потерять или показать неверно?» Ответ на него задаёт всю архитектуру. Нельзя терять деньги: значит, транзакции, идемпотентность, сверка, CP-хранилище. Нельзя показывать устаревшую ленту: значит, кэш и AP. Ничего критичного нет? Строй в разы проще и скажи это вслух. Это сильный ход, а не слабость.

Шаг 2. Оценка нагрузки - вслух и с округлением

Вопрос этого шага: сколько это будет запросов, байтов и гигабайтов? Без чисел любое дальнейшее решение остаётся вкусовщиной: обосновать кэш, реплики или шардинг нечем. Считай на бумаге и грубо: цель не точность, а порядок величины, который определит, нужен ли шардинг и какого класса хранилище. Полезные округления: в сутках 86 400 ≈ 105 секунд, в году ≈ 3·107 секунд, 1 млн запросов в день ≈ 12 rps.

ВеличинаКак считатьПример: соцсеть, 10 млн DAU
Средний RPSDAU × действий в день ÷ 86 400107 × 20 ÷ 105 = 2 000 rps
Пиковый RPSсредний × 3…5 (суточный профиль), × 10 при событиях2 000 × 4 = 8 000 rps
Чтение/записьспросить; для соцсетей 100:1, для логов 1:100записей ~80 rps, чтений ~7 900 rps
Размер записисложить поля честно, добавить 30–50 % на индексы и накладныепост: 300 Б текст + 200 Б метаданных ≈ 0.5 КБ
Объём в деньзаписей в день × размер7·106 постов × 0.5 КБ ≈ 3.5 ГБ/день
Объём за год×365, плюс репликация ×3≈ 1.3 ТБ/год, с репликами ≈ 4 ТБ
ТрафикRPS × размер ответа8 000 × 20 КБ ≈ 160 МБ/с ≈ 1.3 Гбит/с
Память под кэшгорячие 20 % данных, правило Паретоактивные ленты: 106 × 50 КБ ≈ 50 ГБ

Из расчёта вслух делают три вывода: (1) влезают ли данные на один узел (до нескольких ТБ влезают, дальше шардинг); (2) справится ли одна БД с записью (тысячи rps записи уже граница, за которой нужен шардинг или другое хранилище); (3) нужен ли кэш (при чтении 100:1 - да, всегда). Если цифры показали, что хватает одной реплики Postgres, так и скажи: простое решение, обоснованное расчётом, ценится выше, чем Kafka «на всякий случай».

Шаг 3. Дизайн API

Вопрос этого шага: что именно клиент присылает и что получает в ответ? Именно в API расплывчатые требования впервые превращаются в конкретику. Пока не написан POST /v1/posts, слова «пользователь публикует пост» ничего не значат; как только написан, сразу видно, чего не хватает во входных данных и какие сценарии остались непокрытыми. Хватит 3–5 методов по главным сценариям - остальное отнимет время у архитектуры.

Что проговорить вслух на этом шаге:

  • Ресурсы и глаголы, коды ответов, формат ошибки. Единый конверт ошибки (код, сообщение, request_id) стоит дёшево, а замечают его всегда.
  • Пагинация курсором, а не offset: ?cursor=&limit=. OFFSET 100000 на большой таблице гарантированно тормозит, а курсор ещё и устойчив к вставкам между страницами.
  • Идемпотентность там, где есть деньги или побочные эффекты: заголовок Idempotency-Key на POST.
  • Версионирование и совместимость (/v1, аддитивные изменения).
  • Лимиты: максимальный limit, ограничение размера тела, rate limit по ключу - если их нет, интервьюер обязательно заметит.
POST /v1/posts              Idempotency-Key: <uuid>   -> 201 {id, created_at}
GET  /v1/feed?cursor=<c>&limit=20                    -> 200 {items[], next_cursor}
GET  /v1/posts/{id}                                   -> 200 | 404
DELETE /v1/posts/{id}                                 -> 204 | 404

Шаг 4. Модель данных и выбор хранилища

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

  • Вопросы к модели: какой ключ шардирования (и не создаст ли он горячие партиции), какие запросы должны быть быстрыми, где нужны транзакции, что можно денормализовать.
  • Реляционная БД идёт по умолчанию: транзакции, джойны, ограничения целостности, понятная эксплуатация. Ответ «Начну с Postgres, потому что нагрузка это позволяет» звучит сильно, а не наивно.
  • Key-value / wide-column (Redis, Cassandra, DynamoDB) берут, когда доступ строго по ключу, запись идёт потоком, а горизонтальный рост важнее джойнов.
  • Поиск (Elasticsearch, OpenSearch) закрывает полнотекст и фасеты. Всегда как вторичный индекс, никогда как источник истины.
  • Объектное хранилище (S3) держит файлы, картинки, видео, бэкапы. Метаданные в БД, байты в S3, и это почти всегда правильный ответ.
  • Колоночное / OLAP (ClickHouse) считает аналитику и агрегаты по большим объёмам, отдельно от оперативного контура.
  • Брокер (Kafka, RabbitMQ) - не хранилище, а транспорт и буфер; но Kafka с длинным retention заодно позволяет переиграть историю.

Шаг 5. Компоненты и потоки

Вопрос этого шага: что происходит с одним конкретным запросом от нажатия кнопки до ответа? Схема из кубиков сама по себе не доказывает ничего. Доказывает запрос, который ты по ней провёл: на этом проходе и вылезают недостающие компоненты. Поэтому рисуй не «кубики», а путь запроса. Пройди вслух два-три сценария от клиента до хранилища и обратно: что происходит при записи, что при чтении, что при отказе на каждом шаге. Стандартный набор слоёв: клиент → CDN → балансировщик → gateway (authn, rate limit) → сервисы приложения → кэш → хранилище, плюс сбоку брокер и асинхронные воркеры.

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

Шаг 6. Узкие места и масштабирование

Вопрос этого шага: что сломается первым, если нагрузка вырастет в десять раз? Отвечать надо цифрами из шага 2, а не ощущениями, и на каждое лекарство сразу называть цену - интервьюер ждёт именно этого, а не перечня технологий.

  • Найди узкое место по расчёту, а не по интуиции. Обычно это запись в БД, горячий ключ или один сервис на пути всех запросов.
  • Порядок действий по стоимости: сначала кэш и индексы, потом реплики для чтения, потом вынос тяжёлого в асинхронный контур, и только потом шардинг. Шардинг стоит дороже всего, поэтому его называют последним и с обоснованием.
  • Ключ шардирования разбирают по вариантам: по user_id (равномерно, но кросс-пользовательские запросы дороги), по времени (удобно удалять старое, но последняя партиция горячая), по хешу (равномерно, но диапазонные запросы невозможны).
  • Горячие ключи стоят особняком: знаменитость с 50 млн подписчиков, товар дня, «магазин №1». Лечится репликацией ключа, локальным кэшем, отдельным путём обработки.

Шаг 7. Отказоустойчивость

Этот вопрос ты задаёшь к каждому нарисованному компоненту по очереди: «а что, если он сейчас упадёт?» Любой компонент на схеме однажды окажется недоступен, и схема из презентации отличается от схемы из эксплуатации ровно наличием ответа, а не красотой картинки. Минимальный набор тем, по которым ответ готов заранее: репликация и failover БД (и потеря данных при асинхронной репликации), несколько зон доступности, таймауты и ретраи с jitter, circuit breaker, деградация функциональности (лента без рекомендаций лучше, чем пустой экран), бэкапы и проверка восстановления, graceful shutdown и rolling update без потери запросов.

Шаг 8. Мониторинг

Вопрос этого шага: как ты узнаешь, что система сломалась, раньше пользователей? Двух-трёх предложений достаточно, но они должны быть конкретными. RED для сервисов, USE для ресурсов, плюс бизнес-метрики, которые ловят то, чего не видят технические: «заказов в минуту упало вдвое, а 5xx нет» и есть самый частый признак настоящего инцидента. Дальше: распределённый трейсинг с trace_id сквозь все сервисы, структурные логи, алерты на симптомы (SLO нарушается), а не на причины (CPU 80 %), дашборд лага очередей и лага репликации.

Типичные ошибки кандидата в этой секции
  1. Начать с решения. Первые же слова: «поставим Kafka и Kubernetes», а что строим, ещё не выяснено. Мгновенный минус.
  2. Не задать ни одного вопроса. Молча проектировать по своей фантазии - значит решить не ту задачу.
  3. Не считать. Без цифр невозможно обосновать ни шардинг, ни кэш, ни выбор хранилища; всё превращается в перечисление модных слов.
  4. Оверинжиниринг. Микросервисы, CQRS, event sourcing и три вида кэша для сервиса на 100 rps. Интервьюер проверяет чувство меры не меньше, чем знания.
  5. Молчать и думать про себя. Оценивают ход рассуждения; неозвученные мысли не существуют. Проговаривай варианты, даже отвергаемые.
  6. Не называть цену решений. Каждое «добавим кэш» закрывай фразой «ценой будет инвалидация и окно устаревания».
  7. Спорить с интервьюером или упираться в первую идею. На «а если так?» надо обдумать и, если аргумент верный, вслух согласиться и поменять решение. Это плюс, а не минус.
  8. Игнорировать отказы. Схема без ответа на «что упадёт и что тогда» остаётся схемой из презентации, а не из эксплуатации.
  9. Уйти в детали слишком рано. Полчаса про формат JSON, когда не нарисована архитектура. Держи уровень абстракции и опускайся по запросу.
  10. Забыть про эксплуатацию: миграции, деплой, мониторинг, стоимость железа. Одно предложение про каждое сильно поднимает впечатление.

Вопросы

12
Суть: это read-heavy key-value сервис с редиректом. Ключевые решения: генерация ключа счётчиком + base62 (а не хешем), кэш перед БД с высоким хитрейтом, 302 вместо 301, счётчики кликов асинхронно через брокер.

Требования и расчёт

  • Функционально: создать короткую ссылку (опционально свой алиас и TTL), перейти по ней, посмотреть статистику. Не делаем: аналитику в реальном времени, A/B, биллинг.
  • Нефункционально: для чтения латентность p99 меньше 50 мс и доступность 99.99 % (редирект лежит на критичном пути), запись может быть медленнее.
  • Цифры: 100 млн новых ссылок в месяц ≈ 40 записей/с; чтение к записи 100:1 → 4 000 чтений/с, пик ×4 ≈ 16 000 rps. Хранение: 500 Б на запись × 100 млн × 12 мес ≈ 600 ГБ/год - влезает в один сервер, шардинг пока не нужен. Горячие 10 % ссылок дают ~90 % трафика → кэш на 20–50 ГБ закрывает почти всё.

Генерация ключа: счётчик + base62 против хеша

Длина ключа: base62 даёт 627 ≈ 3.5·1012 - семи символов хватит на десятилетия.

Счётчик + base62Хеш от URL (MD5/SHA, первые N символов)
Коллизииневозможны - счётчик уникален по построениюпарадокс дней рождения: при 109 ссылок и 7 символах коллизии неизбежны, нужна проверка и повтор
Одинаковый URL дваждыдве разные короткие ссылки (обычно это и нужно - своя статистика)одна и та же ссылка (иногда плюс, иногда утечка приватности)
Предсказуемостьключи идут подряд - можно перебрать чужие ссылкинепредсказуемо
Узкое местосам счётчикнет, но есть проверка коллизий = лишнее чтение

На практике берут счётчик, а его узкое место снимают так: не ходить в общий счётчик на каждую ссылку, а выдавать инстансам диапазоны (range allocator: сервис берёт блок из 10 000 идентификаторов одним INCRBY в Redis или UPDATE ... RETURNING в БД и раздаёт локально). Уходит и сетевой хоп, и единая точка отказа на горячем пути. Предсказуемость лечим перемешиванием битов перед кодированием (обратимая перестановка, Feistel-шифр на 41 бите: 2 в сорок второй уже больше, чем 62 в седьмой) - ключи остаются уникальными, но перестают идти подряд. Без общего счётчика можно обойтись и вовсе, взяв Snowflake-подобный id (время + id инстанса + последовательность).

Хранилище и кэш

  • Модель тривиальна: short_key PRIMARY KEY, long_url, user_id, created_at, expires_at. Доступ строго по первичному ключу, идеальный кандидат для key-value, но Postgres на 600 ГБ и 40 записях/с справится без вопросов, и начинать стоит с него.
  • Кэш: Redis, ключ → URL, TTL несколько часов, LRU. Хитрейт 90+ % за счёт распределения Ципфа. При промахе идём в БД и прогреваем кэш (single-flight, чтобы 1000 параллельных запросов на один холодный ключ не устроили stampede).
  • Записи иммутабельны, и это сильно упрощает жизнь: нет инвалидации кэша, можно кэшировать сколь угодно агрессивно, можно раздавать через CDN.

301 или 302 - вопрос с подвохом

301 (Moved Permanently) кэшируется браузером надолго: следующий переход пойдёт мимо твоего сервиса. Меньше нагрузки, но ты теряешь статистику кликов и не можешь изменить или отозвать ссылку - браузер уже никогда не спросит. 302 (Found) не кэшируется: каждый переход приходит к тебе, статистика полная, ссылку можно отключить. Правильный ответ: 302 по умолчанию, потому что бизнес-ценность продукта держится на статистике и управлении ссылками; 301 остаётся опцией для тех, кому нужна максимальная скорость и не нужна аналитика. Отдельная тонкость: для редиректа корректнее 307/308, если важно сохранить метод и тело запроса, но для ссылок это несущественно.

Счётчики кликов

Инкремент строки в БД на каждый клик убивает сервис на первой же горячей ссылке. Правильный путь: редирект отвечает сразу, а событие клика уходит в брокер; отдельный потребитель агрегирует и пишет в аналитическое хранилище (ClickHouse) или инкрементит счётчик в Redis с периодическим сбросом в БД. Точность «примерно» здесь допустима, и это надо проговорить как осознанный выбор.

Запись: создать короткую ссылку (~40 rps) client api range allocator блок из 10 000 id base62(id) store: key → url Чтение: переход по ссылке (пик ~16 000 rps) browser LB redirect svc redis cache hit ≈ 90 % store miss → прогрев 302 Found + Location: <long url> Счётчик кликов - вне горячего пути redirect svc kafka aggregator clickhouse ответ клиенту не ждёт запись клика: статистика «примерно», зато редирект быстрый инкремент строки в БД на каждый клик убил бы сервис на первой же вирусной ссылке
Три независимых пути. Запись редкая и может быть медленной; чтение массовое, его держит кэш; аналитика асинхронна и на латентность редиректа не влияет.
Чем добить

«Кастомный алиас?» - уникальность проверяем отдельно, через INSERT ... ON CONFLICT, и алиасы живут в том же пространстве ключей, чтобы не пересечься со сгенерированными (например, генерируемые всегда длиной 7, кастомные от 8, или отдельный префикс). «Удаление и TTL?» - поле expires_at, фоновая чистка партициями по дате, кэш с тем же TTL. «Защита от злоупотреблений?» - rate limit на создание, проверка URL по спискам вредоносных, запрет рекурсии (короткая ссылка на короткую ссылку). «Мультирегион?» - ссылки иммутабельны, поэтому чтение реплицируется в каждый регион и раздаётся локально; запись оставляем в одном регионе, диапазоны id при этом расходятся по регионам без конфликтов.

Суть: алгоритм - sliding window counter или token bucket (последний, если нужны всплески); состояние - в Redis, атомарно через Lua; решение принимается на gateway; при отказе Redis - fail open, а не fail closed.

Требования

  • Ограничивать по разным ключам: API-ключ, user_id, IP, эндпоинт, и по комбинациям. Разные лимиты для разных тарифов.
  • Проверка должна укладываться в единицы миллисекунд, иначе лимитер сам станет узким местом.
  • Точность: «примерно» приемлемо; распределённый лимитер, который гарантирует строгое «не более 100», требует согласования и стоит дороже, чем польза.
  • Ответ при превышении: 429 плюс заголовки X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset и Retry-After - без них клиенты будут долбить в цикле.

Алгоритмы

АлгоритмКак работаетПлюсы / минусы
Fixed windowсчётчик на окно (минуту), сброс по границепроще всего; всплеск на границе: 100 в конце минуты + 100 в начале = 200 за секунду
Sliding window logхранить метки времени всех запросов, считать те, что в окнеточно; память O(запросов) - дорого на больших лимитах
Sliding window counterтекущее окно + взвешенный остаток предыдущегодефолтный выбор: почти точно, память O(1), нет проблемы границы
Token bucketтокены капают с постоянной скоростью, запрос забирает токен, ёмкость ограниченаразрешает контролируемый всплеск (накопленные токены) - идеален для API
Leaky bucketочередь с постоянной скоростью вытеканиясглаживает трафик до ровного; всплески не пропускает вовсе

Практический выбор: token bucket, если хочется разрешать короткие всплески (нормальное поведение реальных клиентов), sliding window counter, если важна предсказуемость. Оба делаются в Redis двумя-тремя полями и Lua-скриптом.

# token bucket в Redis: атомарно через Lua (иначе гонка между GET и SET)
# KEYS[1] = bucket:user:42 ; ARGV = rate, capacity, now, cost
# HMGET tokens last_refill -> долить (now-last)*rate, обрезать по capacity
# если tokens >= cost: списать, вернуть 1 ; иначе вернуть 0 и время до пополнения
EVALSHA <sha> 1 bucket:user:42 10 100 1730000000 1

Архитектура

  • Где принимается решение. На gateway/edge - как можно раньше, чтобы отброшенный запрос не тратил ресурсы. Отдельный «сервис лимитера» добавляет сетевой хоп и на горячем пути только вредит; лучше библиотека внутри gateway плюс общий Redis.
  • Локальный слой + глобальный. Каждый инстанс держит локальный счётчик и синхронизируется с Redis не на каждом запросе, а пачками или по «квоте» (инстанс берёт из общего лимита долю и расходует локально). Сетевой вызов уходит из горячего пути ценой небольшой неточности.
  • Хранилище. Redis (в идеале рядом, в том же регионе). Для мультирегиона держат лимиты по регионам, а не глобальный счётчик: кросс-региональная синхронизация на каждый запрос невозможна по латентности.
  • Конфигурация лимитов меняется динамически, без релиза: правила в конфиг-сервисе с горячей перезагрузкой, потому что лимиты меняют чаще, чем код.
Что делать, если Redis недоступен

Fail open, то есть пропускать запросы. Лимитер прикрывает сервис от злоупотреблений, в функциональность продукта он не входит; отказ лимитера не должен превращаться в отказ сервиса. При этом переход в fail open должен быть заметным (метрика + алерт) и, желательно, деградированным: локальный счётчик на инстансе с грубым лимитом продолжает работать и защищает от совсем уж дикого трафика. Fail closed оправдан только там, где превышение лимита само по себе опасно: попытки ввода пароля, дорогая внешняя операция.

На чём ловят

Неатомарная проверка: GET, потом INCR - при конкуренции лимит протекает; нужен Lua или INCR с EXPIRE в одной транзакции. Лимит только по IP - за NAT сидит целый офис, а злоумышленник меняет адреса; нужен многоуровневый ключ. Без Retry-After клиенты начинают ретраить синхронно и устраивают storm. И забыть, что 429 тоже стоит денег: если каждый отброшенный запрос проходит authn и три сетевых хопа, злоумышленник всё равно тебя утопит, так что отбрасывать надо как можно раньше и дешевле.

Суть: fanout on write (push) - записать пост в ленты всех подписчиков заранее: чтение мгновенное, запись дорогая. Fanout on read (pull) - собирать ленту в момент запроса: запись дешёвая, чтение дорогое. Ответ в проде - гибрид: push для обычных, pull для знаменитостей, слияние на чтении.

Расчёт, который определяет выбор

  • 10 млн DAU, каждый заходит 10 раз в день → ~1 200 чтений ленты/с, пик ×4 ≈ 5 000.
  • Постов: 1 млн в день → ~12 записей/с. Соотношение чтение/запись ≈ 100:1, значит, оптимизировать надо чтение.
  • Средний подписчик: 200 фолловеров. При fanout on write один пост порождает 200 вставок → 12 × 200 = 2 400 вставок/с - вполне подъёмно.
  • А теперь знаменитость с 50 млн подписчиков: один твит = 50 млн вставок. При 10 000 вставок/с это полтора часа на один пост, и всё это время часть подписчиков его не видит. Вот здесь чистый push и ломается.
  • Хранение лент: 10 млн активных × 500 записей × 100 Б ≈ 500 ГБ в Redis - дорого, поэтому материализуют ленту только для активных пользователей (заходил за последние N дней) и ограничивают глубину.
Fanout on write (push) автор fanout worker feed:user1 feed:user2 feed:userN N вставок при записи читатель чтение = 1 запрос лента уже готова Fanout on read (pull) автор posts 1 вставка читатель posts автора 1 posts автора 2 posts автора M чтение = M запросов + слияние и сортировка Гибрид - то, что работает в проде обычные авторы (< 10k фолловеров) push: пишем в ленты подписчиков знаменитости (> 10k) pull: не разносим, читаем на лету на чтении: merge готовой ленты и свежих постов знаменитостей
Порог переключения выбирается по расчёту, а не по вкусу. Стоимость push растёт линейно с числом подписчиков, а стоимость pull - с числом подписок читателя; гибрид оставляет каждой стратегии тот участок, где она дешевле.

Гибрид в деталях

  1. Порог, скажем, 10 000 подписчиков. Ниже него работает fanout on write в Redis-список feed:{user_id} (обрезанный до 500–1000 записей, хранятся только id постов).
  2. Выше порога не разносим вообще. При чтении: берём готовую ленту, отдельно читаем свежие посты тех знаменитостей, на кого подписан читатель (их обычно единицы-десятки и они прекрасно кэшируются), сливаем по времени/ранжированию.
  3. Fanout выполняется асинхронно воркерами из очереди, публикация поста не ждёт разноса. Для активных подписчиков приоритет выше: те, кто онлайн, получают пост за секунды, остальные - за минуты.
  4. Материализуем ленты только активных пользователей. Для вернувшегося через полгода лента собирается на лету и потом материализуется.

Дополнительные вопросы, которые почти всегда задают

  • Удаление поста. Из миллионов лент его не вычищают, а фильтруют при чтении по «удалённым» (проверка id по множеству удалённых или по флагу в кэше постов).
  • Ранжирование вместо хронологии. Тогда лента собирается из кандидатов и скоринга на чтении, и push вырождается в «список кандидатов», а не в готовую ленту.
  • Согласованность. Лента eventual по определению: подписался - новые посты появятся, старые подтянутся отдельным процессом. Это нормально, и это надо назвать компромиссом, а не багом.
  • Хранилище постов берут key-value или шардированное по post_id; ленты живут в Redis; связи подписок лежат отдельно, шардированные по follower_id для «на кого подписан» и по followee_id для «кто подписан» (две денормализованные таблицы, потому что нужны оба направления).
Суть: постоянные соединения на отдельном stateless-слое gateway, реестр «пользователь → на каком gateway сидит», сообщения сначала сохраняются, потом доставляются; порядок задаётся серверным номером в пределах диалога; подтверждения - трёхуровневые (принято сервером, доставлено, прочитано).

Транспорт

  • WebSocket берут по умолчанию: двунаправленно, низкие накладные, работает через браузер. Альтернативы: SSE (только сервер→клиент, зато проще и переживает прокси), long polling (fallback), для мобильных - пуш-каналы (APNs/FCM), когда приложение в фоне и соединение держать нельзя.
  • Слой соединений отдельный от бизнес-логики. Gateway держит сокеты и не знает домена; логика - в сервисах. Так логику можно деплоить, не рвя миллионы соединений.
  • Реестр присутствия: user_id → gateway_id в Redis с TTL, обновляется heartbeat. Чтобы доставить сообщение, сервис смотрит реестр и шлёт на нужный gateway (напрямую или через топик/канал по gateway_id).
  • Масштаб: одна Go-машина спокойно держит сотни тысяч соединений (~2–10 КБ на соединение при аккуратных буферах), так что 10 млн онлайна закрываются десятками узлов, а не тысячами. Узкое место обычно не CPU, а память под буферы и количество файловых дескрипторов.

Путь сообщения и подтверждения

  1. Клиент отправляет сообщение с собственным client_msg_id (UUID), и это ключ идемпотентности: повтор после обрыва не создаст дубль.
  2. Сервис сначала персистит сообщение и присваивает ему серверный порядковый номер seq в пределах диалога, потом отвечает отправителю ack: sent. Порядок «сохранить → подтвердить» принципиален: подтверждать до записи - значит терять сообщения при падении.
  3. Дальше доставка получателям. Онлайн идёт через gateway, а для оффлайна сообщение просто лежит в истории, и клиент заберёт его при следующем подключении по своему last_seen_seq.
  4. Получатель присылает delivered, при открытии чата - read. Оба статуса тоже сообщения, и они тоже могут потеряться, поэтому хранятся как «максимальный подтверждённый seq на пользователя», а не как флаг на каждом сообщении: так они идемпотентны и компактны.
Ordering: почему нельзя по времени клиента

Часы клиентов расходятся на минуты, и сортировка по ним даёт перепутанный диалог. Порядок задаёт сервер: монотонный seq в пределах чата (счётчик на диалог: INCR в Redis или bigserial/последовательность на шарде чата). Тогда клиент всегда может сказать «у меня есть до 417, дай дальше», а дырка в нумерации сразу сигналит о потере. Глобальный порядок между разными чатами не нужен, и это очень удобно: шардирование по chat_id становится естественным.

Хранение истории

  • Паттерн доступа один: «последние N сообщений чата» и «сообщения чата после seq». Это идеальный кандидат для wide-column хранилища: ключ партиции chat_id, кластеризация по seq DESC (Cassandra/ScyllaDB) или Postgres, шардированный по chat_id, с партиционированием по времени.
  • Расчёт: 10 млн DAU × 50 сообщений/день = 500 млн сообщений/день × 300 Б ≈ 150 ГБ/день. Значит, нужны политика хранения (горячие последние месяцы, холодное - в дешёвое хранилище) и обязательное партиционирование по времени, чтобы удалять срезанием партиций, а не DELETE.
  • Медиа не хранятся в БД: файл в S3, в сообщении - ссылка и метаданные.
  • Групповые чаты: не копировать сообщение каждому участнику (fanout on write), а хранить один раз в чате и держать на пользователя только курсор прочитанного. Копирование оправдано только при малом размере групп и жёстком требовании к скорости чтения.

Онлайн-статусы

Самая недооценённая часть: наивное «рассылать всем контактам при каждом изменении» даёт квадратичный трафик. На практике: статус в Redis с коротким TTL, обновляемый heartbeat; рассылка изменений только тем, кто сейчас смотрит на этого пользователя (открыт чат или список); агрегация и throttling («не чаще раза в 10–30 секунд»); «был в сети недавно» с огрублением вместо точной секунды, что и дешевле, и приватнее.

Чем добить

«Переподключение?» Клиент хранит last_seq по каждому чату, при коннекте синхронизируется запросом «дай всё после N»; экспоненциальный backoff с jitter, иначе после перезапуска gateway миллион клиентов вернётся одновременно и положит его снова. «Сквозное шифрование?» Тогда сервер не может ни искать, ни модерировать, а история восстанавливается только на устройствах; выбор архитектурный, с большими последствиями, и назвать его стоит. «Как деплоить gateway, не рвя соединения?» Drain: перестать принимать новые, попросить клиентов переподключиться порциями, дать время на миграцию. Разорвёшь все сразу, получишь thundering herd.

Суть: единая точка приёма событий → правила (кому, каким каналом, можно ли сейчас) → очереди по каналам и приоритетам → воркеры-адаптеры к провайдерам с ретраями и circuit breaker. Дедупликация обязательна, потому что доставка at-least-once, а повторный push пользователь замечает мгновенно.

Компоненты

  1. Приём. API или подписка на события домена. Вход всегда несёт event_id для дедупликации и user_id.
  2. Движок правил / preferences. Решает, каким каналам слать (push, email, SMS, in-app, webhook), и смотрит на подписки пользователя, отписки, язык, часовой пояс, тихие часы и глобальные настройки типа уведомления. Здесь же живут отписка и юридические требования: для email ссылка отписки обязательна.
  3. Шаблонизатор. Отдельно от логики: текст, локализация, версии шаблонов. Шаблон в коде обернётся болью при первой же правке маркетологом.
  4. Очереди по каналу и приоритету. Транзакционные («код подтверждения», «платёж не прошёл») идут в отдельную очередь с высоким приоритетом; маркетинговые в свою, и её можно тормозить или дропать при перегрузке.
  5. Воркеры-адаптеры к провайдерам (APNs, FCM, SMTP/SES, SMS-агрегатор). У каждого свой пул (bulkhead), свой rate limit под квоту провайдера, свой circuit breaker.
  6. Трекинг. Статусы: queued → sent → delivered → opened → failed, вебхуки от провайдеров, отдельная обработка «жёстких» отказов: по невалидному токену удаляем устройство, по bounce email помечаем адрес.

Надёжность и дедупликация

  • Ретраи с экспонентой и jitter, разные политики по каналам: push дешёвый - можно повторять активно; SMS стоит денег, поэтому число попыток ограничено, а лимит расходов обязателен.
  • Ключ дедупликации = (user_id, event_id, channel) в Redis/БД с TTL. Без него at-least-once доставка событий превращается в «пять одинаковых пушей подряд», и это самая заметная пользователю поломка.
  • Дедупликация по смыслу, не только по id: «10 человек лайкнули ваш пост» сворачивается в одно уведомление в окне (например, 15 минут) вместо десяти пушей. Требование продуктовое, но живёт в этой же системе.
  • Rate limit на пользователя: не больше N уведомлений в час независимо от того, сколько событий произошло. Защищает и пользователя, и репутацию отправителя.
  • DLQ для окончательно неудавшихся: оттуда их можно разобрать и отправить заново.

Тихие часы и планирование

Тихие часы считаются в часовом поясе пользователя, а не сервера. На этом ошибаются постоянно. Внутри окна тихих часов уведомление не выбрасывается, а откладывается до открытия окна (это ровно задача планировщика отложенных задач из вопроса 9). Транзакционные и security-уведомления («вход с нового устройства») проходят всегда, и список исключений задаётся типом уведомления, а не отдельным флагом на каждом сообщении.

Отдельная тонкость масштаба: массовая рассылка на 10 млн пользователей не уходит одним залпом. Её разбивают на пачки и притормаживают (и по квоте провайдера, и по нагрузке на свои сервисы, потому что после пуша все эти люди одновременно откроют приложение, а вторичный пик на бэкенде часто больше, чем сама рассылка).

На чём ловят

«Отправим прямо из сервиса заказов» - упал SMTP, и заказы перестают создаваться. Уведомления всегда за очередью. Второе: не разделили транзакционные и маркетинговые, маркетинговая рассылка забивает очередь, и код подтверждения приходит через двадцать минут, когда он уже протух. И третье: забыть про обратную связь от провайдера. Никто не разбирает bounce и невалидные токены - и система годами долбит мёртвые адреса и попадает в спам-листы.

Суть: байты никогда не проходят через твой бэкенд. Клиент получает presigned URL и заливает напрямую в объектное хранилище; бэкенд хранит только метаданные. Раздача - через CDN с подписанными ссылками. Большие файлы - multipart с возобновлением, дедупликация - по хешу содержимого.

Загрузка

  1. Клиент запрашивает POST /uploads с именем, размером, типом и (желательно) хешем содержимого.
  2. Бэкенд проверяет права, квоту, лимит размера и допустимый тип, создаёт запись метаданных со статусом PENDING и выдаёт presigned URL с коротким TTL (минуты) и жёсткими условиями: конкретный ключ объекта, Content-Type, размер. Потолок «не больше N байт» в S3 ставит только presigned POST (условие content-length-range в политике), а presigned PUT умеет лишь зашить в подпись точный размер, заявленный клиентом.
  3. Клиент заливает напрямую в S3. Твои серверы не видят ни байта - это экономит трафик, память и снимает проблему «файл на 5 ГБ через Go-хендлер».
  4. Подтверждают двумя способами: либо клиент дергает POST /uploads/{id}/complete, либо (надёжнее) бэкенд слушает событие от хранилища о появлении объекта. Второе устойчиво к тому, что клиент закрыл вкладку. Статус переходит в READY.
  5. Фоном: антивирус, извлечение метаданных, превью и транскодирование. Всё через очередь, не в момент загрузки.
  6. Уборка мусора: объекты, оставшиеся в PENDING дольше суток, удаляются (lifecycle policy в бакете + сверка с метаданными). Без этого хранилище копит недозагруженные файлы, за которые ты платишь.

Большие файлы: чанки

  • Multipart upload: файл режется на части (обычно 5–100 МБ), каждая заливается по своему presigned URL, в конце вызывается complete со списком частей и их ETag.
  • Что это даёт: возобновление после обрыва (перезалить одну часть, а не весь файл), параллельность (несколько частей одновременно, на длинных RTT это заметно быстрее), обход лимита на размер одного запроса.
  • Клиент хранит состояние загрузки (какие части уже приняты), чтобы после перезапуска продолжить. Незавершённые multipart-загрузки тоже надо чистить политикой жизненного цикла, иначе за них капает счёт.

Дедупликация по хешу

Считаем SHA-256 содержимого (на клиенте, тогда можно вообще не заливать, или на сервере при обработке). Ключ объекта = хеш, метаданные ссылаются на него. Если файл с таким хешем уже есть, загрузка проходит мгновенно, место не тратится. Три обязательные оговорки: (1) нужен счётчик ссылок или отложенное удаление, иначе один пользователь удалит файл и сломает его у другого; (2) «мгновенная загрузка» по хешу без проверки владения открывает уязвимость: зная хеш, можно получить чужой файл, поэтому доступ проверяется по метаданным, а не по факту совпадения хеша; (3) если клиент шифрует файлы своим ключом, дедупликация не работает: одинаковые файлы превращаются в разный шифротекст. Ключ, выведенный из хеша самого файла, это обходит, но тогда сервер видит, что у двух людей один и тот же файл.

Раздача

  • CDN перед хранилищем: файлы иммутабельны (ключ содержит хеш или версию), поэтому кэшируются вечно с Cache-Control: public, max-age=31536000, immutable. Обновление файла = новый ключ, а не инвалидация.
  • Приватный контент раздаётся подписанными URL от CDN с коротким TTL и (для важного) привязкой к IP или подписью в куке. Не «секретная неугадываемая ссылка», если контент действительно приватный: такие ссылки утекают через реферер, мессенджеры и историю.
  • Range-запросы для видео и докачки, ETag/If-None-Match для условных запросов.
  • Стоимость: исходящий трафик обычно самая дорогая строка счёта. CDN берут ради скорости, но окупается он тем, что кэш-хит дешевле обращения к хранилищу.
На чём ловят

Проксировать файлы через свой сервис («так удобнее проверять права») - и упереться в трафик и память на первом же росте. Права проверяются при выдаче ссылки, а не при передаче байтов. Второе: presigned URL без ограничения размера и Content-Type, клиент зальёт что угодно и сколько угодно. Третье - доверять расширению файла и Content-Type от клиента: тип проверяют по сигнатуре содержимого на стороне сервера, иначе получишь HTML с JS, отданный с твоего домена. И четвёртое: забыть про уборку недозагруженных объектов и незавершённых multipart.

Суть: подсказки - это чтение с латентностью в десятки миллисекунд на каждое нажатие клавиши. Ответ строится заранее: trie, в узлах которого уже лежит готовый топ-K, целиком в памяти, обновляемый пачками офлайн. Кэш префиксов и debounce на клиенте убирают большую часть нагрузки ещё до бэкенда.

Требования и расчёт

  • p99 меньше 50–100 мс, иначе подсказки не успевают за печатью и бесполезны.
  • Нагрузка обманчиво большая: 10 млн поисков в день × ~5 нажатий = 50 млн запросов в день ≈ 600 rps средних, пик ×5 ≈ 3 000. Debounce 100–150 мс на клиенте режет это в 2–3 раза бесплатно.
  • Свежесть: для большинства запросов обновление раз в час-сутки нормально; для трендов («новость дня») нужен отдельный быстрый путь.

Структура данных

  • Trie. Путь по дереву соответствует префиксу. Наивный вариант «дойти до узла и обойти всё поддерево» слишком медленный: у популярного префикса «а» миллионы листьев.
  • Трюк, который и есть ответ: в каждом узле заранее хранится готовый топ-K (K = 5–10) завершений с их весами. Тогда запрос сводится к спуску по префиксу за O(len(prefix)) и возврату готового списка. Память растёт (K строк в каждом узле), зато чтение становится тривиальным.
  • Сжатие: radix tree уменьшает число узлов в разы; для очень больших словарей берут succinct-структуры или FST (так устроен Lucene).
  • Где живёт: целиком в памяти сервиса, реплики читают одну и ту же иммутабельную версию. Альтернатива без trie: Redis sorted set на префикс (ZRANGE pfx:{prefix} 0 9 REV) - проще в эксплуатации, но памяти больше и обновлять тяжелее.

Обновление

  1. Логи запросов → агрегация за окно (час/сутки) в частоты → фильтрация (стоп-слова, мат, персональные данные, редкие «хвосты», запросы с нулевым результатом) → пересборка структуры.
  2. Пересобирать целиком и подменять атомарно, а не мутировать на месте: новая версия trie строится офлайн, выкатывается на реплики, переключается указателем. Это снимает все проблемы блокировок и даёт мгновенный откат.
  3. Веса считаются не по одной частоте: свежесть (экспоненциальное затухание старых периодов), персонализация и география ложатся отдельными слоями поверх базового топа, чтобы не пересобирать всё под каждого пользователя.
  4. Тренды держат в отдельном маленьком «горячем» слое в Redis, который сливается с основным результатом на чтении. Свежесть появляется без пересборки основной структуры.

Кэш и клиент

  • Кэш префиксов на стороне сервиса: короткие префиксы (1–3 символа) дают подавляющую долю запросов и почти не меняются, поэтому их ответы кэшируются с большим TTL. Распределение резко Ципфовское, и небольшой кэш даёт очень высокий хитрейт.
  • На клиенте: debounce (не слать на каждый символ), отмена предыдущего запроса, локальный кэш уже полученных префиксов (пользователь набрал «моск», а ответ для «мос» у клиента уже есть, и он отфильтрует локально), плюс подстраховка от гонки: ответы приходят не в том порядке, и показывать надо только ответ на актуальный префикс.
Чем добить

«Опечатки?» Расстояние Дамерау-Левенштейна на уровне 1–2: обход trie с допуском ошибок или отдельный индекс n-грамм. Полноценный нечёткий поиск в реальном времени дорог, поэтому обычно ограничиваются известным списком популярных опечаток. «Многоязычность и раскладка?» - нормализация (регистр, диакритика), транслитерация и «неправильная раскладка» (ghbdtn → привет) отдельными путями поиска. «Персонализация?» Личная история идёт небольшим отдельным источником и сливается с общим топом на чтении; персональный trie на каждого пользователя не строят.

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

Модель: платёж - конечный автомат

CREATE TABLE payments (
    id              uuid PRIMARY KEY,
    idempotency_key text        NOT NULL,
    user_id         bigint      NOT NULL,
    amount          bigint      NOT NULL,        -- только целые: копейки/центы
    currency        char(3)     NOT NULL,
    state           text        NOT NULL,        -- CREATED|AUTHORIZED|CAPTURED|FAILED|REFUNDED
    provider_ref    text,                        -- id операции на стороне провайдера
    version         int         NOT NULL DEFAULT 0,
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now(),
    UNIQUE (user_id, idempotency_key)            -- защита от двойного создания
);

Три вещи, которые надо сказать про модель. Деньги считаем целым числом в минимальных единицах: float для денег сразу поднимает красный флаг. Переходы только вперёд и только условные: UPDATE ... WHERE id=$1 AND state='CREATED' делает повторы идемпотентными. Двойная запись для баланса: серьёзные системы хранят не «баланс», а неизменяемый журнал проводок и выводят баланс из него. Тогда история восстановима и расхождения ловятся арифметикой.

Поток

  1. Вход: POST /payments с Idempotency-Key. Повтор с тем же ключом возвращает тот же платёж, а не создаёт новый. Ключ и платёж создаются одной транзакцией.
  2. Только вызов провайдера неидемпотентен изначально. Передаём провайдеру свой ключ идемпотентности (нормальные провайдеры это поддерживают), сохраняем provider_ref.
  3. Неопределённость. Если ответ не пришёл, нельзя считать платёж неуспешным: он мог пройти. Состояние остаётся «в процессе», и стартует опрос статуса у провайдера по своему ключу, пока тот не ответит определённо.
  4. Вебхуки от провайдера подтверждают операцию асинхронно. Обязательно: проверить подпись, дедуплицировать по id события, обработать идемпотентно и выдержать любой порядок (вебхук «captured» может прийти раньше, чем ответ на исходный запрос).
  5. Outbox: смена состояния платежа и событие для остальной системы (payment.captured) пишутся одной транзакцией; релей публикует в брокер. Никаких прямых вызовов «сообщить сервису заказов» из транзакции.
  6. Сага для составных сценариев («оплата + резерв товара + бонусы»): оркестратор с персистентным состоянием, компенсации (возврат оформляется новой операцией, а не откатом), необратимые шаги в конце.

Сверка (reconciliation) - обязательная часть, а не опция

Любая распределённая система рано или поздно расходится с внешним контуром. Ежедневный (а для крупных - ежечасный) процесс выгружает реестр операций провайдера и сравнивает с собственной базой по трём признакам: есть у нас - нет у них (мы считаем платёж успешным, а денег нет), есть у них - нет у нас (деньги списаны, а заказ не создан - самое опасное), суммы или статусы различаются. Расхождения попадают в отдельную очередь ручного разбора со своим SLA. Кто называет сверку без подсказки, тот с деньгами работал, а не читал про них.

Что ещё стоит упомянуть

Аудит: неизменяемый журнал всех изменений состояния - кто, когда, по какому запросу; требование регуляторов и главный инструмент разбора инцидентов. Хранение реквизитов: не хранить вовсе - токенизация на стороне провайдера (PCI DSS); фраза «номер карты у нас не появляется» закрывает половину вопросов. Тайм-ауты и статусы: у каждого «ожидающего» состояния свой предельный срок, после которого платёж принудительно проверяется или отменяется. Мониторинг бизнес-метрик: конверсия авторизаций по провайдеру и по банку. Её падение видно раньше, чем ошибки в логах.

На чём ловят

Считать деньги в float. Использовать таймаут как признак отказа («не ответил - значит, не прошло»). Не завести ключ идемпотентности на входе: клиент нажал «оплатить» дважды, и списалось дважды. Обрабатывать вебхук неидемпотентно - провайдер повторит его, и возврат уйдёт дважды. И самое частое: не подумать, что делать, если деньги списались, а бизнес-операция не выполнилась. Правильный ответ включает и автоматическую компенсацию, и ручной разбор как терминальный путь.

Суть: три реализации под разные требования - Redis ZSET (просто, до миллионов задач), таблица в БД с FOR UPDATE SKIP LOCKED (надёжно, транзакционно, миллиарды), timing wheel в памяти (точность до миллисекунд, но только для ближнего горизонта). Промышленный ответ - двухуровневая схема: БД как долговременное хранилище, память как точный таймер на ближайшую минуту.

Требования, от которых зависит выбор

  • Точность: «примерно через час» (планировщик раз в минуту) или «ровно в 12:00:00.000» (таймер в памяти). Разница в стоимости на порядок.
  • Горизонт: минуты, дни или годы вперёд (напоминания, подписки).
  • Объём: тысячи задач или сотни миллионов «висящих» одновременно.
  • Гарантии: at-least-once (норма) или дубли недопустимы; переживает ли задача рестарт; можно ли её отменить и перепланировать.
  • Профиль: равномерно или пиками (всё на «ровно 9:00» случается очень часто и ломает наивные реализации).

Три подхода

ПодходКакПлюсыМинусы
Redis ZSETscore = время запуска; воркер делает ZRANGE due -inf <now> BYSCORE и атомарно забираетпросто, быстро, точность до секундынадёжность = надёжность Redis; память под все задачи; забор должен быть атомарным (Lua), иначе дубли
Таблица в БДSELECT ... WHERE run_at <= now() AND state='SCHEDULED' ORDER BY run_at LIMIT 100 FOR UPDATE SKIP LOCKEDтранзакционно с бизнес-данными, переживает всё, отмена и перепланирование тривиальны, миллиарды строк на партицияхточность = интервал опроса; нагрузка на БД; нужен вакуум/партиционирование
Timing wheelкольцевой массив слотов, тик двигает указатель; задача кладётся в слот по остатку от деленияO(1) на добавление и на срабатывание, миллионы таймеров, точность до тикав памяти → теряется при рестарте; горизонт ограничен размером колеса (иерархические колёса снимают это частично)

Есть ещё готовые механизмы брокеров: delayed exchange в RabbitMQ, DLX с TTL, SQS DelaySeconds (до 15 минут), scheduled messages в Pulsar. Они удобны для коротких задержек и плохи для длинных горизонтов и для отмены - сообщение, уже лежащее в очереди с задержкой, обычно нельзя ни отменить, ни перепланировать.

Уровень 1 - долговременное хранилище (горизонт: годы) producer schedule api tasks (id, run_at, state, payload) PARTITION BY RANGE(run_at) · index(run_at) WHERE state='SCHEDULED' отмена = UPDATE state перепланирование = UPDATE run_at Уровень 2 - точный таймер в памяти (горизонт: ближайшая минута) loader (раз в 30 с) run_at <= now()+60s FOR UPDATE SKIP LOCKED state → CLAIMED timing wheel тик 10 мс · O(1) на таймер ready queue worker worker worker DLQ Упал процесс между CLAIMED и выполнением - задача зависла: нужен claim с TTL и возврат просроченных в SCHEDULED Гарантия - at-least-once, поэтому сама задача обязана быть идемпотентной
БД хранит долго, память считает точно. Загрузчик переносит в колесо только ближний горизонт, поэтому число задач в памяти ограничено минутным окном, а не всей базой.

Детали, за которые ставят плюс

  • Claim с TTL, а не просто статус. Забирая задачу, воркер пишет claimed_until = now() + interval '5 min'; фоновый процесс возвращает в SCHEDULED всё, у чего срок истёк. Иначе упавший воркер забирает задачу с собой навсегда.
  • Идемпотентность выполнения обязательна: гарантия at-least-once, и при неудачном подтверждении задача выполнится дважды.
  • Ретраи с экспонентой и DLQ: неуспешная задача перепланируется на now() + backoff, после N попыток уходит в DLQ с алертом.
  • Пик на круглое время. Все задачи на «ровно 9:00» создают всплеск, который положит и БД, и потребителей. Лечат это jitter при планировании (размазать по окну ±N минут, если бизнес позволяет) и ограничением скорости выдачи задач воркерам.
  • Партиционирование по run_at: старые выполненные партиции отрезаются мгновенно вместо тяжёлого DELETE; индекс по run_at делают частичным, только по state='SCHEDULED', чтобы он оставался маленьким при миллиардах выполненных задач.
  • Точность против масштаба, названная явно: опрос БД раз в секунду даёт точность ±1 с и нагрузку 1 запрос/с - для 99 % задач этого достаточно. Точность в миллисекунды требует таймеров в памяти и, значит, механизма восстановления после рестарта. Требовать точность выше необходимой значит заниматься оверинжинирингом, и про это стоит сказать вслух.
Суть: ответ строится не как список технологий, а как порядок действий по возрастанию стоимости: измерить → кэш и индексы → вертикально → горизонтально stateless → реплики чтения → асинхронный хвост → шардинг. И честно назвать, что не масштабируется линейно.

Правильная структура ответа

  1. Сначала уточнить, x10 чего. Чтений, записей, объёма данных или числа пользователей? Это совершенно разные проблемы: x10 чтений лечится кэшем и репликами почти бесплатно, x10 записи упирается в шардинг или смену хранилища, x10 объёма - в партиционирование и архивацию.
  2. Посмотреть на измерения, а не гадать. «Открою дашборд и посмотрю, где насыщение: CPU, диск, пул соединений, лаг очереди, лаг репликации». Ответ «сначала измерю» на этом вопросе всегда правильный.
  3. Дальше по возрастанию стоимости:
    • Кэш и индексы обходятся дешевле всего. Один пропущенный индекс часто даёт те самые 10× без единой новой машины.
    • Вертикально: просто увеличить машину. Скучно, но до определённого размера дешевле любой переработки архитектуры, и зрелость как раз в том, чтобы это признать.
    • Горизонтально stateless-слой, то есть добавить реплик приложения за балансировщиком. Работает, только если приложение действительно stateless.
    • Реплики для чтения, обязательно с оговоркой про лаг и read-your-writes.
    • Вынести тяжёлое в асинхронный контур: всё, что не нужно клиенту для ответа, уезжает в очередь. Часто это даёт больше, чем новое железо.
    • Шардинг идёт последним и стоит дороже всего: выбор ключа, ребалансировка, кросс-шардовые запросы, распределённые транзакции. Назвать ключ и его недостатки обязательно.
  4. Назвать, что не масштабируется: единственный лидер БД на запись; горячий ключ или горячая партиция; глобальный счётчик/последовательность; блокировки на популярной строке; внешний провайдер с жёсткой квотой; кросс-шардовые транзакции и джойны; всё, что требует глобального порядка.
  5. Не забыть про деньги и эксплуатацию. x10 нагрузки превращается в x10 счёта, если ничего не оптимизировать. Иногда правильный ответ звучит как «сжать данные, удалить старое, поднять хитрейт кэша», а не «купить в 10 раз больше серверов».
Фраза, которая работает

«x10 по чтению я закрою кэшем и репликами почти без изменений архитектуры. x10 по записи упрётся в мастер БД. Здесь я бы сначала вынес всё некритичное в асинхронный контур и посмотрел, хватит ли этого, а если нет - шардировал бы по user_id, потому что 95 % запросов идут в пределах одного пользователя; ценой станут кросс-пользовательские отчёты, которые придётся делать в отдельном аналитическом хранилище.» Здесь есть и порядок действий, и обоснование ключа, и названная цена - ровно то, что оценивают.

Суть: первым падает самый узкий разделяемый ресурс, и в 80 % систем это база данных на запись. Хороший ответ называет конкретного кандидата, объясняет метрику, по которой это будет видно, и говорит, что произойдёт дальше по цепочке.

Список кандидатов по частоте

  1. База данных. Сначала не «падает», а деградирует: растёт время запросов, копятся блокировки, забивается пул соединений, растёт лаг репликации. Подкатегории: одна горячая строка (счётчик, баланс), длинные транзакции, отсутствующий индекс, автовакуум не успевает.
  2. Пул соединений. Очень частый и неочевидный первый рубеж: приложение масштабируется горизонтально, каждый инстанс держит 50 соединений, на 20 инстансов выходит 1000, а БД держит 500. Дальше все ждут в очереди за соединением, и латентность взрывается при свободных CPU.
  3. Кэш. Не сам Redis, а хитрейт: когда объём данных растёт, горячее множество перестаёт помещаться, хитрейт падает с 95 % до 80 %, и нагрузка на БД учетверяется при том же трафике. Самая коварная нелинейность в системе.
  4. Внешние зависимости с квотой: платёжный провайдер, SMS-шлюз, геокодер. Их лимит не растёт вместе с тобой.
  5. Диск. IOPS и место: логи, WAL, временные файлы сортировок. Кончилось место под WAL - БД встала целиком: классическая причина полной остановки.
  6. Единственные по природе компоненты: лидер кластера, шедулер, воркер, который «почему-то один», глобальный счётчик.
  7. Сеть и балансировщик: лимит соединений, полоса, число портов при NAT.

Как отвечать, чтобы это звучало как опыт

Не перечислять список, а разобрать свою схему: «в этой системе первым сдастся мастер БД: при 5 000 rps записи по 3 индексам на таблицу мы упрёмся в IOPS; увижу я это по росту p99 времени коммита и по длине очереди в пуле соединений; следом деградирует всё, что делит с ней пул, потому что таймауты начнут копить горутины». То есть: компонент → метрика → следствие.

Обязательно упомянуть каскад

Настоящие аварии почти никогда не выглядят как «упал один компонент». Выглядят так: БД замедлилась → запросы висят на таймаутах → горутины и соединения не освобождаются → сервис перестаёт отвечать на healthcheck → оркестратор убивает поды → оставшиеся получают ещё больше нагрузки → клиенты ретраят → нагрузка растёт ещё. Этот сценарий и средства против него (быстрые таймауты, bulkhead, circuit breaker, load shedding, отдельный порт для healthcheck, ретраи на одном уровне) тянут на сильнейшую часть ответа.

Суть: пройти по схеме и для каждого компонента спросить «что если его нет?». Важно назвать не только очевидные (мастер БД, балансировщик), но и неочевидные: DNS, конфиг-сервис, сервис аутентификации, общий Redis, единственный CI, и главное - общая зона отказа у якобы независимых реплик.

Чек-лист по схеме

КомпонентПочему SPOFЧто делают
Мастер БДзапись идёт только тудареплика + автоматический failover; принять, что при асинхронной репликации возможна потеря последних транзакций
Балансировщиквесь вход через негопара с VRRP / managed LB / DNS с несколькими адресами и health checks
API Gatewayединая точка входа by designнесколько stateless-реплик, деградация при недоступности зависимостей (кэш решений authn)
Redis без кластерападение = потеря кэша и всплеск на БД одновременнокластер/sentinel; и обязательно - способность работать при пустом кэше (single-flight, ограничение параллелизма к БД)
Брокербез него встают все асинхронные потокикластер с репликацией; outbox на стороне продюсера, чтобы события не терялись, пока брокер недоступен
Сервис аутентификацииего отказ = отказ всего продуктакэш валидации токенов с TTL, локальная проверка подписи JWT без похода в сервис
Конфиг-сервис / discoveryбез него не стартуют новые подыкэш последней валидной конфигурации на диске, работа на старых значениях
DNS«интернет сломался» почти всегда начинается здесьнесколько провайдеров, разумные TTL, устойчивость клиентов к смене адреса
Один регион / одна зонаавария ЦОД кладёт всёнесколько AZ обязательно; мультирегион - дорого, обсуждается отдельно
Внешний провайдерединственный платёжный шлюзвторой провайдер и переключение - или честно признать риск и заложить деградацию
Неочевидные SPOF, за которые дают дополнительные баллы
  • Общая зона отказа. Три реплики БД - но все в одной стойке, на одном гипервизоре или в одной AZ. Формально резерв есть, фактически нет. Тот же вопрос про общий блок питания, общий сетевой аплинк, общего провайдера.
  • Общая судьба по конфигурации. Плохой конфиг или битый релиз выкатывается одновременно на все реплики - резервирование не спасает. Лечится канареечными выкатками и поэтапным раскатом конфигурации.
  • Схема БД и миграции. Одна ломающая миграция кладёт все инстансы сразу; отсюда правило expand–migrate–contract и обратная совместимость на два релиза.
  • Сертификаты и секреты. Истёкший TLS-сертификат кладёт систему целиком, как бы плотно её ни зарезервировали по остальным осям.
  • Люди и процессы. Единственный человек, который знает, как разворачивать систему; единственный CI, без которого нельзя выкатить хотфикс; бэкап, из которого ни разу не восстанавливались (такой бэкап и не бэкап вовсе).
Правильная концовка ответа

Не «уберём все SPOF», а «уберу те, что не соответствуют целевой доступности, и явно приму остальные». Убрать очередную точку отказа стоит денег и сложности, а сложность сама по себе порождает аварии. Для 99.9 % достаточно резервирования внутри региона; мультирегион с активной записью стоит совершенно других денег, и предлагать его без требования значит заниматься тем же оверинжинирингом. Осознанно принять риск нормально для инженера, и интервьюеры ценят это выше списка из десяти кластеров.

Что стоит запомнить

  • Сначала выясни сценарии, границы scope и целевые p99/доступность; неверные требования делают последующие расчёты бесполезными.
  • Оцени отдельно чтения, записи, объём, retention и пики; одинаковый коэффициент роста не означает одинаковое решение.
  • Для каждого компонента назови метрику насыщения и каскад отказа, а не только резервную копию компонента.

Частые ошибки

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

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проверь нагрузочную оценку

Вычисли средний read/write RPS из входного описания и назови данные, которых не хватает для capacity plan.

traffic:
  dau: 2000000
  reads_per_user_day: 5
  writes_per_user_day: 1
  peak_to_average: unknown
Ответ и разбор

Чтения: 10 млн / 86 400 ≈ 116 RPS в среднем; записи: 2 млн / 86 400 ≈ 23 RPS. Среднее не учитывает суточные пики, burst, распределение активности, fan-out на внутренние запросы и стоимость каждой операции. Уточни peak factor и p99 цель до sizing.

Код-ревью

2. Найди недостающие требования

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

product: social_feed
users: 10000000
components: [kafka, redis, services: 20]
Ответ и разбор

Нужно выяснить главные сценарии и границы MVP (публикация, лента, поиск, модерация), соотношение чтений и записей и пиковую нагрузку, а также целевые p99/доступность и допустимую свежесть ленты. Уточни retention/объём данных и модель fan-out. Без этого выбор Kafka/Redis/числа сервисов не следует из задачи.

Применение

3. Оцени первый узкий ресурс

У API три реплики, каждая держит 40 DB connections, а кластер БД допускает 100 соединений приложений. Какое ограничение уже видно и что проверишь перед увеличением пула?

Ответ и разбор

Потенциальный максимум приложения равен 120 соединениям, что превышает лимит 100. Ограничь pool per pod так, чтобы суммарное число с запасом укладывалось в лимит, включая миграции/фоновые jobs и другие сервисы. Перед ростом пула посмотри DB CPU/IO, ожидание соединений, длительность запросов и число активных транзакций.

Применение

4. Нарисуй поведение при отказе кэша

Redis упал, а API автоматически направляет все промахи в БД. Назови каскад отказа и два предохранителя, которые помогут остановить рост нагрузки.

Ответ и разбор

Ошибки Redis становятся промахами, поток запросов резко увеличивает DB load, запросы замедляются, занимают pool и goroutines, затем растут таймауты и клиентские retries. Поставь bounded concurrency/singleflight для загрузок и load shedding/circuit breaker с короткими deadline. Если stale данные допустимы, ограниченно отдавай их; настрой retries на одном уровне и метрики cache error vs miss.