Системный дизайн: расчёты, компоненты, типовые задачи
Кратко о теме
Заголовок раздела «Кратко о теме»Системный дизайн на собеседовании — это не проверка знания «правильной архитектуры», а проверка процесса. Интервьюер смотрит, умеете ли вы превратить размытое «спроектируй чат» в набор измеримых требований, посчитать нагрузку на салфетке, выбрать хранилище под профиль доступа, назвать узкие места и честно проговорить, что вы не делаете и почему. Почти все задачи решаются одним и тем же каркасом: функциональные требования → нефункциональные (RPS, latency, объём, доступность, консистентность) → оценка нагрузки в числах → API-контракт → модель данных → верхнеуровневая схема компонентов → масштабирование и точки отказа → эксплуатация (метрики, деградация, миграции). Если вы держите этот порядок, любая из семидесяти задач ниже — это подстановка чисел в одну и ту же рамку.
Вторая половина навыка — арифметика порядков. Нужно на память знать, что оперативная память отдаёт десятки-сотни гигабайт в секунду с задержкой около сотни наносекунд, NVMe — гигабайты в секунду с задержкой десятков микросекунд, HDD — сотни мегабайт в секунду последовательно, но всего сотню-две случайных операций в секунду, а RTT внутри дата-центра — десятые доли миллисекунды против десятков-сотен миллисекунд между континентами. Из этих чисел напрямую выводятся решения: «сообщения — LSM-хранилище, потому что мы пишем больше, чем читаем случайно», «этот индекс не влезет в память, значит будет random read по диску», «межрегиональная синхронная репликация не даст нам p99 в 200 мс». Кандидат, который считает, отличается от кандидата, который перечисляет модные слова.
Третье — понимание, что все решения парные. Fan-out on write ускоряет чтение и удорожает запись. Кэш снижает задержку и вносит рассинхрон. Синхронная репликация даёт линеаризуемость и убивает latency и доступность. Шардирование даёт горизонтальный рост и убивает кросс-шардовые транзакции и join. Асинхронные события развязывают сервисы и превращают «атомарную операцию» в сагу с компенсациями. На собеседовании ценится не выбор, а фраза «я выбираю А, плачу за это Б, и вот при каком условии я поменяю решение».
Отдельная категория заданий — «пул требований», когда интервьюер диктует список: DAU, размер сообщения, «реалтайм», «история всегда». Это не вопросы, а вводные данные, и правильная реакция — не кивнуть, а сразу перевести каждое требование в проектное следствие: из DAU и числа сообщений получить RPS и объём в год, из «реалтайма» — WebSocket и бюджет задержки, из «истории всегда» — тиринг хранения вместо TTL. Ниже требования такого рода разобраны именно так: что оно означает, во что превращается в архитектуре и какой уточняющий вопрос стоит задать.
Вопросы и ответы
Заголовок раздела «Вопросы и ответы»Как бы вы спроектировали сервис для сокращения ссылок?
Заголовок раздела «Как бы вы спроектировали сервис для сокращения ссылок?»Коротко. Это read-heavy KV-сервис: два эндпоинта (POST /links создаёт короткий код, GET /{code} отдаёт редирект), хранилище «код → длинный URL», шардированное по хешу кода, перед ним кэш горячих кодов, генерация кода — из монотонного счётчика в base62 (или Snowflake-подобный ID), аналитика переходов — асинхронно через очередь, а не в синхронном пути редиректа.
Глубже. Ключевые развилки, которые интервьюер ждёт:
- Генерация кода. Три варианта. (1) Хеш от URL (MD5/SHA), берём первые 7–8 символов base62 — нужна проверка коллизий и обработка «тот же URL от двух пользователей». (2) Счётчик: сервис берёт у координатора (etcd/ZooKeeper/отдельная таблица) диапазон, например 10 000 ID, и раздаёт локально без сетевых вызовов; кодируем в base62. Плюс — нет коллизий и проверок; минус — коды предсказуемы и перечислимы, что решается перемешиванием битов (Feistel/умножение по модулю). (3) Заранее сгенерированный пул свободных кодов в отдельной таблице. Для интервью честный ответ: диапазоны счётчика + обфускация.
- Ёмкость. 62^7 ≈ 3,5·10^12 — семи символов хватит на любой реалистичный горизонт.
- Хранилище. Схема простая:
code (PK), long_url, owner_id, created_at, expires_at. Профиль — точечное чтение по PK, никаких join и range-сканов, поэтому подходит и Postgres с шардированием, и Cassandra/Scylla, и DynamoDB. Кастомные алиасы — уникальный индекс наcode, конфликт → 409. - Кэш. Распределение переходов сильно перекошено (закон Пуассона тут не работает, работает Zipf): 10–20 % кодов дают 80–90 % трафика. LRU-кэш в Redis плюс локальный in-process кэш на несколько секунд снимает почти всю нагрузку с БД. Записи иммутабельны, поэтому инвалидация нужна только при удалении/истечении.
- Редирект.
301кэшируется браузером и CDN — минимум нагрузки, но убивает аналитику и возможность поменять цель.302/307— каждый переход приходит к нам, аналитика полная. Обычно берут302и явно это объясняют. - Аналитика. В обработчике редиректа только «положить событие в буфер/Kafka», агрегация — отдельный консьюмер в ClickHouse. Никаких
UPDATE ... SET clicks = clicks + 1на горячем пути. - Что ещё спросят: защита от фишинга (проверка URL по blacklist), rate limit на создание, приватные/одноразовые ссылки, TTL и удаление, мультирегион (реплики на чтение в каждом регионе, запись — в один).
Task: Design the architecture of Twitter
Заголовок раздела «Task: Design the architecture of Twitter»Коротко. Ядро задачи — не «где хранить твиты», а как строить домашнюю ленту: fan-out on write (при публикации разложить ID твита в предпосчитанные ленты подписчиков в Redis) даёт быстрое чтение, fan-out on read (собрать ленту в момент запроса из твитов тех, на кого подписан) — дешёвую запись. Правильный ответ — гибрид: fan-out on write для обычных пользователей и fan-out on read для «селебрити» с миллионами подписчиков.
Глубже. Разбиение на сервисы: tweet-service (запись твита, ID — Snowflake, чтобы был сортируемым по времени), graph-service (подписки: follower_id → followee_id, две таблицы для обоих направлений), timeline-service (материализованные ленты), fanout-worker (консьюмер события «новый твит»), search (инвертированный индекс, Elasticsearch/Manticore), media (объектное хранилище + CDN), notification. Домашняя лента хранится как список ID твитов в Redis (LPUSH + LTRIM до 800–1000 элементов), сами твиты — в KV/шардированном хранилище и в кэше; отдача ленты = один Redis-запрос + мультиget твитов.
Числа для оценки: ~200–400 млн DAU, средний твит ~300 байт текста + метаданные, чтений на порядок-два больше, чем записей (примерно 100:1). Именно асимметрия оправдывает предпосчёт. Проблема «Justin Bieber»: 100 млн подписчиков × запись в 100 млн ключей на один твит — недопустимо, поэтому твиты таких аккаунтов подмешиваются на чтении, а результат мерджится с материализованной лентой по timestamp. Дополнительно: ленты только для активных пользователей (не строим ленту тому, кто не заходил месяц — соберём на первом заходе), шардирование Redis по user_id, идемпотентность fan-out по (tweet_id, user_id), отдельная «лента упоминаний», ранжирование как отдельный слой поверх хронологии.
Task: Design a URL shortener service (100k rps)
Заголовок раздела «Task: Design a URL shortener service (100k rps)»Коротко. См. выше про устройство сокращателя; специфика этой формулировки — обосновать числами, что 100k rps держится кэшем и горизонтальным масштабированием stateless-слоя, а не «мощной БД». При 100k rps редиректов и попадании в кэш 95 % на БД доходит ~5k rps точечных чтений по ключу — это нагрузка на 3–5 шардов с репликами.
Глубже. Прикидка на салфетке. 100 000 rps × 86 400 с ≈ 8,6·10^9 переходов в сутки. Записей при соотношении 100:1 — около 1000 rps, ~86 млн новых ссылок в сутки; запись code + url + метаданные ~ 500 байт → ~43 ГБ/сутки, ~16 ТБ/год без репликации, ×3 с репликацией. Трафик редиректов: ответ 302 с заголовками ~ 300–500 байт → 100k × 400 B ≈ 40 МБ/с ≈ 320 Мбит/с плюс входящие запросы — то есть сеть не проблема, проблема — соединения и CPU на TLS. Один Go-инстанс отдаёт такой ответ на уровне десятков тысяч rps, значит нужно порядка 10–20 инстансов с запасом на пик и на выкатку. Кэш: если горячих кодов 100 млн × 500 байт ≈ 50 ГБ — это кластер Redis из нескольких узлов или локальный кэш на инстансах.
Отдельно стоит проговорить: (1) редирект идеально кэшируется на CDN/edge, и если бизнес согласен на 301 для части ссылок, значительная доля 100k rps вообще не доходит до наших серверов; (2) счётчики переходов — только через асинхронный конвейер, иначе горячая запись становится узким местом; (3) деградация: если БД недоступна, а кода нет в кэше — отдаём 503 с Retry-After, но не роняем весь сервис; (4) шардирование по code (хеш) даёт равномерность и не требует ребалансировки при консистентном хешировании.
How does a server processor differ from a desktop and a laptop processor?
Заголовок раздела «How does a server processor differ from a desktop and a laptop processor?»Коротко. Серверный CPU оптимизирован под пропускную способность и надёжность многопоточной нагрузки: много ядер на умеренной частоте, поддержка ECC-памяти, 8–12 каналов памяти против 2 у десктопа, десятки-сотни линий PCIe, возможность многосокетных конфигураций, большой L3, RAS-функции и высокий устойчивый TDP. Десктопный — под однопоточную частоту и цену, ноутбучный — под энергоэффективность и тепловой пакет, из-за чего он агрессивно троттлит на длительной нагрузке.
Глубже. Практически значимые для инженера следствия:
- Память. ECC ловит однобитовые ошибки; на масштабе тысяч серверов и терабайтов RAM они происходят регулярно, поэтому для БД это обязательное требование. Число каналов определяет пропускную способность: 8 каналов DDR5 у сервера дают в 3–4 раза больше ГБ/с, чем 2 канала десктопа, и это важно именно для многоядерных workload’ов, где на ядро приходится своя доля полосы.
- Ядра и частота. Серверный сокет — это условно 32–128 ядер на 2,0–3,0 ГГц базовой; десктоп — 8–24 ядра, но 4,5–5,7 ГГц в турбо. Отсюда: однопоточный бенчмарк на ноутбуке разработчика может быть быстрее, чем в prod, и это регулярно ломает интуицию при профилировании.
- NUMA. Многосокетные и даже односокетные многочиплетные серверы дают несколько NUMA-узлов: доступ к «чужой» памяти дороже. Для Go это выражается в разбросе задержек; лечится привязкой процессов/памяти (
numactl, cpuset) или запуском нескольких инстансов по одному на узел. - PCIe и I/O. 128 линий PCIe против ~20–28 у десктопа — это про количество NVMe и сетевых карт 25/100 Гбит/с.
- Устойчивость нагрузки. Серверное охлаждение рассчитано на 100 % загрузку 24/7; ноутбук на длительной 100 % нагрузке снижает частоту в разы. Плюс у мобильных чипов гибридная топология (P-/E-ядра), из-за которой планировщик ОС может переносить горячую горутину на медленное ядро.
- Прочее. Больше кеша L3 (десятки-сотни МБ), поддержка виртуализации и SR-IOV, аппаратное шифрование памяти, hot-plug, longer-life валидация и длительные поставки, отсутствие встроенной графики.
What is the typical CPU-to-RAM data exchange speed?
Заголовок раздела «What is the typical CPU-to-RAM data exchange speed?»Коротко. Пропускная способность одного канала DDR4-3200 — примерно 25,6 ГБ/с (3200 МТ/с × 8 байт), DDR5-4800 — около 38 ГБ/с. Десктоп с двумя каналами — 50–100 ГБ/с, серверный сокет с 8–12 каналами DDR5 — примерно 300–500 ГБ/с. Задержка одиночного доступа в память — порядка 70–100 нс, то есть на два порядка больше, чем попадание в L1.
Глубже. Формула: канал × 8 байт × частоту передач. Отсюда любые числа считаются в уме: DDR5-5600, 8 каналов → 5600 × 8 × 8 ≈ 358 ГБ/с теоретического пика; на практике реальный STREAM-бенчмарк даёт 60–80 % от пика.
Для системного дизайна важнее иерархия задержек, её стоит помнить как порядки: L1 ~1 нс, L2 ~4 нс, L3 ~10–20 нс, локальная RAM ~80–100 нс, удалённый NUMA-узел ~130–150 нс, NVMe-чтение ~20–100 мкс, RTT внутри ДЦ ~0,2–0,5 мс, между регионами ~30–150 мс. Практический вывод для Go-кода: последовательный проход по слайсу структур в разы быстрее прохода по слайсу указателей не из-за «магии», а потому что префетчер угадывает линейный доступ и промах в память стоит как ~300 инструкций. Отсюда же интуиция «данные в памяти — это почти бесплатно по сравнению с сетью, но не бесплатно по сравнению с кешем».
What is the typical speed of data exchange with storage?
Заголовок раздела «What is the typical speed of data exchange with storage?»Коротко. HDD 7200 rpm: последовательно 150–250 МБ/с, случайно 80–200 IOPS, задержка 5–10 мс. SATA SSD: ~550 МБ/с (упирается в интерфейс), 50–100 тыс. IOPS, задержка 100–300 мкс. NVMe PCIe 4.0: 5–7 ГБ/с, до ~1 млн IOPS, задержка 20–100 мкс; PCIe 5.0 — до ~14 ГБ/с. Сетевое хранилище добавляет к этому RTT сети и ограничение линка (10/25/100 Гбит/с ≈ 1,2/3/12 ГБ/с).
Глубже. Главное, что нужно проговорить: для дисков различие между последовательным и случайным доступом принципиально. У HDD случайный доступ дороже последовательного на два-три порядка, потому что платится seek + rotational latency, — именно из этого исторически выросли LSM-деревья (Cassandra, RocksDB, ClickHouse), которые превращают случайные записи в последовательные, и именно поэтому B-tree-индекс, не влезающий в page cache, убивает производительность Postgres на HDD. У NVMe разрыв меньше, но не исчез: очередь запросов (queue depth) определяет, доберётесь ли вы до заявленного миллиона IOPS — при QD=1 вы получите ~10–20 тыс. IOPS, потому что задержка одной операции ~50–100 мкс.
Второе — облачные диски ведут себя не как локальные: у managed-томов IOPS и полоса квотируются и часто пропорциональны размеру тома, есть burst-баланс, который заканчивается, и сетевая природа даёт задержку 0,5–2 мс вместо 50 мкс. Третье — fsync: реальная стоимость коммита транзакции определяется не пропускной способностью, а задержкой синхронной записи WAL, и наличие конденсатора/BBU в контроллере меняет её в разы.
What types of storage systems exist?
Заголовок раздела «What types of storage systems exist?»Коротко. Есть три ортогональные классификации: по способу доступа (блочное, файловое, объектное), по модели данных (реляционные, KV, документные, wide-column, графовые, time-series, поисковые, лог/очередь, blob) и по назначению (OLTP против OLAP/DWH/lakehouse). Ещё одна ось — где физически: локальный диск (DAS), сетевой файловый доступ (NAS/NFS), сетевое блочное (SAN/iSCSI/EBS), объектное (S3), плюс носитель: RAM, NVMe, SSD, HDD, лента.
Глубже. По способу доступа: блочное отдаёт сектора, ФС строит клиент — годится под БД; файловое отдаёт POSIX-семантику и разделяемый доступ, но плохо масштабируется на миллиарды мелких файлов; объектное — immutable-объекты по ключу с HTTP API, безграничная ёмкость, дешёвое хранение, но высокая задержка (десятки мс) и нет частичной перезаписи — идеально под холодную историю, бэкапы, медиа, parquet-файлы для аналитики.
По модели: реляционные (PostgreSQL, MySQL) — транзакции, join, сложные запросы, вертикальное масштабирование записи; KV (Redis, DynamoDB, etcd) — точечный доступ по ключу и максимальный RPS; документные (MongoDB) — агрегаты без жёсткой схемы; wide-column/LSM (Cassandra, ScyllaDB, HBase) — огромный поток записи, запросы по заранее известному ключу партиции, линейное масштабирование; графовые (Neo4j) — обход связей; time-series (Prometheus, VictoriaMetrics, TimescaleDB) — метрики со сжатием по времени; поисковые (Elasticsearch) — инвертированный индекс, полнотекст и фасеты; колоночные аналитические (ClickHouse) — агрегаты по миллиардам строк; лог (Kafka) — упорядоченный поток с retention. И правило выбора: сначала профиль доступа (что за запросы, соотношение чтения и записи, объём, требования к консистентности), потом хранилище, а не наоборот.
What is the typical amount of RAM on a server?
Заголовок раздела «What is the typical amount of RAM on a server?»Коротко. Типовой двухсокетный сервер общего назначения сегодня — 256–512 ГБ RAM; узлы под БД и кэши — 512 ГБ–2 ТБ; «мелкие» compute-узлы под k8s — 64–128 ГБ. Ориентир при подборе — 4–8 ГБ на ядро; максимум на сокет упирается в число каналов × слотов × ёмкость модуля (например 12 каналов × 2 DIMM × 128 ГБ ≈ 3 ТБ на сокет).
Глубже. Для системного дизайна важна не «типичная цифра», а связанные с ней прикидки. Первое: рабочий набор (working set) должен влезать в память, иначе вы платите за случайное чтение с диска — отсюда классический вопрос «сколько у нас горячих данных и влезут ли индексы». Второе: у Postgres практический ориентир — shared_buffers порядка 25 % RAM, остальное под page cache, поэтому 512 ГБ RAM реально означает «примерно 400+ ГБ данных мы читаем из памяти». Третье: Redis-узел с 100+ ГБ данных неудобен операционно (долгий BGSAVE, долгая репликация, длинный форк), поэтому кэши шардируют узлами по 16–64 ГБ. Четвёртое: в Go на большой heap важно помнить, что GC-цикл сканирует живые объекты — 100 ГБ живого heap с миллиардами указателей даёт заметные паузы и большой CPU-оверхед на маркировку, поэтому такие объёмы держат в off-heap-структурах или в mmap.
Как бы ты спроектировал сервис по генерации коротких ссылок из длинных?
Заголовок раздела «Как бы ты спроектировал сервис по генерации коротких ссылок из длинных?»Коротко. См. выше — это тот же сокращатель ссылок. Формулировка «генерации» акцентирует именно алгоритм получения кода: монотонный счётчик с выдачей диапазонов инстансам + кодирование в base62 + обфускация, чтобы коды не перебирались; хеш-подход требует разрешения коллизий и потому проигрывает.
Глубже. Если интервьюер давит на генерацию, полезно показать код кодирования и объяснить, почему счётчик не становится узким местом: инстанс берёт диапазон из 10 000 ID одним запросом и дальше выдаёт ID из локального атомика, то есть на 1000 rps создания это один сетевой запрос раз в 10 секунд.
package shortener
import "strings"
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// Encode переводит числовой ID в base62-код.func Encode(id uint64) string { if id == 0 { return "0" } var sb strings.Builder base := uint64(len(alphabet)) buf := make([]byte, 0, 11) for id > 0 { buf = append(buf, alphabet[id%base]) id /= base } for i := len(buf) - 1; i >= 0; i-- { sb.WriteByte(buf[i]) } return sb.String()}Обфускация: перед кодированием умножаем ID на большое взаимно простое с 62^7 число по модулю 62^7 — получаем биекцию, коды выглядят случайными, но коллизий по-прежнему нет и обратная функция существует.
Пул задач: rate limiter, tinyurl shortener, kv storage, поиск авиабилетов, доставки конфигов, подписки на поиски.
Заголовок раздела «Пул задач: rate limiter, tinyurl shortener, kv storage, поиск авиабилетов, доставки конфигов, подписки на поиски.»Коротко. Это список задач, из которых интервьюер выбирает одну; готовиться нужно так, чтобы для каждой был готов каркас на 5 минут. Ниже — по одному абзацу-скелету на каждую.
Глубже.
- Rate limiter. Token bucket как основной алгоритм (даёт burst + среднюю скорость), состояние в Redis, атомарность через Lua-скрипт, ключ —
tenant:endpoint. Подробнее — в отдельном вопросе ниже. - TinyURL. См. выше: KV, base62 из счётчика, кэш, 302, асинхронная аналитика.
- KV storage (спроектировать своё). Клиентская библиотека с консистентным хешированием, репликация N=3, кворумы R/W с настраиваемым уровнем, версионирование (векторные часы или last-write-wins по HLC), хинтед-хендофф и anti-entropy (Merkle-деревья) для восстановления, на узле — LSM (memtable + SSTable + компакция) и WAL, gossip для членства кластера. Фактически надо пересказать Dynamo/Cassandra и явно назвать, что вы выбираете: AP с eventual consistency или CP с кворумами и линеаризуемыми чтениями.
- Поиск авиабилетов. Особенность — данные не наши: цены и наличие живут у GDS/провайдеров, они медленные (секунды), дорогие по квотам и быстро устаревают. Отсюда: агрегатор с параллельным веером запросов к провайдерам, таймаут на провайдера и частичная выдача (стриминг результатов в UI), многоуровневый кэш с коротким TTL по ключу «направление+даты+пассажиры», отдельный конвейер предпрогрева популярных направлений, дедупликация и склейка перелётов с пересадками (граф маршрутов, поиск k лучших путей), финальная перепроверка цены перед оплатой (price verification), идемпотентное бронирование с сагой.
- Доставка конфигов. Централизованное версионируемое хранилище (etcd/Consul/собственный сервис поверх Postgres), клиенты — long-poll или watch/streaming, а не опрос раз в секунду; обязательны версии и генерации, атомарное применение (весь конфиг целиком, а не по ключу), поэтапный rollout по процентам/лейблам, канареечные группы, локальный кеш на диске (чтобы стартовать при недоступном конфиг-сервисе), валидация схемы на записи, аудит «кто и что поменял», автоматический откат по метрикам.
- Подписки на поиски (saved search / alerts). Инвертированная задача поиска: обычно много документов и один запрос, здесь — много запросов и поток новых документов. Решение — percolator-подход: храним подписки как запросы в индексе (Elasticsearch percolate query) или строим собственное индексирование предикатов, каждый новый документ прогоняем по индексу подписок, попадания отправляем в очередь нотификаций с дедупликацией и агрегацией («не более одного письма в час»), тяжёлые/редкие подписки считаем батчем по расписанию.
3й тех. этап - системный дизайн;
Заголовок раздела «3й тех. этап - системный дизайн;»Коротко. Это не вопрос, а описание этапа собеседования: обрывок исходника, конкретная задача не восстанавливается. Полезно держать в голове формат: 45–60 минут, задача формулируется в одну фразу, от вас ждут диалога, а не монолога.
Глубже. Рабочий бюджет времени на такой этап: 5–10 минут на уточнение требований и фиксацию scope, 5 минут на оценку нагрузки в числах, 5 минут на API и модель данных, 15–20 минут на схему компонентов и обоснование выбора хранилищ, 10 минут на масштабирование, отказы и узкие места, остаток — на вопросы интервьюера в глубину («а что если этот узел упал», «а как ты это раскатишь»). Главные провалы: молча начать рисовать; не спросить про масштаб; спроектировать «на бесконечность» вместо заявленных требований; не назвать ни одного компромисса.
10Кб на сообщение;
Заголовок раздела «10Кб на сообщение;»Коротко. Это вводное требование задачи про чат: размер одного сообщения. Практический вывод — сразу пересчитать его в объём: при 90 млн сообщений в сутки (см. расчёт ниже) 10 КБ дают ~900 ГБ в сутки, ~330 ТБ в год до репликации и ~1 ПБ с тройной репликацией. Это и есть ключевая цифра всей задачи.
Глубже. Стоит вслух усомниться в цифре: чистый текст в чате объявлений — это 100–500 байт, 10 КБ означает либо запас «на всё», либо учёт метаданных, индексов и оверхеда хранилища. Уточняющий вопрос: «10 КБ — это payload или запись в хранилище с индексами?» Если payload — сжатие батчей сообщений zstd/lz4 даёт 3–5× на тексте, и 900 ГБ/сутки превращаются в 200–300 ГБ/сутки. Если это уже с оверхедом — считаем как есть и сразу проектируем тиринг: горячие сообщения (последние недели) в быстром хранилище, холодные — сжатыми батчами в объектном хранилище. Ещё одно следствие: 10 КБ на сообщение при 3–15 тыс. записей/с — это 30–150 МБ/с потока записи, что важно и для сети, и для выбора LSM-хранилища, и для настроек Kafka (размер сообщения, батчинг, retention).
DAU: 3 000 000;
Заголовок раздела «DAU: 3 000 000;»Коротко. Базовая цифра для всех расчётов: 3 млн активных пользователей в сутки. Вместе с «30 сообщений в день» это 90 млн сообщений/сутки; при активности, сжатой в 8 часов, среднее — ~3100 запросов записи в секунду, пик ×3–5 — примерно 10–15 тыс. записей/с.
Глубже. Правильная последовательность: DAU × действий на пользователя = событий в сутки; делим не на 86 400, а на реальное окно активности (здесь 8 часов = 28 800 с) — получаем средний RPS; умножаем на пиковый коэффициент 3–5 (вечерний пик, часовые пояса) — получаем то, под что проектируем. Отдельно считаем одновременные соединения: если из 3 млн DAU в пике онлайн 10–20 %, это 300–600 тыс. открытых WebSocket, то есть при 100 тыс. соединений на узел — 5–10 узлов плюс запас на выкатку и отказ. Держать надо оба числа: RPS определяет CPU и БД, соединения определяют память и число gateway-узлов.
MAU: 30 000 000;
Заголовок раздела «MAU: 30 000 000;»Коротко. 30 млн месячной аудитории при 3 млн DAU даёт stickiness DAU/MAU = 10 %. Для дизайна это важно двумя вещами: общий размер профильных данных и «длинный хвост» пользователей, для которых не нужно держать горячие структуры в памяти.
Глубже. Из MAU считается объём метаданных: 30 млн пользователей × 15 чатов ≈ 450 млн чатов, каждый чат — запись с участниками, ссылкой на объявление, счётчиком непрочитанных, курсором последнего прочитанного; при ~300 байтах на запись это ~135 ГБ только списков чатов — влезает в одну машину, но растёт, поэтому шардируется по user_id. Второе следствие: списки чатов и счётчики непрочитанных держим в кэше только для активных (DAU), а не для всех MAU — иначе кэш в 10 раз больше нужного. Третье: соотношение MAU/DAU полезно для оценки роста и для планирования миграций (сколько данных «спящих» пользователей можно увезти в холодный слой).
Предположительный рост до 10 млн DAU за 3 года (*);
Заголовок раздела «Предположительный рост до 10 млн DAU за 3 года (*);»Коротко. Требование означает трёхкратный рост, то есть архитектура должна масштабироваться линейно добавлением узлов, а не переписыванием: stateless-сервисы за балансировщиком, шардирование данных с самого начала (пусть даже 4 шарда на старте), партиционирование по времени для истории, отсутствие глобальных счётчиков и сквозных транзакций.
Глубже. 10 млн DAU при том же профиле — это 300 млн сообщений/сутки, ~10 тыс. записей/с в среднем и 30–50 тыс. в пике, ~3 ТБ/сутки при 10 КБ на сообщение и порядка 1 ПБ/год до репликации. Ключевой инженерный вывод: трёхкратный рост нагрузки — это не страшно (добавили реплики и шарды), а вот трёхкратный рост объёма при требовании «история всегда» — страшно, потому что стоимость растёт монотонно и никогда не убывает. Значит с первого дня закладываем: партиционирование сообщений по времени (легко отцеплять и увозить старые партиции), сжатие, тиринг в объектное хранилище, и схему шардирования, которая переживает добавление шардов — консистентное хеширование или логические шарды (например 1024 виртуальных шарда, раскладываемых по физическим узлам). Звёздочка (*) в исходнике обычно значит «желательное, а не обязательное требование» — стоит уточнить и не переусложнять из-за него дизайн.
сообщений в день 10 сообщений в 3 чата (30 сообщений);
Заголовок раздела «сообщений в день 10 сообщений в 3 чата (30 сообщений);»Коротко. Профиль активности одного пользователя: 30 сообщений в сутки, распределённых по трём активным чатам. Отсюда: 3 млн × 30 = 90 млн сообщений/сутки, ~3100 msg/s среднее в 8-часовом окне, 10–15 тыс. msg/s в пике.
Глубже. Важнее среднего — форма распределения. Активны три чата из пятнадцати, то есть доступ сильно локализован: почти все чтения и записи попадают в небольшое подмножество чатов, что делает кэш последних сообщений чата очень эффективным. Дальше: 10 сообщений в чате за день — это короткие диалоги «покупатель–продавец», а не бесконечные групповые треды; значит запрос «последние N сообщений чата» почти всегда обслуживается одной партицией и одним range-сканом по (chat_id, created_at). Ключ партиционирования — chat_id: он даёт локальность (весь диалог рядом), естественный порядок по времени внутри партиции и отсутствие горячих партиций, поскольку чаты маленькие. Для Cassandra/Scylla это PRIMARY KEY ((chat_id), message_id) с message_id как TimeUUID/Snowflake для сортировки и курсорной пагинации.
читаем/пишем 1:1;
Заголовок раздела «читаем/пишем 1:1;»Коротко. Соотношение 1:1 — редкость и важный сигнал: это write-heavy система по сравнению с типичным сервисом (где 100:1 в пользу чтения). Значит нельзя надеяться, что кэш решит проблему нагрузки, и нужно хранилище, оптимизированное под запись: LSM-based (Cassandra/ScyllaDB) либо Postgres с партиционированием и аккуратным числом индексов.
Глубже. Причина 1:1 в чате понятна: каждое сообщение доставляется одному собеседнику, то есть fan-out равен 1, и читается оно ровно один раз — в отличие от ленты, где один пост читают тысячи. Практические следствия: (1) fan-out on write не нужен — доставляем через открытое соединение или пуш, а не раскладываем по инбоксам; (2) каждый лишний индекс на таблице сообщений напрямую бьёт по пропускной способности записи, поэтому индексов минимум — только тот, что задан ключом партиционирования; (3) кэш нужен не для разгрузки БД, а для latency горячих экранов (список чатов, последние сообщения при открытии); (4) стоит проверить, не проседает ли запись из-за WAL/fsync, и рассмотреть батчинг записи (несколько сообщений одной транзакцией/одним батчем в Kafka). И обязательно уточните у интервьюера, что входит в «чтение»: если каждое открытие чата подгружает 50 сообщений, реальное соотношение по объёму данных уже не 1:1.
8 часов активно в день;
Заголовок раздела «8 часов активно в день;»Коротко. Окно активности, на которое надо делить суточный объём. 90 млн сообщений / 28 800 с ≈ 3100 msg/s среднего трафика, а не 1040 msg/s, как получилось бы при делении на сутки. Пиковый коэффициент поверх этого — 3–5×.
Глубже. Это классическая ловушка расчёта: кандидат делит на 86 400 и недооценивает нагрузку втрое. Дальше стоит сказать, что реальный пик определяется не только окном, но и формой суточной кривой: у чатов на маркетплейсе это вечер и обед по локальному времени, поэтому при одном регионе пик резкий, а при нескольких часовых поясах кривая сглаживается. Из «8 часов активности» следуют и операционные решения: окно для тяжёлых операций (компакции, бэкапы, ребалансировка шардов, миграции схемы) — ночные часы; автоскейлинг по расписанию плюс по метрике; ёмкость кластера считаем по пику, а не по среднему, и держим headroom, чтобы выдержать отказ зоны доступности (N+1 по зонам: если зон три, каждая должна тянуть 50 % при потере одной).
в одном чате 10 сообщений;
Заголовок раздела «в одном чате 10 сообщений;»Коротко. Средний размер диалога — 10 сообщений, то есть партиции по chat_id очень маленькие (10 × 10 КБ ≈ 100 КБ), горячих партиций нет, а запрос «дай историю чата» всегда читает одну партицию целиком.
Глубже. Маленькие партиции — это хорошо для сбалансированности и плохо для эффективности хранения: миллиарды мелких партиций дают заметный оверхед метаданных и в LSM-хранилищах, и в объектном сторедже при архивации. Отсюда два решения. Первое: не архивировать чаты по одному, а собирать холодные чаты в крупные сжатые батчи (например, все чаты за сутки в набор parquet/SSTable-файлов по 128–512 МБ) — иначе S3 с миллиардами объектов по 100 КБ станет дорогим и медленным. Второе: пагинация по чату почти не нужна — 10 сообщений отдаются одним запросом, поэтому API можно делать простым (GET /chats/{id}/messages?before=<cursor>&limit=50), но курсор всё равно вводим сразу, потому что распределение имеет хвост: будут чаты на тысячи сообщений, и именно они станут «толстыми партициями», для которых нужен лимит на размер партиции (например, разбивать по (chat_id, bucket_month)).
на человека в среднем по 15 чатов;
Заголовок раздела «на человека в среднем по 15 чатов;»Коротко. 15 чатов на пользователя × 30 млн MAU ≈ 450 млн чатов; активны из них 3, то есть список чатов — короткий экран, который целиком влезает в один запрос и прекрасно кэшируется.
Глубже. Модель данных из этой цифры выводится прямо: таблица chat (id, advert_id, participants, created_at, last_message_at, last_message_preview) и таблица chat_member / user_chats, шардированная по user_id, чтобы «список моих чатов» читался с одного шарда одним range-сканом по (user_id, last_message_at DESC). Это классический дубль данных: сообщения шардируются по chat_id, списки чатов — по user_id; связность поддерживается асинхронно (событие «новое сообщение» обновляет last_message_at, превью и счётчик непрочитанных). Здесь же уточняющий вопрос: нужно ли сортировать чаты по последнему сообщению (да, всегда) и нужен ли поиск по чатам (если да — отдельный индекс, и это отдельный объём). Счётчик непрочитанных — не COUNT(*) по сообщениям, а инкрементируемое поле/Redis-счётчик, сбрасываемое курсором last_read_message_id.
условно бесконечно денег.
Заголовок раздела «условно бесконечно денег.»Коротко. Требование снимает бюджетные ограничения, но не физические: деньги покупают реплики, зоны доступности, SSD вместо HDD, managed-сервисы и запас ёмкости, но не отменяют CAP, скорость света и сложность эксплуатации. Правильная реакция — не «поставим всё самое дорогое», а «уберём из обсуждения экономию на надёжности и сосредоточимся на корректности и latency».
Глубже. Что реально меняется: не жалеем на 3 реплики в 3 зонах и на N+1 по ёмкости; берём NVMe и много RAM, чтобы рабочий набор был в памяти; используем managed Kafka/Postgres/Redis, чтобы не тратить людей на эксплуатацию; держим полный дубль индексов и read-модели вместо «умных» экономных схем; можем позволить холодное хранение с тройной избыточностью и долгие retention. Что НЕ меняется: межрегиональная синхронная репликация всё равно даст +30–100 мс на коммит; шардирование всё равно ломает кросс-шардовые транзакции; за деньги нельзя купить линеаризуемость без потери доступности при разделении сети. И отдельно стоит сказать вслух, что «бесконечно денег» не означает «бесконечно инженеров»: сложность архитектуры — это тоже расход, и упрощение (один Postgres с партиционированием вместо самописного распределённого хранилища) остаётся правильным решением, пока хватает по числам.
2 участника;
Заголовок раздела «2 участника;»Коротко. Чат ровно на двоих (покупатель и продавец) — это огромное упрощение: нет групповой рассылки, fan-out = 1, нет ролей и админов, права проверяются простым «ты один из двух участников», и уникальность чата задаётся ключом (advert_id, buyer_id).
Глубже. Что именно упрощается: (1) доставка — надо найти одно соединение собеседника, а не N; (2) счётчики непрочитанных — по одному на участника, без сложной агрегации; (3) идентификация чата — детерминированная: chat_id = hash(advert_id, buyer_id) или уникальный индекс на эту пару, что делает создание чата идемпотентным (повторный «написать продавцу» не создаёт второй диалог); (4) отсутствие групп снимает вопрос «как разослать 1000 участникам одно сообщение» и связанную с ним проблему толстых партиций и очередей. Стоит явно зафиксировать это как границу scope: «групповые чаты вне scope; если они появятся, изменится модель доставки — понадобится fan-out через брокер и список участников как отдельная сущность с версионированием».
условный реалтайм;
Заголовок раздела «условный реалтайм;»Коротко. «Условный реалтайм» = доставка за десятки-сотни миллисекунд без строгих гарантий, то есть WebSocket (или long-poll как fallback) вместо polling’а, но без жёстких требований hard real-time. Практический бюджет: p99 доставки собеседнику, который онлайн, — 200–300 мс.
Глубже. Из «реалтайма» вытекает целый пласт решений. Транспорт: WebSocket с бинарным протоколом или SSE для входящего потока; на мобильных — переподключения, экспоненциальный backoff и jitter, keepalive-пинги (а также учёт того, что мобильные NAT рвут idle-соединения через 30–60 с). Порядок и доставка: сообщение сначала персистится (иначе «реалтайм» превратится в потерю сообщений при падении узла), потом рассылается; клиент генерирует client_message_id для идемпотентности и локального «отправляется»-состояния; сервер присваивает монотонный seq внутри чата, чтобы клиент мог упорядочить и обнаружить дыры. Гарантия — at-least-once с дедупликацией на клиенте по client_message_id; exactly-once сквозь сеть недостижим. Оффлайн: если получателя нет онлайн, сообщение остаётся в истории и уходит в push-нотификацию, а при подключении клиент делает «догон» по since_seq. Важно проговорить бюджет задержки по компонентам: клиент→gateway 20–50 мс мобильной сети, gateway→запись в БД 5–20 мс, публикация и роутинг через брокер 5–20 мс, gateway→получатель 20–50 мс.
чат должен быть привязан к объявлению;
Заголовок раздела «чат должен быть привязан к объявлению;»Коротко. Объявление — часть идентичности чата: chat(advert_id, buyer_id, seller_id) с уникальным индексом на (advert_id, buyer_id). Это делает создание диалога идемпотентным и позволяет показывать карточку объявления в шапке чата, но требует хранить снапшот данных объявления, потому что объявление может измениться или исчезнуть.
Глубже. Ключевое инженерное решение — денормализация: в чате храним не только advert_id, но и снимок нужных полей на момент создания (заголовок, цена, первая картинка, ссылка). Причины: (1) карточка чата не должна делать синхронный вызов в сервис объявлений на каждый рендер списка (это N+1 по сети и точка отказа); (2) объявление может быть снято/удалено, а показывать «о чём был разговор» нужно; (3) цена может измениться, и историческая цена «на момент общения» — юридически и продуктово полезная информация. Синхронизация — через события advert.updated/advert.removed из сервиса объявлений: обновляем превью, помечаем флаг advert_state. Границы сервисов: чат не владеет объявлениями и не лезет в их базу; он подписан на их события и хранит свою проекцию.
история чатов должна быть всегда;
Заголовок раздела «история чатов должна быть всегда;»Коротко. Требование «хранить вечно» запрещает TTL и делает объём монотонно растущим (~330 ТБ/год при 10 КБ и 90 млн сообщений/сутки), поэтому единственная работающая стратегия — тиринг: горячее в быстром хранилище, холодное — сжатыми батчами в объектном, с прозрачным для API доступом.
Глубже. Конкретно: сообщения партиционируются по времени (месяц/квартал) поверх шардирования по chat_id. Свежие партиции — на NVMe в основном хранилище с индексами; партиции старше, например, 6 месяцев — экспортируются в сжатые файлы в S3-совместимом хранилище, метаданные (где лежит чат за такой период) остаются в быстрой БД. Чтение старой истории идёт через отдельный путь с более высокой допустимой задержкой (сотни мс–секунды) — и это надо согласовать с продуктом: «история всегда доступна» не обязано означать «за 50 мс». Плюс сжатие: текст жмётся zstd в 3–5 раз, батчами по чату/периоду — а не по одному сообщению, иначе словарь не работает. Отдельно: «всегда» конфликтует с GDPR/152-ФЗ и правом на удаление, поэтому нужен механизм удаления по запросу пользователя даже в immutable-архиве (шифрование каждого чата отдельным ключом и удаление ключа — стандартный приём crypto-shredding). И бэкапы: вечная история означает вечные бэкапы, их стоимость и время восстановления надо посчитать заранее.
только текст (нет медиа);
Заголовок раздела «только текст (нет медиа);»Коротко. Отсутствие медиа радикально упрощает систему: не нужны объектное хранилище на горячем пути, пресайнед-URL, транскодинг, антивирус, CDN, прогресс загрузки и отдельные квоты — остаётся один поток небольших текстовых записей.
Глубже. Что снимается со стола и стоит проговорить как явно исключённый scope: загрузка файлов (обычно двухфазная — клиент получает presigned URL, грузит напрямую в S3, потом отправляет сообщение со ссылкой), генерация превью, модерация изображений, ограничение размера и типа, дедупликация по хешу, отдача через CDN с подписанными ссылками. Что остаётся важным даже для «только текста»: ограничение длины сообщения на входе (иначе кто-то пришлёт 10 МБ), санитизация и экранирование при рендере (XSS), нормализация Unicode и корректная работа с рунами при обрезке (обрезать по байтам нельзя — сломаете символ), детект ссылок и телефонов (продуктовая антифрод-задача маркетплейсов: попытки увести сделку из платформы), а также модерация текста — асинхронный консьюмер, который может скрыть сообщение постфактум.
чат остается даже если объявление снято или удалено;
Заголовок раздела «чат остается даже если объявление снято или удалено;»Коротко. Прямое следствие — жизненный цикл чата независим от объявления: никаких foreign key с ON DELETE CASCADE в сервис объявлений (его вообще нет, это другой сервис), только денормализованный снапшот и флаг состояния объявления. См. выше про привязку к объявлению.
Глубже. Реализация: сервис чатов подписан на события advert.archived/advert.deleted, при получении помечает advert_state = archived|deleted в своих чатах и меняет отображение (карточка с плашкой «объявление снято», ссылка неактивна), но ни сообщения, ни сам чат не удаляет. Дополнительные продуктовые правила, которые стоит уточнить: можно ли писать в чат по снятому объявлению (обычно нет — переводим в read-only, что упрощает нагрузку), видно ли чат в списке (обычно да, но ниже в сортировке). Технически «мягкое» состояние вместо удаления — это ещё и защита от ошибок: удаление объявления по ошибке или ретрай события не приводит к потере переписки; события обрабатываются идемпотентно по (advert_id, version).
важно чтобы были notification пользователю о сообщениях;
Заголовок раздела «важно чтобы были notification пользователю о сообщениях;»Коротко. Нужна отдельная подсистема нотификаций: сервис чатов публикует событие «новое сообщение», консьюмер решает, доставлено ли оно уже по открытому соединению, и если нет — отправляет push (APNs/FCM) или email/SMS по правилам, с агрегацией, дедупликацией и учётом настроек пользователя.
Глубже. Ключевая логика — «не спамить»: перед отправкой пуша проверяем presence (есть ли активное соединение и открыт ли этот чат), даём задержку 5–15 секунд (может, пользователь прочитает сам), агрегируем несколько сообщений одного чата в один пуш («3 новых сообщения»), уважаем quiet hours и настройки каналов. Дедупликация по (user_id, chat_id, message_id) в Redis с TTL. Доставка — at-least-once с идемпотентным ключом, потому что и Kafka, и APNs/FCM могут дать дубли; на клиенте пуши схлопываются по collapse_key/thread-id. Отдельно: реакция на невалидные токены (FCM возвращает UNREGISTERED — токен надо удалить), приоритеты (сообщение в чате — высокий, маркетинговое — низкий, и они не должны конкурировать в одной очереди), ретраи с экспоненциальным backoff и лимитами провайдера. Подробнее — в вопросе про пуш-сервис в конце файла.
Можно сделать поисковый запрос и получить страницу поисковой выдачи - SERP (Search Engine Results Page);
Заголовок раздела «Можно сделать поисковый запрос и получить страницу поисковой выдачи - SERP (Search Engine Results Page);»Коротко. Это функциональное требование задачи про маркетплейс: нужен отдельный поисковый слой — инвертированный индекс (Elasticsearch/OpenSearch/Manticore), в котором лежит денормализованный документ объявления, а не запросы в OLTP-базу с LIKE '%...%'. Поиск читает только из индекса, ранжирует и отдаёт страницу из 20–50 карточек с курсорной пагинацией и фасетами.
Глубже. Архитектура SERP: запрос → парсинг и нормализация (лемматизация, исправление опечаток, синонимы) → фильтры (категория, регион, цена, атрибуты) → выборка кандидатов из инвертированного индекса → ранжирование (двухфазное: дешёвый BM25/фильтры на тысячах кандидатов, дорогая ML-модель на топ-100) → обогащение карточек (цена/наличие могут браться из быстрого KV, если они меняются чаще индекса) → отдача. Индекс шардируется по хешу документа, реплики — под чтение; типичная цель по latency — p99 в 100–300 мс. Пагинация — только курсорная (search_after), потому что глубокий from/size в распределённом индексе стоит O(from × shards). Кэш SERP по нормализованному ключу запроса с коротким TTL (десятки секунд) снимает пик по популярным запросам, но требует аккуратности с персонализацией и геолокацией — они входят в ключ кэша. Отдельно: подсказки (suggest) — это другой индекс и другой сервис.
Можно посмотреть карточку отдельного объявления;
Заголовок раздела «Можно посмотреть карточку отдельного объявления;»Коротко. Это самый нагруженный read-путь: точечное чтение по advert_id из OLTP/KV с агрессивным кэшированием (CDN + Redis + локальный кеш), при этом изменчивые части (цена, наличие, счётчик просмотров) отделяются от стабильных, чтобы не инвалидировать всю карточку.
Глубже. Практическая композиция: GET /adverts/{id} возвращает агрегат, собранный из нескольких источников — базовые поля из основной БД (шард по advert_id), продавец из сервиса пользователей (кэшируется отдельно), похожие объявления из рекомендаций (асинхронно, с деградацией — если не ответили за 50 мс, отдаём без них). Кэширование: Redis с TTL 1–5 минут и инвалидацией по событию advert.updated; ключ версии (advert:{id}:v{version}) избавляет от гонок инвалидации. Защита от «промаха в кэш на популярном ключе» (cache stampede) — single-flight на инстансе плюс probabilistic early expiration. Счётчик просмотров — не в транзакции карточки, а через асинхронный поток событий с батчевой агрегацией. Для снятых объявлений — 410/страница-заглушка, но сама запись не удаляется (см. требование про чаты).
Можно посмотреть список своих объявлений;
Заголовок раздела «Можно посмотреть список своих объявлений;»Коротко. Это персональный read-модель-запрос: выборка по owner_id с сортировкой и пагинацией. Если основное хранилище шардировано по advert_id, нужен отдельный индекс/проекция, шардированная по owner_id, иначе запрос превратится в scatter-gather по всем шардам.
Глубже. Классическая проблема шардирования: два разных ключа доступа к одной сущности. Варианты: (1) вторичная таблица user_adverts(owner_id, advert_id, status, updated_at), шардированная по owner_id, обновляемая транзакционно (если один шард) или через outbox-события (если разные) — это eventual consistency, и её надо компенсировать read-your-writes (после создания объявления сразу показываем его из локального кэша/ответа команды); (2) глобальный вторичный индекс в поисковом движке с фильтром owner_id — удобно, но задержка индексации даёт «я добавил, а в списке нет»; (3) шардировать всё по owner_id изначально — тогда «мои объявления» дешёвы, но карточка по advert_id требует маршрутизации (обычно решается тем, что advert_id содержит в себе идентификатор шарда). Число объявлений на пользователя мало (единицы-десятки), кроме профессиональных продавцов — там нужны пагинация, фильтр по статусу и, возможно, отдельный «кабинет» с собственными индексами.
Можно добавить новое объявление которое появится в поисковой выдаче.
Заголовок раздела «Можно добавить новое объявление которое появится в поисковой выдаче.»Коротко. Путь записи: валидация → запись в основную БД в транзакции вместе с outbox-записью → событие advert.created в Kafka → индексатор кладёт документ в поисковый индекс. Появление в выдаче — eventual, обычно секунды; синхронно писать в Elasticsearch из обработчика нельзя (двойная запись без транзакции = рассинхрон).
Глубже. Детали, которые ждут: (1) Outbox — единственный надёжный способ атомарно «сохранить и опубликовать»; иначе при падении между COMMIT и producer.Send объявление есть, а в поиске его нет навсегда. (2) Модерация: обычно объявление сначала в статусе pending, в индекс попадает только active — значит индексатор фильтрует по статусу и реагирует на события смены статуса. (3) Порядок событий: ключ партиции Kafka — advert_id, чтобы created и последующий updated не переставились местами; в самом документе — монотонная version, и индексатор пишет с external version (Elasticsearch умеет отбрасывать устаревшие версии). (4) Read-your-writes: после создания пользователь ожидает увидеть объявление в «моих объявлениях» немедленно — это читается из OLTP, а не из индекса, поэтому проблема исчезает; в общей выдаче задержка допустима. (5) Реиндексация: нужен механизм полного перестроения индекса из БД (bulk-обход + переключение алиаса), потому что маппинг рано или поздно меняется. (6) Идемпотентность создания — по Idempotency-Key от клиента, иначе двойной тап даёт два объявления.
Задача по system design;
Заголовок раздела «Задача по system design;»Коротко. Обрывок исходника, конкретная задача не восстанавливается. Универсальная заготовка ответа — каркас: требования (функциональные и нефункциональные) → числа → API → данные → компоненты → масштабирование → отказы → эксплуатация.
Глубже. Полезно иметь заготовленный набор уточняющих вопросов, который годится для любой задачи: сколько пользователей и какой профиль нагрузки; какое соотношение чтения и записи; какая допустимая задержка (p50/p99) и доступность; насколько свежими должны быть данные (можно ли eventual); какой объём хранения и на какой срок; есть ли мультирегиональность; что критично — консистентность или доступность; какие есть внешние зависимости и их SLA; что вне scope. Пять минут таких вопросов экономят полчаса проектирования не того.
Реализовать rate limiter
Заголовок раздела «Реализовать rate limiter»Коротко. Базовый алгоритм — token bucket: ведро ёмкостью burst пополняется со скоростью rate токенов в секунду, запрос берёт токен или отклоняется с 429 и Retry-After. В распределённом варианте состояние живёт в Redis, а атомарность «прочитать–посчитать–записать» обеспечивается Lua-скриптом.
Глубже. Сравнение алгоритмов: fixed window (счётчик на минуту) — прост, но допускает двойной всплеск на стыке окон; sliding window log (упорядоченное множество таймстемпов) — точен, но O(N) по памяти на клиента; sliding window counter (взвешенная сумма текущего и предыдущего окна) — хороший компромисс, используется в Cloudflare; leaky bucket — сглаживает выход с постоянной скоростью, годится для защиты downstream; token bucket — разрешает burst, что обычно и нужно API.
В Go для локального ограничения есть готовый golang.org/x/time/rate (это как раз token bucket) — им закрывается per-process лимит. Для глобального лимита на кластер:
package ratelimit
import ( "context" "time"
"github.com/redis/go-redis/v9")
// Атомарный token bucket в Redis: KEYS[1] — ключ клиента,// ARGV: rate (токенов/с), burst, now (сек), cost.var script = redis.NewScript(`local tokens_key = KEYS[1] .. ":tokens"local ts_key = KEYS[1] .. ":ts"local rate, burst, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])local tokens = tonumber(redis.call("GET", tokens_key))local ts = tonumber(redis.call("GET", ts_key))if tokens == nil then tokens = burst; ts = now endtokens = math.min(burst, tokens + (now - ts) * rate)local allowed = 0if tokens >= cost then tokens = tokens - cost allowed = 1endlocal ttl = math.ceil(burst / rate) * 2redis.call("SET", tokens_key, tokens, "EX", ttl)redis.call("SET", ts_key, now, "EX", ttl)return {allowed, tostring(tokens)}`)
func Allow(ctx context.Context, rdb *redis.Client, key string, rate, burst float64) (bool, error) { res, err := script.Run(ctx, rdb, []string{key}, rate, burst, float64(time.Now().UnixNano())/1e9, 1).Slice() if err != nil { return false, err } allowed, _ := res[0].(int64) return allowed == 1, nil}Что ещё спросят: где ставить лимитер (на edge/API Gateway для грубой защиты, в сервисе — для бизнес-квот); ключ лимита (IP, user, API-key, пара «клиент+эндпоинт», с учётом того, что IP за NAT — это тысячи пользователей); что делать при недоступности Redis (fail-open с локальным лимитом — обычно правильнее, чем ронять весь трафик); как снизить нагрузку на Redis (локальные «под-ведра»: каждому инстансу выдаётся доля квоты, синхронизация раз в N мс — приблизительно, но дёшево); заголовки X-RateLimit-Limit/Remaining/Reset и корректный Retry-After; отличие rate limiting от throttling/backpressure и от circuit breaker; защита от «громких соседей» через квоты на арендатора и приоритетные очереди.
Есть высоконагруженный сервис с таблицей в PostgreSQL на несколько миллионов записей. Запрос с фильтрацией по двум колонкам (WHERE column_B = ? AND column_C = ? ) работает медленно. В таблице есть только первичный ключ, других индексов нет. Как вы найдете, что можно оптимизировать, и как оптимизируете этот запрос?
Заголовок раздела «Есть высоконагруженный сервис с таблицей в PostgreSQL на несколько миллионов записей. Запрос с фильтрацией по двум колонкам (WHERE column_B = ? AND column_C = ? ) работает медленно. В таблице есть только первичный ключ, других индексов нет. Как вы найдете, что можно оптимизировать, и как оптимизируете этот запрос?»Коротко. Сначала диагностика: EXPLAIN (ANALYZE, BUFFERS) покажет Seq Scan на несколько миллионов строк, pg_stat_statements подтвердит, что этот запрос — топовый по суммарному времени. Лечение: составной индекс CREATE INDEX CONCURRENTLY idx ON t (column_B, column_C); после этого — ANALYZE и повторный EXPLAIN для подтверждения Index Scan.
Глубже. Порядок действий полностью:
- Найти виновника.
pg_stat_statements(сортировка поtotal_exec_time),auto_explainсlog_min_duration,pg_stat_activityдля зависших. Не оптимизируем то, что не измерили. - Прочитать план.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE): смотрим фактические строки против оценочных (расхождение = устаревшая статистика или коррелированные колонки),shared readпротивshared hit(сколько ушло в диск), тип узла. - Индекс. Оба условия — равенство, поэтому порядок колонок в составном индексе не влияет на применимость, но влияет на переиспользование другими запросами: первой ставим ту колонку, которая чаще используется отдельно; при прочих равных — более селективную. Создавать обязательно
CONCURRENTLY, чтобы не братьACCESS EXCLUSIVEна нагруженной таблице (и помнить, что при неудаче останетсяINVALID-индекс, который надо дропнуть). - Уточнения. Если запрос возвращает мало колонок — покрывающий индекс
(column_B, column_C) INCLUDE (col_x)даёт Index Only Scan (но только при свежей visibility map, то есть при работающем autovacuum). Если один из фильтров — константа с малой долей строк (напримерstatus = 'active'), лучше частичный индексWHERE status = 'active'— он меньше и дешевле в поддержке. Если селективность плохая (индекс вернёт 30 % таблицы) — индекс не поможет, надо смотреть в сторону партиционирования по одной из колонок или изменения запроса. - Статистика и корреляция. Если планировщик недооценивает из-за зависимости между колонками —
CREATE STATISTICS (dependencies, ndistinct) ON column_B, column_C FROM t, затемANALYZE. - Проверить не-индексные причины. Раздувание таблицы (
pg_stat_user_tables.n_dead_tup, не работает autovacuum), нехваткаwork_mem(сортировки на диск), неверныйrandom_page_costдля SSD (4 по умолчанию — это про HDD, для SSD 1.1–1.5), приведение типов, из-за которого индекс не используется (напримерcolumn_B—bigint, а параметр приходит какnumeric), функция над колонкой (lower(column_B) = ?требует индекса по выражению), локали/text_pattern_opsдляLIKE 'x%'. - Цена. Каждый индекс замедляет запись и ест место; на write-heavy таблице это надо посчитать, а не добавлять индексы «на всякий случай». И проверить, нет ли уже дублирующего индекса, который просто перекрывается новым.
Then a system design section.
Заголовок раздела «Then a system design section.»Коротко. Обрывок исходника, конкретная задача не восстанавливается — фраза лишь фиксирует, что после алгоритмической секции идёт системный дизайн. См. каркас ответа в вопросе «Задача по system design;».
Как спроектировать сервис рассылки отчетов 100 тыс. клиентам ровно один раз?
Заголовок раздела «Как спроектировать сервис рассылки отчетов 100 тыс. клиентам ровно один раз?»Коротко. «Ровно один раз» сквозь сеть недостижим, поэтому строим at-least-once доставку + идемпотентность на приёмнике: материализуем план рассылки как 100 тыс. строк-задач с уникальным ключом (report_run_id, client_id), воркеры разбирают их через SELECT ... FOR UPDATE SKIP LOCKED, отправка помечается в той же транзакции состоянием, а у провайдера рассылки используется идемпотентный Message-Id/idempotency_key.
Глубже. Схема по шагам:
- Запуск. Планировщик создаёт
report_run(id, period, status)— одна запись на запуск, с уникальным индексом на(report_type, period), чтобы двойной запуск крона не создал вторую рассылку. - Материализация плана. В отдельной транзакции (или батчами) заполняем
report_task(run_id, client_id, state, attempts, idempotency_key, sent_at);UNIQUE(run_id, client_id)— гарантия «одна задача на клиента». Материализация обязательна: без неё после падения непонятно, кому уже отправили. - Обработка. Воркеры (N штук, горизонтально) берут пачку:
SELECT ... WHERE run_id=$1 AND state='pending' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100.SKIP LOCKEDдаёт конкурентную выборку без конфликтов и без внешнего брокера. Переводим вsending, генерируем отчёт, вызываем провайдера сidempotency_key = hash(run_id, client_id), помечаемsent. - Дубликаты. Опасное место — падение между «отправили» и «закоммитили». Поэтому: перед отправкой коммитим
sendingс попыткой, после успеха —sent; задачи, зависшие вsendingдольше таймаута, перепроверяем у провайдера поidempotency_key(если он поддерживает lookup) или отправляем повторно, полагаясь на идемпотентность провайдера. Если провайдер не идемпотентен вовсе, честный ответ: гарантируем at-least-once и минимизируем окно дублей, дедуплицируя на своей стороне по ключу. - Темп и отказы. Rate limit на провайдера (лимиты SMTP/SMS), экспоненциальный backoff по
attempts, DLQ для окончательно неудачных, отдельный отчёт «кому не доставили». - Наблюдаемость. Прогресс
sent/total, алерт если рассылка не завершилась за окно, идемпотентный «дозапуск» — повторный старт того жеrun_idпросто добираетpending. - Масштаб. 100 тыс. писем — это немного: даже 50 отправок/с дают 33 минуты; узкое место почти всегда генерация отчёта (PDF/выгрузка), а не отправка, поэтому генерацию распараллеливаем и кешируем общие части.
Как масштабировать приложение, вертикальное/горизонтальное масштабирование?
Заголовок раздела «Как масштабировать приложение, вертикальное/горизонтальное масштабирование?»Коротко. Вертикально — увеличить ресурсы одного узла (CPU, RAM, NVMe): просто, без изменений в коде, но упирается в потолок железа и не даёт отказоустойчивости. Горизонтально — добавить узлы за балансировщиком: практически безгранично и даёт HA, но требует, чтобы сервис был stateless, а состояние — шардированным или вынесенным.
Глубже. Практический порядок: сначала профилирование и оптимизация (часто ×2–10 бесплатно), затем вертикальное (быстро и дёшево до определённого размера), затем горизонтальное. Для stateless-слоя горизонтальное масштабирование тривиально: реплики + L7-балансировщик + HPA по CPU/RPS/latency; важно вынести всё состояние — сессии в Redis/JWT, файлы в объектное хранилище, кеш — либо локальный и «best effort», либо распределённый.
Сложность всегда в состоянии. Базы масштабируются иначе: чтение — репликами (но с лагом и, значит, с eventual consistency и риском read-after-write аномалий), запись — шардированием по ключу (и тогда теряются кросс-шардовые транзакции и join) либо разделением по доменам (functional partitioning). Для кэшей — консистентное хеширование. Для потоков — партиции Kafka (число партиций = потолок параллелизма консьюмеров). Отдельно: закон Амдала и универсальный закон масштабируемости — при добавлении узлов растёт стоимость координации, поэтому линейности не бывает; узкое место просто переезжает (с приложения на БД, с БД на сеть, с сети на блокировки). Ещё оси, которые стоит назвать: масштабирование по функциям (разделить монолит на сервисы с разными профилями нагрузки), по данным (шарды), по географии (регионы и CDN), а также «масштабирование вниз» — деградация, load shedding и приоритизация трафика, когда ресурсов не хватает.
Какие есть уровни согласованности между системами можете назвать?
Заголовок раздела «Какие есть уровни согласованности между системами можете назвать?»Коротко. Со стороны репликации/распределённых систем: линеаризуемость (strong), sequential, causal, session-гарантии (read-your-writes, monotonic reads, monotonic writes, writes-follow-reads) и eventual consistency. Отдельная, часто путаемая ось — уровни изоляции транзакций (read uncommitted / read committed / repeatable read / serializable).
Глубже. Иерархия сверху вниз: линеаризуемость — система ведёт себя как одна копия данных, любое чтение видит последнюю завершённую запись; цена — координация (кворумы, консенсус) и потеря доступности при разделении сети (CP в CAP). Sequential — все узлы видят операции в одном порядке, но не обязательно в реальном времени. Causal — причинно связанные операции видны в правильном порядке, независимые могут расходиться; это максимум, достижимый без потери доступности. Session-гарантии — практичный набор: «прочитаю то, что сам записал», «чтения не откатываются назад во времени». Eventual — при отсутствии новых записей реплики когда-нибудь сойдутся; ничего не говорит о промежуточных состояниях.
Что важно проговорить: (1) уровни изоляции про конкурентные транзакции на одной копии, уровни консистентности — про видимость записей на разных копиях; «Serializable» и «линеаризуемость» вместе дают strict serializability. (2) В кворумных системах R + W > N даёт чтение самой свежей записи, но не линеаризуемость сама по себе (нужен read-repair и аккуратность). (3) На практике выбор делается по операциям, а не по системе целиком: списание денег/остатков — строгая консистентность, лента и счётчики — eventual. (4) У Postgres синхронная репликация (synchronous_commit = on с remote_apply) даёт консистентное чтение с реплики ценой latency; по умолчанию реплика отстаёт, и читать с неё «свой только что записанный заказ» нельзя. (5) Между системами (БД + Kafka + поиск + кэш) консистентность вообще не автоматическая: её обеспечивают outbox, идемпотентность, версии и сверки (reconciliation).
Какие виды хранилища ты знаешь?
Заголовок раздела «Какие виды хранилища ты знаешь?»Коротко. См. выше «What types of storage systems exist?». Кратко: по доступу — блочное, файловое, объектное; по модели — реляционные, KV, документные, wide-column, графовые, time-series, полнотекстовые, колоночные аналитические, лог/очередь; по назначению — OLTP, OLAP, кеш, архив.
Глубже. Отличие от англоязычного варианта вопроса — здесь чаще ждут, что вы свяжете тип с задачей. Готовый набор соответствий: сессии и счётчики → Redis; заказы и деньги → PostgreSQL (транзакции); лента сообщений и метрики огромного объёма → Cassandra/ScyllaDB; полнотекстовый поиск и фасеты → Elasticsearch; аналитика по миллиардам событий → ClickHouse; файлы и архив → S3-совместимое объектное хранилище; конфигурация и лидер-элекшн → etcd/Consul; поток событий с переигрыванием → Kafka; графы связей → Neo4j/специализированный индекс. И честная оговорка: в 80 % задач правильный ответ — PostgreSQL, а экзотика вводится только под доказанное числами требование.
Актуальность данных: система должна возвращать всегда актуальное количество оставшихся товаров и не допускать резерва закончившегося товара;
Заголовок раздела «Актуальность данных: система должна возвращать всегда актуальное количество оставшихся товаров и не допускать резерва закончившегося товара;»Коротко. Это требование строгой консистентности на операции резерва: единственный источник правды по остатку каждой SKU, атомарный декремент под условием quantity >= n и никаких кэшей и реплик в пути записи. Показывать остаток можно приблизительно (кэш), но резервировать — только через транзакционную проверку.
Глубже. Реализация в Postgres: UPDATE stock SET reserved = reserved + $n WHERE sku_id = $1 AND available - reserved >= $n RETURNING ... — атомарно, без явных локов, и 0 rows означает «не хватило». Шардирование по sku_id даёт линейный рост, потому что резервы разных товаров независимы. Проблема горячих SKU (флеш-распродажа, тысячи запросов в секунду на одну строку) решается разбиением остатка на K «под-остатков» (sku_id, bucket), по которым запросы распределяются случайно, с переливом между бакетами; либо перемещением счётчика в Redis с атомарным DECRBY и Lua-проверкой, но тогда Redis становится источником правды и нужна персистентность/AOF и восстановление из БД.
Резерв обязан иметь TTL: reservation(id, sku_id, qty, state, expires_at), истёкшие возвращаются фоновым воркером — иначе брошенные корзины «съедят» склад. Оформление заказа — сага: резерв → оплата → подтверждение; на каждом шаге компенсация (отмена резерва). Идемпотентность по ключу заказа обязательна, иначе ретрай спишет остаток дважды. Отображение «осталось 3 штуки» на карточке можно брать из кэша с задержкой в секунды и честно писать «мало» вместо точного числа; правда проверяется в момент добавления в корзину и ещё раз в момент оформления. Явно проговорите вывод: «не допускать резерва закончившегося» = линеаризуемость по ключу SKU, и это ограничивает мультирегиональность — либо мастер по SKU в одном регионе, либо распределение квоты остатка по регионам.
Высокая доступность и надежность: система должна быть доступна 99.99% времени и обеспечивать сохранность данных даже при сбоях оборудования;
Заголовок раздела «Высокая доступность и надежность: система должна быть доступна 99.99% времени и обеспечивать сохранность данных даже при сбоях оборудования;»Коротко. 99,99 % — это 52,6 минуты недоступности в год (около 4,3 минуты в месяц), что исключает ручное вмешательство при отказах: нужны автоматический failover, минимум 3 зоны доступности, отсутствие единой точки отказа и деплой без даунтайма. Сохранность данных — синхронная репликация (RPO≈0) плюс бэкапы с PITR и регулярными учениями по восстановлению.
Глубже. Разложение по компонентам: балансировщики — минимум два в разных зонах с anycast/DNS-failover; приложения — N+1 реплик по зонам, rolling update с health-check и graceful shutdown; БД — синхронная реплика в другой зоне (при коммите ждём подтверждения хотя бы одной), автоматический failover (Patroni/managed), асинхронная реплика в другом регионе для DR; кэш — деградация без него, а не отказ. Важно посчитать, что доступность системы — произведение доступностей последовательных зависимостей: пять компонентов по 99,99 % дают 99,95 %. Отсюда — уменьшать число синхронных зависимостей на критическом пути, добавлять таймауты, ретраи с jitter, circuit breaker и graceful degradation (например, карточка товара отдаётся без блока рекомендаций).
Про сохранность: RPO (сколько данных допустимо потерять) и RTO (за сколько восстановиться) должны быть заданы числом. Синхронная репликация даёт RPO 0, но платит latency; асинхронная — RPO в секунды-минуты. Бэкапы: полный + WAL/binlog для PITR, хранение в другом регионе, обязательная проверка восстановлением (бэкап, который ни разу не восстанавливали, не существует). Против логических ошибок (кто-то выполнил DELETE без WHERE) реплики не спасают — спасает только PITR. И организационная часть: 99,99 % требует error budget, алертов по симптомам (а не по причинам), дежурства и отработанных runbook’ов.
Быстрый доступ к данным: Время отклика системы не должно превышать 200 мс для 99-го перцентиля операций;
Заголовок раздела «Быстрый доступ к данным: Время отклика системы не должно превышать 200 мс для 99-го перцентиля операций;»Коротко. p99 200 мс — это бюджет, который надо расписать по компонентам: сеть до клиента, балансировщик, сервис, БД, внешние вызовы. Практическое правило — на синхронную цепочку из K зависимостей нельзя тратить больше 200 мс суммарно, а значит либо цепочка короткая, либо часть работы уходит в асинхрон.
Глубже. Пример разложения: TLS+сеть 20–40 мс (мобильный клиент — больше), gateway 2–5 мс, бизнес-логика 5–10 мс, 1–2 запроса в Postgres по индексу 1–5 мс каждый, Redis 0,3–1 мс, вызов соседнего сервиса по gRPC 5–15 мс. Запас нужен потому, что p99 системы определяется хвостами: если один вызов имеет p99 = 100 мс и вы делаете их последовательно три, ваш p99 будет заметно хуже суммы медиан. Инструменты борьбы с хвостом: параллельные вызовы вместо последовательных, hedged requests (продублировать запрос, если первый не ответил за p95), таймауты по бюджету (передавать оставшийся дедлайн через context.Context), кэш, отказ от N+1, батчинг, connection pooling (установка соединения и TLS-хендшейк — десятки мс), пре-warm пулов.
Отдельные источники хвостов, которые надо назвать: GC-паузы (в Go обычно доли миллисекунды, но большой heap и аллокационное давление их растят), CPU throttling в Kubernetes при заданном cpu limit (cfs quota — типичная причина странных p99), исчерпание пула соединений к БД (ожидание в очереди), блокировки в БД, autovacuum/компакции, холодный старт после деплоя, ретраи, которые множат нагрузку. Измерять надо перцентили на сервере и на клиенте (они отличаются на сетевой хвост) и по каждому эндпоинту отдельно — общий p99 по всем ручкам бессмыслен.
Что такое консистентное хэширование?
Заголовок раздела «Что такое консистентное хэширование?»Коротко. Это способ распределить ключи по узлам так, чтобы при добавлении или удалении узла переезжала лишь малая доля ключей (~K/N), а не почти все, как при hash(key) % N. Узлы и ключи отображаются на общее кольцо хешей, ключ принадлежит первому узлу по часовой стрелке; равномерность обеспечивается виртуальными узлами (по 100–500 «токенов» на физический узел).
Глубже. Проблема, которую оно решает: при % N изменение N меняет владельца почти каждого ключа — для кэша это лавина промахов, для хранилища — полная перебалансировка данных. На кольце уход узла перераспределяет только его диапазон между соседями. Виртуальные узлы нужны, потому что при малом числе узлов случайное размещение точек на кольце даёт перекос в разы; они же позволяют учитывать разную мощность узлов (более мощному — больше токенов).
Где применяется: Amazon Dynamo и Cassandra/ScyllaDB (кольцо токенов, репликация — на следующие N-1 узлов по кольцу с учётом rack/DC awareness), memcached-клиенты (ketama), Riak, шардированные прокси, балансировка соединений в service mesh. Альтернативы, которые стоит знать: rendezvous hashing (HRW) — для каждого ключа считаем hash(key, node) и берём максимум; проще кольца, тоже даёт минимальное перемещение, легко поддерживает веса; jump consistent hash — компактный алгоритм O(ln N) без хранения кольца, но узлы должны нумероваться подряд и можно удалять только последний. Отдельная тема — consistent hashing with bounded loads: чистое кольцо не защищает от горячих ключей, поэтому добавляют ограничение «узел не может быть загружен больше чем в c раз выше среднего», и переполнение уходит следующему узлу. И типичная ошибка: считать, что консистентное хеширование само по себе решает перекос нагрузки — оно решает перекос ключей, а не запросов.
Клиенты могут получать бонусы за покупки, отзывы, участие в акциях и т. п.;
Заголовок раздела «Клиенты могут получать бонусы за покупки, отзывы, участие в акциях и т. п.;»Коротко. Требование задаёт событийную природу начислений: разные источники (покупка, отзыв, акция) публикуют события, отдельный сервис лояльности применяет к ним правила и пишет начисление в append-only реестр (ledger) двойной записи. Ключевые свойства — идемпотентность по (source, event_id) и расширяемость правил без деплоя.
Глубже. Модель данных: bonus_transaction(id, user_id, amount, type, source, source_event_id, expires_at, created_at, balance_after) с UNIQUE(source, source_event_id) — это и есть защита от двойного начисления при ретраях Kafka. Баланс не хранится как единственная изменяемая цифра, а вычисляется из реестра и кэшируется как материализованная проекция (user_balance с версией), потому что аудит и разбор претензий требуют полной истории. Начисления «за покупку» обычно отложенные: баллы становятся доступны после окончания срока возврата (например через 14 дней), поэтому у транзакции есть состояние pending → available → spent/expired и планировщик переходов.
Расширяемость: «и т. п.» в требовании означает, что источников будет много, поэтому сервис лояльности не должен знать про каждый — он принимает унифицированное событие loyalty.accrual_requested{user_id, source, event_id, context} и прогоняет его через конфигурируемый набор правил (rule engine: условия по категории, сумме, сегменту клиента, периоду). Правила версионируются, у каждого — период действия, применённое правило записывается в транзакцию для объяснимости («почему мне начислили 37 баллов»). Обязательны лимиты (максимум за период) — см. вопрос про админку, антифрод (отзывы ради баллов, самовыкупы) и отдельный поток компенсаций: при возврате покупки начисленные баллы должны быть списаны, а если они уже потрачены — уйти в минус или в задолженность, что тоже правило.
Клиенты может выбрать категории товаров с повышенным кашбаком на месяц;
Заголовок раздела «Клиенты может выбрать категории товаров с повышенным кашбаком на месяц;»Коротко. Это версионируемая по времени сущность: user_category_selection(user_id, category_id, period_start, period_end, rate, selected_at). Начисление ищет действующий выбор на дату покупки, поэтому смена категорий в следующем месяце не переписывает историю, а добавляет новую запись периода.
Глубже. Тонкости, которые обычно и проверяют: (1) Правила выбора — сколько категорий можно выбрать, до какого числа месяца, можно ли поменять внутри периода (если да — фиксируем момент смены и применяем к покупкам по дате, а не задним числом). (2) Расчёт по дате покупки, а не по дате обработки события: событие может прийти с задержкой, и начисление должно смотреть на конфигурацию, действовавшую в момент транзакции — это «темпоральные» данные, и в БД они выражаются диапазоном daterange с ограничением на пересечение (EXCLUDE USING gist). (3) Определение категории покупки: приходит из каталога, может быть иерархия — нужно правило «наиболее специфичная категория выигрывает» и фиксация категории в событии, чтобы последующая перекатегоризация товара не меняла прошлое. (4) Уведомления: напомнить выбрать категории в начале месяца — отдельный конвейер. (5) Нагрузка мизерная (десятки миллионов записей раз в месяц), поэтому здесь важна корректность, а не масштаб; но пик выбора в первые дни месяца стоит упомянуть. (6) Кэш активного выбора в Redis по user_id с инвалидацией по событию, потому что он читается на каждом начислении.
Клиенты должны иметь возможность просматривать баллы: баллов, историю начислений и списаний бонусных баллов, доступные категории повышенного кашбака через мобильное приложение и web;
Заголовок раздела «Клиенты должны иметь возможность просматривать баллы: баллов, историю начислений и списаний бонусных баллов, доступные категории повышенного кашбака через мобильное приложение и web;»Коротко. Это read-путь: GET /loyalty/balance (материализованный баланс с разбивкой на доступные/ожидающие/сгорающие), GET /loyalty/transactions?cursor= (курсорная пагинация по реестру), GET /loyalty/categories (доступные и выбранные категории). Один API для обоих клиентов (BFF при необходимости), кэш баланса в Redis, история — прямо из реестра по индексу (user_id, created_at DESC).
Глубже. Что важно: (1) Баланс должен показываться не одной цифрой, а структурой: доступно сейчас, ожидает подтверждения, сгорит до даты X и сколько — иначе поддержка утонет в вопросах. (2) Read-your-writes: после списания баллов на кассе пользователь немедленно смотрит баланс, поэтому читаем из мастера или инвалидируем кэш синхронно в той же транзакции-эффекте. (3) История может быть длинной у активных клиентов — курсорная пагинация (WHERE (created_at, id) < (?, ?)), без OFFSET. (4) Данные читаются из read-модели, обновляемой транзакционно с записью в реестр (одна БД — один commit) или через проекцию, если реестр вынесен. (5) Локализация и человекочитаемые описания («Кэшбэк 5 % за покупку в категории Электроника, заказ №…») — формируются из сохранённого контекста транзакции, а не собираются в рантайме походами в другие сервисы. (6) Нагрузка read-heavy, соотношение к записи 10:1 и выше, поэтому реплики на чтение и кэш решают почти всё.
Клиенты должны иметь возможность использовать бонусы для частичной оплаты покупок;
Заголовок раздела «Клиенты должны иметь возможность использовать бонусы для частичной оплаты покупок;»Коротко. Списание — двухфазное: при оформлении заказа делаем hold (резерв баллов с TTL и идемпотентным ключом заказа), при успешной оплате — capture (перевод в списание), при отмене/таймауте — release. Атомарность обеспечивается транзакцией в реестре с проверкой доступного баланса, а взаимодействие с сервисом оплаты — сагой с компенсацией.
Глубже. Почему двухфазность: оплата — распределённая операция (баллы у нас, деньги у эквайринга), и без резерва возможны и двойное списание, и «оплатили деньгами, а баллы не списались». Реализация: UPDATE user_balance SET available = available - $n, held = held + $n WHERE user_id = $1 AND available >= $n — атомарная проверка и резерв одной операцией; плюс запись в реестр с типом hold и idempotency_key = order_id. Повторный вызов с тем же ключом возвращает существующий резерв, а не создаёт второй.
Дополнительные правила, которые почти всегда есть в требованиях: максимальная доля заказа, оплачиваемая баллами (например 30 %), минимальный остаток к оплате деньгами, запрет на списание в некоторых категориях, курс «балл → рубль». Порядок списания при наличии сгорающих баллов — FIFO по expires_at (списываем сначала то, что скоро сгорит) — значит реестр хранит баллы «партиями» (buckets), и списание распределяется по ним; это делает списание не изменением одной цифры, а транзакцией над несколькими партиями. Возврат покупки: возвращаем ровно те партии с их исходными сроками сгорания (или новым сроком по правилу) — иначе возвратами можно продлевать баллы бесконечно. И обязательно: все операции идемпотентны, все ошибки — с явной семантикой (недостаточно баллов ≠ временная ошибка), сверка (reconciliation) с сервисом заказов раз в сутки.
Администратор системы должен иметь возможность гибкой конфигурации системы: управление категориями, ограничения на максимальное начисление за период, дата сгорания бонусов и пр.
Заголовок раздела «Администратор системы должен иметь возможность гибкой конфигурации системы: управление категориями, ограничения на максимальное начисление за период, дата сгорания бонусов и пр.»Коротко. Нужен конфигурационный слой с версионированием и периодами действия: правила хранятся как данные (а не в коде), у каждой версии есть valid_from/valid_to, автор и аудит; движок начислений всегда применяет версию, действовавшую на дату события. Изменения проходят через валидацию, предпросмотр эффекта и раскатку.
Глубже. Практическая реализация: таблицы category, rule(id, type, predicate, rate, priority, valid_from, valid_to, version, author, status), limit(scope, period, max_amount); предикаты — либо декларативный JSON-DSL (набор условий по полям события), либо встраиваемый скриптовый движок, но лучше первое: DSL проверяем схемой, тестируем и безопасен. Лимиты «максимум за период» реализуются счётчиками в реестре: перед начислением считаем сумму начислений пользователя за окно (или держим денормализованный агрегат user_period_accrual(user_id, period, sum) с атомарным инкрементом под ограничение) — важно, чтобы это была та же транзакция, что и начисление, иначе лимит обходится гонкой.
Сгорание: у каждой партии баллов expires_at, вычисляемый по правилу на момент начисления и сохранённый в записи; фоновая задача раз в сутки переводит просроченные партии в expired идемпотентно (по дате и партии), плюс предупреждающие уведомления за N дней. Никогда не пересчитывать сгорание «на лету по текущей конфигурации» — иначе изменение правила задним числом отберёт баллы у клиентов. Админка требует: RBAC и четырёхглазый принцип на опасные изменения, аудит-лог всех правок, dry-run («сколько бы начислилось за прошлый месяц по новому правилу» на исторических событиях), поэтапную раскатку на сегмент аудитории, мгновенный откат к предыдущей версии, и кэш конфигурации в сервисах с версией и watch-обновлением (см. вопрос про доставку конфигов).
DevOps смотрит и видит, что упираемся в CPU. Код написан нормально. Нужно масштабироваться. Что делать?
Заголовок раздела «DevOps смотрит и видит, что упираемся в CPU. Код написан нормально. Нужно масштабироваться. Что делать?»Коротко. Сначала убедиться, что это действительно CPU и действительно наш: профиль pprof, проверка троттлинга cgroup и GOMAXPROCS относительно лимита контейнера. Если всё честно — масштабируемся горизонтально: stateless-реплики за балансировщиком + HPA, а если сервис stateful — шардируем по ключу.
Глубже. Порядок, который стоит проговорить:
- Проверить измерение. «Упираемся в CPU» может означать: реальную загрузку, троттлинг из-за
cpu limit(в Kubernetes cfs quota режет процесс каждые 100 мс — метрикаcontainer_cpu_cfs_throttled_seconds_total), либо загрузку, вызванную не нашей работой (soft-irq, sidecar, шумный сосед). Классика Go: контейнеру дали 2 CPU, аGOMAXPROCSравен числу ядер хоста (например 64) — рантайм создаёт 64 P, растёт число потоков, планировщик и GC работают вхолостую, растёт latency. Лечитсяautomaxprocsили явнымGOMAXPROCS; начиная с Go 1.25 рантайм по умолчанию учитывает лимит CPU в cgroup, но в проде часто более старые версии, так что проверять надо. - Посмотреть, куда уходит CPU.
go tool pprofна профиль CPU: часто это JSON-сериализация, регулярки, лишние аллокации (и, как следствие, GC — смотримGOGC,GOMEMLIMITи долю времени в GC), криптография/TLS, компрессия, отсутствие батчинга. «Код написан нормально» — утверждение интервьюера, но безопасно уточнить: «профиль смотрели? Если 40 % вencoding/json, переход на кодогенерацию даст больше, чем удвоение флота». - Вертикально. Если узел не максимальный — увеличить CPU-лимит/инстанс: самое дешёвое по инженерному времени решение, работает до потолка узла.
- Горизонтально. Условие — stateless. Тогда: реплики + балансировщик (L7, least-request вместо round-robin для разнородных запросов), HPA по CPU и по RPS/latency, PDB и запас на выкатку, прогрев (readiness только после прогрева пулов и кэшей). Проверить, что нижележащие зависимости выдержат больше коннектов: удвоив число подов, вы удваиваете пул соединений к Postgres — вот здесь обычно и ломается; ставим PgBouncer.
- Если stateful. Шардирование по ключу (consistent hashing), партиции Kafka как единица параллелизма, разделение по функциям.
- Снизить работу. Кэш (в том числе на edge), батчинг, gRPC/protobuf вместо JSON, вынос тяжёлых вычислений в асинхронные воркеры, load shedding и приоритизация при перегрузке.
У “типичного ”CRUD сервиса - в какой-то момент некоторые методы стали работать медленно и сервис начал таймаутить. Сервис состоит из:
Заголовок раздела «У “типичного ”CRUD сервиса - в какой-то момент некоторые методы стали работать медленно и сервис начал таймаутить. Сервис состоит из:»Коротко. Формулировка обрывается на перечислении компонентов, поэтому отвечаем методикой: локализовать по метрикам (какие ручки, когда началось, коррелирует ли с деплоем/ростом трафика/фоновой задачей), затем по слоям — приложение, пул соединений, БД, внешние зависимости — и уже потом чинить конкретную причину.
Глубже. Чек-лист диагностики в порядке вероятности для CRUD-сервиса:
- БД. Самый частый источник. Рост таблицы перевалил за размер, при котором Seq Scan стал заметен; распух индекс или таблица (
n_dead_tup, autovacuum не успевает); появился долгий транзакционный лок (pg_locks,pg_stat_activityсwait_event); план запроса «перевернулся» из-за статистики; N+1 запросов после невинного изменения кода. Инструменты:pg_stat_statements,auto_explain, слоу-лог. - Пул соединений. Метрика «ожидание в очереди пула» — если она ненулевая, latency растёт нелинейно. Причины: увеличили число подов, забыли
SetMaxOpenConns, длинные транзакции держат соединения, отсутствие таймаутов. - Внешние зависимости. Соседний сервис деградировал, у нас нет таймаута/circuit breaker’а — его p99 стал нашим p99, потоки/горутины копятся, растёт память, начинаются таймауты «везде». Ретраи без ограничения усиливают проблему (retry storm).
- Приложение. GC-давление и рост heap, блокировки на общем мьютексе, утечка горутин (
/debug/pprof/goroutine), рост очередей, CPU-троттлинг (см. предыдущий вопрос), медленный логгер в синхронном режиме. - Инфраструктура. Диск исчерпал burst-баланс IOPS, сетевые ретрансмиты, деградация ноды, соседи по хосту.
- Нагрузка. Изменился профиль: новый клиент делает выборки без фильтров, вырос размер ответов, включили фичу с тяжёлым запросом.
Что делать сразу (митигация): выставить и ужесточить таймауты на всех исходящих вызовах и запросах к БД (statement_timeout), добавить circuit breaker и лимит конкурентности (bulkhead), включить load shedding по глубине очереди, откатить последний деплой, если корреляция по времени очевидна. Дальше — устранение корневой причины и постмортем. Обязательно назовите метрики, по которым это видно: RED (rate, errors, duration) по ручкам, USE (utilization, saturation, errors) по ресурсам, а также распределённые трассировки — они за минуту показывают, в каком спане ушло время.
После было устное проектирование их системы (чаты). Как будешь отправлять сообщения, что такое веб-сокеты, их преимущества, как будешь контролировать соединения и кому что отправлять, как будет построено взаимодействие с сервисом чата, сообщений, профиля. У них я так понял Event-driven архитектура, так что выведут на это решение и будут накидывать как контролировать эти события, какие топики создавать.
Заголовок раздела «После было устное проектирование их системы (чаты). Как будешь отправлять сообщения, что такое веб-сокеты, их преимущества, как будешь контролировать соединения и кому что отправлять, как будет построено взаимодействие с сервисом чата, сообщений, профиля. У них я так понял Event-driven архитектура, так что выведут на это решение и будут накидывать как контролировать эти события, какие топики создавать.»Коротко. Схема: тонкие stateless WebSocket-gateway держат соединения и регистрируют presence (user_id → node_id в Redis с TTL); сообщение из сокета идёт в chat-service, тот персистит его и публикует событие в Kafka с ключом chat_id; консьюмер доставки находит узлы получателей и через внутренний канал (Kafka-топик на узел или Redis Pub/Sub) отправляет во все их соединения; если получатель офлайн — событие уходит в notification-service на пуш.
Глубже. По пунктам вопроса.
Что такое WebSocket и его преимущества. Полнодуплексный канал поверх одного TCP-соединения, устанавливаемый HTTP Upgrade-хендшейком (RFC 6455), после чего обмен идёт кадрами без HTTP-заголовков. Преимущества против polling: нет постоянных запросов и накладных расходов на заголовки/TLS-хендшейк, задержка доставки — один сетевой хоп, сервер может пушить сам. Против long-poll: нет постоянного переустановления соединения и связанных с ним таймаутов. Против SSE: двунаправленность и бинарные кадры (SSE проще и лучше проходит через прокси, но только сервер→клиент). Минусы, которые надо назвать честно: соединение — это состояние на сервере (память, файловые дескрипторы), балансировка становится нетривиальной, промежуточные прокси рвут idle-соединения (нужны ping/pong и keepalive), мобильные сети требуют переподключений с backoff, а деплой требует аккуратного дренажа соединений.
Контроль соединений. На узле — реестр в памяти: map[userID][]*conn под шардированным мьютексом (или sync.Map по шардам), лимит соединений на пользователя, heartbeat каждые 20–30 с, срабатывание read deadline → закрытие. Глобально — presence-реестр в Redis: SET presence:{user_id}:{conn_id} = node_id EX 60 с продлением по heartbeat; при отключении — удаление. Он же используется, чтобы решить «слать пуш или нет». При рестарте узла клиенты переподключаются к другим — нужен jitter при реконнекте, иначе получите thundering herd. Число соединений на узел: реалистично 100–500 тыс. при аккуратном обращении с буферами (по умолчанию буферы чтения/записи на соединение съедают память быстрее всего — их уменьшают и переиспользуют через пул).
Кому что отправлять. Два подхода. (1) Через presence-реестр: консьюмер узнаёт узлы получателей и шлёт адресно (эффективно, но требует актуального реестра и обработки гонок при переподключении). (2) Через широковещание: каждый gateway-узел подписан на общий топик/канал и сам фильтрует «мои ли это пользователи» (просто и устойчиво, но трафик растёт линейно от числа узлов — годится до десятков узлов). На практике берут (1) с фолбэком: если адресная доставка не нашла соединение, сообщение всё равно в истории, и клиент получит его при «догоне» по since_seq.
Взаимодействие сервисов. gateway (соединения, аутентификация по токену при Upgrade) → chat-service (создание чата, права, метаданные) → message-service (персист, история, курсоры прочитанного) → profile-service (имена, аватары — читается через кэш, не на каждое сообщение) → notification-service. Синхронные вызовы — gRPC для команд с ответом; всё остальное — события.
Топики Kafka. Разумный набор: chat.messages (ключ chat_id, гарантирует порядок в диалоге; консьюмеры — доставка, нотификации, аналитика, антифрод), chat.read_receipts, chat.presence (или вовсе не в Kafka, а в Redis — presence эфемерен и высокочастотен), chat.events (создан чат, участник добавлен), notifications.outbound. Правила: ключ партиции = сущность, порядок которой важен; число партиций задаёт потолок параллелизма и его сложно уменьшать; ретеншн для chat.messages короткий (это транспорт, а источник правды — БД); обработка идемпотентна по message_id; отдельный DLQ-топик на каждый консьюмер-групп; схемы событий версионируются (Schema Registry, обратная совместимость).
Гарантии. At-least-once везде, дедупликация по client_message_id на клиенте и message_id на сервере, монотонный seq внутри чата для упорядочивания и обнаружения пропусков, ack от клиента о доставке и о прочтении.
появится интеграция с настоящей базой данных;
Заголовок раздела «появится интеграция с настоящей базой данных;»Коротко. Требование из задачи про бронирование: заменить in-memory на реальную БД. Ключевое — не «подключить драйвер», а перенести инварианты в БД: уникальность бронирования на пересекающиеся даты выражается ограничением в схеме, а не проверкой в коде, потому что проверка в коде проигрывает гонке.
Глубже. В PostgreSQL правильный инструмент — exclusion constraint:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE booking ( id bigserial PRIMARY KEY, room_id bigint NOT NULL, guest_id bigint NOT NULL, stay daterange NOT NULL, state text NOT NULL DEFAULT 'confirmed', created_at timestamptz NOT NULL DEFAULT now(), EXCLUDE USING gist (room_id WITH =, stay WITH &&) WHERE (state = 'confirmed'));Теперь две параллельные транзакции не смогут забронировать пересекающиеся периоды одного номера — база отклонит вторую, и в Go это придёт как pq/pgx-ошибка с кодом 23P01 (exclusion_violation), которую надо отобразить в понятный 409. Остальная часть перехода: слой репозитория за интерфейсом (чтобы тесты домена оставались без БД), миграции (goose/golang-migrate) в CI и в релизном пайплайне, экспансивно-совместимые изменения схемы (сначала добавить колонку nullable, потом заполнить, потом сделать NOT NULL — «expand/contract»), пул соединений с явными лимитами и таймаутами (SetMaxOpenConns, SetConnMaxLifetime, statement_timeout), передача context.Context во все запросы, транзакционные границы на уровне use-case, а не репозитория, и интеграционные тесты на реальном Postgres через testcontainers. И явная проверка: что происходит при повторной отправке формы — нужен Idempotency-Key и уникальный индекс по нему.
появится отправка письма-подтверждения о бронировании;
Заголовок раздела «появится отправка письма-подтверждения о бронировании;»Коротко. Письмо нельзя отправлять внутри транзакции бронирования: используем transactional outbox — в той же транзакции пишем строку в outbox, отдельный воркер (или CDC через Debezium) читает её и вызывает почтового провайдера, помечая отправку идемпотентным ключом.
Глубже. Почему не «просто вызвать SMTP после commit»: между коммитом и вызовом процесс может умереть — письма не будет и никто об этом не узнает; а вызов до коммита даёт письмо о бронировании, которого нет (транзакция откатилась). Outbox решает обе проблемы, давая at-least-once: письмо может уйти дважды при неудачном ретрае, поэтому передаём провайдеру Message-Id/idempotency key и/или дедуплицируем по (booking_id, template).
Практические детали: воркер выбирает пачку SELECT ... FROM outbox WHERE state='pending' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100, экспоненциальный backoff по attempts, DLQ и алерт при исчерпании попыток, чистка отправленных записей партиционированием по дате. Само письмо рендерится из шаблона с версионированием и локализацией; вложение/ICS-файл генерируется на лету. Отдельный notification-service лучше, чем отправка из сервиса бронирования: он владеет провайдерами, лимитами, подавлением дублей, unsubscribe и отписками, и умеет несколько каналов. Порядок гарантий: подтверждение — важное транзакционное письмо, ему нужен высокий приоритет и отдельная очередь от маркетинговых рассылок. Наблюдаемость: метрика «бронирований без отправленного письма за 5 минут» — прямой алерт на сломанный outbox.
появится возможность бронирования нескольких номеров.
Заголовок раздела «появится возможность бронирования нескольких номеров.»Коротко. Бронирование набора номеров должно быть атомарным «всё или ничего»: одна транзакция, вставляющая N строк с exclusion constraint, — если хоть одна конфликтует, откатывается всё. При этом строки/номера надо захватывать в детерминированном порядке (сортировка по room_id), иначе получите взаимоблокировки при пересекающихся заявках.
Глубже. Что меняется в модели: появляется агрегат reservation(id, guest_id, state, total_price) и reservation_item(reservation_id, room_id, stay); инвариант «номер не занят дважды» остаётся на уровне item’ов. Если все номера в одной БД — задача решается одной транзакцией и это правильный ответ; усложнять не надо. Если номера разных отелей живут в разных шардах/сервисах — транзакции нет, и нужна сага: последовательный/параллельный резерв каждого номера с TTL, затем подтверждение; при неудаче любого шага — компенсирующая отмена уже сделанных резервов. Компенсации обязаны быть идемпотентными и переживать падение координатора (состояние саги персистится, воркер дочитывает незавершённые).
Дополнительно: частичное бронирование как продуктовая опция («доступно 2 из 3 номеров — бронировать?») превращает жёсткое all-or-nothing в диалог, и это надо уточнить у интервьюера. Цена набора считается на момент подтверждения и фиксируется; при долгом резерве нужен TTL и явное «резерв истёк». Дедлоки: помимо сортировки по room_id, помогает SELECT ... FOR UPDATE в том же порядке и короткие транзакции; в Postgres дедлок будет корректно обнаружен и одна транзакция откатится с 40P01, поэтому в коде нужен ограниченный ретрай на этот код ошибки.
Чаты становятся популярными, число пользователей растет и нам скоро не хватит свободного места на диске для хранения всей переписки. Что нам делать?
Заголовок раздела «Чаты становятся популярными, число пользователей растет и нам скоро не хватит свободного места на диске для хранения всей переписки. Что нам делать?»Коротко. Порядок действий: (1) посчитать реальный рост и структуру занятого места; (2) убрать дешёвое — сжатие, лишние индексы, раздувание, старые бэкапы; (3) ввести тиринг: горячее в быстром хранилище, холодное сжатыми батчами в объектном; (4) шардировать, чтобы объём делился между узлами; (5) обсудить с продуктом retention, если «хранить вечно» не жёсткое требование.
Глубже. Разбор по шагам с числами задачи (90 млн сообщений/сутки × 10 КБ ≈ 900 ГБ/сутки, ~330 ТБ/год, ×3 репликации ≈ 1 ПБ/год):
- Диагностика. Где именно место: данные, индексы, WAL/коммит-логи, раздувание (bloat) от обновлений и удалений, бэкапы, логи, tombstone’ы в LSM. Часто половина занята не сообщениями.
- Сжатие. Текст сжимается zstd в 3–5 раз, но только батчами: сжимать сообщение по 200 байт отдельно бессмысленно. В Cassandra/Scylla сжатие SSTable включено по умолчанию, в Postgres TOAST жмёт только большие значения — значит имеет смысл хранить историю чанками (например, «пачка сообщений чата за месяц» одним сжатым blob’ом в холодном слое).
- Тиринг. Партиционирование таблицы сообщений по времени; партиции старше N месяцев выгружаются в объектное хранилище (сжатые файлы, крупные объекты по 128–512 МБ, а не по одному чату — иначе стоимость и латентность запросов к миллиардам мелких объектов убьют идею) и отцепляются от горячей БД (
DETACH PARTITION+DROP). В горячем слое остаётся индекс «где лежит архив этого чата». Доступ к архиву — медленнее (сотни мс), это согласуется с продуктом. - Шардирование. Если объём не помещается на узел, добавляем узлы: шардирование по
chat_idс консистентным хешированием. Это не уменьшает суммарный объём, но снимает проблему «места на конкретном диске» и даёт линейный рост. - Уменьшение объёма на источнике. Проверить, не храним ли лишнего: дублирование payload в нескольких таблицах, толстые JSON-метаданные на каждое сообщение, избыточные индексы (каждый индекс на 300 ТБ данных — это десятки терабайт), хранение полного профиля отправителя в каждой записи вместо
user_id. - Retention. Самый эффективный рычаг, но он продуктовый: удалять чаты по неактивным объявлениям через N лет, удалять по запросу пользователя, не хранить служебные и спам-сообщения. Если требование «история всегда» жёсткое — остаётся только тиринг и сжатие, и надо честно показать стоимость: 1 ПБ/год в объектном хранилище — это уже статья бюджета, и её надо принести бизнесу как решение.
- Операционно. Мониторинг тренда свободного места с прогнозом «дней до заполнения», алерт заранее, а не по факту; проверенная процедура расширения (добавление шарда, ребалансировка) с оценкой времени — перелить 300 ТБ по сети 10 Гбит/с — это около 3 суток на полной полосе, и это надо знать заранее.
Детали: Продумать кейсы когда сервис отваливается и как об этом узнать быстро и максимально точно (где хранить данные и как добиться того, чтобы сообщения не ушли 2 раза, но и хотя бы один раз ушли точно). По ресурсам условно нет ограничений.
Заголовок раздела «Детали: Продумать кейсы когда сервис отваливается и как об этом узнать быстро и максимально точно (где хранить данные и как добиться того, чтобы сообщения не ушли 2 раза, но и хотя бы один раз ушли точно). По ресурсам условно нет ограничений.»Коротко. Exactly-once в распределённой системе недостижим как свойство доставки, но достижим как свойство эффекта: делаем at-least-once доставку плюс идемпотентного получателя. Состояние (кому отправлено, с каким ключом, каков результат) храним в транзакционной БД рядом с бизнес-данными через transactional outbox, а факт отправки фиксируем уникальным ключом идемпотентности до/во время вызова внешнего канала. О падении узнаём не по «сервис не отвечает», а по симптомам: heartbeat/lease в БД, лаг консьюмера, счётчик «просроченных» записей в outbox и алерт на рост p99.
Глубже. Схема: бизнес-транзакция пишет в свою таблицу и в outbox одним коммитом — так исключается классическая рассинхронизация «в БД записали, в брокер не отправили» или наоборот. Отдельный воркер (или Debezium/CDC) читает outbox и публикует в брокер минимум один раз; после подтверждения помечает строку отправленной. На стороне отправителя сообщений пользователю каждая единица работы получает стабильный idempotency_key (например, sha256(recipient_id|template_id|period)), и таблица sent_messages(idempotency_key PRIMARY KEY, status, provider_msg_id, sent_at) выступает дедупликатором: INSERT ... ON CONFLICT DO NOTHING — если вставка не прошла, значит кто-то уже отправляет или отправил. Пограничный случай «упали между вызовом провайдера и записью результата» решается двухфазно: сначала строка со статусом in_flight и TTL/lease, потом вызов провайдера с тем же ключом идемпотентности (все нормальные почтовые/SMS/push-провайдеры его поддерживают), потом перевод в sent. Зависшие in_flight подбирает reaper и переспрашивает провайдера по ключу.
CREATE TABLE outbox ( id BIGSERIAL PRIMARY KEY, aggregate_id TEXT NOT NULL, topic TEXT NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), published_at TIMESTAMPTZ);CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;package outbox
import ( "context" "database/sql")
// CreateWithEvent атомарно меняет состояние агрегата и кладёт событие в outbox.func CreateWithEvent(ctx context.Context, db *sql.DB, orderID string, payload []byte) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `INSERT INTO orders (id, status) VALUES ($1, 'new')`, orderID); err != nil { return err } if _, err := tx.ExecContext(ctx, `INSERT INTO outbox (aggregate_id, topic, payload) VALUES ($1, $2, $3)`, orderID, "orders.created", payload); err != nil { return err } return tx.Commit()}Про «узнать быстро и точно»: liveness/readiness-пробы ловят только полную смерть процесса, поэтому добавляем (1) heartbeat-таблицу или lease в etcd/Redis, где каждый воркер обновляет last_seen, и алерт на «нет обновления > 3 интервалов»; (2) бизнес-метрику «возраст самой старой неотправленной записи в outbox» — она детектирует и падение, и зависание, и логическую ошибку, чего не делает CPU-метрика; (3) лаг consumer group в Kafka; (4) сквозной synthetic-канареечный прогон раз в минуту. Алертим по симптому (растёт возраст очереди, падает success rate), а причину ищем по трейсам и логам. Раз ресурсов не жалко — держим воркеры в active-active с шардированием по хешу ключа и leader election на партицию, чтобы падение одного инстанса приводило к перехвату его партиций за секунды, а не к простою.
Какие способы горизонтального масштабирования известны?
Заголовок раздела «Какие способы горизонтального масштабирования известны?»Коротко. Три больших класса: реплицирование stateless-слоя за балансировщиком, шардирование (партиционирование) данных по ключу, и разделение нагрузки по типу — read-реплики для чтения, асинхронная обработка через очередь для записи. Плюс вспомогательные приёмы: кеширование, CDN, функциональная декомпозиция на сервисы, CQRS.
Глубже. Stateless-слой масштабируется тривиально: любые N инстансов за L4/L7-балансировщиком, состояние сессии — в Redis или в JWT. Данные — самое сложное: сначала вертикальные разрезы (functional sharding: разные таблицы в разные БД), потом горизонтальные (по хеш-ключу, по диапазону, по географии/тенанту), потом консистентное хеширование, чтобы при добавлении узла переезжала доля 1/N данных, а не всё. Отдельно — read scaling через реплики (нужно принять replication lag и уметь читать «свои записи» через read-your-writes: sticky на мастер в течение окна или чтение по LSN), и write scaling через батчинг, очередь и sharded-счётчики. Классические ограничения, которые надо назвать: закон Амдала (последовательная доля не масштабируется), общая точка — БД, распределённые транзакции и «горячие» ключи (hot shard). Практическое дополнение: hot-key лечится добавлением соли к ключу и агрегацией на чтении.
Связь: В одной переговорке может быть несколько устройств.
Заголовок раздела «Связь: В одной переговорке может быть несколько устройств.»Коротко. Это связь 1:N — room 1 — N device, устройство принадлежит ровно одной переговорке в один момент времени. В схеме это внешний ключ device.room_id (nullable, если устройство может быть «на складе»), а не таблица-связка.
Глубже. Стоит уточнить у интервьюера, может ли устройство перемещаться между переговорками: если да, то нужна история привязок (device_room_assignment(device_id, room_id, from_ts, to_ts)), иначе отчёты «что стояло в комнате в прошлом квартале» не построить. Также уточнить, бывают ли устройства-агрегаты (кодек управляет камерой и микрофоном): тогда появляется иерархия parent_device_id. Индекс CREATE INDEX ON devices(room_id) обязателен — по нему идёт основной запрос выдачи оборудования комнаты. Ключ device.serial_number UNIQUE даёт естественную идемпотентность при регистрации устройства агентом.
Получение списка всех переговорок с их оборудованием;
Заголовок раздела «Получение списка всех переговорок с их оборудованием;»Коротко. GET /api/v1/rooms?office_id=&page=&limit= возвращает список комнат с вложенным массивом устройств; на бэкенде это два запроса (комнаты по фильтру + устройства по room_id IN (...)) со сборкой в памяти, чтобы не ловить N+1 и не раздувать выдачу join-ом.
Глубже. Данные почти статические (комнат — сотни/тысячи, устройств — единицы тысяч), значит это идеальный кандидат для кеша с инвалидацией по событию и для ETag/If-None-Match. Пагинация — keyset (WHERE (building, id) > (:b, :id) ORDER BY building, id LIMIT :n), а не OFFSET, чтобы страница не «плыла». Текущее состояние устройства (online/volume) в этот ответ лучше не подмешивать напрямую из горячего хранилища на каждый запрос: либо отдавать краткий статус из Redis батчем (MGET), либо вернуть только last_seen_at и ссылку на детальный эндпоинт. Ответ:
{"items":[{"id":"room-42","name":"Сатурн","capacity":10, "devices":[{"id":"dev-1","type":"display","model":"...","state":"on","volume":30}]}], "next_cursor":"eyJpZCI6..."}Просмотр текущего состояния конкретного устройства;
Заголовок раздела «Просмотр текущего состояния конкретного устройства;»Коротко. GET /api/v1/devices/{id}/state — читаем «последнее известное состояние» из быстрого хранилища (Redis-хеш или таблица device_state с одной строкой на устройство), плюс отдаём updated_at и признак свежести (stale, если heartbeat старше 2–3 интервалов).
Глубже. Важно развести «желаемое состояние» (desired) и «фактическое» (reported) — модель как в IoT-хабах (device twin/shadow). Пользователь видит reported, команда меняет desired, а агент устройства сводит их и репортит обратно. Тогда UI честно показывает pending, если команда отправлена, но подтверждения ещё нет. Состояние обновляется потоком телеметрии (MQTT/gRPC-стрим/HTTP long-poll от агента), пишется в Redis для чтения и в поток событий для истории. Никогда не считать состояние «по последней команде» — устройство могли выключить кнопкой на корпусе.
Управление устройствами: включение/выключение, регулировка громкости;
Заголовок раздела «Управление устройствами: включение/выключение, регулировка громкости;»Коротко. Команды — асинхронные и идемпотентные: POST /api/v1/devices/{id}/commands с телом {"type":"set_volume","value":30} и заголовком Idempotency-Key, ответ 202 Accepted с command_id и статусом; фактическое исполнение подтверждается отдельно, статус смотрится через GET /commands/{id} или пуш по WebSocket/SSE.
Глубже. Синхронный PUT, который ждёт ответа железки, — главная ошибка в этой задаче: устройство может быть offline, отвечать секунды или залипнуть, а HTTP-таймаут API этого не переживёт. Команда кладётся в очередь (per-device очередь или ключ партиционирования = device_id, чтобы сохранить порядок команд для одного устройства), у неё есть TTL — просроченную команду не исполняем, а помечаем expired, иначе телевизор включится через час после того, как это стало неактуально. Ретраи безопасны, потому что команды сформулированы как «установить абсолютное значение» (set_volume=30), а не «прибавить 5» — абсолютные операции идемпотентны по природе. Отдельно нужны авторизация (кто может управлять комнатой), rate limit на устройство и защита от «дребезга» (coalescing нескольких быстрых set_volume в последнюю).
Система должна отслеживать и сохранять историю изменений состояния устройств.
Заголовок раздела «Система должна отслеживать и сохранять историю изменений состояния устройств.»Коротко. Пишем append-only журнал событий device_state_events(device_id, ts, field, old_value, new_value, source, actor), партиционированный по времени, с ретеншеном по требованиям; текущее состояние — это материализованная проекция журнала.
Глубже. Хранилище выбираем по объёму: если событий немного (тысячи устройств × десятки изменений в день) — обычный PostgreSQL с декларативным партиционированием по месяцам и BRIN-индексом по ts; если это плотная телеметрия — TimescaleDB/ClickHouse с TTL и агрегатами. Обязательно фиксировать source (команда пользователя / физическая кнопка / расписание / агент) и actor — 80% ценности истории именно в ответе «кто выключил проектор». Дедупликация: если устройство репортит состояние каждые N секунд, писать в историю надо только изменения (change-data), а не каждый heartbeat, иначе объём вырастет на два порядка. Идемпотентность записи — по (device_id, ts, field) или по монотонному seq от агента, чтобы ретраи агента не задваивали события.
Фронтенд рассматривать не нужно.
Заголовок раздела «Фронтенд рассматривать не нужно.»Коротко. Это ограничение скоупа: проектируем только API-контракты, бэкенд-сервисы и хранилища; но контракт всё равно проектируем «под потребителя» — какие экраны он обслуживает, какие поля нужны за один вызов, как получать обновления в реальном времени.
Глубже. Правильная реакция на такую строчку — вслух зафиксировать, что из скоупа выпадает (рендеринг, состояние UI, авторизация в браузере), но что остаётся: пагинация и фильтры, формат ошибок, версионирование API, способ доставки обновлений (WebSocket/SSE/поллинг), CORS и лимиты. И не увлекаться: если фронта нет в скоупе, не тратить время интервью на обсуждение BFF, если только сам интервьюер не спросит.
Как на проекте было реализовано горизонтальное и вертикальное масштабирование в кластере?
Заголовок раздела «Как на проекте было реализовано горизонтальное и вертикальное масштабирование в кластере?»Коротко. Вопрос про личный опыт. Каркас ответа: назвать конкретный сервис и его профиль нагрузки → сказать, что stateless-часть масштабировали горизонтально через HPA в Kubernetes по кастомной метрике (RPS или лаг очереди), а не по CPU → сказать, где упирались в вертикальный предел (обычно БД) и что с этим сделали → назвать цифры до/после.
Глубже. Интервьюер хочет услышать понимание, что горизонтальное масштабирование в кластере — это replicas + HPA/KEDA + корректные requests/limits (иначе шедулер не сможет расставить поды), + PodDisruptionBudget и anti-affinity, чтобы реплики не жили на одной ноде, + readinessProbe, чтобы трафик не шёл в непрогретый под. Вертикальное — это увеличение requests/limits пода и/или переезд на более крупные ноды, VPA, а для БД — увеличение инстанса, потому что PostgreSQL горизонтально по записи сам не масштабируется. Хорошая история звучит так: «упирались в connection pool БД, добавление подов только ухудшало ситуацию — поставили PgBouncer в transaction pooling, вынесли тяжёлые чтения на реплики, только после этого HPA дал линейный рост; масштабировали по метрике kafka_consumergroup_lag через KEDA, потому что CPU у консьюмера низкий и по нему автоскейл не срабатывал». Типичные ошибки: рассказывать про «поставили побольше подов» без единой метрики, и путать масштабирование нод кластера (cluster-autoscaler) с масштабированием приложения.
Система позволяет сторонним системам зачислять баллы за выполнение “полезных действий ”
Заголовок раздела «Система позволяет сторонним системам зачислять баллы за выполнение “полезных действий ”»Коротко. Публичный write-API POST /api/v1/points с обязательным Idempotency-Key (или бизнес-ключом external_id от системы-источника), запись в журнал начислений points_ledger как immutable-строку и асинхронное обновление агрегатов; ответ 202 или 200 с текущим балансом.
Глубже. Баллы — это деньги, поэтому модель строго ledger-овая: только append, никаких UPDATE balance = balance + x в качестве источника правды. Таблица points_ledger(id, family_id, member_id, source_system, external_id, amount, action_type, created_at, UNIQUE(source_system, external_id)) — уникальный индекс и есть механизм exactly-once: повторный вызов от партнёра с тем же external_id вернёт тот же результат вместо второго начисления. Внешние системы разные и ненадёжные, поэтому нужны: аутентификация по API-ключу/mTLS с квотами на партнёра, rate limit, валидация словаря action_type (иначе партнёр начислит миллион за «лайк»), лимиты «не более X баллов в сутки на пользователя» и антифрод-проверки асинхронно с возможностью сторно (компенсирующая запись с отрицательным amount, а не удаление строки). Из ledger CDC-потоком наполняются read-модели: баланс участника, сумма семьи за месяц, лидерборд.
Система позволяет просмотреть Топ-10 семей за текущий месяц по сумме баллов всех членов семьи
Заголовок раздела «Система позволяет просмотреть Топ-10 семей за текущий месяц по сумме баллов всех членов семьи»Коротко. Лидерборд держим в Redis Sorted Set с ключом на период (lb:2026-08), ZINCRBY при каждом начислении и ZREVRANGE key 0 9 WITHSCORES на чтение — это O(log N) на запись и O(log N + 10) на чтение, вместо GROUP BY по всему ledger.
Глубже. Считать топ-10 запросом SELECT family_id, SUM(amount) ... WHERE created_at >= date_trunc('month', now()) GROUP BY family_id ORDER BY 2 DESC LIMIT 10 можно только при небольших объёмах: это полный скан месячного среза. При миллионах семей нужен инкрементальный агрегат. Redis ZSET решает задачу, но он не источник правды — при рестарте/сбое его надо уметь пересобрать из ledger (реплей за месяц) и периодически сверять. Ключ включает период, чтобы «текущий месяц» не требовал пересчёта: в начале месяца заводится новый ключ, старый уходит в холодное хранение как снапшот. Сам топ-10 при 99.99% доступности лучше отдавать из локального кеша сервиса с TTL 1–5 секунд — топ меняется постоянно, но точность «до секунды» никому не нужна, зато это снимает нагрузку и защищает от проблем с Redis. Отдельно продумать ничьи (одинаковый score — стабильная сортировка по времени достижения или по family_id) и переход через границу месяца (по таймзоне какой из семей? — уточнить).
Система позволяет просмотреть место и количество баллов своей семьи с разбивкой на каждого из членов семьи
Заголовок раздела «Система позволяет просмотреть место и количество баллов своей семьи с разбивкой на каждого из членов семьи»Коротко. GET /api/v1/families/me/rank возвращает {rank, total_points, members:[{member_id, points}]}: место берём ZREVRANK по тому же ZSET, сумму — ZSCORE, разбивку — из агрегата по участникам (HGETALL fam:{id}:2026-08 или отдельный ZSET на семью).
Глубже. ZREVRANK даёт точный ранг за O(log N), это дешёвая операция, но у неё есть неприятное свойство при миллионах семей: соседи по рангу постоянно меняются, поэтому UI «вы на 142 857 месте» лучше дополнять «топ-10 в вашем регионе/лиге». Разбивку по участникам можно хранить как Redis Hash fam:{family_id}:{period} с HINCRBY member_id amount — тогда весь ответ собирается двумя round-trip’ами или одним Lua-скриптом/pipeline. Источник правды — тот же ledger, поэтому в детальном экране «история начислений» ходим уже в PostgreSQL по индексу (member_id, created_at DESC). Не забыть про приватность: показывать поимённую разбивку можно только членам этой же семьи (авторизация по членству), а в публичном топ-10 — только название семьи и сумму.
Система позволяет получать состояние рейтинга в реальном времени
Заголовок раздела «Система позволяет получать состояние рейтинга в реальном времени»Коротко. «Реальное время» здесь — секунды, а не миллисекунды: push через WebSocket/SSE подписчикам топа, обновление лидерборда синхронно при записи в ledger, а рассылка изменений — с троттлингом (не чаще раза в 1–2 секунды на канал).
Глубже. Обязательно уточнить SLA свежести: «real-time» с задержкой 1 с и с задержкой 100 мс — это разные системы по стоимости. Практичная схема: запись в ledger → outbox/CDC → Kafka → consumer обновляет ZSET и публикует дельту в Redis Pub/Sub → gateway-инстансы с открытыми WebSocket рассылают клиентам. Клиент при подключении получает полный снапшот топа, дальше — только дельты с монотонным version; при разрыве переподключается и запрашивает снапшот заново (иначе рассинхрон). Чтобы не убить систему при вирусной активности, обновления коалесируются: раз в тик берётся текущее состояние топ-10, а не каждое отдельное изменение. Fallback на поллинг раз в 5 секунд для клиентов без WebSocket — обязателен, и он же держит нагрузку, если push-канал деградировал.
Доступность API 99.99%, latency API не более 100 мс
Заголовок раздела «Доступность API 99.99%, latency API не более 100 мс»Коротко. 99.99% — это ~4.4 минуты недоступности в месяц, значит нужны минимум две зоны доступности, active-active сервисы без единой точки отказа, автоматический (не ручной) фейловер БД и деплой без даунтайма. 100 мс — надо уточнить, это среднее или p99 и включает ли сеть до клиента; для p99 на бэкенде это означает чтение из кеша/read-модели, отсутствие синхронных походов в 3+ сервиса и жёсткие таймауты.
Глубже. Считать «бюджет» latency вслух: 100 мс p99 = TLS/LB (~5 мс) + сервис (~10 мс CPU) + 1–2 обращения в Redis (~1–2 мс) + запас на GC-паузы и сетевые всплески. Синхронный запрос в PostgreSQL с сортировкой по миллионам строк в этот бюджет не помещается, отсюда и предварительно посчитанные агрегаты. Для 99.99% важно: ретраи только идемпотентных операций с экспоненциальным backoff и джиттером, hedged requests для чтений, circuit breaker, graceful shutdown с дренированием соединений, health-check, который проверяет зависимости, но не «каскадит» (если Redis лёг — сервис отдаёт деградированный ответ, а не 503). И честно сказать про error budget: 99.99% на уровне всей системы недостижимо, если хоть одна синхронная зависимость даёт 99.9%; либо делаем её асинхронной, либо считаем SLO по каждому эндпоинту отдельно.
Система должна хранить историю изменений за последние 3 года для разбора инцидентов и споров службой поддержки
Заголовок раздела «Система должна хранить историю изменений за последние 3 года для разбора инцидентов и споров службой поддержки»Коротко. Отделяем горячий операционный слой (последние недели, PostgreSQL, быстрые точечные запросы) от холодного архива (ClickHouse или S3/Parquet + движок запросов), партиционируем по времени, включаем ретеншен ровно на 3 года и делаем поддержке отдельный read-only API поиска по user_id/family_id/периоду.
Глубже. Прикинуть объём: если это ledger начислений и на пользователя приходится ~10 событий в день при 10 млн пользователей — 100 млн строк/день, 100+ млрд за 3 года; в PostgreSQL это не живёт, в ClickHouse с колоночным сжатием — вполне (десятки ТБ). Партиционирование по месяцам + DROP PARTITION для протухших данных вместо DELETE (иначе autovacuum и bloat съедят систему). Для споров важны не только события, но и неизменяемость: append-only, отсутствие UPDATE, желательно хеш-цепочка или подпись, чтобы можно было доказать, что запись не правили. Для GDPR-подобных требований предусмотреть псевдонимизацию (в архиве хранить user_ref, а PII — в отдельной таблице, удаление которой «обезличивает» историю, не ломая аудит).
Хранить историю перемещений за 1 год
Заголовок раздела «Хранить историю перемещений за 1 год»Коротко. Это про гео-треки курьеров: сырые точки храним недолго (7–30 дней) для операционных задач, а на год — упрощённый трек (Douglas–Peucker / только значимые точки и события) в колоночном или time-series хранилище с партициями по дням и TTL на год.
Глубже. Расчёт: 300 000 курьеров × одна точка в 30 с = 2 880 точек в сутки на курьера, ~864 млн точек в сутки, ~315 млрд за год. Даже при 16–20 байтах на точку после сжатия (delta+gorilla по времени и координатам) это порядка 5–7 ТБ, при наивном хранении в PostgreSQL с индексами — десятки ТБ и неработающие запросы. Отсюда решения: (1) писать сырьё в ClickHouse/Timescale батчами по 10–50 тыс. строк; (2) на длительное хранение оставлять прореженный трек — точки, где изменилась скорость/направление, плюс все бизнес-события (прибыл, забрал, доставил), что даёт сжатие в 5–20 раз без потери смысла для разбора спора; (3) партиции по дате + TTL toDate(ts) + INTERVAL 1 YEAR DELETE. Уточнить у интервьюера, зачем нужен год: если только для разбора претензий — прореженного трека достаточно; если для ML-моделей ETA — нужно сырьё, но тогда в S3/Parquet, а не в оперативной БД.
Допустимая задержка обновления данных - 1 минута
Заголовок раздела «Допустимая задержка обновления данных - 1 минута»Коротко. Минутный SLA свежести — это подарок: можно не делать синхронный путь и push, а батчить записи, обновлять агрегаты раз в 10–30 секунд и отдавать операторам данные из кеша/read-модели. Главное — сделать саму задержку измеримой метрикой (data_freshness_seconds) и алертить на её превышение.
Глубже. Из минутного окна следуют конкретные упрощения: координаты пишем не по одной, а батчами (мобильное приложение может копить 2 точки и слать раз в минуту — сразу вдвое меньше запросов и радикально меньше расхода батареи); обновление позиций на карте операторам — раз в 10–15 секунд пачкой по видимой области, а не отдельным сообщением на каждую точку; агрегаты и тепловые карты — минутными окнами в стриме. Важно проговорить, что 1 минута — это про отображение позиции, но не про критичные события: «курьер нажал SOS» или «заказ доставлен» должны идти отдельным приоритетным каналом с секундной доставкой. Это классический приём — разделить поток на «телеметрию» (можно терять и задерживать) и «события» (нельзя терять).
Курьеров всего: 300К
Заголовок раздела «Курьеров всего: 300К»Коротко. 300 тыс. курьеров при точке раз в 30 секунд дают ~10 000 записей/с в среднем и 20–30 тыс./с в пик — это средняя нагрузка на приём, но большая на хранение; ключ шардирования — courier_id (хеш), горячее состояние «последняя позиция» — 300 тыс. записей, что легко помещается в память одного Redis.
Глубже. Считаем вслух: 300 000 / 30 = 10 000 rps, при том что онлайн одновременно, скорее всего, не все — уточнить долю активных (например, 30% в час пик → 3 000 rps, но проектировать надо на пик по всей стране). Один Go-сервис на приём телеметрии спокойно держит несколько тысяч простых POST/gRPC в секунду, значит нужно 5–20 подов + LB, приём кладёт данные в Kafka (партиций 64–128 по hash(courier_id)), дальше два консьюмера: «last position» → Redis (GEOADD/hash), «history» → ClickHouse батчами. Последняя позиция всех курьеров: 300 000 × ~100 байт = 30 МБ — тривиально для Redis, можно даже держать копию в памяти каждого read-сервиса и обновлять из Pub/Sub. Отдельно оценить трафик: 10 000 rps × ~200 байт полезной нагрузки ≈ 2 МБ/с, с TLS и HTTP-заголовками в разы больше — повод использовать бинарный протокол (gRPC/protobuf) и persistent-соединения вместо нового TLS-хендшейка на каждую точку.
Операторов у системы - 1000 одновременно
Заголовок раздела «Операторов у системы - 1000 одновременно»Коротко. 1000 операторов — это мало по RPS, но много по «широковещательности»: каждый смотрит на карту с сотнями курьеров, и наивная реализация «шлём каждому все обновления» даёт умножение трафика в тысячи раз. Решение — подписка на видимую область (geo-bounding box / geohash-тайлы) и агрегирование обновлений в пачки раз в 10–15 секунд.
Глубже. Технически: WebSocket-соединение на оператора (1000 соединений — ерунда для одного Go-инстанса, но держим минимум 2–3 для отказоустойчивости), подписки хранятся как множество geohash-префиксов; консьюмер позиций публикует апдейты в Pub/Sub по каналам-тайлам, gateway фильтрует и шлёт только релевантное. При зуме «вся Россия» отдаём не 300 тыс. точек, а кластеры/тепловую карту, посчитанные заранее по тайлам — иначе браузер оператора умрёт раньше сервера. Плюс лимит: не более N объектов в ответе, дальше — агрегация. Права доступа: оператор обычно видит только свой регион/подразделение, это и естественный шардинг подписок.
География - вся Россия
Заголовок раздела «География - вся Россия»Коротко. 11 часовых поясов означают: все временные метки в UTC с явным хранением таймзоны события, «сутки» и отчёты считаются в локальном времени региона, а пиковая нагрузка размазана по времени, но имеет две-три волны. Также это повод обсудить географическое размещение (точки присутствия/CDN на востоке) и региональное шардирование данных.
Глубже. Практические следствия: (1) ключ партиционирования может включать регион — это и локальность запросов оператора, и возможность вынести обработку ближе к пользователю; (2) отчёты «за сегодня» без указания таймзоны — источник бесконечных багов, поэтому в API всегда явный tz или ISO-8601 со смещением; (3) деплой и maintenance-окна: «ночь» в Москве — рабочее утро во Владивостоке, поэтому только rolling-обновления без даунтайма; (4) требования по локализации данных (152-ФЗ) — персональные данные граждан РФ хранятся на серверах в РФ, значит облако/ЦОД выбираем соответствующие, и это стоит проговорить как ограничение, а не как деталь. Мультирегиональность внутри страны обычно решается двумя ЦОД в active-active для stateless и active-passive с синхронной/полусинхронной репликой для БД.
Координаты отправляются каждые 30 с
Заголовок раздела «Координаты отправляются каждые 30 с»Коротко. Это задаёт нижнюю границу нагрузки на запись (300K/30 с ≈ 10K rps) и верхнюю границу точности трека; частоту стоит сделать адаптивной — реже, когда курьер стоит или не на заказе, чаще на финальном участке доставки.
Глубже. С 30-секундным интервалом трек по городу получается грубым (машина за 30 с проезжает ~300–500 м), поэтому для отображения на карте нужен map-matching и интерполяция, а для расчёта пробега — привязка к дорожному графу, иначе «пробег по прямой» будет систематически занижен. На стороне клиента: копить точки локально в SQLite и досылать после потери связи (важно для тоннелей и подвалов), с монотонным seq и серверной дедупликацией по (courier_id, ts) — иначе досылка задвоит трек. Сервер должен отбрасывать явно битые точки (скачок на 500 км за 30 с, точность GPS > 500 м, timestamp из будущего) — фильтр качества данных обязателен, о нём часто забывают. Пакетная отправка (например, 4 точки раз в 2 минуты) снижает и число запросов, и энергопотребление; проверить, что это укладывается в требование «задержка не более 1 минуты» — если нет, компромисс: батч раз в 30 с из 1–2 точек.
Что такое вертикальное/горизонтальное масштабирование?
Заголовок раздела «Что такое вертикальное/горизонтальное масштабирование?»Коротко. Вертикальное (scale up) — увеличиваем ресурсы одного узла: CPU, RAM, быстрее диск. Горизонтальное (scale out) — добавляем узлы и распределяем нагрузку между ними. Вертикальное проще и не требует изменений в коде, но упирается в физический потолок и даёт единую точку отказа; горизонтальное практически безгранично, но требует stateless-дизайна, шардирования данных и умения жить с сетевыми отказами.
Глубже. Правильный ответ на собеседовании включает «сначала вертикально, потом горизонтально»: современный сервер с 128 ядрами и 1–2 ТБ RAM закрывает нагрузку, которую десять лет назад пришлось бы шардировать, и стоит дешевле, чем распределённая система с её сложностью. Разворот наступает, когда (а) нужна отказоустойчивость (один узел = одна точка отказа независимо от его мощности), (б) рост стоимости становится нелинейным, (в) упёрлись в потолок железа. Отдельно стоит упомянуть, что stateless-слой масштабируется горизонтально бесплатно, а stateful (БД) — с оговорками: read-реплики масштабируют чтение, шардирование — запись, но ценой потери кросс-шардовых транзакций и join’ов. В Kubernetes это HPA (горизонтально) и VPA (вертикально), причём одновременно их на один и тот же ресурс включать нельзя.
Что такое хайлоад и какие критерии?
Заголовок раздела «Что такое хайлоад и какие критерии?»Коротко. Highload — не про абсолютные цифры, а про ситуацию, когда нагрузка близка к пределу выбранной архитектуры и её нельзя закрыть простым «докупить железа»: система требует специальных решений (шардирование, кеш, асинхронность, денормализация). Формальных критериев нет; ориентиры — десятки тысяч RPS, терабайты данных, миллионы пользователей, но 200 rps тяжёлой аналитики тоже highload.
Глубже. Полезная формулировка: система становится высоконагруженной, когда стоимость линейного роста ресурсов растёт быстрее, чем нагрузка, и когда отказ отдельного компонента становится нормой, а не аварией. Практические признаки: приходится думать о профиле чтения/записи, о хвостовых задержках (p99, а не среднем), о bulk/батчинге, о том, что кеш и БД не помещаются на один узел, о backpressure. На собеседовании хорошо назвать метрики, которыми это измеряется: RPS, QPS к БД, p50/p95/p99 latency, объём данных и темп роста, размер рабочего набора относительно RAM, IOPS, сетевой трафик, saturation по CPU/диску/пулу соединений. И привести пример «одна и та же цифра — разный highload»: 10 000 rps GET по ключу из Redis — рутина, 10 000 rps с записью в транзакцию с четырьмя индексами — уже нет.
1-я страница с общим списком заказов. Заказы могут быть на разный вид транспорта - поезд, автобус, самолет. На ней отражена базовая информация о заказе;
Заголовок раздела «1-я страница с общим списком заказов. Заказы могут быть на разный вид транспорта - поезд, автобус, самолет. На ней отражена базовая информация о заказе;»Коротко. Список — это read-модель: одна денормализованная таблица/индекс orders_list(user_id, order_id, type, status, depart_at, title, price, updated_at) с общими для всех видов транспорта полями, keyset-пагинацией по (depart_at DESC, order_id) и без единого join’а к сервисам поставщиков.
Глубже. Ключевое проектное решение — полиморфизм заказов. Три варианта: (1) единая таблица с общими полями + JSONB details (просто, гибко, но слабая типизация), (2) таблица-родитель + таблицы по типам (class table inheritance — чисто, но join на детальном экране), (3) отдельные сервисы на каждый вид транспорта + агрегирующий сервис списка, наполняемый событиями. Для списка правильнее всего (3) с материализованным представлением: сервисы «поезд/автобус/самолёт» публикуют события order.created/updated, агрегатор кладёт в свою read-модель ровно те поля, что нужны первому экрану. Это даёт стабильный p99 (одна выборка по индексу), устойчивость к падению отдельного поставщика (список покажется, пусть и с чуть устаревшим статусом) и простую пагинацию. Кеш на пользователя в Redis с инвалидацией по событию закрывает повторные открытия экрана. Уточнить у интервьюера: сортировка по дате поездки или по дате создания, показывать ли прошедшие поездки, нужен ли объединённый заказ «мультимодальная поездка».
2-я страница - это детальная информация по каждому заказу.
Заголовок раздела «2-я страница - это детальная информация по каждому заказу.»Коротко. GET /api/v1/orders/{id} — маршрутизируем по типу заказа в соответствующий сервис (или читаем из его read-модели) и отдаём полный набор полей: маршрут, места, пассажиры, документы, правила возврата, статус оплаты. Здесь допустимо чуть большее время ответа и обращение к источнику правды, потому что запросов на порядок меньше, чем к списку.
Глубже. Разделение «список из read-модели, деталь из источника» — стандартный и защищаемый trade-off: на детальном экране критична актуальность (пользователь видит номер места и статус возврата), на списке — скорость. Если поставщик отвечает медленно или недоступен, деталь отдаём из кеша с явным stale_at и баннером «данные могли устареть» — это лучше, чем 500. Полиморфные детали лучше отдавать с полем-дискриминатором type и типизированным payload под каждый тип, а не пытаться сделать «универсальную схему» — иначе клиент всё равно будет ветвиться по типу, но по неявным признакам. Идемпотентность и права: заказ доступен только владельцу; ID заказа — не последовательный, а UUID/ULID, чтобы нельзя было перебрать чужие.
System design: design the architecture of a population census system for China.
Заголовок раздела «System design: design the architecture of a population census system for China.»Коротко. Перепись — это не highload по RPS в привычном смысле, а гигантский пакетный ETL с жёсткими требованиями к полноте, дедупликации и приватности: ~1.4 млрд записей, сбор через мобильные приложения переписчиков (часто offline-first) и веб-самозапись, приём в очередь, дедупликация по национальному ID, staging → нормализация → аналитические витрины.
Глубже. Расчёт: 1.4 млрд анкет за 2–3 недели активной фазы ≈ 1.4e9 / (14 × 86400) ≈ 1150 записей/с в среднем и, с учётом дневного профиля и пиков, 5–15 тыс./с — это вполне обычная нагрузка на запись, но объём (при 2–5 КБ на анкету — 3–7 ТБ сырых данных) и требование «ни одна анкета не потеряна и не посчитана дважды» делают задачу интересной. Архитектура: offline-first клиент с локальной БД и синхронизацией (каждая анкета имеет client_uuid — ключ дедупликации), API-gateway в регионах, приём пишет в durable-очередь (Kafka с репликацией и acks=all) → консьюмеры валидируют и кладут в staging (ClickHouse/HDFS/Parquet) → дедупликация и матчинг с реестром населения (по национальному ID, с fuzzy-матчингом для случаев без него) → нормализованное хранилище + витрины для агрегатов по регионам. Обязательно: география шардирования по административным единицам (провинция/уезд), потому что все отчёты строятся именно так; строгий аудит и неизменяемость (кто, когда, где заполнил — с геометкой и подписью устройства); шифрование PII в покое и в транзите, разделение «идентифицирующие данные» и «ответы анкеты» в разных хранилищах со связкой через токен, доступ к агрегатам — без доступа к персональным строкам; k-анонимность/дифференциальная приватность при публикации статистики. Отказоустойчивость: клиент никогда не теряет данные (локальная очередь + ретраи), сервер подтверждает только после fsync/репликации, повторная отправка идемпотентна по client_uuid. Контроль качества — отдельная подсистема: проверка охвата по домохозяйствам, выявление аномалий (переписчик, «заполнивший» 400 анкет за час), повторный обход по выборке.
В общем тут как на системном дизайне нужно постоянно уточнять и спрашивать про особенности, и проговаривать все свои шаги.
Заголовок раздела «В общем тут как на системном дизайне нужно постоянно уточнять и спрашивать про особенности, и проговаривать все свои шаги.»Коротко. Это описание метода, а не вопрос: на system design оценивают в первую очередь коммуникацию — задавай уточняющие вопросы в начале и на каждой развилке, озвучивай предположения вслух и фиксируй их на «доске», объясняй, почему выбираешь вариант A, а не B.
Глубже. Работающий скелет первых 10 минут: (1) кто пользователи и какие 3–5 основных сценариев (остальное явно вынести за скоуп); (2) масштаб — DAU/MAU, RPS, объём и рост данных; (3) нефункциональные — доступность, latency, консистентность, срок хранения, приватность; (4) ограничения — бюджет, команда, существующий стек, дедлайн. Дальше на каждом шаге проговариваешь: «предполагаю, что чтений в 100 раз больше записей — если это не так, скажите, дизайн изменится». Плохой признак для интервьюера — молчаливое рисование прямоугольников и «универсальная» архитектура, не выведенная из требований. Второй плохой признак — не управлять временем: увязнуть в схеме БД и не дойти до масштабирования. Полезно вслух держать таймлайн: «сейчас 15 минут на API и данные, потом 15 на схему, потом обсудим отказы».
Приходилось ли тебе выбирать стук для сервисов?
Заголовок раздела «Приходилось ли тебе выбирать стук для сервисов?»Коротко. Похоже на опечатку — вопрос про выбор стека для сервисов. Каркас ответа: назвать критерии выбора (профиль нагрузки, экспертиза команды, экосистема, эксплуатация), привести конкретный случай с альтернативами и объяснить, чем закончилось и что бы поменял.
Глубже. Интервьюер проверяет, отличаешь ли ты инженерный выбор от вкусовщины. Критерии, которые стоит назвать: (1) характер нагрузки — много I/O-конкурентности → Go/Java с пулами, тяжёлые вычисления → C++/Rust, много ML → Python-обвязка; (2) экспертиза и найм — язык, который никто в команде не знает, стоит дороже любых бенчмарков; (3) экосистема — драйверы БД, клиенты брокера, готовые библиотеки observability; (4) эксплуатация — размер образа, время старта (важно для автоскейла и serverless), потребление памяти, предсказуемость GC; (5) организационное — сколько разных стеков компания готова поддерживать (обычно правило «2 языка максимум»). Хороший ответ содержит trade-off: «выбрали Go вместо Python для сервиса агрегации, потому что упирались в CPU на сериализации и хотели предсказуемый p99; проиграли в скорости прототипирования и в библиотеках для ML, поэтому ML-часть оставили на Python и связали gRPC». Если это всё-таки вопрос про выбор транспорта («стук» = как сервисы стучатся друг к другу), ответ тот же по форме: REST для внешних и простых внутренних API, gRPC для внутренних высоконагруженных и стриминга, асинхронные события через брокер там, где не нужен синхронный ответ; критерий — нужен ли вызывающему результат прямо сейчас.
Знаком ли с моделью C4? Architecture as a code? Писал ли ADR?
Заголовок раздела «Знаком ли с моделью C4? Architecture as a code? Писал ли ADR?»Коротко. C4 — четыре уровня диаграмм (Context → Container → Component → Code) от Саймона Брауна, дающие единый язык описания архитектуры на разной глубине. Architecture as code — описание диаграмм и инфраструктуры текстом в репозитории (Structurizr DSL, PlantUML/C4-PlantUML, Mermaid, diagrams.py) с ревью через PR. ADR — короткий документ на одно архитектурное решение: контекст, рассмотренные варианты, решение, последствия.
Глубже. На практике из C4 реально живут два верхних уровня: Context (кто пользователи и с какими внешними системами мы интегрированы) и Container (какие деплоимые единицы, БД, очереди и как они связаны). Component-уровень быстро протухает, Code-уровень генерируется из кода и обычно не нужен. Ценность «архитектуры как кода» именно в том, что диаграмма лежит рядом с кодом, версионируется, ревьюится и рендерится в CI, поэтому не расходится с реальностью на полгода, как картинка в Confluence. ADR — самый недооценённый инструмент: типичный шаблон MADR/Nygard содержит Status (proposed/accepted/superseded), Context, Decision, Consequences (обязательно и негативные), и хранится в docs/adr/0007-use-kafka-for-order-events.md. Главная польза — через год понятно, почему выбрали именно так и какие ограничения были на тот момент, а решения не переигрываются по кругу. Если опыта нет, честнее сказать «читал, применял частично: диаграммы держали в PlantUML в репозитории, ADR не вели, о чём жалели при онбординге» — это лучше, чем изображать знакомство.
Продавец может размещать карточку товара
Заголовок раздела «Продавец может размещать карточку товара»Коротко. POST /api/v1/products с Idempotency-Key, валидацией по схеме категории, сохранением в PostgreSQL как источник правды и статусом draft → moderation → active; публикация в поисковый индекс и в кеш каталога — асинхронно через outbox/CDC.
Глубже. Карточка — write-редкая, read-частая сущность, поэтому запись оптимизировать не нужно, а вот путь до читателей — нужно. Модель: products(id, seller_id, category_id, title, description, price, status, version, created_at, updated_at) плюс product_attributes или JSONB attrs, валидируемый JSON-схемой конкретной категории (у телефонов — диагональ, у обуви — размер). Медиа не хранятся в БД: клиент получает pre-signed URL и грузит в объектное хранилище напрямую, в карточке — только ключи. Обязательны: модерация (ручная или ML) перед публикацией, ограничение частоты создания на продавца (антиспам), оптимистическая блокировка по version при редактировании, и версионирование карточки (история изменений цены и описания нужна и для споров, и для аналитики). После коммита событие product.published → консьюмеры обновляют Elasticsearch/OpenSearch, счётчики категорий и инвалидацию кеша.
Товары распределены по категориям
Заголовок раздела «Товары распределены по категориям»Коротко. Категории — дерево (обычно 3–5 уровней), товар привязан к листовому узлу; для запросов «всё в поддереве» используем materialized path или nested set / ltree в PostgreSQL, а не рекурсивные обходы на каждый запрос.
Глубже. Ключевой вопрос — «товар в одной категории или в нескольких». В большинстве маркетплейсов основная категория одна (она определяет набор атрибутов и комиссию), плюс возможны дополнительные размещения — тогда нужна связка product_categories(product_id, category_id, is_primary). Дерево удобно хранить с денормализованным путём: categories(id, parent_id, path ltree, level, name, slug) — запрос «товары в категории и всех подкатегориях» превращается в фильтр path <@ 'electronics.phones' или в предпосчитанный список ID поддерева, закешированный в памяти сервиса (категорий тысячи — влезает целиком). В поисковый документ товара кладём весь путь категорий массивом, тогда фасетная фильтрация в Elasticsearch работает без join’ов. Отдельно продумать переезд категории (перенос поддерева) — это фоновая реиндексация, а не онлайн-операция.
Продавец не может создавать свои категории, то есть все категории заводим мы
Заголовок раздела «Продавец не может создавать свои категории, то есть все категории заводим мы»Коротко. Категории — справочник, управляемый админкой: медленно меняющиеся данные, которые можно целиком держать в памяти каждого инстанса и обновлять по событию или раз в минуту. Это сильно упрощает дизайн: атрибуты, комиссии, модерация и фасеты привязаны к контролируемому словарю.
Глубже. Практическое следствие — категория становится частью контракта: у каждой листовой категории есть JSON-схема атрибутов и версия схемы; товар хранит category_schema_version, чтобы изменение справочника не «ломало» ранее созданные карточки задним числом. Изменения справочника проходят через отдельный процесс: черновик → ревью → публикация с миграцией товаров (при переименовании — простая, при слиянии/разделении — фоновая переклассификация). Раз продавцы не создают категории, нужен канал обратной связи «запросить новую категорию», иначе продавцы начнут пихать товар в неподходящие узлы, что убьёт качество поиска. Кеш справочника — с явной версией (etag/version), сервисы сравнивают версию и подтягивают дельту.
Покупатели могут зайти на карточку товара и купить (механику покупки мы не проектируем)
Заголовок раздела «Покупатели могут зайти на карточку товара и купить (механику покупки мы не проектируем)»Коротко. GET /api/v1/products/{id} — самый горячий read-эндпоинт: отдаём из кеша (Redis + локальный in-process кеш с коротким TTL), карточка собирается заранее как денормализованный документ, а цена/остаток подмешиваются отдельным быстрым чтением, потому что меняются чаще всего.
Глубже. Разделение «редко меняющееся тело карточки» и «часто меняющиеся цена/остаток» — главный приём: тело кешируется агрессивно (минуты, CDN для анонимных пользователей), цена и наличие читаются из отдельного key-value с TTL в секунды. Так одна акция с изменением цен не инвалидирует весь кеш каталога. Защита от «горячего товара» (вирусная карточка): локальный кеш на инстансе + request coalescing (singleflight), чтобы 10 000 одновременных промахов не превратились в 10 000 запросов в БД. Раз механику покупки не проектируем, всё равно стоит проговорить границу: карточка отдаёт product_id и offer_id, дальше начинается checkout-домен со своей резервацией остатков; это правильная граница сервисов. Метрики: hit ratio кеша, p99 карточки, доля деградированных ответов (без остатка).
Есть возможность поиска товара по названию, описанию
Заголовок раздела «Есть возможность поиска товара по названию, описанию»Коротко. Полнотекстовый поиск — отдельная подсистема на Elasticsearch/OpenSearch, наполняемая асинхронно из событий каталога; PostgreSQL LIKE '%...%' не годится, tsvector подойдёт только для небольшого каталога и без фасетов и ранжирования.
Глубже. Индексируем денормализованный документ: title, description, бренд, путь категорий, атрибуты-фасеты, цена, рейтинг продавца, признаки для ранжирования (продажи, CTR). Морфология русского языка — обязательный анализатор (стеммер/лемматизатор), плюс синонимы и опечаточник (fuzzy/did you mean). Задержка индексации 1–10 секунд обычно приемлема — это надо явно согласовать, потому что продавцы жалуются «загрузил товар, а его не видно». Консистентность обеспечивается тем, что источник правды — PostgreSQL, а индекс полностью восстановим реиндексацией (обязательно иметь процедуру полной переиндексации с алиасами: строим новый индекс, атомарно переключаем alias). Ранжирование — гибрид текстовой релевантности (BM25) и бизнес-сигналов; на собеседовании достаточно назвать это и упомянуть, что «чистый BM25 в e-commerce не работает». Пагинация в поиске — search_after, не from/size с большим offset.
Есть возможность просматривать товары в категории
Заголовок раздела «Есть возможность просматривать товары в категории»Коротко. Листинг категории с фильтрами и сортировкой обслуживается тем же поисковым индексом (запрос без текста, но с фильтром по пути категории и фасетами), а не БД: только так дёшево считаются счётчики фасетов и работает сортировка по цене/популярности.
Глубже. Первые страницы популярных категорий с дефолтной сортировкой — кешируются целиком (это 90% трафика), «хвост» с редкими комбинациями фильтров идёт в индекс. Пагинация — keyset/search_after; «страница 500» не поддерживается ни одним нормальным маркетплейсом и это правильный ответ на вопрос про глубокую пагинацию. Счётчики «найдено N товаров» при больших числах считаются приблизительно (track_total_hits: 10000), потому что точный подсчёт по миллионам документов стоит дорого и никому не нужен. Отдельно продумать SEO-нагрузку от ботов (отдельный пул/лимиты) и то, что персонализация выдачи ломает кеш — поэтому персонализируем только часть блоков, а базовый листинг оставляем общим.
отправить сообщения;
Заголовок раздела «отправить сообщения;»Коротко. POST /api/v1/chats/{chat_id}/messages (или отправка через уже открытый WebSocket) с клиентским message_uuid для идемпотентности; сервер присваивает монотонный seq внутри чата, персистит сообщение и только после успешной записи подтверждает клиенту и рассылает получателю.
Глубже. Порядок сообщений в чате обеспечивается серверным счётчиком на чат (seq = last_seq + 1 в транзакции или через отдельный аллокатор), а не временем клиента — часы на телефонах врут. Идемпотентность: UNIQUE(chat_id, client_msg_uuid) — повтор при ретрае вернёт то же сообщение вместо дубля (типичная ошибка в мессенджерах: пользователь в метро жмёт «отправить», сеть отваливается, клиент ретраит — приходит два сообщения). Путь: API/WS-gateway → сервис чатов → запись в хранилище (Cassandra/ScyllaDB с ключом партиции chat_id и кластерным seq DESC, либо PostgreSQL с шардированием по chat_id, если объёмы умеренные) → публикация в шину → доставка получателю по его открытому WS или пуш-уведомление. Ack’и трёхступенчатые: sent (сервер принял), delivered (доставлено устройству), read (прочитано) — это разные события и их надо разделять сразу.
принять прочитать сообщения;
Заголовок раздела «принять прочитать сообщения;»Коротко. Чтение истории — GET /api/v1/chats/{chat_id}/messages?before_seq=&limit=50 (обратная keyset-пагинация по seq), доставка новых — push по WebSocket; статус «прочитано» — отдельная операция POST /chats/{id}/read с up_to_seq, обновляющая курсор чтения пользователя.
Глубже. Курсор чтения хранится как одно число на пару (чат, пользователь) — read_up_to_seq, а не флаг на каждом сообщении: это в разы дешевле и позволяет мгновенно считать «непрочитанных N» как last_seq - read_up_to_seq. Отправителю статус «прочитано» отправляется как событие, но с throttling’ом. При переподключении клиент присылает свой last_known_seq, сервер отдаёт дельту — так закрывается дыра «сообщения, пришедшие пока не было сети». Если клиент отстал слишком сильно, отдаём не дельту, а «перезагрузите историю». Обязательно уточнить требования по приватности: отключаемые галочки прочтения — это уже продуктовое решение, влияющее на модель.
чаты связанные с объявлениями пользователей;
Заголовок раздела «чаты связанные с объявлениями пользователей;»Коротко. Чат идентифицируется тройкой (объявление, покупатель, продавец) — это естественный уникальный ключ, обеспечивающий идемпотентность создания: повторное нажатие «написать» возвращает существующий чат, а не плодит новые.
Глубже. Схема: chats(id, listing_id, buyer_id, seller_id, created_at, last_message_seq, last_message_at, UNIQUE(listing_id, buyer_id)) — продавец в этой связке определяется объявлением, поэтому в ключ его можно не включать (но стоит хранить для выборок). Привязка к объявлению означает денормализацию его карточки в чат (заголовок, цена, фото на момент создания или актуальные), потому что список чатов должен рисоваться без похода в сервис объявлений. Важный кейс: объявление снято/удалено/цена изменилась — чат обязан жить дальше (это отдельно указано в условиях), значит нужен снапшот минимальных полей объявления в чате и мягкое удаление объявлений. Ещё кейс: один и тот же покупатель пишет по двум объявлениям одного продавца — это два разных чата, и это осознанное продуктовое решение, которое стоит проговорить.
инициализация чата происходит как только пользователь нажимает на кнопку написать;
Заголовок раздела «инициализация чата происходит как только пользователь нажимает на кнопку написать;»Коротко. POST /api/v1/chats с телом {listing_id} — операция идемпотентная (get-or-create по уникальному ключу (listing_id, buyer_id)), возвращает chat_id; создание пустого чата без сообщений допустимо, но такие «пустышки» надо чистить или не показывать в списке продавца.
Глубже. Реализация get-or-create: INSERT ... ON CONFLICT (listing_id, buyer_id) DO UPDATE SET updated_at = now() RETURNING id — один round-trip, без гонок при двойном клике. Пустой чат не должен генерировать уведомление продавцу и не должен попадать в его список до первого сообщения — иначе продавцы утонут в шуме, а мы получим бесполезную нагрузку. Альтернативный дизайн — вообще не создавать чат до первого сообщения (клиент держит «черновик» локально, а первый POST /messages создаёт чат транзакционно); он чище, но требует, чтобы API сообщений умел принимать listing_id вместо chat_id. Оба варианта защитимы, главное — объяснить, почему выбран этот. Проверки на входе: покупатель ≠ продавец, объявление активно (или разрешено писать по архивным), лимит на число создаваемых чатов в час (антиспам).
чаты являются “рeeт-то-рeeр ”для общения продавца и покупателя;
Заголовок раздела «чаты являются “рeeт-то-рeeр ”для общения продавца и покупателя;»Коротко. Имеется в виду peer-to-peer в смысле «диалог ровно двух участников», а не P2P-сеть: сообщения всё равно идут через сервер. Это сильно упрощает дизайн — нет групп, нет ролей, fan-out на одного получателя, счётчики непрочитанных тривиальны.
Глубже. Диалог из двух участников позволяет: (а) хранить участников прямо в строке чата (buyer_id, seller_id) без таблицы chat_members; (б) не решать задачу fan-out на тысячи подписчиков — доставка идёт максимум на устройства одного пользователя; (в) держать «список чатов» как простой индекс (user_id, last_message_at DESC). Стоит вслух зафиксировать, что это не end-to-end шифрование: сервер видит содержимое (нужно для модерации, антифрода и жалоб), а «peer-to-peer» в условии — про число участников. Если интервьюер спросит про E2E, честный ответ: это ломает серверный поиск по сообщениям, модерацию и историю на новом устройстве, поэтому для маркетплейса обычно не делают; максимум — шифрование в транзите и в покое.
просмотр списка чатов;
Заголовок раздела «просмотр списка чатов;»Коротко. GET /api/v1/chats?cursor=&limit=20 — выборка по индексу (user_id, last_message_at DESC) из денормализованной таблицы user_chats, где уже лежат превью последнего сообщения, данные собеседника, снапшот объявления и счётчик непрочитанных.
Глубже. Наивная реализация «взять чаты пользователя, для каждого достать последнее сообщение» — это N+1 и главный источник тормозов в мессенджерах. Правильно: при отправке сообщения обновлять две строки user_chats (по строке на участника) в том же событийном потоке — last_message_preview, last_message_at, unread_count. Это денормализация с eventual consistency, что для списка чатов абсолютно приемлемо. Сортировка по last_message_at DESC требует, чтобы этот столбец был в индексе вместе с user_id; пагинация — keyset по (last_message_at, chat_id). Счётчик непрочитанных пересчитывается как last_seq - read_up_to_seq, а не инкрементируется вслепую — так он самовосстанавливается после сбоев. Кеш списка в Redis на активных пользователей плюс инвалидация по событию нового сообщения закрывает основной трафик.
“Realtime ”сообщения должны доставляться от отправителя до получателя с задержкой до 3 секунд.
Заголовок раздела «“Realtime ”сообщения должны доставляться от отправителя до получателя с задержкой до 3 секунд.»Коротко. 3 секунды — мягкое требование: хватает WebSocket (или SSE) с постоянным соединением через gateway-слой, роутинга сообщений через Redis Pub/Sub или Kafka по user_id, и пуш-уведомлений (APNs/FCM) для офлайн-получателей. Никакого специального экзотического стека не требуется.
Глубже. Бюджет 3 с: запись в БД (10–30 мс) + публикация в шину (5–20 мс) + доставка на gateway с открытым сокетом получателя (10 мс) + сеть мобильного клиента (100–1000 мс) — с большим запасом. Ключевая инженерная задача — не latency, а «где сейчас сокет получателя»: реестр присутствия (user_id → gateway_instance) в Redis с TTL и heartbeat, либо широковещание по всем gateway через Pub/Sub-канал (проще, дороже по трафику, при десятках инстансов вполне работает). Если получатель офлайн — сообщение уже сохранено, доставим при подключении по seq-дельте, плюс шлём push. Обязательны: heartbeat/ping-pong для детекта мёртвых соединений, backpressure на медленных клиентах (ограничение буфера, при переполнении — разрыв и заставить переподключиться с догрузкой истории), graceful shutdown с равномерным переподключением (jitter), иначе рестарт gateway устроит thundering herd. Метрика SLA — гистограмма end-to-end задержки, измеряемая по клиентским ack’ам, а не серверными таймингами.
Клиенты (внешние системы): Создают заказы на перевозку через интеграцию; Источники: маркетплейсы, внешние логистические платформы, корпоративные системы.
Заголовок раздела «Клиенты (внешние системы): Создают заказы на перевозку через интеграцию; Источники: маркетплейсы, внешние логистические платформы, корпоративные системы.»Коротко. Внешние интеграции — отдельный слой: публичный API (REST/JSON + вебхуки, опционально async-приём файлов/EDI), аутентификация по API-ключу/OAuth-клиенту на партнёра, Idempotency-Key или client_order_id с уникальным индексом, квоты и rate limit на партнёра, версионирование контракта.
Глубже. Приём заказа должен быть асинхронным по сути: POST /orders валидирует синтаксис и права, кладёт заказ в статусе accepted и отвечает 202 с order_id; тяжёлая валидация (адреса, тарифы, доступность) идёт фоном, а результат сообщается вебхуком и доступен через GET /orders/{id}. Это критично при 20 млн заказов в сутки: синхронная валидация с походами в геокодер и тарифный движок не выдержит нагрузку и свяжет доступность нашего API с доступностью партнёрских сервисов. Изоляция партнёров обязательна — bulkhead: у каждого свой лимит и своя очередь, иначе один маркетплейс с батчем на 5 млн заказов положит приём для всех. Для крупных клиентов — batch-эндпоинт (POST /orders/batch до 1000 заказов, с постатейным результатом) и/или загрузка файлов в S3 с обработкой по событию. Вебхуки — с ретраями, экспоненциальным backoff, подписью тела (HMAC) и DLQ; плюс поллинг как альтернатива для тех, кто не умеет принимать вебхуки. Обязательна «песочница» и контрактные тесты, иначе интеграции будут ломаться на каждом релизе.
Водители (исполнители): Используют мобильное приложение; Получают информацию о маршрутах, точках погрузки/разгрузки; Отправляют статусы (прибыл, загрузил, выехал и т. д.).
Заголовок раздела «Водители (исполнители): Используют мобильное приложение; Получают информацию о маршрутах, точках погрузки/разгрузки; Отправляют статусы (прибыл, загрузил, выехал и т. д.).»Коротко. Мобильный клиент — offline-first: маршрутное задание скачивается целиком и кешируется локально, статусы пишутся в локальную очередь с client_event_id и досылаются при появлении сети; сервер дедуплицирует по этому id и упорядочивает по клиентскому seq и серверному времени приёма.
Глубже. Связь у водителя рвётся постоянно (склады, трассы, подземные паркинги), поэтому любая синхронная модель «нажал кнопку — ждём 200 OK» неприемлема. Локальная очередь + идемпотентная досылка + понятная индикация «не синхронизировано» — базовое требование. Конфликты: водитель офлайн отметил «загрузил», а диспетчер тем временем переназначил маршрут — нужна политика разрешения (обычно: событие принимается, но помечается как late/conflicting и уходит на разбор диспетчеру, а не молча теряется). Статусы моделируются конечным автоматом с явными допустимыми переходами (assigned → en_route_pickup → at_pickup → loaded → en_route_delivery → at_delivery → unloaded → done), недопустимый переход — 409 с текущим состоянием, чтобы клиент подтянул актуальное. Push-канал для доставки новых заданий (FCM/APNs + поллинг как fallback), экономия батареи и трафика (батчинг гео, дельта-обновления заданий), обязательные фото/подписи на приёмке — грузятся отдельно в объектное хранилище по pre-signed URL, чтобы не блокировать статус.
Построение маршрутов: Для каждого заказа формируются маршруты так, чтобы заказ доехал от точки А до точки В; Возможны промежуточные точки (хабы) - заказ может разбиваться на несколько этапов; Каждый этап (маршрут) имеет своего исполнителя.
Заголовок раздела «Построение маршрутов: Для каждого заказа формируются маршруты так, чтобы заказ доехал от точки А до точки В; Возможны промежуточные точки (хабы) - заказ может разбиваться на несколько этапов; Каждый этап (маршрут) имеет своего исполнителя.»Коротко. Планировщик — отдельный сервис (и отдельная нагрузка): по заказу и сети хабов строит план из этапов (legs), каждый со своим типом, окном времени и исполнителем; результат сохраняется как план с версией, а исполнение идёт по этапам независимо.
Глубже. Модель данных: orders — коммерческая сущность; shipments/legs — этапы (A→X, X→Y, Y→B) с sequence_no, leg_type, from_hub_id, to_hub_id, planned_start/end, assignee_id, status; route — рейс транспортного средства, в который упакованы этапы нескольких заказов. Планирование — это NP-трудная задача (VRP/задача о покрытии), поэтому в проде используют эвристики и солверы (OR-Tools и подобные) с ограничением по времени расчёта; на собеседовании достаточно сказать это, назвать входные ограничения (грузоподъёмность, объём, окна доставки, режим труда водителя, совместимость груза) и предложить архитектурную схему: очередь заданий на планирование → пул воркеров-солверов (CPU-bound, масштабируем горизонтально по числу задач) → сохранение плана. Пересчёт плана — регулярный (например, каждые N минут для незапущенных этапов) и событийный (авария, отказ водителя). Важно развести «планируемое» и «фактическое» состояние: план версионируется, а исполнение фиксируется как факты, иначе после каждого репланирования потеряется история.
Составной маршрут (многоэтапная доставка): Один заказ может включать несколько связанных маршрутов: Пример: A→X→Y→B: A→X - первая миля или смешанный тип; X→Y - магистральная перевозка; Y→B - последняя миля или смешанный тип. Каждый маршрут: Имeeт свой тип; Исполняется отдельно; Связан с другими маршрутами в рамках единого заказа. Система должна обеспечивать: Согласованность между маршрутами; Статусы каждого этапа и заказа в целом; В одном маршруте несколько заказов.
Заголовок раздела «Составной маршрут (многоэтапная доставка): Один заказ может включать несколько связанных маршрутов: Пример: A→X→Y→B: A→X - первая миля или смешанный тип; X→Y - магистральная перевозка; Y→B - последняя миля или смешанный тип. Каждый маршрут: Имeeт свой тип; Исполняется отдельно; Связан с другими маршрутами в рамках единого заказа. Система должна обеспечивать: Согласованность между маршрутами; Статусы каждого этапа и заказа в целом; В одном маршруте несколько заказов.»Коротко. Это связь многие-ко-многим: заказ состоит из упорядоченных этапов, а рейс объединяет этапы многих заказов. Согласованность обеспечивается сагой/оркестратором на уровне заказа: статус заказа — производная функция от статусов его этапов, а не независимо редактируемое поле.
Глубже. Схема: order 1—N order_leg (упорядоченные, с sequence_no), route 1—N order_leg (в один рейс попадают этапы разных заказов), плюс hub как точка перевалки. Правило согласованности: этап N+1 не может начаться, пока этап N не завершён и груз не принят хабом — это проверяется в конечном автомате и подтверждается событием приёмки на хабе (сканирование), а не доверием к статусу от водителя. Статус заказа вычисляется правилом агрегации: «в пути», если хоть один этап активен; «задержан», если фактическое время этапа вышло за плановое окно; «доставлен», только когда завершён последний этап. Реализуется как проекция: событие смены статуса этапа → пересчёт агрегата заказа → публикация order.status_changed (с дедупликацией, чтобы не слать одинаковые статусы). Компенсации: если этап сорвался (машина сломалась), сага не «откатывает» физику, а запускает репланирование остатка маршрута — это и есть компенсирующее действие. Транзакционных границ две: внутри одного этапа — сильная консистентность в БД; между этапами и заказом — eventual с идемпотентными обработчиками. Отдельный кейс из условия «в одном маршруте несколько заказов»: значит блокировки и обновления надо делать по route_id и по order_id аккуратно, чтобы не ловить дедлоки — обычно порядок захвата фиксируют (сначала route, потом orders по возрастанию id) либо обновляют через очередь событий, где обработка на route_id однопоточная.
Назначение исполнителя: Система должна находить подходящего исполнителя (водителя/ТС) для каждого маршрута.
Заголовок раздела «Назначение исполнителя: Система должна находить подходящего исполнителя (водителя/ТС) для каждого маршрута.»Коротко. Матчинг — отдельный сервис: фильтрация кандидатов по жёстким ограничениям (тип ТС, грузоподъёмность, допуски, география, режим труда) → скоринг по стоимости/времени подачи/рейтингу → предложение с TTL → подтверждение водителем с оптимистичной блокировкой, чтобы один водитель не был назначен на два рейса одновременно.
Глубже. Гонка назначений — главная техническая проблема. Решения: назначение через единый упорядоченный поток по driver_id (партиция Kafka), либо через транзакцию с условным апдейтом UPDATE drivers SET current_route_id = :r WHERE id = :d AND current_route_id IS NULL — если 0 строк обновлено, водитель занят. Предложения (offers) имеют TTL 30–60 секунд; истёкшие возвращаются в пул. При автоматическом назначении нужен фоллбэк на диспетчера, если кандидатов нет. Модель «водитель принимает» против «система назначает» — продуктовое решение, обязательно уточнить: от него зависит, нужен ли аукцион/broadcast нескольким водителям сразу (тогда — гонка на подтверждение) или прямое назначение. Пространственный поиск кандидатов — по geohash/S2-ячейкам вокруг точки погрузки, с расширением радиуса, если пусто. Метрики качества: доля автоназначений, среднее время до назначения, доля отказов водителей.
Статусы и трекинг: Водители отправляют статусы по маршрутам через приложение; Система должна отображать текущий статус заказа и маршрутов.
Заголовок раздела «Статусы и трекинг: Водители отправляют статусы по маршрутам через приложение; Система должна отображать текущий статус заказа и маршрутов.»Коротко. Поток статусов — append-only событийный журнал (leg_events), из него строятся две проекции: «текущий статус этапа/заказа» для оперативного чтения и история для аудита; клиентам статус отдаётся через API и вебхуки, диспетчерам — push по WebSocket.
Глубже. Событие несёт client_event_id (дедупликация), leg_id, type, occurred_at (время на устройстве), received_at (серверное), геометку и, при необходимости, вложения. Расхождение occurred_at/received_at — норма при офлайне, и именно поэтому проекция должна применять события по occurred_at, но защищаться от «событий из прошлого», которые уже неактуальны (late-arriving data): либо применяем только если оно продвигает автомат вперёд, либо помечаем конфликт. Трекинг для внешних клиентов — вебхук на каждое значимое изменение + публичная страница/эндпоинт трекинга с ограниченной детализацией. Для диспетчерской панели — те же приёмы, что и в задаче с курьерами: подписка на регион/подмножество рейсов, батчинг обновлений. Отдельно нужен «watchdog» на отсутствие статусов: если по этапу нет событий дольше ожидаемого, поднимается алерт диспетчеру — это то самое «быстро и точно узнать, что что-то отвалилось».
Система должна быть способна обрабатывать до 20 миллионов заказов в сутки;
Заголовок раздела «Система должна быть способна обрабатывать до 20 миллионов заказов в сутки;»Коротко. 20 млн/сутки — это ~230 заказов/с в среднем и, при типичном пиковом коэффициенте 3–5, порядка 700–1200/с в пик. Для приёма это немного (несколько подов stateless-сервиса + Kafka), но объём данных за годы (миллиарды строк) требует шардирования и партиционирования по времени с самого начала.
Глубже. Считаем вслух: 20e6 / 86400 ≈ 231 rps; при 5 КБ на заказ — 100 ГБ/сутки сырых данных, ~36 ТБ/год, плюс события статусов (по 10–30 на заказ) — ещё в разы больше. Значит: оперативная БД хранит только «живые» заказы (недели), завершённые уезжают в архив (ClickHouse/S3) по расписанию; партиционирование по дате создания, шардирование по hash(order_id) или по клиенту. Приём делаем асинхронным (202 Accepted), чтобы пиковый батч от партнёра сглаживался очередью, а не ронял базу. Заметная деталь: цифра «20 млн заказов» плохо стыкуется со следующим пунктом про 15 000 маршрутов и 10 000 водителей — это стоит проговорить вслух как вопрос к интервьюеру (см. следующий пункт), потому что 20 млн, скорее всего, относится к сообщениям/операциям или к пиковой проектной мощности, а не к реальным грузоперевозкам.
Поддержка до 15 000 активных грузоперевозок (маршрутов) в день;
Заголовок раздела «Поддержка до 15 000 активных грузоперевозок (маршрутов) в день;»Коротко. 15 000 маршрутов в день — очень мало (0.17/с), это не нагрузочное требование, а объём оперативных данных: несколько десятков тысяч активных сущностей влезают в память, диспетчерская панель может работать почти без БД. Несостыковка с «20 млн заказов» — повод задать уточняющий вопрос.
Глубже. Если оба числа верны буквально, то на один маршрут приходится ~1300 заказов — это правдоподобно для сборных грузов/посылок (магистральная фура везёт тысячи посылок), но не для паллетной логистики. Хороший ход на интервью: явно сформулировать гипотезу — «заказ» здесь единица товара/посылки, а «маршрут» — рейс ТС; тогда планировщик работает с агрегированными единицами (контейнер/паллета/мешок), а не с каждым заказом по отдельности, и это меняет дизайн: появляется промежуточная сущность «грузовое место» и иерархия order → parcel → container → leg. Ещё следствие: 15 000 маршрутов в день означает, что задача планирования решается пакетно (раз в несколько минут) и не требует распределённого солвера — хватит пула из нескольких воркеров.
Одновременная работа до 10 000 активных водителей, подключенных через мобильное приложение;
Заголовок раздела «Одновременная работа до 10 000 активных водителей, подключенных через мобильное приложение;»Коротко. 10 000 одновременных мобильных клиентов — это порядка 10 000 постоянных соединений (WebSocket/gRPC-стрим) или редкий поллинг; при телеметрии раз в 30 с это ~330 rps на приём. Нагрузка небольшая: 2–3 gateway-инстанса с запасом, но нужны корректные лимиты дескрипторов, keep-alive и план переподключений.
Глубже. Один Go-процесс с epoll спокойно держит десятки тысяч WebSocket-соединений (память — основной ограничитель, ~10–50 КБ на соединение с буферами, то есть 10 000 × 30 КБ ≈ 300 МБ). Держим минимум 3 инстанса за L4-балансировщиком с поддержкой long-lived соединений (важно: у многих LB дефолтный idle timeout 60 с — нужен ping каждые 20–30 с, иначе постоянные разрывы). При рестарте gateway 10 000 клиентов переподключатся одновременно — обязателен jitter на клиенте и постепенный дренаж на сервере. Если push не нужен постоянно, можно вообще обойтись поллингом раз в 30 с (330 rps — ничто) и пуш-уведомлениями для срочного — это дешевле в эксплуатации; выбор стоит проговорить как trade-off между сложностью и задержкой.
Горизонтальное масштабирование компонентов: при росте нагрузки система должна масштабироваться без деградации производительности.
Заголовок раздела «Горизонтальное масштабирование компонентов: при росте нагрузки система должна масштабироваться без деградации производительности.»Коротко. Требование выполняется, если все сервисы stateless, состояние вынесено в шардируемые хранилища, обработка событий партиционирована по бизнес-ключу, а автоскейл настроен по метрике, отражающей реальную нагрузку (лаг очереди, RPS), а не по CPU.
Глубже. Конкретика для этой системы: приём заказов и API — stateless, HPA по RPS; консьюмеры статусов — KEDA по consumer lag, число подов ≤ числу партиций (иначе лишние простаивают, о чём часто забывают); планировщик — CPU-bound пул воркеров, масштабируется по длине очереди задач; gateway для водителей — stateful по соединениям, масштабируется, но требует реестра присутствия и sticky/consistent routing; PostgreSQL — вертикально + read-реплики + шардирование по клиенту/региону при росте. «Без деградации производительности» проверяется нагрузочным тестированием с профилем, близким к боевому, и наличием сформулированного SLO; надо честно сказать, что линейного масштабирования не бывает — узкими местами станут БД, внешние интеграции (геокодер, тарифы) и блокировки на общих ресурсах, и назвать, что с этим делать (кеш, батчинг, шардирование, идемпотентные ретраи, ограничение конкурентности к внешним API).
Представим что есть готовый поисковик, где пользователь вводит длинный запрос и система отдает поисковую выдачу. Компания хочет к этому поиску добавить поисковые советы в виде списка из нескольких вариантов, которые должны появляться по мере ввода текста запроса. Необходимо спроектировать такую систему.
Заголовок раздела «Представим что есть готовый поисковик, где пользователь вводит длинный запрос и система отдает поисковую выдачу. Компания хочет к этому поиску добавить поисковые советы в виде списка из нескольких вариантов, которые должны появляться по мере ввода текста запроса. Необходимо спроектировать такую систему.»Коротко. Autocomplete — это read-heavy сервис с жёстким бюджетом задержки (p99 ≤ 50–100 мс, иначе подсказки не успевают за печатью): офлайн-пайплайн строит из логов запросов словарь «префикс → топ-K подсказок», онлайн-часть отдаёт готовый топ-K из структуры в памяти (trie/FST) с кешем на edge, обновляясь раз в несколько минут/часов.
Глубже. Разбор по частям.
Нагрузка. Каждый символ ввода — потенциальный запрос, поэтому autocomplete генерирует в 5–10 раз больше запросов, чем сам поиск. При 10 тыс. поисков/с это 50–100 тыс. запросов подсказок/с. Первое, что делает клиент, — debounce 50–150 мс и отмена предыдущего запроса; второе — кеширование префиксов на клиенте (набрал «мос», потом «моск» — часть можно отфильтровать локально).
Данные. Источник — логи запросов за скользящее окно (например, 7–30 дней) с частотами, отфильтрованные от мусора, PII, порнографии и запрещёнки, нормализованные (нижний регистр, схлопывание пробелов, раскладка). Офлайн-джоб (Spark/ClickHouse) считает query → count, отсекает хвост по порогу, применяет time-decay (свежие запросы весят больше — иначе подсказки застревают в прошлогодних трендах) и строит индекс.
Структура данных. Классика — trie/prefix tree, в узлах которого предпосчитан топ-K завершений: тогда ответ — это спуск по префиксу (O(len)) и чтение готового списка, без обхода поддерева. На практике для компактности берут FST/succinct-trie (как в Lucene) или просто отсортированный массив + бинарный поиск по префиксу с предпосчитанным топ-K на префиксах до длины L. Размер: 10–50 млн уникальных запросов × ~40 байт после сжатия — единицы гигабайт, помещается в память одного узла, значит шардировать можно, но чаще достаточно реплицировать целиком (полная реплика на каждом инстансе = линейное масштабирование чтения и нулевая сетевая задержка).
Онлайн-путь. CDN/edge-кеш (короткие префиксы вроде «а», «мос» дают колоссальный hit ratio) → сервис подсказок в памяти → ответ 5–10 вариантов. Персонализация и контекст (история пользователя, регион, язык) подмешиваются как отдельный, небольшой персональный список, слитый с глобальным на лету — так глобальный кеш не разрушается.
Свежесть. Двухуровневая схема: основной индекс перестраивается офлайн раз в час/сутки, а «горячие» тренды (внезапные события) добавляются near-real-time из стрима — отдельный маленький индекс, который сливается с основным на чтении. Полная пересборка → атомарное переключение (build-and-swap), никакого редактирования индекса на месте.
Дополнительно. Опечатки — fuzzy-поиск по префиксу (ограниченный Левенштейн, не более 1–2 правок, только когда точных совпадений мало, потому что fuzzy дорог); ранжирование — частота + time-decay + CTR подсказок + длина; A/B-тестирование обязательно, метрики — CTR подсказки, доля запросов, завершённых через подсказку, экономия символов, p99 latency. Отказоустойчивость: если сервис подсказок недоступен, поиск обязан работать без него — подсказки всегда «best effort», деградируем молча.
Одинаковые ресурсы узлов: Все узлы (ноды) баз данных имеют одинаковые характеристики потребления ресурсов (CPU/RAM/Disk);
Заголовок раздела «Одинаковые ресурсы узлов: Все узлы (ноды) баз данных имеют одинаковые характеристики потребления ресурсов (CPU/RAM/Disk);»Коротко. Это упрощающее допущение: все узлы БД одного типа потребляют одинаковый набор ресурсов, поэтому размещение сводится к целочисленной задаче «сколько слотов фиксированного размера влезает в сервер», а не к общей многомерной упаковке с произвольными профилями. Планировать ёмкость можно в единицах «слот», а не в гигабайтах.
Глубже. Что это требование реально даёт архитектуре и о чём стоит проговорить вслух.
Модель ресурса. Узел описывается вектором (cpu, ram, disk) — например, (8 vCPU, 64 GiB, 2 TiB NVMe). Если все узлы одинаковы, ёмкость сервера выражается одним числом floor(min(cpu_free/cpu_req, ram_free/ram_req, disk_free/disk_req)) — количество слотов. Bin packing вырождается в счётный учёт: у сервера «свободно 3 слота». Это резко упрощает и планировщик (не нужны сложные скоринговые функции по многомерной фрагментации), и планирование закупок (ёмкость ЦОД = сумма слотов).
Что всё равно нельзя игнорировать. Одинаковые заявки не означают одинаковое фактическое потребление: реальные кластеры БД расходятся по нагрузке в разы, а диск растёт со временем. Поэтому корректная формулировка — «одинаковые requests», а планировщик размещает по requests, но обязан отдельно смотреть на реальную утилизацию (usage) и на рост диска: диск, в отличие от CPU, невозможно «отжать» на лету, и его переполнение означает жёсткую аварию БД. Практика: request = гарантия, оверкоммит по CPU допустим (например, 1.5–2x), по RAM для БД — нет (OOM-killer убьёт primary), по диску — нет, плюс держим headroom 20–30 % на рост WAL, чекпоинты, pg_dump/бэкап и на переезд соседей при отказе ЦОД.
Ловушка «фрагментация исчезла». Даже при одинаковых узлах фрагментация возможна, если серверы разной ёмкости (гетерогенное железо) или часть слотов заблокирована метками. Тогда честно: «узлы гомогенны, серверы — нет», и в скоринге появляется предпочтение best-fit (доупаковать почти полный сервер), чтобы сохранять целые свободные серверы под будущие крупные кластеры и под эвакуацию при отказе.
Уточняющий вопрос интервьюеру. «Одинаковы узлы внутри одного кластера или вообще все узлы всех кластеров? Есть ли разные классы (dev/prod, small/medium/large)?» Обычно ответ — «есть 3–5 типоразмеров (t-shirt sizes)», и это уже почти так же просто: конечный набор форм упаковки вместо континуума.
Выделенные сервера: Некоторые кластеры (или узлы) должны разворачиваться только на выделенных серверах с определенными метками (тегами);
Заголовок раздела «Выделенные сервера: Некоторые кластеры (или узлы) должны разворачиваться только на выделенных серверах с определенными метками (тегами);»Коротко. Это требование к системе меток и ограничений размещения: у сервера есть labels/taints, у заявки — nodeSelector/affinity и tolerations, и планировщик на этапе фильтрации отбрасывает все серверы, не удовлетворяющие требованию. Метки должны быть частью источника правды о топологии, версионироваться и проверяться при каждом цикле планирования, а не только при первом размещении.
Глубже. Ключевая мысль: нужны два независимых механизма, потому что они решают разные задачи.
- Селектор (притяжение) — «этот кластер хочет серверы с
storage=nvme-gen4,pci-dss=true,dc=m9». Это выбор кандидата со стороны нагрузки. Без второго механизма он бесполезен: обычная нагрузка спокойно займёт выделенные серверы, потому что ей никто не запретил. - Taint/репеллент (отталкивание) — «на серверах с
dedicated=billing:NoScheduleне размещать ничего, кроме того, у кого есть соответствующий toleration». Это защита выделенного пула со стороны железа.
Модель данных: server(id, dc, rack, labels JSONB, taints JSONB, capacity, state), placement_constraint(cluster_id, required_labels, forbidden_labels, tolerations, exclusivity). Отдельно стоит поддержать уровень эксклюзивности: shared (можно соседей), dedicated-per-tenant (только этот тенант), dedicated-per-cluster (один узел БД на сервер, вообще без соседей — для нагрузок, чувствительных к noisy neighbour, и для требований регулятора).
Как это реализуется в планировщике. Классическая двухфазная схема kube-scheduler: Filter (жёсткие предикаты — метки, taints, свободная ёмкость, эксклюзивность, зоны отказа) → Score (мягкие предпочтения — балансировка, антиаффинность, стоимость, локальность данных) → Reserve/Bind. Метки идут только в Filter; попытка выразить «выделенность» через скоринг («будем стараться не ставить туда чужих») — типичная ошибка: под нагрузкой «стараться» превращается в «поставили».
Эксплуатационные детали. Метки нужно откуда-то брать: из CMDB/inventory (роль, владелец, класс железа), из агента на сервере (реальная модель CPU, наличие NVMe, версия ядра) и из ручных аннотаций SRE. Расхождение между заявленными и реальными метками — источник тяжёлых инцидентов («думали, что NVMe, а там SATA»), поэтому агент периодически репортит факты, а контроллер сверяет их с CMDB и поднимает алерт при drift. Изменение метки на живом сервере не должно молча ломать инварианты: сняли pci-dss=true — дешедулер обязан заметить нарушение и запланировать переезд, а не просто «забыть».
Размещение: Нужно учитывать это требование и не размещать такие узлы на обычных серверах.
Заголовок раздела «Размещение: Нужно учитывать это требование и не размещать такие узлы на обычных серверах.»Коротко. Это инвариант, который должен проверяться в трёх точках: при первичном размещении (Filter), при любом перемещении/пересоздании (тот же Filter в дешедулере) и постоянно фоновым верификатором, который сравнивает фактическое состояние с ограничениями и репортит нарушения. Инвариант, проверяемый только на входе, всегда рано или поздно нарушается.
Глубже. Почему одной проверки при создании мало: метки серверов меняются, ограничения кластеров меняются, узлы пересоздаются в аварийном режиме (когда велик соблазн «разместить хоть куда-нибудь»), кто-то правит БД руками. Поэтому:
- Admission/валидация заявки. Если ни один сервер не проходит фильтр — заявка не размещается, а уходит в состояние
Pendingс внятной причиной («0/412 серверов подошли: 380 без меткиdedicated=billing, 22 нет ёмкости, 10 в maintenance»). Это прямая калька сkubectl describe podи на собеседовании воспринимается очень хорошо: диагностируемость отказа планирования важнее самого алгоритма. - Никогда не деградировать инвариант молча. При аварии ЦОД соблазн «разместим на обычных серверах, потом переедем» должен быть явным решением: либо запрещено политикой (
strict), либо разрешено с флагомallowDegradedи обязательной записью нарушения в реестр долга, чтобы дешедулер вернул узел на место. - Разделение жёстких и мягких правил.
requiredDuringScheduling(метки, эксклюзивность, ЦОД) — нарушать нельзя никогда.preferred(балансировка по стойкам, близость к соседям) — можно нарушить и потом починить. Явно разложить требования по этим двум корзинам — половина ответа на такой вопрос. - Continuous verification. Отдельный воркер раз в N минут прогоняет все размещения через те же предикаты, что и планировщик (переиспользуя код, а не дублируя логику), и пишет метрику
placement_violations{type="label|affinity|dc|exclusivity"}. Ноль нарушений — не предположение, а измеряемый SLI.
На базе этих требований вам необходимо спроектировать две связанные подсистемы: Шедулер (resource manager) и Дешедулер (descheduler).
Заголовок раздела «На базе этих требований вам необходимо спроектировать две связанные подсистемы: Шедулер (resource manager) и Дешедулер (descheduler).»Коротко. Шедулер отвечает на вопрос «куда положить узел прямо сейчас» и работает в момент создания/пересоздания; дешедулер отвечает на вопрос «где текущее состояние разошлось с желаемым» и работает непрерывно, вытесняя узлы, размещение которых стало плохим или недопустимым. Общая для них часть — модель топологии, предикаты размещения и реестр размещений; связь — дешедулер не двигает узлы сам, а формирует заявки, которые исполняет шедулер.
Глубже. Архитектурно это классический control loop: желаемое состояние (спецификации кластеров + политики) → наблюдаемое состояние (инвентарь серверов + фактические размещения + телеметрия) → разница → план действий → исполнение → снова наблюдение.
Почему их разделяют. Шедулер должен быть быстрым и синхронным по отношению к заявке (создать кластер — секунды-минуты), у него простая обязанность и он обязан быть детерминированным. Дешедулер — фоновый, медленный, консервативный, с бюджетами и окнами обслуживания: его ошибки дороже (он трогает работающие БД с данными). Разные SLA, разные риски, разный темп — разные компоненты. При этом они обязаны использовать один и тот же код предикатов, иначе дешедулер начнёт выселять узлы в места, которые шедулер считает валидными, и наоборот — получится «пинг-понг».
Общие инварианты системы. (1) Узлы одного кластера БД распределены по 3 ЦОД так, чтобы потеря любого одного ЦОД не убивала кворум: для 3 узлов — 1/1/1, для 5 — 2/2/1, для 7 — 3/2/2; общее правило — в любом ЦОД не больше floor((N-1)/2) узлов. (2) Два узла одного кластера не живут на одном физическом сервере (а лучше и не в одной стойке — общий ToR-свитч и PDU). (3) Метки/эксклюзивность соблюдены. (4) Ни один сервер не перегружен по requests.
Синхронизация и конкуренция. Оба контура пишут в один реестр, поэтому нужен единственный активный экземпляр каждого (leader election через etcd/Consul lease) либо оптимистические блокировки на строке сервера (UPDATE servers SET allocated = allocated + 1 WHERE id = ? AND allocated + 1 <= capacity) — иначе два параллельных решения переполнят сервер. На собеседовании стоит явно сказать: «размещение — это распределённая транзакция резервирования, я делаю её через двухфазный резерв в БД: Reserve (строка reservation с TTL) → Bind (успешный старт узла) → Release по таймауту, если исполнитель не подтвердил».
Шедулер (Resource Manager)
Заголовок раздела «Шедулер (Resource Manager)»Коротко. Это сервис, который принимает заявку «нужно N узлов с профилем R и ограничениями C» и возвращает конкретный список серверов, атомарно резервируя на них ёмкость. Внутри — конвейер Filter → Score → Reserve → Bind поверх снапшота топологии, с журналом решений и объяснением, почему выбраны именно эти серверы.
Глубже. Разложим по слоям.
API. POST /v1/placements с телом «cluster_id, node_count, resources{cpu, ram, disk}, constraints{required_labels, tolerations, exclusivity, dc_policy}, priority, idempotency_key»; ответ — plan_id + список кандидатов + TTL резерва. Плюс POST /v1/placements/{id}/commit, DELETE /v1/placements/{id} (освободить резерв), GET /v1/placements?cluster_id= и POST /v1/simulate (dry-run: «влезет ли в текущую топологию ещё 40 таких кластеров?» — крайне полезно для capacity planning и для оценки «что будет, если ЦОД упадёт»).
Снапшот топологии. Планировать по живым запросам к БД на каждый шаг — медленно и неконсистентно. Правильно: держать в памяти лидера копию инвентаря (десятки тысяч серверов — это единицы мегабайт), обновлять инкрементально из потока событий, а на время одного цикла планирования работать с иммутабельным снапшотом. Так решение детерминировано и воспроизводимо, а на снапшоте можно прогонять симуляции.
Алгоритм для одного узла.
- Filter (жёсткие предикаты): есть свободная ёмкость по всем трём осям; метки удовлетворяют required; taints покрыты tolerations; сервер в состоянии
Ready(неMaintenance/Draining/Cordoned); нет узла того же кластера на этом сервере; квота ЦОД для этого кластера не исчерпана; эксклюзивность не нарушена. - Score (веса, сумма нормированных оценок): равномерность по ЦОД и стойкам (главный вес); spread по серверам внутри ЦОД либо, наоборот, best-fit — в зависимости от политики; свободный диск с запасом на рост; реальная утилизация сервера (usage, не только requests); «возраст»/надёжность железа; локальность к остальным узлам кластера (сетевая задержка внутри ЦОД).
- Reserve: резерв в транзакции с проверкой ёмкости; TTL, чтобы зависший провижининг не съедал ресурсы навсегда.
- Bind: после подтверждения от исполнителя резерв превращается в размещение, событие пишется в историю.
Групповое размещение. Кластер БД нельзя размещать по одному узлу «жадно»: можно поставить два узла удачно, а на третьем упереться и получить кластер без кворумной раскладки. Нужна gang scheduling семантика: либо размещаем все N узлов, удовлетворяя топологическому ограничению, либо не размещаем ничего (all-or-nothing). Практически: перебор ЦОД в порядке «сначала обязательная квота на каждый ЦОД, потом остаток», внутри ЦОД — жадный выбор по score с бэктрекингом на 1–2 шага. Полный поиск не нужен: задача NP-трудная в общем случае, но при гомогенных узлах и небольших N (3–7) жадный алгоритм с проверкой инварианта даёт решение практически всегда, а редкие неудачи корректно уходят в Pending.
Приоритеты и вытеснение. Если ёмкости нет, есть три варианта: очередь Pending + алерт вместимости; расширение пула (заявка в capacity-планирование, интеграция с автоскейлом железа/облака); preemption — вытеснение узлов кластеров с более низким приоритетом (dev/staging) ради prod. Preemption для БД — опасная штука (это перенос данных, а не рестарт stateless-пода), поэтому обычно её ограничивают классами нагрузки, у которых допустима переустановка, и всегда делают «сначала создай новое, потом убей старое».
Наблюдаемость. Метрики: время цикла планирования (p50/p99), доля Pending и разбивка причин отказа предикатов, распределение свободных слотов по ЦОД, «сколько кластеров ещё влезет», «переживём ли отказ любого одного ЦОД» (headroom-симуляция как регулярная проверка, а не как разовое упражнение).
Учитывать актуальную топологию (расположение ЦОД, доступные сервера, их загруженность и метки);
Заголовок раздела «Учитывать актуальную топологию (расположение ЦОД, доступные сервера, их загруженность и метки);»Коротко. Нужен отдельный слой инвентаря: иерархия «регион → ЦОД → зал/ряд → стойка → сервер», состояние и метки каждого сервера, ёмкость и фактическая утилизация, и всё это — актуальное с задержкой в секунды, а не «выгрузка из Excel раз в неделю». Планировщик работает с иммутабельным снапшотом этой модели, а расхождение снапшота с реальностью считается отдельным классом инцидентов.
Глубже. Из чего собирается актуальность.
Модель топологии. Домены отказа вкладываются: ЦОД (питание, канал, охлаждение) ⊃ стойка (ToR-свитч, PDU) ⊃ сервер (железо) ⊃ диск. Ограничения размещения формулируются в терминах этих доменов, а не в терминах «сервер 42»: spread by dc (required), spread by rack (preferred). Это позволяет позже добавить уровень (например, «зал») без переписывания предикатов. Строго говоря, стоит хранить не строку dc, а список ключей топологии, как topologyKey в Kubernetes.
Источники данных и их различие. (а) CMDB/инвентарь — что вообще существует и кому принадлежит; (б) агент на сервере (push каждые 10–30 с или pull через Prometheus) — что реально происходит: свободный CPU/RAM/диск, SMART, версия ядра, температура; (в) сам реестр размещений — что мы уже пообещали (allocated). Свободная ёмкость = capacity − allocated (по requests), а не «capacity − usage»: планировать по фактическому потреблению — классическая ошибка, приводящая к каскадному оверкоммиту, когда идущие подряд заявки размещаются на один и тот же «пока пустой» сервер. Usage используется как мягкий сигнал в скоринге и как триггер дешедулера.
Свежесть и деградация. Данные приходят с задержкой, поэтому: heartbeat с TTL (нет отчёта 60–90 с → Unknown, ещё через N → NotReady), состояние сервера — явный конечный автомат Ready → Cordoned → Draining → Maintenance → Decommissioned (плюс Unknown). В состоянии Unknown нельзя ни размещать новое, ни поспешно эвакуировать — сначала подтверждение отказа из независимого источника (см. следующий вопрос про split-brain).
Масштаб. Три ЦОД по несколько тысяч серверов — это десятки тысяч записей и десятки тысяч heartbeat в минуту: тривиальная нагрузка для одной PostgreSQL плюс кеш в памяти. Оптимизировать тут нечего, важнее консистентность: снапшот с версией (resource_version), инкрементальные обновления через watch/CDC, и запрет планировать на снапшоте старше X секунд.
Уточняющие вопросы. Есть ли отдельная сеть между ЦОД и какова её пропускная способность и RTT (это понадобится и для кворума БД, и для оценки миграций); считаются ли ЦОД равнозначными по стоимости и мощности; бывают ли частично деградированные ЦОД (упала половина стоек).
Учитывать заявки на ресурсы (CPU, RAM, Disk);
Заголовок раздела «Учитывать заявки на ресурсы (CPU, RAM, Disk);»Коротко. Заявка — это контракт: requests (гарантия, по ней ведётся учёт и размещение) и опционально limits (потолок). Планировщик считает вместимость по requests по всем трём осям сразу и обязан отдельно относиться к диску: CPU можно перепродать, RAM для БД — нет, диск — нет и он ещё и растёт со временем.
Глубже. Разбор по ресурсам.
CPU — сжимаемый ресурс: при нехватке процесс тормозит, но живёт. Допустим умеренный оверкоммит (коэффициент 1.2–2.0), настраиваемый политикой и разный для prod/dev. Для БД важна не только доля CPU, но и стабильность: cgroup throttling у PostgreSQL/MySQL проявляется как всплески latency, поэтому для prod чаще ставят requests == limits (гарантированный класс) и оверкоммит не применяют.
RAM — несжимаемый: нехватка = OOM = потеря primary и failover. Оверкоммит запрещён, headroom обязателен (page cache для БД — это производительность, а не «свободная память»). Отдельно учитываем, что БД любит huge pages и что часть RAM занята системой — планировать нужно от allocatable, а не от capacity.
Disk — самый неприятный: несжимаемый, медленно освобождаемый и растущий. Учитывать нужно три величины: объём (GiB), IOPS/пропускную способность и тип носителя (метка). Игнорирование IOPS — типичная ошибка: три узла БД, каждый по requests помещается по объёму, вместе кладут NVMe по IOPS, и получается noisy neighbour. Поэтому в вектор ресурса добавляют disk_iops и disk_bw, а на выделенных серверах — просто эксклюзивность. Плюс политика заполнения: не размещать сверх 70–80 % ёмкости диска, потому что нужен запас на WAL, чекпоинты, репак/vacuum full, локальный бэкап и на приём чужих реплик при аварии.
Изменение заявки (vertical scaling). Отдельный сценарий: кластеру нужно больше RAM. Если сервер вмещает — увеличиваем requests на месте (in-place resize), иначе это уже задача переезда, то есть заявка к шедулеру + работа дешедулера. Стоит проговорить, что рост requests существующего узла может «взорвать» ранее валидное размещение, поэтому изменение заявки идёт через тот же Filter.
Квоты. Помимо физической ёмкости есть логические квоты на тенанта/команду/окружение: max_cpu, max_nodes, max_clusters. Проверка квоты — на входе в API, до планирования, и это тоже причина отказа, которую нужно уметь объяснить пользователю.
Хранить историю размещений и уметь оперативно реагировать, если один из серверов или ЦОД стал недоступен (например, нужно пересоздать часть узлов где-то еще).
Заголовок раздела «Хранить историю размещений и уметь оперативно реагировать, если один из серверов или ЦОД стал недоступен (например, нужно пересоздать часть узлов где-то еще).»Коротко. История — это append-only журнал событий размещения (кто, что, куда, когда, почему, каким решением), поверх которого материализуется текущее состояние; реакция на отказ — event-driven контур: детект (с подтверждением из нескольких точек) → классификация (сервер / стойка / ЦОД) → политика (ждать или эвакуировать) → заявки на пересоздание с соблюдением всех исходных инвариантов, с бюджетом одновременных операций.
Глубже.
Зачем нужна история, кроме аудита. (1) Разбор инцидентов: «почему этот узел оказался в M9, хотя политика запрещала» — ответ должен находиться за минуту, вместе со снимком входных данных решения. (2) Восстановление состояния после потери реестра — событийный журнал позволяет пересобрать текущую картину. (3) Аналитика: как менялась фрагментация, сколько миграций в месяц, какие серверы чаще всего дают отказ. (4) Идемпотентность повторов: по idempotency_key заявки видно, что решение уже принималось.
Схема примерно такая: placement_events(id, ts, cluster_id, node_id, server_id, dc, action ENUM(reserve, bind, release, evict, fail), reason, actor, decision_snapshot JSONB, plan_id) — партиционирование по времени, ретеншен год-два, холодные партиции в объектное хранилище. Текущее состояние — таблица placements (одна строка на узел), обновляемая в той же транзакции, что и событие (outbox/CDC для внешних подписчиков).
Детект недоступности. Три уровня сигнала, чтобы не устроить массовую эвакуацию из-за сетевой ряби:
- heartbeat агента (пропал —
Unknown); - независимый health-check со стороны (проверка из другого ЦОД, IPMI/BMC, свитч-порт);
- сигнал от самой БД (кластер сообщает, что реплика недоступна, failover произошёл).
Решение об эвакуации принимается по совокупности и никогда по одному пропавшему heartbeat. Отдельная защита от split-brain: если из ЦОД-А не видно ЦОД-Б, это может означать «Б упал» или «упала сеть между А и Б». Поэтому контроль-плейн шедулера сам должен жить в трёх ЦОД с кворумом (лидер выбирается большинством), а решения принимает только тот, кто в большинстве. Классическая катастрофа — оба «половинных» контроллера начинают пересоздавать узлы друг друга.
Политика реакции.
- Сервер недоступен < grace period (5–15 мин). Ничего не делаем, кроме алерта: перезагрузка ядра и рестарт занимают минуты, а пересоздание узла БД с терабайтом данных — часы. Порог должен быть настраиваемым и зависеть от того, остался ли у кластера кворум и сколько реплик живо. Если кластер потерял кворум — реакция немедленная.
- Сервер недоступен дольше. Помечаем размещения как
Lost, создаём заявки на пересоздание недостающих узлов с теми же ограничениями. Важно: сначала поднять и синхронизировать новый узел, ввести его в кластер, и только потом окончательно списать старый (иначе двойная потеря во время восстановления). - Отказ ЦОД целиком. Это одновременно сотни узлов. Массовое пересоздание в двух оставшихся ЦОД нарушит топологический инвариант (в одном ЦОД окажется больше половины узлов) и, скорее всего, упрётся в ёмкость и в сеть. Поэтому политика: приоритет 1 — кластеры, потерявшие кворум (их надо чинить любой ценой, возможно, с временным нарушением spread и с явной пометкой долга); приоритет 2 — кластеры, оставшиеся без резерва отказоустойчивости; приоритет 3 — всё остальное ждёт возврата ЦОД. Плюс глобальный rate limit (например, не более X одновременных восстановлений и не более Y ТБ/ч трафика восстановления), иначе шторм восстановления добьёт выжившие ЦОД. Ёмкость под это должна планироваться заранее: правило N+1 по ЦОД означает, что суммарной свободной ёмкости двух ЦОД хватает, чтобы принять нагрузку третьего.
- Ручной стоп-кран. Глобальный флаг «не выполнять автоматические эвакуации» и подтверждение оператором для массовых операций. При нештатной ситуации автоматика, работающая по неверной картине мира, опаснее бездействия.
Ключевая метрика. Время от отказа до восстановления избыточности (не до «узел создан», а до «данные синхронизированы и кворум восстановлен») — именно она определяет реальный риск потери данных при втором отказе.
Дешедулер (Descheduler)
Заголовок раздела «Дешедулер (Descheduler)»Коротко. Дешедулер — фоновый reconciliation-контур, который сравнивает фактические размещения с политиками и вытесняет узлы, размещённые «неправильно»: нарушение меток и антиаффинити после изменений, перекос по ЦОД после аварийных пересозданий, перегруженные и недогруженные серверы, вывод железа из эксплуатации. Он не перемещает узлы сам — он формирует заявки, которые исполняет шедулер, и обязан работать с бюджетами и окнами, потому что каждое его действие — это миграция данных.
Глубже.
Триггеры (правила детекции).
- Нарушение жёстких ограничений: метка сервера изменилась/снята, появился taint, два узла одного кластера оказались на одном сервере или стойке, топологическая раскладка по ЦОД нарушена после аварийного восстановления.
- Долг после деградированного размещения: запись «размещено с нарушением, вернуть при первой возможности».
- Ресурсный дисбаланс: сервер стабильно перегружен по фактическому usage (а не по requests) — например, диск заполнен на 85 % и растёт; или, наоборот, дефрагментация — освободить почти пустые серверы, чтобы получить целые свободные под будущие кластеры и под аварийный резерв.
- Операционные задачи: плановый вывод сервера/стойки/зала в обслуживание (
Draining), замена железа, обновление гипервизора/ядра, вывод ЦОД. - Изменение спецификации: кластеру подняли requests, и он больше не помещается на текущем сервере.
Механика. Цикл: собрать снапшот → прогнать детекторы → получить список кандидатов на вытеснение с указанием причины и веса → отфильтровать по бюджетам безопасности → для каждого кандидата попросить шедулер найти новое место (в режиме dry-run, до того как что-то трогать) → если места нет, ничего не делать и алертить → если есть, запустить миграцию через исполнителя → зафиксировать в истории.
Бюджеты и предохранители — самая важная часть ответа.
- PDB-подобное правило: нельзя вытеснять узел, если кластер после этого теряет кворум или остаётся без реплики; одновременно из одного кластера двигаем не более одного узла.
- Глобальный лимит: не более N параллельных миграций во всём парке и не более M на один ЦОД/стойку/сеть; лимит по трафику (ТБ/ч), потому что ресинк — это сетевой шторм.
- Cooldown: узел, только что переехавший, не трогаем X часов; сервер, только что освобождённый, не заселяем сразу.
- Окна обслуживания: миграции — в согласованные окна, кроме аварийных случаев и code freeze/сезонных пиков (чёрная пятница) — вообще запрет.
- Антициклы: решение о переезде принимается только если новое место строго лучше по целевой функции с гистерезисом (порог), иначе узлы будут ездить туда-сюда. Полезно логировать «почему переезд» и иметь режим
dry-run/recommend-only, где дешедулер только предлагает, а SRE подтверждает — с этого режима такие системы обычно и начинают эксплуатацию.
Отдельная мысль для собеседования. Дешедулер в мире БД принципиально отличается от Kubernetes descheduler: там под можно просто убить (evict), и он поднимется в другом месте за секунды, потому что состояния нет. Здесь «вытеснение» = «создать новый узел, синхронизировать сотни гигабайт или терабайты, ввести в кластер, переключить роль, снять старый». Поэтому descheduler для БД — это скорее планировщик миграций с длительными шагами, saga-подобным исполнением (каждый шаг идемпотентен, есть компенсации) и обязательной возможностью отменить/откатить на любом этапе до финального переключения.
При «перeeзде» нужно сохранить исходные принципы размещения (3 ЦОД, разные физические сервера и т. д.);
Заголовок раздела «При «перeeзде» нужно сохранить исходные принципы размещения (3 ЦОД, разные физические сервера и т. д.);»Коротко. Переезд обязан проверяться теми же предикатами, что и первичное размещение, причём проверять нужно итоговое состояние кластера, а не только новую точку: перед стартом миграции симулируем результат («если узел X переедет с сервера A на сервер B, останется ли раскладка по ЦОД кворумной, не встретятся ли два узла на одной стойке») и запускаем, только если инвариант сохраняется.
Глубже.
Порядок операций. Правильная последовательность — add-then-remove, а не remove-then-add: поднять новый узел, реплицировать на него данные, дождаться, что он догнал (лаг ниже порога), ввести в кластер как полноправного участника, при необходимости переключить роль (switchover, не failover), и только затем вывести старый. При этом в промежуточном состоянии кластер временно имеет N+1 узел — и вот тут возникает тонкость с кворумом: добавление члена в Raft/consensus-группу меняет размер кворума, поэтому переконфигурация должна идти по одному узлу за раз (single-node membership change), иначе можно потерять кворум прямо во время миграции. Если промежуточная конфигурация из N+1 узлов ломает раскладку по ЦОД (например, 3→4 узла дают 2/1/1 — это ещё нормально; но 2/2/0 — уже нет), выбор целевого сервера должен учитывать и промежуточное состояние.
Что именно проверяем перед стартом. Кандидат B проходит Filter (метки, taints, ёмкость с учётом того, что старый узел ещё не освобождён — значит нужен реальный свободный слот, а не «освободится потом»); итоговая раскладка по ЦОД удовлетворяет в любом ЦОД ≤ floor((N-1)/2) узлов; на B нет другого узла того же кластера; стойка B не совпадает со стойкой других узлов (preferred); эксклюзивность соблюдена.
Куда переезжать при массовой эвакуации. При отказе или выводе целого ЦОД сохранить «3 ЦОД» физически невозможно, если ЦОД всего три. Тогда варианты, которые нужно проговорить: (а) временно работать в двух ЦОД с явно зафиксированной деградацией (кластер переживает отказ ещё одного ЦОД только частично) и планом возврата; (б) заранее иметь четвёртый ЦОД/зону как резерв; (в) для кворумных систем — witness/arbiter-узел в третьей локации, который не хранит данные, но участвует в голосовании: дешёвый способ сохранить кворумную семантику при двух «полноценных» ЦОД. Ответ «просто размажем по двум ЦОД и забудем» — плохой; хороший ответ обязательно включает регистрацию долга и автоматический возврат, когда ЦОД вернётся.
Верификация после. Миграция считается завершённой не когда старый узел удалён, а когда: новый узел в кворуме, лаг репликации нулевой, бэкап с нового топологического состава успешно снят, инварианты перепроверены верификатором, метрики размещения обновлены. И только потом — освобождение ресурсов старого сервера в реестре (с задержкой/«корзиной», чтобы можно было откатиться).
Учитывать сложность миграции данных (объемы, сетевые ограничения).
Заголовок раздела «Учитывать сложность миграции данных (объемы, сетевые ограничения).»Коротко. Переезд узла БД — это в первую очередь перекачка данных, поэтому в целевой функции планировщика миграций должна быть стоимость: ETA ≈ объём / доступная пропускная способность, с учётом того, что межЦОДовый канал общий и конкурирует с продуктивным трафиком репликации. Отсюда — приоритет локальных переездов, троттлинг, ограничение параллелизма и окна.
Глубже.
Арифметика, которую стоит произнести вслух. Узел БД на 2 ТБ. При выделенной полосе 10 Гбит/с и реальной утилизации ~70 % это ≈ 875 МБ/с, то есть 2 ТБ ≈ 40 минут — теоретически. Но канал между ЦОД обычно не 10 Гбит/с на один поток и не свободен: если мы выделяем на восстановление 1 Гбит/с (≈ 125 МБ/с), тот же узел едет ≈ 4,5 часа. Десять таких узлов подряд — двое суток; десять параллельно на том же канале — те же двое суток, только с деградацией продуктива. Вывод, который ждёт интервьюер: параллелизм миграций ограничен не CPU и не планировщиком, а сетью между ЦОД, и это должно быть явным ресурсом, который планировщик резервирует так же, как CPU и диск.
Как удешевить перенос.
- Локальность. Переезд внутри одного ЦОД (тем более внутри стойки) на порядок дешевле межЦОДового. Поэтому скоринг миграции: сначала ищем валидное место в том же ЦОД (это ещё и сохраняет раскладку автоматически), только потом в другом.
- Восстановление из бэкапа + догон по WAL/binlog. Вместо перекачки с живого primary поднимаем новый узел из объектного хранилища (бэкап уже лежит в S3-совместимом сторадже, часто в том же ЦОД) и догоняем логом. Нагрузка на продуктивный узел минимальна, скорость упирается в скорость хранилища бэкапов.
- Каскадная репликация. Синхронизировать новый узел не с primary, а с локальной репликой в том же ЦОД — межЦОДовый трафик не растёт.
- Инкрементальные и delta-методы (
pg_rewind, rsync-подобная досинхронизация, снапшоты ФС/LVM/ZFS send), если старый и новый узел разделяют общее прошлое. - Перенос диска вместо данных. Если хранилище сетевое (SAN/Ceph/облачные диски), «переезд» — это отмонтировать/примонтировать том, секунды вместо часов. Стоит спросить, локальные ли диски: ответ полностью меняет дизайн дешедулера.
- Сжатие и троттлинг. Компрессия потока (за счёт CPU) и жёсткие лимиты (
rate limitна уровне репликации/tc/QoS), чтобы восстановление не съело канал у продуктива.
Планирование как задача с бюджетом. Дешедулер должен вести «бюджет миграций»: сколько ТБ в час можно двигать суммарно и на каждое направление ЦОД↔ЦОД, сколько параллельных операций, в какие часы. Каждая заявка на переезд получает оценку ETA и cost, очередь упорядочивается по «польза/стоимость»: устранение нарушения кворумной раскладки — высокая польза; косметическая балансировка 2 ТБ через полстраны — низкая, откладывается.
Риски долгих миграций. Операция на часы обязана быть возобновляемой (перезапуск координатора не начинает всё сначала), иметь прогресс и ETA в UI/метриках, таймаут с автоматической отменой и очисткой недосозданного узла, и не удерживать блокировок/резервов бесконечно. Плюс проверка перед стартом: хватит ли места на целевом сервере с учётом того, что БД во время синка растёт, и хватит ли места на источнике под накопление WAL (замедленный синк = растущий лог = риск переполнить диск primary — очень частая реальная авария).
Дали системный дизайн: Реализовать онлайн редактор текста (типа Яндекс.Кода, гугл-таблиц и т. п.).
Заголовок раздела «Дали системный дизайн: Реализовать онлайн редактор текста (типа Яндекс.Кода, гугл-таблиц и т. п.).»Коротко. Ядро — совместное редактирование: клиенты держат WebSocket к сессионному серверу конкретного документа, шлют операции (не итоговый текст), сервер сериализует их в единый порядок и рассылает остальным; конфликты решаются OT или CRDT, состояние хранится как «снапшот + журнал операций», а сам документ — единица шардирования и единица консистентности.
Глубже.
Функциональные требования. Создание/открытие документа, одновременное редактирование несколькими пользователями, курсоры и выделения соседей (presence), автосохранение, история версий и откат, комментарии, права доступа (владелец/редактор/комментатор/читатель), работа при кратковременном обрыве сети (офлайн-буфер и догон), экспорт. Явно откладываем: полнотекстовый поиск по всем документам, права на уровне ячеек/диапазонов, реалтайм-формулы для таблиц (это отдельная вычислительная подсистема).
Нефункциональные. Задержка появления чужого символа — p99 ≤ 150–200 мс в пределах региона; никакой потери подтверждённых операций (durability важнее доступности записи); документ всегда сходится к одинаковому состоянию у всех клиентов (strong eventual consistency); доступность 99,9 %; поддержка 50–100 одновременных редакторов на документ (жёсткий предел, иначе UX всё равно разваливается).
Оценка нагрузки. 10 млн DAU, из них одновременно онлайн ~500 тыс. (5 %), реально печатают в каждый момент ~10 % онлайна = 50 тыс. Человек печатает 5–7 символов/с, но клиент батчит операции окном 50–100 мс → ~10 операций/с на активного редактора → ~500 тыс. операций/с в пике на весь сервис. Операция — 50–200 байт, значит входящий поток ≈ 50–100 МБ/с; исходящий больше в K раз, где K — среднее число соавторов в документе (обычно 1–3, редко десятки) → ~150–300 МБ/с broadcast. WebSocket-соединений — 500 тыс.; при 50 тыс. соединений на узел (реалистично для Go при аккуратной работе с буферами: 500 тыс. × ~40–60 КБ буферов ≈ 2–3 ГБ RAM на узел) нужно ~10–20 узлов плюс запас. Хранение: журнал операций сжимается снапшотами; документ в 100 КБ + история ≈ 1 МБ; 500 млн документов ≈ 500 ТБ — это уже про тиринг в объектное хранилище.
Модель конкурентности — главная развилка.
OT (Operational Transformation), как в Google Docs: клиент шлёт операцию с номером ревизии, на которой она построена; сервер трансформирует её относительно операций, пришедших раньше, присваивает новую ревизию и рассылает; клиент трансформирует входящие относительно своих неподтверждённых. Плюсы: компактные операции, простая модель «текст + позиции», проверенная временем. Минусы: требует центрального сервера-сериализатора (что для нас нормально — документ и так шардирован по одному владельцу), и функции трансформации сложно писать корректно, особенно для богатой структуры (таблицы, форматирование).
CRDT (RGA/LSEQ/Yjs/Automerge): каждый символ получает глобально уникальный, устойчиво упорядочиваемый идентификатор, слияние коммутативно и не требует центрального порядка. Плюсы: офлайн и p2p «из коробки», сервер может быть тупым релеем, сходимость доказуема. Минусы: метаданные на символ (в наивной реализации кратный оверхед; современные реализации вроде Yjs его сильно ужимают), «надгробия» удалённых символов требуют GC, интенционально странные результаты при некоторых конкурентных правках.
Практический ответ для интервью: CRDT (Yjs-подобный) для текста + сервер как авторитетный сериализатор и точка персистентности. Это даёт простую серверную часть, честный офлайн и убирает самый сложный класс багов OT-трансформаций; центральный сервер всё равно нужен для прав доступа, персистентности и рассылки.
Session ownership. У каждого документа должен быть ровно один активный владелец-процесс, иначе два узла присвоят одну ревизию. Реализация: consistent hashing по doc_id + lease в etcd/Redis; клиенты роутятся по doc_id (sticky на уровне L7 или редирект после handshake). Если владелец умирает, lease истекает, новый узел поднимает документ из «последний снапшот + хвост журнала», клиенты переподключаются и досылают неподтверждённые операции (они у них в локальном буфере, каждая с client-id и порядковым номером → идемпотентная дедупликация на сервере).
Модель данных.
documents(id, owner_id, title, created_at, updated_at, snapshot_ref, latest_rev, storage_tier)doc_ops(doc_id, rev, client_id, client_seq, payload BYTEA, ts)— PK(doc_id, rev), append-only, шардирование поdoc_id, партиционирование по времени/ревизииdoc_snapshots(doc_id, rev, ref_in_object_storage, size, ts)— снапшот каждые ~1000 операций или 30 секунд, старые операции ниже последнего снапшота можно архивироватьacl(doc_id, subject_id, role),doc_versions(doc_id, rev, label, author)— именованные версии для «истории»- Presence — только в Redis с TTL (это эфемерные данные, терять их не жалко).
Компромиссы, которые надо назвать. (1) Sticky-роутинг по документу даёт простую консистентность, но делает «горячий документ» точкой отказа и ограничивает масштаб одним узлом — приемлемо, потому что число редакторов одного документа физически ограничено. (2) Персистить каждую операцию синхронно — надёжно, но добавляет к каждой операции RTT до БД; компромисс — подтверждать клиенту после записи в реплицированный лог (Kafka/Raft) либо батчить операции окном 20–50 мс и подтверждать пачкой, честно признав, что при падении узла теряется последнее окно. (3) Снапшоты чаще — быстрее рестарт, больше запись. (4) Мультирегион: документ живёт в одном регионе (домашний регион по владельцу), кросс-регион редактируется с повышенной задержкой — попытка сделать документ активным в двух регионах ломает единый порядок операций и не окупается.
Что ещё спросят. Как показать курсоры (позиции надо трансформировать вместе с операциями); как сделать undo (пер-пользовательский undo — это инверсия своей операции, трансформированная относительно последующих чужих); как ограничить абьюз (rate limit операций на клиента); как работает «просмотр истории» (перемотка = снапшот + применение операций до нужной ревизии); безопасность (проверка прав при handshake и при каждой операции, а не только при открытии); большие документы (разбиение на блоки/страницы и загрузка по мере прокрутки).
Спроектировать handler
Заголовок раздела «Спроектировать handler»Коротко. Handler — тонкий слой: разобрать и провалидировать вход, прокинуть context с таймаутом и трассировкой, вызвать сервисный слой, преобразовать доменную ошибку в HTTP-статус, отдать ответ. Никакой бизнес-логики, никаких обращений к БД напрямую, никакого context.Background() внутри и никаких паник наружу.
Глубже. Формулировка задания обрывочная, поэтому на собеседовании первым делом уточняем: какой протокол (HTTP/gRPC), какой метод, идемпотентен ли он, какие требования по задержке и нагрузке. Дальше — каркас, который ждут.
Слои. transport (handler) → service (бизнес-логика) → repository (хранилище). Handler зависит от интерфейса сервиса, а не от реализации — это то, что делает его тестируемым.
Обязанности handler по пунктам. (1) Метод и роутинг. (2) Ограничение размера тела (http.MaxBytesReader) — иначе тривиальный DoS. (3) Декодирование с DisallowUnknownFields, если контракт строгий. (4) Валидация полей и бизнес-инвариантов входа, ответ 400 с машиночитаемым описанием ошибок. (5) Аутентификация/авторизация (обычно middleware, но проверка прав на конкретный ресурс — часто в сервисе). (6) Идемпотентность для небезопасных методов: заголовок Idempotency-Key, таблица ключей с сохранённым ответом. (7) Контекст: таймаут вызова меньше, чем клиентский, и передача его вниз до драйвера БД. (8) Маппинг ошибок: errors.Is/As доменных ошибок в 404/409/422/429/500, наружу — без деталей внутренностей, внутрь лога — со стеком и trace_id. (9) Наблюдаемость: метрики (RED — rate, errors, duration с лейблом маршрута, но без высококардинальных значений вроде id), структурный лог, спан трассировки. (10) Корректные заголовки: Content-Type, Cache-Control, Location для 201.
package api
import ( "context" "encoding/json" "errors" "log/slog" "net/http" "time")
type CreateOrderRequest struct { CustomerID string `json:"customer_id"` ItemID string `json:"item_id"` Quantity int `json:"quantity"`}
func (r CreateOrderRequest) Validate() error { switch { case r.CustomerID == "": return errors.New("customer_id is required") case r.ItemID == "": return errors.New("item_id is required") case r.Quantity <= 0 || r.Quantity > 100: return errors.New("quantity must be in 1..100") } return nil}
type OrderService interface { Create(ctx context.Context, idempotencyKey string, req CreateOrderRequest) (string, error)}
var ( ErrNotFound = errors.New("not found") ErrConflict = errors.New("conflict") ErrInsufficient = errors.New("insufficient stock"))
type OrderHandler struct { svc OrderService log *slog.Logger timeout time.Duration}
func NewOrderHandler(svc OrderService, log *slog.Logger) *OrderHandler { return &OrderHandler{svc: svc, log: log, timeout: 2 * time.Second}}
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), h.timeout) defer cancel()
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields()
var req CreateOrderRequest if err := dec.Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_body", err.Error()) return } if err := req.Validate(); err != nil { writeError(w, http.StatusUnprocessableEntity, "validation_failed", err.Error()) return }
id, err := h.svc.Create(ctx, r.Header.Get("Idempotency-Key"), req) if err != nil { h.writeServiceError(ctx, w, err) return }
w.Header().Set("Location", "/v1/orders/"+id) writeJSON(w, http.StatusCreated, map[string]string{"order_id": id})}
func (h *OrderHandler) writeServiceError(ctx context.Context, w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrNotFound): writeError(w, http.StatusNotFound, "not_found", "resource not found") case errors.Is(err, ErrConflict): writeError(w, http.StatusConflict, "conflict", "resource already exists") case errors.Is(err, ErrInsufficient): writeError(w, http.StatusUnprocessableEntity, "insufficient_stock", "not enough items") case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): writeError(w, http.StatusGatewayTimeout, "timeout", "upstream timeout") default: h.log.ErrorContext(ctx, "create order failed", slog.Any("err", err)) writeError(w, http.StatusInternalServerError, "internal", "internal error") }}
func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v)}
func writeError(w http.ResponseWriter, code int, kind, msg string) { writeJSON(w, code, map[string]string{"error": kind, "message": msg})}Что вокруг handler. Middleware-цепочка: recover (паника → 500 + лог, не падение процесса), requestID/trace, logging, metrics, auth, rate limit, CORS, timeout. На сервере обязательно ReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeout (иначе Slowloris), graceful shutdown через srv.Shutdown(ctx). Ответ на «как тестировать»: httptest.NewRequest + httptest.NewRecorder + мок сервиса; таблица кейсов «валидный вход, невалидный JSON, нарушение валидации, доменная ошибка, таймаут». Типичные ошибки кандидатов: писать SQL прямо в handler, игнорировать r.Context(), вызывать w.WriteHeader дважды, возвращать 200 с полем "error" внутри, отдавать наружу текст ошибки БД, не ограничивать размер тела.
Задача: Требуется построить пуш сервис;
Заголовок раздела «Задача: Требуется построить пуш сервис;»Коротко. Пуш-сервис — это шина доставки: приём заданий (адресных и массовых) → раскрытие адресата в список device-токенов → фильтрация (права, тихие часы, частотные лимиты, дедуп) → отправка в APNs/FCM/HMS/WebPush через пул воркеров с ретраями и учётом лимитов провайдера → обработка обратной связи (невалидные токены, отписки) и сбор статистики. Ключевые свойства: at-least-once + идемпотентность, приоритезация транзакционных пушей над массовыми, устойчивость к «шторму» рассылок.
Глубже.
Функциональные требования. Регистрация/обновление токена устройства; отправка одному пользователю по user_id (на все его устройства); сегментные и массовые кампании (миллионы адресатов); отложенная отправка и отправка «в местное утро» по таймзоне пользователя; шаблоны и локализация; настройки пользователя (категории уведомлений, тихие часы, отписки); дедупликация; статистика (отправлено/доставлено/открыто); ретраи и DLQ. Вне скоупа на первом шаге: in-app inbox, SMS/email-каналы (но архитектуру стоит сразу делать многоканальной).
Нефункциональные. Транзакционный пуш (код подтверждения, «водитель подъехал») — p99 от приёма до сдачи провайдеру ≤ 1–2 с; маркетинговая кампания — задержка до минут, но с гарантированным темпом; не более одного экземпляра одного и того же уведомления на устройство; доступность приёма 99,95 %; провайдеры (APNs/FCM) — внешние и ненадёжные, их деградация не должна ронять сервис.
Оценка нагрузки. 50 млн устройств, 1 млрд пушей в сутки → в среднем ~12 тыс./с, пик ×5 = ~60 тыс./с. Отдельно кампания: 10 млн адресатов за 5 минут = ~33 тыс./с сверху. Полезная нагрузка — до 4 КБ (лимит APNs), в среднем ~500 байт: 1 млрд × 0,5 КБ = 500 ГБ/сутки трафика к провайдерам. Токены: 50 млн × ~200 байт = ~10 ГБ — целиком помещается в шардированный Redis/KV как кеш поверх основной БД. Логи доставки: 1 млрд событий/сутки × ~200 байт ≈ 200 ГБ/сутки → ClickHouse с TTL 30–90 дней, а не OLTP-база. Пропускная способность к APNs достигается мультиплексированием HTTP/2: сотни-тысячи параллельных стримов на соединение, десятки соединений — значит воркеров нужно не «60 тысяч горутин на запрос», а пул с ограниченной конкурентностью и переиспользованием соединений.
Модель данных.
devices(device_id PK, user_id, platform, token, app_version, locale, tz, last_seen_at, status)— индексы поuser_idи поtoken(токен меняется, его надо уметь находить и переназначать другому пользователю: смена аккаунта на устройстве — источник «чужих» пушей, если это не обработать).preferences(user_id, category, enabled, quiet_hours_start, quiet_hours_end, tz).notifications(id, dedup_key, user_id, category, template_id, payload JSONB, priority, ttl, scheduled_at, state)—dedup_keyс уникальным индексом обеспечивает идемпотентность приёма.deliveries(notification_id, device_id, provider, state, attempts, provider_msg_id, error_code, ts)— это уже поток, ему место в ClickHouse/логах, а не в OLTP.campaigns(id, segment_ref, template_id, rate_limit, window, state, progress).
Ключевые решения и компромиссы.
Приоритеты и изоляция. Разные топики (или разные consumer group и пулы воркеров) для транзакционных и массовых пушей — обязательное требование. Иначе рассылка на 10 млн адресатов «съедает» очередь, и код подтверждения приходит через 20 минут. Дополнительно — квоты на отправителя, чтобы один продукт не выел мощность у всех.
Идемпотентность и дедуп. At-least-once на всех стыках, значит дубликаты неизбежны: обязателен dedup_key (например, hash(user_id, category, business_event_id)) с проверкой в Redis (SETNX с TTL, скажем, 24 ч) перед отправкой. Плюс collapse_id/apns-collapse-id и collapse_key в FCM, чтобы устройство схлопывало серию обновлений одного события в одно уведомление.
Ретраи. Классифицировать ошибки провайдера: 5xx и таймауты — ретрай с экспоненциальной задержкой и джиттером в отдельный retry-топик с задержкой (delay-топики или scheduled-очередь); 429 — уважать Retry-After и снижать темп (адаптивный rate limiter); 400 BadDeviceToken/Unregistered — не ретраить, а пометить токен невалидным и удалить. Обязателен circuit breaker на провайдера и DLQ для всего, что не ушло за N попыток. TTL уведомления: пуш «водитель подъехал» через час не нужен — expiry передаётся провайдеру (apns-expiration, FCM ttl) и проверяется у нас перед отправкой.
Тихие часы и таймзоны. «Отправить в 9 утра по местному времени» превращается в 24 волны по таймзонам: планировщик раскладывает кампанию по бакетам таймзон и запускает каждую в свой момент. Наивная реализация «выбрать всех, у кого сейчас 9 утра» ежеминутным запросом по 50 млн строк — верный способ положить БД; правильно — предпосчитанные бакеты (tz, hour) или sorted-set в Redis с временем отправки.
Массовые кампании. Раскрытие сегмента в 10 млн user_id не должно происходить в одном воркере: сегмент материализуется офлайн (ClickHouse/Spark → файл в объектном хранилище), а затем читается порциями и публикуется в Kafka с контролируемым темпом (token bucket). Прогресс сохраняется (offset в файле), чтобы кампанию можно было приостановить, возобновить и отменить — «отмена кампании» после старта обязана работать, это регулярное требование бизнеса.
Доставка и метрика открытий. «Доставлено» от APNs/FCM означает лишь «принято провайдером»; реальная доставка подтверждается только приложением (silent push с ACK или отчёт при открытии). На собеседовании важно это различать и не обещать гарантию доставки: канал принципиально best-effort, поэтому критичные уведомления дублируются другим каналом (SMS/in-app inbox), а inbox внутри приложения — источник правды, тогда как пуш — просто сигнал.
Масштабирование и узкие места. Партиционирование Kafka по user_id (порядок для одного пользователя и естественный шардинг); resolver и dispatcher — stateless, скейлятся горизонтально; узкое место — не наш CPU, а лимиты и задержки провайдера плюс connection pool HTTP/2 (следить за MAX_CONCURRENT_STREAMS, не открывать соединение на запрос, держать keep-alive). Token store — read-heavy, кешируется. Метрики, по которым видно проблему: лаг консьюмеров по приоритетным топикам, доля ошибок по провайдерам и кодам, темп удаления невалидных токенов (резкий скачок — признак сломанного релиза приложения), p99 «приём → сдача провайдеру» отдельно для транзакционных.
Частые ошибки на собесе
Заголовок раздела «Частые ошибки на собесе»- Начинать с рисования квадратиков и выбора технологий до того, как уточнены сценарии, объёмы и SLA. Архитектура, не выведенная из требований, не защищается.
- Не считать нагрузку в числах. Без «300K / 30 с = 10K rps» и «315 млрд точек за год ≈ 5–7 ТБ» невозможно обосновать ни выбор хранилища, ни необходимость шардирования.
- Обещать exactly-once доставку. Правильная формулировка — at-least-once + идемпотентный получатель (уникальный ключ, дедуп-таблица), а атомарность «изменил состояние + отправил событие» — через transactional outbox, а не через «сначала коммит, потом продюсер».
- Считать агрегаты (топ-10, счётчики непрочитанных, «найдено N») запросом
GROUP BYпо всей истории вместо инкрементальных проекций, ZSET и денормализованных read-моделей. - Делать команды к внешним устройствам/партнёрам синхронными HTTP-вызовами с ожиданием результата и без TTL, а затем удивляться таймаутам и «зависшим» операциям.
- Игнорировать отказы: нет ответа на «что если этот блок упал» для каждого элемента схемы, нет таймаутов, ретраев с джиттером, circuit breaker, DLQ и метрик, по которым падение видно за секунды, а не по жалобе пользователя.
- Молчать. Интервьюер оценивает ход мысли: невысказанное предположение считается отсутствующим, а вопрос «а сколько тут пользователей?» — плюс, а не минус.
- Забывать про эксплуатацию и данные во времени: ретеншен, партиционирование, миграции, реиндексация поиска, восстановление кеша/индекса из источника правды.
Что почитать
Заголовок раздела «Что почитать»- Martin Kleppmann, «Designing Data-Intensive Applications» — базовая книга по репликации, партиционированию, консистентности и потоковой обработке.
- System Design Primer — https://github.com/donnemartin/system-design-primer (структура разбора задач, оценки «на салфетке»).
- Transactional Outbox и Idempotent Consumer — https://microservices.io/patterns/data/transactional-outbox.html и https://microservices.io/patterns/communication-style/idempotent-consumer.html
- Google SRE Book, главы про SLO/SLI и обработку перегрузок — https://sre.google/sre-book/table-of-contents/
- C4 model — https://c4model.com/ , и шаблоны ADR — https://adr.github.io/ , https://github.com/joelparkerhenderson/architecture-decision-record
Список исходных вопросов с привязкой к компаниям: ../questions/system-design.md