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