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 |
|---|---|---|
| Средний RPS | DAU × действий в день ÷ 86 400 | 107 × 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 %),
дашборд лага очередей и лага репликации.
- Начать с решения. Первые же слова: «поставим Kafka и Kubernetes», а что строим, ещё не выяснено. Мгновенный минус.
- Не задать ни одного вопроса. Молча проектировать по своей фантазии - значит решить не ту задачу.
- Не считать. Без цифр невозможно обосновать ни шардинг, ни кэш, ни выбор хранилища; всё превращается в перечисление модных слов.
- Оверинжиниринг. Микросервисы, CQRS, event sourcing и три вида кэша для сервиса на 100 rps. Интервьюер проверяет чувство меры не меньше, чем знания.
- Молчать и думать про себя. Оценивают ход рассуждения; неозвученные мысли не существуют. Проговаривай варианты, даже отвергаемые.
- Не называть цену решений. Каждое «добавим кэш» закрывай фразой «ценой будет инвалидация и окно устаревания».
- Спорить с интервьюером или упираться в первую идею. На «а если так?» надо обдумать и, если аргумент верный, вслух согласиться и поменять решение. Это плюс, а не минус.
- Игнорировать отказы. Схема без ответа на «что упадёт и что тогда» остаётся схемой из презентации, а не из эксплуатации.
- Уйти в детали слишком рано. Полчаса про формат JSON, когда не нарисована архитектура. Держи уровень абстракции и опускайся по запросу.
- Забыть про эксплуатацию: миграции, деплой, мониторинг, стоимость железа. Одно предложение про каждое сильно поднимает впечатление.
Вопросы
12Требования и расчёт
- Функционально: создать короткую ссылку (опционально свой алиас и 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 с периодическим сбросом в БД. Точность «примерно» здесь допустима, и это надо проговорить как осознанный выбор.
«Кастомный алиас?» - уникальность проверяем отдельно, через
INSERT ... ON CONFLICT, и алиасы живут в том же пространстве ключей,
чтобы не пересечься со сгенерированными (например, генерируемые всегда длиной 7,
кастомные от 8, или отдельный префикс). «Удаление и TTL?» - поле
expires_at, фоновая чистка партициями по дате, кэш с тем же TTL.
«Защита от злоупотреблений?» - rate limit на создание, проверка URL
по спискам вредоносных, запрет рекурсии (короткая ссылка на короткую ссылку).
«Мультирегион?» - ссылки иммутабельны, поэтому чтение
реплицируется в каждый регион и раздаётся локально; запись оставляем
в одном регионе, диапазоны id при этом расходятся по регионам без конфликтов.
Требования
- Ограничивать по разным ключам: 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 (в идеале рядом, в том же регионе). Для мультирегиона держат лимиты по регионам, а не глобальный счётчик: кросс-региональная синхронизация на каждый запрос невозможна по латентности.
- Конфигурация лимитов меняется динамически, без релиза: правила в конфиг-сервисе с горячей перезагрузкой, потому что лимиты меняют чаще, чем код.
Fail open, то есть пропускать запросы. Лимитер прикрывает сервис от злоупотреблений, в функциональность продукта он не входит; отказ лимитера не должен превращаться в отказ сервиса. При этом переход в fail open должен быть заметным (метрика + алерт) и, желательно, деградированным: локальный счётчик на инстансе с грубым лимитом продолжает работать и защищает от совсем уж дикого трафика. Fail closed оправдан только там, где превышение лимита само по себе опасно: попытки ввода пароля, дорогая внешняя операция.
Неатомарная проверка: GET, потом INCR - при конкуренции
лимит протекает; нужен Lua или INCR с EXPIRE в одной транзакции.
Лимит только по IP - за NAT сидит целый офис, а злоумышленник меняет адреса;
нужен многоуровневый ключ. Без Retry-After клиенты начинают
ретраить синхронно и устраивают storm. И забыть, что 429 тоже стоит денег:
если каждый отброшенный запрос проходит authn и три сетевых хопа, злоумышленник
всё равно тебя утопит, так что отбрасывать надо как можно раньше и дешевле.
Расчёт, который определяет выбор
- 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 дней) и ограничивают глубину.
Гибрид в деталях
- Порог, скажем, 10 000 подписчиков. Ниже него работает fanout on write в Redis-список
feed:{user_id}(обрезанный до 500–1000 записей, хранятся только id постов). - Выше порога не разносим вообще. При чтении: берём готовую ленту, отдельно читаем свежие посты тех знаменитостей, на кого подписан читатель (их обычно единицы-десятки и они прекрасно кэшируются), сливаем по времени/ранжированию.
- Fanout выполняется асинхронно воркерами из очереди, публикация поста не ждёт разноса. Для активных подписчиков приоритет выше: те, кто онлайн, получают пост за секунды, остальные - за минуты.
- Материализуем ленты только активных пользователей. Для вернувшегося через полгода лента собирается на лету и потом материализуется.
Дополнительные вопросы, которые почти всегда задают
- Удаление поста. Из миллионов лент его не вычищают, а фильтруют при чтении по «удалённым» (проверка id по множеству удалённых или по флагу в кэше постов).
- Ранжирование вместо хронологии. Тогда лента собирается из кандидатов и скоринга на чтении, и push вырождается в «список кандидатов», а не в готовую ленту.
- Согласованность. Лента eventual по определению: подписался - новые посты появятся, старые подтянутся отдельным процессом. Это нормально, и это надо назвать компромиссом, а не багом.
- Хранилище постов берут key-value или шардированное по
post_id; ленты живут в Redis; связи подписок лежат отдельно, шардированные поfollower_idдля «на кого подписан» и поfollowee_idдля «кто подписан» (две денормализованные таблицы, потому что нужны оба направления).
Транспорт
- WebSocket берут по умолчанию: двунаправленно, низкие накладные, работает через браузер. Альтернативы: SSE (только сервер→клиент, зато проще и переживает прокси), long polling (fallback), для мобильных - пуш-каналы (APNs/FCM), когда приложение в фоне и соединение держать нельзя.
- Слой соединений отдельный от бизнес-логики. Gateway держит сокеты и не знает домена; логика - в сервисах. Так логику можно деплоить, не рвя миллионы соединений.
- Реестр присутствия:
user_id → gateway_idв Redis с TTL, обновляется heartbeat. Чтобы доставить сообщение, сервис смотрит реестр и шлёт на нужный gateway (напрямую или через топик/канал поgateway_id). - Масштаб: одна Go-машина спокойно держит сотни тысяч соединений (~2–10 КБ на соединение при аккуратных буферах), так что 10 млн онлайна закрываются десятками узлов, а не тысячами. Узкое место обычно не CPU, а память под буферы и количество файловых дескрипторов.
Путь сообщения и подтверждения
- Клиент отправляет сообщение с собственным
client_msg_id(UUID), и это ключ идемпотентности: повтор после обрыва не создаст дубль. - Сервис сначала персистит сообщение и присваивает ему серверный
порядковый номер
seqв пределах диалога, потом отвечает отправителюack: sent. Порядок «сохранить → подтвердить» принципиален: подтверждать до записи - значит терять сообщения при падении. - Дальше доставка получателям. Онлайн идёт через gateway, а для оффлайна сообщение
просто лежит в истории, и клиент заберёт его при следующем подключении по своему
last_seen_seq. - Получатель присылает
delivered, при открытии чата -read. Оба статуса тоже сообщения, и они тоже могут потеряться, поэтому хранятся как «максимальный подтверждённый seq на пользователя», а не как флаг на каждом сообщении: так они идемпотентны и компактны.
Часы клиентов расходятся на минуты, и сортировка по ним даёт перепутанный диалог.
Порядок задаёт сервер: монотонный 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.
Компоненты
- Приём. API или подписка на события домена. Вход всегда несёт
event_idдля дедупликации иuser_id. - Движок правил / preferences. Решает, каким каналам слать (push, email, SMS, in-app, webhook), и смотрит на подписки пользователя, отписки, язык, часовой пояс, тихие часы и глобальные настройки типа уведомления. Здесь же живут отписка и юридические требования: для email ссылка отписки обязательна.
- Шаблонизатор. Отдельно от логики: текст, локализация, версии шаблонов. Шаблон в коде обернётся болью при первой же правке маркетологом.
- Очереди по каналу и приоритету. Транзакционные («код подтверждения», «платёж не прошёл») идут в отдельную очередь с высоким приоритетом; маркетинговые в свою, и её можно тормозить или дропать при перегрузке.
- Воркеры-адаптеры к провайдерам (APNs, FCM, SMTP/SES, SMS-агрегатор). У каждого свой пул (bulkhead), свой rate limit под квоту провайдера, свой circuit breaker.
- Трекинг. Статусы:
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 и невалидные токены - и система годами долбит мёртвые адреса и попадает в спам-листы.
Загрузка
- Клиент запрашивает
POST /uploadsс именем, размером, типом и (желательно) хешем содержимого. - Бэкенд проверяет права, квоту, лимит размера и допустимый тип, создаёт запись
метаданных со статусом
PENDINGи выдаёт presigned URL с коротким TTL (минуты) и жёсткими условиями: конкретный ключ объекта,Content-Type, размер. Потолок «не больше N байт» в S3 ставит только presigned POST (условиеcontent-length-rangeв политике), а presigned PUT умеет лишь зашить в подпись точный размер, заявленный клиентом. - Клиент заливает напрямую в S3. Твои серверы не видят ни байта - это экономит трафик, память и снимает проблему «файл на 5 ГБ через Go-хендлер».
- Подтверждают двумя способами: либо клиент дергает
POST /uploads/{id}/complete, либо (надёжнее) бэкенд слушает событие от хранилища о появлении объекта. Второе устойчиво к тому, что клиент закрыл вкладку. Статус переходит вREADY. - Фоном: антивирус, извлечение метаданных, превью и транскодирование. Всё через очередь, не в момент загрузки.
- Уборка мусора: объекты, оставшиеся в
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.
Требования и расчёт
- 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) - проще в эксплуатации, но памяти больше и обновлять тяжелее.
Обновление
- Логи запросов → агрегация за окно (час/сутки) в частоты → фильтрация (стоп-слова, мат, персональные данные, редкие «хвосты», запросы с нулевым результатом) → пересборка структуры.
- Пересобирать целиком и подменять атомарно, а не мутировать на месте: новая версия trie строится офлайн, выкатывается на реплики, переключается указателем. Это снимает все проблемы блокировок и даёт мгновенный откат.
- Веса считаются не по одной частоте: свежесть (экспоненциальное затухание старых периодов), персонализация и география ложатся отдельными слоями поверх базового топа, чтобы не пересобирать всё под каждого пользователя.
- Тренды держат в отдельном маленьком «горячем» слое в Redis, который сливается с основным результатом на чтении. Свежесть появляется без пересборки основной структуры.
Кэш и клиент
- Кэш префиксов на стороне сервиса: короткие префиксы (1–3 символа) дают подавляющую долю запросов и почти не меняются, поэтому их ответы кэшируются с большим TTL. Распределение резко Ципфовское, и небольшой кэш даёт очень высокий хитрейт.
- На клиенте: debounce (не слать на каждый символ), отмена предыдущего запроса, локальный кэш уже полученных префиксов (пользователь набрал «моск», а ответ для «мос» у клиента уже есть, и он отфильтрует локально), плюс подстраховка от гонки: ответы приходят не в том порядке, и показывать надо только ответ на актуальный префикс.
«Опечатки?» Расстояние Дамерау-Левенштейна на уровне 1–2: обход trie
с допуском ошибок или отдельный индекс n-грамм.
Полноценный нечёткий поиск в реальном времени дорог, поэтому обычно ограничиваются
известным списком популярных опечаток. «Многоязычность и раскладка?» -
нормализация (регистр, диакритика), транслитерация и «неправильная раскладка»
(ghbdtn → привет) отдельными путями поиска. «Персонализация?»
Личная история идёт небольшим отдельным источником и сливается с общим топом
на чтении; персональный trie на каждого пользователя не строят.
Модель: платёж - конечный автомат
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' делает повторы
идемпотентными. Двойная запись для баланса:
серьёзные системы хранят не «баланс», а неизменяемый журнал проводок и выводят
баланс из него. Тогда история восстановима и расхождения ловятся арифметикой.
Поток
- Вход:
POST /paymentsсIdempotency-Key. Повтор с тем же ключом возвращает тот же платёж, а не создаёт новый. Ключ и платёж создаются одной транзакцией. - Только вызов провайдера неидемпотентен изначально.
Передаём провайдеру свой ключ идемпотентности (нормальные провайдеры
это поддерживают), сохраняем
provider_ref. - Неопределённость. Если ответ не пришёл, нельзя считать платёж неуспешным: он мог пройти. Состояние остаётся «в процессе», и стартует опрос статуса у провайдера по своему ключу, пока тот не ответит определённо.
- Вебхуки от провайдера подтверждают операцию асинхронно. Обязательно: проверить подпись, дедуплицировать по id события, обработать идемпотентно и выдержать любой порядок (вебхук «captured» может прийти раньше, чем ответ на исходный запрос).
- Outbox: смена состояния платежа и событие для остальной системы
(
payment.captured) пишутся одной транзакцией; релей публикует в брокер. Никаких прямых вызовов «сообщить сервису заказов» из транзакции. - Сага для составных сценариев («оплата + резерв товара + бонусы»): оркестратор с персистентным состоянием, компенсации (возврат оформляется новой операцией, а не откатом), необратимые шаги в конце.
Сверка (reconciliation) - обязательная часть, а не опция
Любая распределённая система рано или поздно расходится с внешним контуром. Ежедневный (а для крупных - ежечасный) процесс выгружает реестр операций провайдера и сравнивает с собственной базой по трём признакам: есть у нас - нет у них (мы считаем платёж успешным, а денег нет), есть у них - нет у нас (деньги списаны, а заказ не создан - самое опасное), суммы или статусы различаются. Расхождения попадают в отдельную очередь ручного разбора со своим SLA. Кто называет сверку без подсказки, тот с деньгами работал, а не читал про них.
Аудит: неизменяемый журнал всех изменений состояния - кто, когда, по какому запросу; требование регуляторов и главный инструмент разбора инцидентов. Хранение реквизитов: не хранить вовсе - токенизация на стороне провайдера (PCI DSS); фраза «номер карты у нас не появляется» закрывает половину вопросов. Тайм-ауты и статусы: у каждого «ожидающего» состояния свой предельный срок, после которого платёж принудительно проверяется или отменяется. Мониторинг бизнес-метрик: конверсия авторизаций по провайдеру и по банку. Её падение видно раньше, чем ошибки в логах.
Считать деньги в float. Использовать таймаут как признак отказа
(«не ответил - значит, не прошло»). Не завести ключ идемпотентности на входе:
клиент нажал «оплатить» дважды, и списалось дважды. Обрабатывать вебхук
неидемпотентно - провайдер повторит его, и возврат уйдёт дважды. И самое частое:
не подумать, что делать, если деньги списались, а бизнес-операция не выполнилась.
Правильный ответ включает и автоматическую компенсацию, и ручной разбор
как терминальный путь.
FOR UPDATE SKIP LOCKED
(надёжно, транзакционно, миллиарды), timing wheel в памяти (точность до миллисекунд,
но только для ближнего горизонта). Промышленный ответ - двухуровневая схема:
БД как долговременное хранилище, память как точный таймер на ближайшую минуту.Требования, от которых зависит выбор
- Точность: «примерно через час» (планировщик раз в минуту) или «ровно в 12:00:00.000» (таймер в памяти). Разница в стоимости на порядок.
- Горизонт: минуты, дни или годы вперёд (напоминания, подписки).
- Объём: тысячи задач или сотни миллионов «висящих» одновременно.
- Гарантии: at-least-once (норма) или дубли недопустимы; переживает ли задача рестарт; можно ли её отменить и перепланировать.
- Профиль: равномерно или пиками (всё на «ровно 9:00» случается очень часто и ломает наивные реализации).
Три подхода
| Подход | Как | Плюсы | Минусы |
|---|---|---|---|
| Redis ZSET | score = время запуска; воркер делает 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.
Они удобны для коротких задержек и плохи для длинных горизонтов и для отмены -
сообщение, уже лежащее в очереди с задержкой, обычно нельзя ни отменить,
ни перепланировать.
Детали, за которые ставят плюс
- 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 % задач этого достаточно. Точность в миллисекунды требует таймеров в памяти и, значит, механизма восстановления после рестарта. Требовать точность выше необходимой значит заниматься оверинжинирингом, и про это стоит сказать вслух.
Правильная структура ответа
- Сначала уточнить, x10 чего. Чтений, записей, объёма данных или числа пользователей? Это совершенно разные проблемы: x10 чтений лечится кэшем и репликами почти бесплатно, x10 записи упирается в шардинг или смену хранилища, x10 объёма - в партиционирование и архивацию.
- Посмотреть на измерения, а не гадать. «Открою дашборд и посмотрю, где насыщение: CPU, диск, пул соединений, лаг очереди, лаг репликации». Ответ «сначала измерю» на этом вопросе всегда правильный.
- Дальше по возрастанию стоимости:
- Кэш и индексы обходятся дешевле всего. Один пропущенный индекс часто даёт те самые 10× без единой новой машины.
- Вертикально: просто увеличить машину. Скучно, но до определённого размера дешевле любой переработки архитектуры, и зрелость как раз в том, чтобы это признать.
- Горизонтально stateless-слой, то есть добавить реплик приложения за балансировщиком. Работает, только если приложение действительно stateless.
- Реплики для чтения, обязательно с оговоркой про лаг и read-your-writes.
- Вынести тяжёлое в асинхронный контур: всё, что не нужно клиенту для ответа, уезжает в очередь. Часто это даёт больше, чем новое железо.
- Шардинг идёт последним и стоит дороже всего: выбор ключа, ребалансировка, кросс-шардовые запросы, распределённые транзакции. Назвать ключ и его недостатки обязательно.
- Назвать, что не масштабируется: единственный лидер БД на запись; горячий ключ или горячая партиция; глобальный счётчик/последовательность; блокировки на популярной строке; внешний провайдер с жёсткой квотой; кросс-шардовые транзакции и джойны; всё, что требует глобального порядка.
- Не забыть про деньги и эксплуатацию. x10 нагрузки превращается в x10 счёта, если ничего не оптимизировать. Иногда правильный ответ звучит как «сжать данные, удалить старое, поднять хитрейт кэша», а не «купить в 10 раз больше серверов».
«x10 по чтению я закрою кэшем и репликами почти без изменений архитектуры.
x10 по записи упрётся в мастер БД. Здесь я бы сначала вынес всё некритичное
в асинхронный контур и посмотрел, хватит ли этого, а если нет - шардировал бы
по user_id, потому что 95 % запросов идут в пределах одного
пользователя; ценой станут кросс-пользовательские отчёты, которые придётся
делать в отдельном аналитическом хранилище.» Здесь есть и порядок действий,
и обоснование ключа, и названная цена - ровно то, что оценивают.
Список кандидатов по частоте
- База данных. Сначала не «падает», а деградирует: растёт время запросов, копятся блокировки, забивается пул соединений, растёт лаг репликации. Подкатегории: одна горячая строка (счётчик, баланс), длинные транзакции, отсутствующий индекс, автовакуум не успевает.
- Пул соединений. Очень частый и неочевидный первый рубеж: приложение масштабируется горизонтально, каждый инстанс держит 50 соединений, на 20 инстансов выходит 1000, а БД держит 500. Дальше все ждут в очереди за соединением, и латентность взрывается при свободных CPU.
- Кэш. Не сам Redis, а хитрейт: когда объём данных растёт, горячее множество перестаёт помещаться, хитрейт падает с 95 % до 80 %, и нагрузка на БД учетверяется при том же трафике. Самая коварная нелинейность в системе.
- Внешние зависимости с квотой: платёжный провайдер, SMS-шлюз, геокодер. Их лимит не растёт вместе с тобой.
- Диск. IOPS и место: логи, WAL, временные файлы сортировок. Кончилось место под WAL - БД встала целиком: классическая причина полной остановки.
- Единственные по природе компоненты: лидер кластера, шедулер, воркер, который «почему-то один», глобальный счётчик.
- Сеть и балансировщик: лимит соединений, полоса, число портов при NAT.
Как отвечать, чтобы это звучало как опыт
Не перечислять список, а разобрать свою схему: «в этой системе первым
сдастся мастер БД: при 5 000 rps записи по 3 индексам на таблицу
мы упрёмся в IOPS; увижу я это по росту p99 времени коммита и по
длине очереди в пуле соединений; следом деградирует всё, что делит с ней пул,
потому что таймауты начнут копить горутины». То есть: компонент → метрика →
следствие.
Настоящие аварии почти никогда не выглядят как «упал один компонент». Выглядят так: БД замедлилась → запросы висят на таймаутах → горутины и соединения не освобождаются → сервис перестаёт отвечать на healthcheck → оркестратор убивает поды → оставшиеся получают ещё больше нагрузки → клиенты ретраят → нагрузка растёт ещё. Этот сценарий и средства против него (быстрые таймауты, bulkhead, circuit breaker, load shedding, отдельный порт для healthcheck, ретраи на одном уровне) тянут на сильнейшую часть ответа.
Чек-лист по схеме
| Компонент | Почему 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 обязательно; мультирегион - дорого, обсуждается отдельно |
| Внешний провайдер | единственный платёжный шлюз | второй провайдер и переключение - или честно признать риск и заложить деградацию |
- Общая зона отказа. Три реплики БД - но все в одной стойке, на одном гипервизоре или в одной 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.