System Design — это не угадывание модных технологий на доске. Сначала вы выясняете, что должен уметь продукт, сколько людей и данных он обслуживает и какую задержку или потерю он может пережить. Затем рисуете путь запроса, находите состояние и самые узкие места и только после этого выбираете балансировщик, кэш, базу, очередь или несколько реплик.
Разбираем: Основы System Design
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Проектирование системы связывает functional requirements с quality attributes: latency, availability, durability, consistency, security и cost. Численные оценки задают порядок величин, SLI измеряет наблюдаемое поведение, SLO задаёт целевой уровень, а архитектурные решения перераспределяют конкретные риски. Горизонтальное масштабирование помогает лишь stateless-слою; состояние, горячие ключи и downstream limits требуют отдельного дизайна.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
System Design
Процесс проектирования компонентов, данных и взаимодействий системы под явные требования, ограничения и failure modes.
Functional requirement
Пользовательская возможность или business rule: загрузить документ, оформить заказ, найти публикацию.
Quality attribute
Измеримое свойство системы: latency, availability, durability, consistency, security или стоимость.
SLI / SLO / SLA
SLI — измерение, SLO — внутренняя цель для него, SLA — договорное обещание с последствиями нарушения.
Throughput и concurrency
Throughput — завершённая работа за единицу времени; concurrency — работа, одновременно находящаяся в системе.
Load balancer
Принимает трафик и распределяет его между здоровыми replicas; сам по себе не масштабирует базу или shared state.
Horizontal scaling
Добавление экземпляров сервиса. Проще для stateless API, сложнее для stateful компонентов и фоновой работы.
Backpressure
Механизм, который не позволяет producer бесконечно подавать работу быстрее, чем consumer способен обработать.
Replication и partitioning
Replication создаёт копии данных для доступности и чтения; partitioning делит набор данных между узлами.
RPO / RTO
RPO ограничивает допустимую потерю данных по времени, RTO — допустимое время восстановления сервиса.
Idempotency
Повтор операции с тем же ключом не создаёт второй business effect — критично при timeout и retry.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Зафиксируйте scope и главные use cases
Назовите actors, операции чтения и записи, данные и то, что сознательно не проектируется в этой версии.
- 02Сформулируйте измеримые цели
Например: 99% чтений быстрее 300 мс за 28 дней, не более 0.1% ошибок и RPO не хуже пяти минут.
- 03Оцените порядок нагрузки
Из DAU, действий на пользователя, peak factor и размера payload получите средний/пиковый RPS, storage и bandwidth.
- 04Нарисуйте request и data flow
Покажите client, edge/load balancer, API, cache, primary database, replicas, queue, workers и внешние зависимости.
- 05Определите владельца состояния
Для каждой записи решите источник истины, ключ доступа, consistency boundary, lifecycle и способ восстановления.
- 06Пройдите по failure modes
Проверьте timeout, duplicate, partial failure, перегрузку, stale cache, lag реплики, потерю зоны и poison message.
- 07Докажите дизайн измерениями
Load test, p95/p99, saturation, queue depth, error rate, fault injection и restore drill проверяют гипотезу лучше стрелок.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Среднее скрывает пик
Средний RPS за сутки может быть небольшим, пока рекламная рассылка создаёт десятикратный всплеск за минуту.
Кэш меняет корректность
Он уменьшает нагрузку и latency, но добавляет TTL, invalidation, stampede и возможность устаревшего ответа.
Retry умножает перегрузку
Без deadline, exponential backoff и jitter повторные запросы усиливают аварию и создают retry storm.
Очередь не создаёт capacity
Она поглощает короткий всплеск. Если входящий rate постоянно выше обработки, backlog и время ожидания растут без границы.
Реплика может отставать
Read replica масштабирует чтение, но read-after-write может потребовать primary или механизма session consistency.
Multi-region — не бесплатная галочка
Он добавляет сетевую задержку, conflict resolution, сложный failover, стоимость и необходимость регулярно проверять recovery.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
const requestsPerDay = dau * actionsPerUser;
const averageRps = requestsPerDay / 86_400;
const peakRps = averageRps * peakFactor;
const replicas = Math.ceil(
peakRps / (rpsPerReplica * targetUtilization),
);Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
const SECONDS_PER_DAY = 86_400;
const BITS_PER_BYTE = 8;
export function estimateCapacity({
dailyActiveUsers,
actionsPerUser,
peakFactor,
averagePayloadKb,
rpsPerReplica,
targetUtilization,
}) {
const requestsPerDay = dailyActiveUsers * actionsPerUser;
const averageRps = requestsPerDay / SECONDS_PER_DAY;
const peakRps = averageRps * peakFactor;
const peakMegabitsPerSecond =
(peakRps * averagePayloadKb * 1024 * BITS_PER_BYTE) / 1_000_000;
const usableRpsPerReplica = rpsPerReplica * targetUtilization;
const servingReplicas = Math.max(
1,
Math.ceil(peakRps / usableRpsPerReplica),
);
return {
requestsPerDay: Math.round(requestsPerDay),
averageRps: Number(averageRps.toFixed(1)),
peakRps: Number(peakRps.toFixed(1)),
peakMegabitsPerSecond: Number(peakMegabitsPerSecond.toFixed(1)),
servingReplicas,
replicasWithOneFailure: servingReplicas + 1,
};
}
export async function systemDesignCapacity(emit) {
const assumptions = {
dailyActiveUsers: 120_000,
actionsPerUser: 35,
peakFactor: 8,
averagePayloadKb: 6,
rpsPerReplica: 250,
targetUtilization: 0.6,
};
emit(
'requirements',
'input',
'Цель: выдержать пользовательский пик и потерю одной API replica',
);
emit(
'capacity',
'assumption',
`Предположения: DAU=${assumptions.dailyActiveUsers}, действий/пользователь=${assumptions.actionsPerUser}, peak factor=${assumptions.peakFactor}`,
);
const estimate = estimateCapacity(assumptions);
emit(
'capacity',
'result',
`Среднее=${estimate.averageRps} RPS; проектный пик=${estimate.peakRps} RPS; egress≈${estimate.peakMegabitsPerSecond} Mbit/s`,
);
emit(
'api-tier',
'headroom',
`Нужно ${estimate.servingReplicas} serving replicas при 60% target utilization; N+1=${estimate.replicasWithOneFailure}`,
);
emit(
'database',
'constraint',
'Расчёт API не доказывает capacity базы, connection pool, cache или downstream',
);
emit(
'validation',
'next-step',
'Следующий шаг: load test с реальным traffic mix и проверкой p95/p99, ошибок и saturation',
);
return estimate;
}
Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
Черновой capacity budget
Получить порядок peak RPS до выбора количества replicas.
const requestsPerDay = dau * actionsPerUser;
const averageRps = requestsPerDay / 86_400;
const peakRps = averageRps * peakFactor;
const replicas = Math.ceil(
peakRps / (rpsPerReplica * targetUtilization),
);- Все входы должны быть подписаны как факт или предположение.
- Проверять нужно не только RPS, но и p95/p99 и ошибки.
- Database, pool и downstream могут стать лимитом раньше CPU API.
Идемпотентная запись
Безопасно пережить повтор POST после timeout клиента.
INSERT INTO payments (idempotency_key, order_id, amount)
VALUES ($1, $2, $3)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id, status;- На idempotency_key нужен UNIQUE constraint.
- Повтор должен вернуть прежний outcome, а не молча потеряться.
- Ключ имеет scope и срок хранения.
Deadline и ограниченный retry
Не ждать downstream бесконечно и не устроить retry storm.
const response = await fetch(url, {
signal: AbortSignal.timeout(800),
});
if (response.status >= 500 && attempt < 2) {
await sleep(backoffWithJitter(attempt));
}- Не каждая операция безопасна для retry.
- Общий deadline важнее суммы независимых timeout.
- 429 и Retry-After требуют отдельной политики.
Bounded queue consumer
Защитить базу от неограниченной параллельности workers.
new Worker('previews', renderPreview, {
connection,
concurrency: 8,
limiter: { max: 40, duration: 1_000 },
});- Concurrency ограничивает работу одного Worker process.
- Rate limit должен учитывать общий downstream budget всех replicas.
- Queue depth и age oldest job нужны для alerting.
Readiness отдельно от liveness
Убрать неготовый Pod из трафика, не перезапуская его без причины.
@Get('/ready')
ready() {
return this.dependencies.canServeTraffic()
? { status: 'ready' }
: (() => { throw new ServiceUnavailableException(); })();
}- Readiness отвечает на вопрос о приёме нового трафика.
- Liveness должна выявлять состояние, исправляемое restart.
- Глубокая проверка всех зависимостей может сама создать нагрузку.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Экспорт отчёта выполнялся внутри HTTP-запроса
Nest endpoint строит тяжёлый отчёт и ждёт внешний storage; при всплеске все HTTP workers удерживают соединения.
Latency растёт до timeout, клиенты повторяют POST, один отчёт создаётся несколько раз, а database pool заканчивается.
@Post(':id/export')
async export(@Param('id') id: string) {
const rows = await this.reports.loadAllRows(id);
const file = await this.renderer.render(rows);
return this.storage.upload(file);
}Долгая CPU/I/O-цепочка привязана к request lifetime, не имеет idempotency key, bounded concurrency и отдельного capacity budget.
@Post(':id/export')
@HttpCode(202)
async export(
@Param('id') id: string,
@Headers('idempotency-key') key: string,
) {
const job = await this.exports.enqueueOnce({ reportId: id, key });
return { jobId: job.id, statusUrl: '/exports/' + job.id };
}
@Processor('exports')
export class ExportWorker extends WorkerHost {
async process(job: Job<ExportJob>) {
return this.exports.buildAndStore(job.data);
}
}HTTP быстро подтверждает приём, durable queue буферизует пик, уникальный ключ дедуплицирует повтор, а Worker concurrency защищает database и storage.
Что делают непривычные вызовы из обоих фрагментов кода.
@HttpCode(202)- Сообщает, что работа принята, но ещё не завершена; клиенту нужен отдельный URL или событие для проверки результата.
@Headers()- Извлекает idempotency key из HTTP header; сервер обязан валидировать его формат, scope и связь с пользователем.
enqueueOnce()- Прикладная операция, которая атомарно создаёт job либо возвращает уже существующий outcome для того же ключа.
WorkerHost.process()- Точка обработки BullMQ job в Nest; её concurrency, timeout, retries и cleanup настраиваются отдельно от controller.
Read replica добавили без правила consistency
После создания публикации UI сразу запрашивает её, а случайный балансировщик чтений отправляет запрос на отстающую replica.
Пользователь периодически получает 404 для только что созданного объекта, хотя запись успешно commit-нулась на primary.
@Get(':id')
findOne(@Param('id') id: string) {
return this.randomReplica.query(
'SELECT * FROM articles WHERE id = $1',
[id],
);
}Replica выбирается без учёта replication lag и пользовательского read-after-write ожидания.
@Get(':id')
findOne(
@Param('id') id: string,
@Headers('x-read-token') token?: string,
) {
const db = token && this.readTokens.isFresh(token)
? this.primary
: this.replica;
return db.query(
'SELECT * FROM articles WHERE id = $1',
[id],
);
}После записи короткоживущий token направляет связанные чтения на primary; остальные масштабируемые чтения остаются на replica.
Что делают непривычные вызовы из обоих фрагментов кода.
x-read-token- Короткоживущий непрозрачный marker, связывающий чтение с недавней записью пользователя без раскрытия database position.
isFresh()- Проверяет подпись, scope и срок token; это policy выбора consistency, а не гарантия синхронной репликации.
this.primary- Connection pool к узлу, принимающему запись и поэтому видящему commit до асинхронных replicas.
SQL $1- Позиционный SQL-параметр отделяет пользовательское значение от текста запроса и предотвращает SQL injection.
Популярные заблуждения
Миф слева, корректная модель справа.
System Design — это нарисовать побольше boxes.
Диаграмма полезна только вместе с требованиями, числами, владельцами состояния, failure modes и проверяемыми trade-offs.
Load balancer автоматически делает систему масштабируемой.
Он распределяет трафик, но shared database, lock, hot key или внешний API могут остаться единственным bottleneck.
Две replicas означают 100% availability.
Одинаковая ошибка deploy, общая зона, corrupt data или неверный failover способны вывести из строя обе копии.
Если есть backup, данные защищены.
Нужны измеримые RPO/RTO, независимое хранение, проверка целостности и регулярно отрепетированный restore.
Autoscaling решает любую перегрузку.
Масштабирование запаздывает и может лишь быстрее перегрузить database или downstream без admission control и backpressure.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Какие functional и non-functional требования вы спросите до рисования архитектуры?
- Как из DAU получить приблизительный peak RPS и почему этого всё ещё недостаточно?
- Чем SLI отличается от SLO и SLA?
- Почему очередь сглаживает всплеск, но не исправляет постоянную нехватку capacity?
- Где в системе нужен idempotency key и какой scope он имеет?
- Когда stale read допустим, а когда чтение нужно направить на primary?
- Как доказать, что backup действительно укладывается в RPO и RTO?
- Какие метрики покажут bottleneck раньше пользовательских timeout?