Шпаргалка по System Design
Процесс проектирования
Прежде чем рисовать схему, полезно договориться о процессе. Хороший дизайн начинается с уточняющих вопросов.
О процессе дизайна хорошо написано здесь:
Там много ссылок, от которых можно плясать.
C4 model
C4 model — это способ описать систему на четырёх уровнях детализации. На интервью обычно достаточно первого и второго уровня, но полезно знать всю лестницу.
- Context — уровень пользователя. Кто пользуется системой, какие внешние системы с ней взаимодействуют.
- Containers — high-level роли: хранилища, приложения, очереди, шлюзы. То, что можно запустить отдельно.
- Components — низкоуровневые компоненты внутри контейнеров: REST-эндпоинты, контроллеры, сервисы.
- Code — код, UML, ERD-диаграммы. На интервью обычно не нужен, но именно этот уровень раскрывает детали реализации.
Базы данных
Типичные БД, которые могут пригодиться при проектировании системы. Таблица ниже — это не «рейтинг лучших», а карта: что выбрать, когда нужна транзакционность, а когда можно пожертвовать согласованностью ради доступности.
| Имя | Область применения | Транзакционность | CAP |
|---|---|---|---|
| SQLiteDB (Key-Value Store) | Простая конфигурация, опции, списки… | ACID | CA |
| Postgres | Ansi SQL (сюда же MySQL…) | ACID | CP |
| Aerospike | NoSQL, SSD/In-memory, mix read/write | Atomic execution of operations | AP |
| Redis | KVS, In-memory, complex, Dynamic caches, sessions, pub-sub, etc | Atomic execution of command blocks and scripts, optimistic locking | (EC, SC with Redis Raft — CP) AP |
| Apache Ignite | KVS, in-memory and store | DACID, Raft | CP/AP |
| Couchbase | NoSQL, KVS, documented, memcache protocol, MapReduce | DACID | CP/AP |
| Yandex DB | OLTP, NewSQL | DACID, Serializable | CP/AP |
| Cassandra | NoSQL, columnar | No, atomic batches | AP |
| Clickhouse | OLAP, SQL support, columnar, analytics | No, atomic batches | AP |
| Memcached | In-memory, KVS, small objects, simple | No | AP |
Метрики
Табличка значений метрик «которые все должны знать» и ещё немного. Метрики нужны, чтобы оценить стоимость и адекватность решения. Ниже — значения, которые помогают от MAU/DAU перейти к RPS и посчитать нагрузку.
| Метрика | Значение |
|---|---|
| Доступ к ссылке в L1-кэш | 0.5 нс |
| Ошибка предсказания ветвления (Branch mispredict) | 5 нс |
| Доступ к ссылке в L2-кэш | 25 нс |
| Доступ к ссылке в основной памяти процессора | 100 нс |
| Сжать 1k байтов Zipy | 10 мкс |
| Отправить 1k по 1 Gbps сетке | 10 мкс |
| Случайное чтение 4kb с SSD | 150 мкс |
| Последовательное чтение 1Mb из ОП | 250 мкс |
| Стоимость сетевого обращения внутри одного ДЦ | 500 мкс |
| Последовательное чтение 1Mb из SSD | 1 мс |
| HDD seek | 10 мс |
| Последовательное чтение 1Mb по 1 Gbps сетке | 10 мс |
| Последовательное чтение 1Mb из HDD | 30 мс |
| Стоимость сетевого обращения между географически разными ДЦ | 150 мс |
| TPS (transactions per second) для Postgres | 100 / 1 cpu |
| TPS для Cassandra | 200 (r+w) / 1 cpu |
| TPS для Clickhouse | ? |
| TPS для YDB | ? |
| Time to process JSON and receive/response by HTTP | 1 ms + Network ping |
| Переход от DAU к RPS | 0.004–0.005 RPS/DAU |
| Переход от MAU к RPS | 0.0001–0.00017 RPS/MAU |
Измерения
| Измерение | Описание |
|---|---|
нс | наносекунды |
мкс | микросекунды |
мс | миллисекунды |
/1cpu | на один CPU в ~~вакууме~~ облаке |
Откуда взялись некоторые метрики
Для PostgreSQL я взял бенчмарк lithium-postgres-batch (postgres, C++, Linux, Full ORM), который на первом месте в cloud по состоянию на 06.05.2022. Вводные: 20 updates, 7917 RPS, Azure D3v2 instances. Смотрим доку по инстансам. И примерно прикидываем (8000 RPS / 20 / 4 ядра) — получается 100 TPS на одно ядро для Postgres.
По Cassandra взял несколько примеров кластеров и посчитал среднее на виртуальный CPU thread.
Оценка нагрузки: back-of-the-envelope
Прежде чем рисовать архитектуру, нужно понять порядок величин. Back-of-the-envelope (BOTE) — это быстрые приблизительные расчёты, которые помогают отбросить заведомо неподходящие варианты и увидеть, где будет узкое место.
От пользователей к RPS
Основная формула: QPS = (DAU × действий на пользователя) / 86 400. Для MAU коэффициент перехода к DAU обычно 2–3×, но проще считать от DAU, если он известен.
| Параметр | Формула / ориентир |
|---|---|
| Средний QPS | (DAU × actions_per_user) / 86 400 |
| Пиковый QPS | Средний QPS × peak_multiplier (обычно 3–5×) |
| Пиковый множитель | 3–5× для большинства систем; выше для событийных пиков |
| Конкурентные пользователи | DAU × %_online × session_length_min / 60 |
Пример: 500M DAU, 10 действий на пользователя в день. Средний QPS = (500M × 10) / 86 400 ≈ 57 870. Пиковый при множителе 4× ≈ 231 500 QPS. При 1K QPS ещё может хватить одной БД; при 100K QPS нужны кэш и read replicas; при 1M QPS — шардирование и CDN.
Оценка хранилища и трафика
Хранилище = DAU × действий × размер_записи × срок_хранения. Не забывайте про репликацию: каждая реплика умножает объём.
Трафик = QPS × средний_размер_ответа. Для медиа-сервисов это часто становится узким местом раньше, чем CPU или БД.
Балансировка нагрузки
Балансировщик распределяет входящий трафик между серверами. Ключевой вопрос на интервью: L4 или L7.
| Уровень | Что видит | Когда использовать |
|---|---|---|
| L4 (TCP) | IP, порт, TCP-флаги | Высокая пропускная способность, минимальная задержка, нетерминированные протоколы |
| L7 (HTTP) | URL, заголовки, cookies, содержимое | Маршрутизация по пути, sticky sessions, SSL-терминация, rate limiting |
Алгоритмы балансировки
| Алгоритм | Когда подходит |
|---|---|
| Round Robin | Серверы одинаковой мощности, запросы однородные |
| Weighted Round Robin | Серверы разной мощности |
| Least Connections | Длительность запросов сильно варьируется |
| IP Hash | Нужны sticky sessions без cookies |
| Consistent Hashing | Распределённый кэш, минимизация перераспределения при добавлении узла |
Кэширование
Кэш — самый быстрый способ снизить latency и нагрузку на БД. Но у него есть цена: инвалидация, консистентность, дополнительная инфраструктура.
Стратегии записи в кэш
| Стратегия | Как работает | Когда подходит |
|---|---|---|
| Cache-aside | Приложение сначала читает кэш, при промахе идёт в БД и кладёт результат в кэш | Универсальный вариант по умолчанию |
| Write-through | Запись идёт одновременно в кэш и в БД | Когда важна консистентность кэша и БД |
| Write-behind | Запись идёт в кэш, в БД попадает асинхронно | Write-heavy нагрузка, допустима eventual consistency |
| Read-through | Кэш сам загружает данные из БД при промахе | Абстракция в библиотеке/прокси |
Типичные проблемы кэша
- Cache penetration — запросы к несуществующим ключам проходят мимо кэша и бьют в БД. Решение: кэшировать «пустой» результат или использовать bloom filter.
- Thundering herd — множество запросов одновременно промахиваются и идут в БД. Решение: lock/single-flight, jitter в TTL.
- Инвалидация — самый сложный аспект. TTL, версионирование ключей, событийная инвалидация через pub/sub.
- Eviction policies — LRU, LFU, FIFO. Выбор зависит от профиля доступа: LRU хорош для временной локальности, LFU — для стабильно популярных ключей.
Очереди сообщений
Очереди отделяют производителя от потребителя, сглаживают пики и позволяют обрабатывать задачи асинхронно.
Гарантии доставки
| Гарантия | Дубликаты? | Потери? | Где применяется |
|---|---|---|---|
| At-most-once | Нет | Да | Метрики, логи — потеря допустима |
| At-least-once | Да | Нет | Большинство брокеров (Kafka, SQS) — требует идемпотентности потребителя |
| Exactly-once | Нет | Нет | Сложно и дорого; Kafka transactions, Flink |
На практике «exactly-once» почти всегда означает «at-least-once + идемпотентная обработка». Это важный тезис для интервью.
Ключевые паттерны
- Backpressure — если потребитель не успевает, нужно замедлить производителя или отбрасывать/буферизовать запросы.
- Dead Letter Queue (DLQ) — сообщения, которые не удалось обработать после N попыток, отправляются в отдельную очередь для разбора.
- Retry с exponential backoff — повторные попытки с увеличивающейся задержкой и jitter, чтобы не долбить упавший сервис.
Шардирование и репликация
Это две ортогональные оси масштабирования БД: шардирование увеличивает пропускную способность записи, репликация повышает доступность и масштабирует чтение.
Стратегии шардирования
| Стратегия | Как работает | Плюсы / минусы |
|---|---|---|
| Hash-based | shard = hash(key) % N | Равномерно, но при изменении N перераспределяются почти все данные |
| Range-based | Диапазоны значений (например, по дате или user_id) | Простота, но возможны hot shards |
| Consistent Hashing | Ключи и узлы отображаются на «кольцо» | При добавлении узла перераспределяется только ~1/N данных |
| Directory-based | Отдельный сервис хранит маппинг ключ → шард | Гибко, но directory становится SPOF и дополнительным узким местом |
Паттерны репликации
| Паттерн | Описание | Примеры |
|---|---|---|
| Single-leader | Один узел принимает записи, остальные реплицируют и обслуживают чтения | PostgreSQL streaming replication, MySQL |
| Multi-leader | Несколько узлов принимают записи, конфликты разрешаются по правилам | Multi-region active-active, некоторые конфигурации Cassandra |
| Leaderless | Любой узел принимает и чтение, и запись; кворумы (R + W > N) обеспечивают консистентность | Cassandra, DynamoDB, Riak |
Replication lag — отставание реплик от лидера. При read-heavy нагрузке это нормально, но если приложение пишет и сразу читает с реплики, пользователь может не увидеть свои же данные. Решения: read-your-writes через привязку сессии к лидеру, quorum reads, или синхронная репликация (ценой latency записи).
Модели консистентности
CAP-теорема говорит, что при сетевом разделении нужно выбирать между консистентностью (C) и доступностью (A). На практике выбор сложнее, и на помощь приходит PACELC: если партиций нет (E), нужно выбирать между latency (EL) и consistency (EC). Если есть - между консистентностью (PC) и доступностью (PA).
| Модель | Гарантия | Задержка | Примеры |
|---|---|---|---|
| Strong | Всегда читается последняя запись | Выше | PostgreSQL, Spanner, etcd |
| Eventual | Реплики сходятся со временем | Ниже | Cassandra, DynamoDB (по умолчанию) |
| Causal | Уважается причинно-следственная связь | Средняя | Некоторые конфигурации MongoDB, CockroachDB |
| Read-your-writes | Пользователь видит свои записи | Зависит от реализации | Сессионная привязка, quorum reads |
На интервью важно не просто назвать модель, а объяснить, какой выбор вы делаете и почему. Например: «Для баланса пользователя выбираем strong consistency, потому что показать старый баланс — это баг, а не trade-off. Для ленты новостей — eventual, потому что задержка в пару секунд не критична».
Latency vs Throughput
Latency — время обработки одного запроса. Throughput — сколько запросов система обрабатывает в единицу времени. Это не одно и то же, и оптимизация одного часто ухудшает другое.
Little's Law: N = λ × W, где N — количество запросов «в полёте», λ — интенсивность поступления (RPS), W — время обработки (latency). Если latency выросла, а RPS остался прежним, количество одновременных запросов растёт, и пулы потоков/соединений могут исчерпаться.
Средняя latency обманчива. На больших системах важнее tail latency: p95 и p99. Именно хвост определяет пользовательский опыт и каскадные отказы. Ориентиры: p99 для кэшированных ответов < 100 мс, для некэшированных < 500 мс, для indexed-запросов к БД 1–10 мс.
Паттерны отказоустойчивости
На интервью смотрят не только на happy path, но и на то, как система ведёт себя при отказах.
| Паттерн | Что делает |
|---|---|
| Circuit Breaker | Прекращает вызовы к упавшему сервису на время, давая ему восстановиться. "закрытым" (CLOSE) сервисам сообщения отправляются, в "открытом" (OPEN) состоянии - блокируются (и это не опечатка). В "полуоткрытом" (HALF OPEN) проходит часть запросов для проверки состояния сервиса. |
| Retry с backoff | Повторяет запрос с увеличивающейся задержкой, чтобы не усугублять перегрузку. |
| Bulkhead | Изолирует пулы ресурсов для разных типов запросов, чтобы отказ одного не уронил всё. |
| Graceful degradation | При перегрузке отключает второстепенные функции, сохраняя основные. |
| Single Point of Failure (SPOF) | Устраняется дублированием компонентов и health-checks. |
API Design
Когда доходит до deep dive, часто просят «покажите API». Вот минимальный набор, который стоит держать в голове.
- Идемпотентность. POST с
idempotency-key— повторный запрос не создаёт дубликат. Критично для платежей и заказов. - Пагинация. Cursor-based предпочтительнее offset-based для больших таблиц: не «плывёт» при вставках и не требует
OFFSET N. - Версионирование.
/v1/ordersvs/v2/ordersили заголовокAccept-Version. Позволяет менять контракт без ломающих изменений. - Rate limiting. Token bucket или sliding window; заголовки
X-RateLimit-Limit,X-RateLimit-Remaining,Retry-After. - Форматы ошибок. Единый JSON:
{"error": {"code": "...", "message": "...", "details": ...}}.
Паттерны работы с данными и транзакциями
Это тема, на которой чаще всего «плывут» кандидаты. Все знают, что такое БД и очередь, но не все понимают, как гарантировать, что запись в БД и отправка события произойдут атомарно. А именно это и есть суть распределённых транзакций.
Проблема dual write
Представьте: сервис заказов принимает запрос на создание заказа. Он должен сохранить заказ в БД и отправить событие OrderCreated в очередь, чтобы сервисы инвентаря и биллинга узнали о новом заказе.
Наивный код выглядит так:
function createOrder(order) {
db.save(order); // шаг 1: сохранили в БД
messageQueue.publish('OrderCreated', order); // шаг 2: отправили событие
return ok;
} Проблема в том, что между шагом 1 и шагом 2 может произойти сбой: сетевая ошибка, падение сервиса очереди, таймаут. Тогда заказ в БД есть, а событие потеряно. Инвентарь не знает о заказе, биллинг не выставит счёт. Данные расходятся.
Это называется dual write problem — проблема двух записей. Её нельзя решить простым try/catch: даже если вы откатите БД, событие уже могло уйти в очередь, и отменить его нельзя.
Transactional Outbox
Решение: записать событие в ту же БД, в той же транзакции, что и бизнес-данные. А потом отдельный процесс читает эти события и публикует их в очередь.
Схема:
- Сервис начинает транзакцию.
- Сохраняет бизнес-объект (заказ) в основную таблицу.
- Сохраняет событие в таблицу
outbox. - Коммитит транзакцию. Теперь и заказ, и событие гарантированно записаны вместе.
- Отдельный воркер (или CDC-коннектор) читает
outboxи публикует события в очередь. - После успешной публикации помечает событие как обработанное (или удаляет).
function createOrder(order) {
db.transaction(() => {
db.orders.insert(order);
db.outbox.insert({
id: uuid(),
type: 'OrderCreated',
payload: JSON.stringify(order),
created_at: now()
});
}); // всё или ничего
return ok;
}
// отдельный воркер
function publishOutboxEvents() {
const events = db.outbox.findUnprocessed();
for (const event of events) {
messageQueue.publish(event.type, event.payload);
db.outbox.markProcessed(event.id);
}
} Гарантия: at-least-once. Событие может быть опубликовано повторно, если воркер упал между публикацией и пометкой. Поэтому потребители должны быть идемпотентными (см. Inbox).
outbox.
Inbox / Idempotent Consumer
Если продюсер гарантирует at-least-once, потребитель должен уметь обрабатывать дубликаты. Паттерн Inbox — зеркальное отражение Outbox на стороне потребителя.
Идея: потребитель, получив сообщение, сначала проверяет, не обрабатывал ли он его раньше. Для этого он ведёт таблицу inbox с идентификаторами обработанных сообщений.
function handleMessage(message) {
db.transaction(() => {
const alreadyProcessed = db.inbox.exists(message.id);
if (alreadyProcessed) {
return; // дубликат, пропускаем
}
// обрабатываем сообщение
processOrder(message.payload);
// записываем факт обработки
db.inbox.insert({ id: message.id, processed_at: now() });
});
messageQueue.ack(message);
} Важно: запись в inbox и бизнес-логика должны быть в одной транзакции. Иначе можно обработать сообщение, упасть до записи в inbox, и при повторной доставке обработать снова.
Ключ дедупликации должен быть стабильным: либо producer-assigned message ID, либо бизнес-ключ (например, order_id + event_type).
Вместе Outbox и Inbox дают effectively-once обработку: at-least-once доставка + идемпотентный потребитель = каждое сообщение обрабатывается ровно один раз с точки зрения бизнес-эффекта.
Saga
Transactional Outbox решает проблему атомарности внутри одного сервиса. Но что если бизнес-транзакция охватывает несколько сервисов? Например: создать заказ → зарезервировать товар → списать деньги → отправить уведомление.
Классический ACID здесь не работает: нет единой БД, а 2PC (см. ниже) слишком дорог и хрупок. Вместо этого используется Saga — последовательность локальных транзакций, где каждая следующая запускается после успеха предыдущей. Если шаг падает — выполняются компенсирующие транзакции, которые отменяют эффекты предыдущих шагов.
Пример саги «оформление заказа»:
- Order Service: создать заказ (статус
PENDING). - Inventory Service: зарезервировать товар.
- Payment Service: списать деньги.
- Order Service: перевести заказ в статус
CONFIRMED.
Если шаг 3 падает — компенсации: вернуть товар на склад (шаг 2), отменить заказ (шаг 1).
| Подход | Как работает | Плюсы | Минусы |
|---|---|---|---|
| Хореография | Каждый сервис публикует событие и слушает события других сервисов. Нет центрального координатора. | Слабая связанность, простота добавления новых участников | Сложно отследить весь flow, трудно отлаживать |
| Оркестрация | Центральный оркестратор вызывает сервисы по очереди и управляет компенсациями. | Централизованный контроль, видимость всего процесса, проще отладка | Оркестратор — потенциальный SPOF, дополнительная связанность |
Two-phase commit (2PC)
Классический протокол атомарного коммита в распределённых системах. Работает через координатора, который сначала спрашивает всех участников «готовы ли вы?» (фаза 1 — prepare), а затем принимает решение «коммит или откат» и рассылает его (фаза 2 — commit/abort).
// Фаза 1: голосование
coordinator -> participant A: PREPARE
participant A: блокирует ресурсы, пишет в WAL, отвечает VOTE_YES / VOTE_NO
coordinator -> participant B: PREPARE
participant B: ... VOTE_YES
// Фаза 2: решение
if (все VOTE_YES) {
coordinator -> A: COMMIT
coordinator -> B: COMMIT
} else {
coordinator -> A: ABORT
coordinator -> B: ABORT
} Проблемы 2PC:
- Блокировки. Участники держат ресурсы заблокированными до конца фазы 2. При медленном координаторе это убивает пропускную способность.
- Single point of failure. Если координатор падает после фазы 1, участники остаются в неопределённом состоянии.
- Latency. Два раунда сетевых вызовов, плюс синхронное ожидание.
Поэтому в микросервисах 2PC используют редко — предпочитают Saga и Outbox. Но на интервью важно знать, что это такое, и уметь объяснить, почему не стоит его использовать в большинстве случаев.
Change Data Capture (CDC)
CDC — это способ читать изменения из БД, не меняя код приложения. Вместо того чтобы писать в таблицу outbox, вы подключаетесь к журналу транзакций БД (WAL в PostgreSQL, binlog в MySQL) и превращаете каждое изменение в событие.
Схема:
- Приложение пишет только в основную таблицу (без outbox).
- CDC-коннектор (Debezium, Maxwell, Oracle GoldenGate) читает WAL/binlog.
- Каждое изменение транслируется в событие и публикуется в Kafka или другую очередь.
| Подход | Плюсы | Минусы |
|---|---|---|
| Outbox-таблица | Проще понять и реализовать, полный контроль над форматом событий, не зависит от конкретной БД | Дополнительная таблица, нужно чистить, воркер — ещё один компонент |
| CDC | Нет изменений в коде приложения, меньше нагрузки на БД (только WAL), события генерируются автоматически | Зависимость от внутреннего формата БД, сложнее эволюция схемы, нужен отдельный коннектор |
Шпаргалка по выбору паттерна
| Ситуация | Что использовать |
|---|---|
| Запись в БД + отправка события в одном сервисе | Transactional Outbox |
| Потребитель получает сообщения с at-least-once | Inbox / Idempotent Consumer |
| Транзакция охватывает 2–5 сервисов, есть чёткие шаги и компенсации | Saga (оркестрация для сложных flow, хореография для простых) |
| Нужно синхронизировать данные с другой системой без изменения кода | CDC |
| Один сервис, одна БД, короткая транзакция | Обычная ACID-транзакция |
| Нужна атомарность между несколькими БД, но нет возможности менять архитектуру | 2PC (редко, только если действительно необходимо) |
Источники и ссылки
Основные материалы
- Интервью в Яндекс.Облако — процесс дизайна системы на интервью.
- Подготовка к собеседованиям в Tinkoff — разбор процесса и типичных вопросов.
- C4 model — официальный сайт подхода.
- TechEmpower Benchmarks — источник данных по производительности Postgres.
- Azure Dv2/Dsv2 series — спецификации инстансов, использованных в бенчмарке.
- Обсуждение статьи в Telegram — задать вопросы и предложить правки.
Паттерны работы с данными
- Transactional Outbox — Chris Richardson, microservices.io. Классическое описание паттерна: как атомарно сохранить бизнес-данные и событие для очереди.
- Saga — Chris Richardson, microservices.io. Паттерн распределённых транзакций через последовательность локальных шагов и компенсаций.
- Transaction Log Tailing — Chris Richardson, microservices.io. Основа CDC: чтение журнала транзакций БД для публикации событий.
- Idempotent Consumer — Azure Architecture Center. Как гарантировать, что повторная доставка сообщения не создаст дубликат.
- Debezium Documentation — CDC-коннектор для PostgreSQL, MySQL, MongoDB, Oracle и других СУБД. Практическая реализация Transaction Log Tailing.
- Transactions in Apache Kafka — Confluent. Как устроены exactly-once транзакции в Kafka и зачем нужны transactional producers.
- AWS Prescriptive Guidance: Transactional Outbox — практическое руководство с примерами реализации и сравнением с CDC.
GitHub-репозитории и подборки
- The System Design Primer — самый известный репозиторий: организованная коллекция ресурсов, примеры дизайнов, Anki-карточки. Идеальная точка входа.
- Awesome System Design Resources — курированная подборка бесплатных ресурсов для изучения концепций и подготовки к интервью. Более 35 000 звёзд.
- System-Design-Fundamentals — русскоязычная база знаний перед интервью: план собеседования, высокоуровневый дизайн, погружение в детали.
- Awesome System Design (madd86) — ещё одна курированная подборка с акцентом на распределённые системы и микросервисы.
- Eventuate Tram — фреймворк Криса Ричардсона для реализации Transactional Outbox и Saga в Java-микросервисах.