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

Основы системного дизайна

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

В краткой версии ты видел обзорную карту. Здесь — механика: почему синхронная репликация дорожает на каждую запись, как выбрать ключ шардирования и почему потом придётся мучиться с resharding’ом, что на самом деле говорит CAP-теорема и как Raft выбирает лидера за считанные секунды. Это язык, на котором инженеры обсуждают архитектуру, — на собеседовании и в продакшене.

Вертикальное масштабирование — увеличить мощность одной машины: больше ядер, больше памяти, быстрее диск (NVMe вместо SSD, local NVMe вместо сетевого). Просто, ничего в коде менять не надо. Пределы: физический потолок железа и нелинейная цена — машина вдвое мощнее стоит втрое дороже. Плюс единая точка отказа: упала — лежало всё.

Горизонтальное масштабирование — добавлять машины и распределять нагрузку между ними. Практически бесконечный потолок (пока хватает денег), отказоустойчивость из коробки, но требует stateless-архитектуры: любой запрос должно быть можно обработать на любой машине.

Вертикальное: Горизонтальное (stateless):
┌───────────┐ ┌───┐ ┌───┐ ┌───┐
│ MEGA │ │app│ │app│ │app│ ← все одинаковые,
│ SERVER │ └───┘ └───┘ └───┘ состояние снаружи
│ (всё │ │ │ │
│ внутри) │ ▼ ▼ ▼
└───────────┘ ┌────────────────────┐
│ Load Balancer (L7) │
└─────────┬──────────┘
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Postgres│ │ Redis │ │ Queue │
│(данные) │ │(сессии, │ │(задачи) │
└─────────┘ │ кэш) │ └──────────┘
└──────────┘

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

Репликация — хранение копий данных на нескольких узлах. Две оси: кто может писать (single-leader / multi-leader) и как быстро копия узнаёт об изменениях (синхронно / асинхронно).

Master-slave (leader-follower) — классика: все записи идут в мастер, он рассылает изменения слейвам. Читаем со слейвов — масштабируем чтение. Самое распространённое устройство: Postgres streaming replication, MySQL replication, Redis replicaof.

запись репликация (binlog/WAL)
Клиент ───────► ┌────────┐ ───────────────────────► ┌────────┐
│ MASTER │ ──────── (async) ──────► │ SLAVE 1│ ──► чтение
└────────┘ ───────────────────────► ┌────────┐
│ │ SLAVE 2│ ──► чтение
└──► (sync) ────────► └────────┘

Синхронная репликация: мастер не подтверждает транзакцию, пока слейв не ответил «записал». Гарантия: слейв всегда идентичен мастеру, failover без потери данных. Цена: задержка каждой записи растёт на время межузловой передачи, и если слейв недоступен — запись встаёт. Дорого, применяют для самого критичного (деньги, идентичность).

Асинхронная репликация: мастер подтверждает сразу, изменения летят слейву в фоне. Быстро, но появляется replication lag: прочитал со слейва данные, которых ещё нет (классика: пользователь обновил профиль, редирект, профиль старый — «где мои изменения?!»). Лечится чтением-after-write с мастера или с lag-aware слейва. Полный разбор моделей репликации, включая multi-leader и конфликты записи, — глава 5 книги «Designing Data-Intensive Applications» Клеппмана.

// Чтение после записи: свои же изменения всегда с мастера
async function updateProfile(userId: string, data: Profile) {
await db.master.update(profiles).set(data).where(eq(profiles.id, userId));
// тот же запрос повторно — с мастера, не со слейва
return db.master.select().from(profiles).where(eq(profiles.id, userId));
}

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

Три стратегии выбора шарда:

Стратегия Как работает Плюсы Минусы
Range По диапазонам ключа: ID 1–1M → шард 1, 1M–2M → шард 2 Легко делать range-запросы внутри шарда Горячие диапазоны: новые данные кучкуются на последнем шарде
Hash shard = hash(key) % N Равномерное распределение Range-запросы бьют по всем шардам; resharding = перекладка всего
Directory Таблица соответствия ключ→шард в отдельной службе Гибкое управление, точечный перенос Directory — точка отказа и узкое место

Consistent hashing — способ сгладить боль resharding’а при хэш-стратегии. Классический % N при переходе с 4 на 5 шардов перемещает ~80% ключей: hash(key) % 4 ≠ hash(key) % 5 почти всегда. Consistent hashing раскладывает ключи и шарды по единому кольцу; ключ достаётся ближайшему по часовой стрелке шарду. При добавлении шарда двигаются только ключи в его сегменте кольца (~20% при 4→5):

Классический hash % N: Consistent hashing:
шард = h % N кольцо [0, 2^32):
N=4: h%4 шард A ────────●───────────────
N=5: h%5 ● S1 ● S2 (новый)
ключи между S1 и S2
смена N → перемещение ~80% ключей переезжают только с A на S2

На consistent hashing построены Cassandra, DynamoDB (виртуальные ноды поверх), memcached-клиенты. Для PostgreSQL-шардирования чаще используют директорию через Citus или приложение-роутер.

Resharding — перекладка данных между шардами. Независимо от стратегии это: добавить шард → зеркалировать трафик → перенести данные (фоном, порциями) → переключить запись → дочитать хвост → убрать старый. Дни работы и часы повышенного риска. Поэтому первый совет любого experienced-инженера: не шардируй, пока не доказано, что без этого никак — сначала индексы, кэш, реплики для чтения, архивация старых данных. Один Postgres на NVMe с грамотными индексами тянет десятки тысяч записей в секунду. Таксономия стратегий партиционирования и опыт боевых систем — глава 6 той же DDIA.

CAP: в распределённой системе при сетевом разделении (P — partition tolerance, а они случаются обязательно: разрыв кабеля, недоступность AZ, GC-пауза на полминуты) можно сохранить только одно из двух:

  • C — Consistency: каждое чтение возвращает последнюю запись или ошибку. Никаких расхождений между узлами.
  • A — Availability: каждый запрос получает ответ, без гарантии, что это самая свежая запись.

Это не «выбери две из трёх» на этапе проектирования, а выбор поведения в момент разделения. Системы делятся на CP и AP:

Система Выбор Поведение при разделении Пример сценария
Postgres, etcd, ZooKeeper CP Отказывать в записи/чтении, пока нет кворума Банк: лучше «сервис недоступен», чем двойное списание
Cassandra, DynamoDB AP Отвечать с локальными данными, сойтись позже Лента соцсети: лучше показать чуть старые лайки, чем белый экран

Кворумы и Raft: согласие без единого начальника

Заголовок раздела «Кворумы и Raft: согласие без единого начальника»

Как узлам договориться, какое значение считать подтверждённым, если они видят мир по-разному? Ответ — кворум: операция считается выполненной, если её подтвердило большинство узлов. Из N узлов кворум — ⌊N/2⌋ + 1:

N = 3: кворум = 2 → переживает отказ 1 узла
N = 5: кворум = 3 → переживает отказ 2 узлов
N = 2: кворум = 2 → переживает отказ 0 узлов (!)

Отсюда железное правило распределённых систем: кластера из двух узлов не существует — либо 1, либо 3+. Упал один из двух — второй не может отличить «партнёр мёртв» от «сеть между нами разорвана» и обязан молчать (иначе split-brain: два мастера, каждый пишет свою правду).

Raft — самый практичный алгоритм консенсуса (etcd, Consul, TiKV, NATS JetStream). Идея на пальцах: у кластера есть лидер. Запись: лидер принимает → рассылает follower’ам → ждёт подтверждений от кворума → отвечает клиенту. Смена лидера: у каждого узла таймер выборов; лидер периодически шлёт heartbeat; heartbeat пропал — таймер сработал, узел становится кандидатом, просит голоса; выигрывает тот, кто собрал кворум голосов за один термин (монотонно растущий номер эпохи — старый лидер с меньшим термом автоматически проигрывает). Пошаговая анимация выборов и формальное описание — на raft.github.io.

Tермин 5: LIDER follower follower
│ heartbeat │ │
│──────────►│──────────►│
│ │ │
запись ───►│ append │ append │ append
клиент │ ◄──────── │ ◄──────── │ (кворум = 2 подтверждения)
│ ответ OK ─┘ │
лидер молчит > timeout → термин 6: выборы → новый лидер

Прикладному разработчику Raft не писать — но три следствия нужно знать: (1) запись через консенсус стоит 2 RTT минимум — латентность фундаментальна; (2) кластер чётного размера бессмысленен; (3) «подождём, пока лидер вернётся» нельзя делать бесконечно — хорошие системы имеют фиксированный election timeout (etcd: 1s на выборы).

Балансировщик распределяет трафик между инстансами приложения. Два уровня:

L4 (транспортный, TCP/UDP) — видит только адреса и порты. Не расшифровывает HTTP, молниеносный, небольшой накладной расход. AWS NLB, IPVS, cloud LB у Hetzner. Подходит для: проксирования TCP/UDP целиком (базы, WebSocket-фермы, игровые серверы), очень высоких RPS.

L7 (прикладной, HTTP) — понимает методы, пути, заголовки, куки. Может маршрутизировать /api/* на один пул, /static/* на другой; прикреплять сессию по куке; делать health-checks на уровне HTTP-ответа; терминировать TLS. AWS ALB, Nginx, HAProxy, Traefik.

# Nginx как L7: разные пулы под разные пути, health-checks, таймауты
upstream api {
least_conn;
server 10.0.2.11:3000 max_fails=2 fail_timeout=10s;
server 10.0.2.12:3000 max_fails=2 fail_timeout=10s;
keepalive 32; # reuse соединений к бэкенду
}
server {
listen 443 ssl http2;
location /api/ {
proxy_pass http://api;
proxy_connect_timeout 2s; # быстрый отказ от мёртвого бэкенда
proxy_read_timeout 30s;
}
location /healthz {
proxy_pass http://api;
access_log off; # не забивать логи
}
}

Синхронный вызов связывает сервисы по времени ответа: медленная зависимость = медленный запрос. Очередь разрывает связь: продюсер кладёт сообщение и живёт дальше, консьюмер обрабатывает в своём темпе. Масштабируется числом консьюмеров, переживает всплески, даёт retry. Очередь как стиль коммуникации между сервисами — базовый паттерн microservices.io:

┌──────┐ POST /orders ┌─────┐ order.created ┌──────┐
│Client│ ─────────────►│ API │ ───────────────►│Queue │
└──────┘ ◄── 202 OK └─────┘ └──┬───┘
│ раздача
┌────────┼────────┐
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│Worker│ │Worker│ │Worker│ ← масштаб
└──┬───┘ └──┬───┘ └──┬───┘ числом
▼ ▼ ▼
обработка: письмо, PDF, вебхук...
упало → retry → DLQ
Свойство RabbitMQ Kafka AWS SQS
Модель Брокер сообщений: exchanges, routing keys, очереди Распределённый лог: топики, партиции, offset Управляемая очередь, FIFO/standard
Доставка At-least-once (с ACK), exactly-once сложно At-least-once по умолчанию; exactly-once в пределах Kafka (транзакции) Standard: at-least-once с дедупликацией окном; FIFO: exactly-once по message group
Порядок В пределах одной очереди гарантируется Только внутри партиции (поэтому ключ партиции = ключ порядка) FIFO: строго; Standard: не гарантируется
Retry/DLQ Dead-letter exchange, TTL, TTL+DLX Отдельные топики retry, нет встроенного DLQ Redrive policy на DLQ-очередь, maxReceiveCount
Пропускная способность Десятки тысяч msg/s на кластер Миллионы msg/s, дешёвое хранение Масштабируется AWS, лимиты на аккаунт
Когда брать Сложная маршрутизация, RPC поверх очередей, moderate throughput Потоки событий, репликация данных между сервисами, аналитика, большие объёмы Уже в AWS, не хочешь эксплуатировать брокер, задачи по одному

Практические выводы из таблицы: порядок в Kafka — только внутри партиции (нужен порядок по пользователю → ключ партиции = user_id); SQS standard может доставить сообщение дважды и не по порядку — консьюмер обязан быть идемпотентным; RabbitMQ не для потоков в миллион сообщений в секунду — берите Kafka.

# SQS: отправка с атрибутами для маршрутизации и дедупликации
import boto3, json, uuid
sqs = boto3.client("sqs")
sqs.send_message(
QueueUrl="https://sqs.eu-west-1.amazonaws.com/123/orders",
MessageBody=json.dumps({"order_id": 4711, "total": 1500}),
MessageAttributes={
"type": {"DataType": "String", "StringValue": "order.created"},
},
MessageDeduplicationId=str(uuid.uuid5(uuid.NAMESPACE_URL, "order:4711")),
)

Backpressure — механизм, при котором быстрый компонент сигнализирует медленному «я перегружен, притормози», вместо того чтобы падать от нехватки памяти. Источники: bounded очереди (RabbitMQ max-length, Kafka max.poll.records), давление в потоках (Node.js highWaterMark в стримах, TCP-окно само есть backpressure на транспорте), bounded пулы (раздел «bulkhead» в следующей главе), реактивные сигналы.

Без backpressure: С backpressure:
fast ──► [queue] ──► slow fast ──► [queue max=1000] ──► slow
растёт при 1000: продюсер блокируется
без границ или получает 429/NAK
OOM ◄──────────────────────────── нагрузка сбрасывается на краю

В HTTP-мире backpressure выражается просто: лимиты на входящие соединения балансировщика, 503 + Retry-After при перегрузке, bounded очередь воркеров. Система, не умеющая сказать «стоп», рано или поздно падает всецело; умеющая — деградирует управляемо: часть запросов ждёт, часть отклоняется, ядро живо.

  1. Репликация для масштабирования записи. Slave’ы дублируют нагрузку на запись (реплицируют её), а не разгружают. Запись масштабируется шардированием и очередями, не репликами.
  2. Ключ шардирования — created_at. Все новые записи летят на один шард («горячий хвост»), остальные простаивают. Ключ должен равномерно размазывать и горячие, и холодные данные.
  3. Чтение со слейва без учёта lag. Пользователь обновил данные, прочитал — старое. Лечится read-your-writes с мастера или мониторингом lag с автоматическим переключением на мастер при росте.
  4. Очередь вместо идемпотентности. «SQS может доставить дважды» — не баг, а контракт. Обработчик без идемпотентности превратит at-least-once в дубли платежей. Идемпотентность решается до выбора брокера.
  5. Кластер из двух узлов. Split-brain гарантирован. Всегда нечётное число узлов консенсуса: 1 или 3 или 5.
  6. Backpressure — «когда-нибудь потом». Система без пределов на очереди, пулы и буферы падает поздно и вся. Пределы — часть архитектуры, а не оптимизация.
  1. Вертикальное или горизонтальное масштабирование — что выберешь и когда? Вертикальное — до упора: просто, без изменений архитектуры. Горизонтальное — когда вертикаль упёрлась в цену/предел или требуется отказоустойчивость. Параллельно с вертикальным готовлю приложение к stateless.
  2. Синхронная или асинхронная репликация? Синхронная — когда потеря данных недопустима (финансы), цена — латентность записи. Асинхронная — для всего остального, с честным учётом lag при чтении.
  3. Объясни CAP-теорему на примере. Сетевой раздел между двумя узлами БД: либо узел отказывает в записи, сохраняя согласованность (CP — Postgres), либо принимает запись и рискует расхождением (AP — Cassandra). CA без разделений — недостижимая идеализация.
  4. Почему кворум из трёх узлов переживает один сбой, а из двух — никакой? Кворум большинства: для 3 — это 2, при живых двух система работает; для 2 — это 2, сбой любого останавливает запись. Кроме того, пара узлов не может разрешить split-brain.
  5. RabbitMQ или Kafka? Задачи по одному с маршрутизацией — RabbitMQ. Потоки событий, высокая пропускная способность, хранение и переигрывание истории — Kafka. Уже в AWS и не хочешь брокер — SQS.
  6. Что такое backpressure и как его реализовать в HTTP-сервисе? Ограничение потока на пропускную способность медленного звена: bounded очереди, лимиты соединений, ответ 503 + Retry-At при переполнении. Цель — деградация управляемая, а не падение всей системы.
  1. Подними Postgres с одной асинхронной репликой (два Docker-контейнера или два инстанса). Напиши скрипт, который пишет в мастер и сразу читает со слейва; намеренно замедли репликацию (pg_wal_receiver / network delay) и покажи чтение «устаревших» данных. Реализуй read-your-writes с мастера.
  2. Реализуй мини-шардирование в приложении: таблица users разделена по hash(user_id) % 2 на два SQLite/Postgres-инстанса. Реализуй роутер чтения/записи. Добавь третий шард и пройди цикл resharding’а для 1% пользователей без даунтайма.
  3. Подними RabbitMQ в Docker, сделай exchange orders с dead-letter exchange. Воркер, падающий на каждом пятом сообщении. Проверь: после maxRetries сообщение оказывается в DLQ. Затем то же на SQS (или симуляции) с maxReceiveCount=3.
  4. Настрой Nginx как L7-балансировщик на два инстанса твоего приложения: least_conn, health-check, proxy_connect_timeout 2s. Убей один инстанс и прогони 1000 запросов: замерь, какой процент ушёл на живой, сколько ошибок.
  5. Нарисуй свою систему с backpressure: где bounded очереди, где 503, где лимиты. Укажи три места, где переполнение сегодня уронит всё.
  • System Design Primer — лучший открытый репозиторий по теме, с картами и чек-листами.
  • The Raft Consensus Algorithm — анимация выборов лидера и репликации.
  • CAP theorem — Jepsen analyses — как модели консистентности выглядят на практике и как их ломают.
  • RabbitMQ vs Kafka — сравнение философий брокера и лога.
  • Designing Data-Intensive Applications (Клеппман) — главная книга по репликации, шардированию и консенсусу. Обязательна к прочтению.