ГЛАВА 25
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Основы System Design

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

System Design — это не угадывание модных технологий на доске. Сначала вы выясняете, что должен уметь продукт, сколько людей и данных он обслуживает и какую задержку или потерю он может пережить. Затем рисуете путь запроса, находите состояние и самые узкие места и только после этого выбираете балансировщик, кэш, базу, очередь или несколько реплик.

ТЕХНИЧЕСКАЯ ОСНОВА

Проектирование системы связывает functional requirements с quality attributes: latency, availability, durability, consistency, security и cost. Численные оценки задают порядок величин, SLI измеряет наблюдаемое поведение, SLO задаёт целевой уровень, а архитектурные решения перераспределяют конкретные риски. Горизонтальное масштабирование помогает лишь stateless-слою; состояние, горячие ключи и downstream limits требуют отдельного дизайна.

Зачем это знатьSenior и AI Product Engineer должны не просто собрать работающий happy path, а объяснить, что произойдёт при десятикратном пике, падении одной зависимости, повторе запроса, отставании реплики и восстановлении из backup. Хороший дизайн делает компромиссы явными и проверяемыми.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01ТРЕБОВАНИЯuse cases · SLO · ограничения
02ПОТОК ДАННЫХAPI · state · sync/async
03МАСШТАБRPS · bytes · concurrency
04ОТКАЗЫtimeouts · retries · recovery
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

System Design

Процесс проектирования компонентов, данных и взаимодействий системы под явные требования, ограничения и failure modes.

02

Functional requirement

Пользовательская возможность или business rule: загрузить документ, оформить заказ, найти публикацию.

03

Quality attribute

Измеримое свойство системы: latency, availability, durability, consistency, security или стоимость.

04

SLI / SLO / SLA

SLI — измерение, SLO — внутренняя цель для него, SLA — договорное обещание с последствиями нарушения.

05

Throughput и concurrency

Throughput — завершённая работа за единицу времени; concurrency — работа, одновременно находящаяся в системе.

06

Load balancer

Принимает трафик и распределяет его между здоровыми replicas; сам по себе не масштабирует базу или shared state.

07

Horizontal scaling

Добавление экземпляров сервиса. Проще для stateless API, сложнее для stateful компонентов и фоновой работы.

08

Backpressure

Механизм, который не позволяет producer бесконечно подавать работу быстрее, чем consumer способен обработать.

09

Replication и partitioning

Replication создаёт копии данных для доступности и чтения; partitioning делит набор данных между узлами.

10

RPO / RTO

RPO ограничивает допустимую потерю данных по времени, RTO — допустимое время восстановления сервиса.

11

Idempotency

Повтор операции с тем же ключом не создаёт второй business effect — критично при timeout и retry.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Зафиксируйте scope и главные use cases

    Назовите actors, операции чтения и записи, данные и то, что сознательно не проектируется в этой версии.

  2. 02
    Сформулируйте измеримые цели

    Например: 99% чтений быстрее 300 мс за 28 дней, не более 0.1% ошибок и RPO не хуже пяти минут.

  3. 03
    Оцените порядок нагрузки

    Из DAU, действий на пользователя, peak factor и размера payload получите средний/пиковый RPS, storage и bandwidth.

  4. 04
    Нарисуйте request и data flow

    Покажите client, edge/load balancer, API, cache, primary database, replicas, queue, workers и внешние зависимости.

  5. 05
    Определите владельца состояния

    Для каждой записи решите источник истины, ключ доступа, consistency boundary, lifecycle и способ восстановления.

  6. 06
    Пройдите по failure modes

    Проверьте timeout, duplicate, partial failure, перегрузку, stale cache, lag реплики, потерю зоны и poison message.

  7. 07
    Докажите дизайн измерениями

    Load test, p95/p99, saturation, queue depth, error rate, fault injection и restore drill проверяют гипотезу лучше стрелок.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Среднее скрывает пик

Средний RPS за сутки может быть небольшим, пока рекламная рассылка создаёт десятикратный всплеск за минуту.

02

Кэш меняет корректность

Он уменьшает нагрузку и latency, но добавляет TTL, invalidation, stampede и возможность устаревшего ответа.

03

Retry умножает перегрузку

Без deadline, exponential backoff и jitter повторные запросы усиливают аварию и создают retry storm.

04

Очередь не создаёт capacity

Она поглощает короткий всплеск. Если входящий rate постоянно выше обработки, backlog и время ожидания растут без границы.

05

Реплика может отставать

Read replica масштабирует чтение, но read-after-write может потребовать primary или механизма session consistency.

06

Multi-region — не бесплатная галочка

Он добавляет сетевую задержку, conflict resolution, сложный failover, стоимость и необходимость регулярно проверять recovery.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
const requestsPerDay = dau * actionsPerUser;
const averageRps = requestsPerDay / 86_400;
const peakRps = averageRps * peakFactor;

const replicas = Math.ceil(
  peakRps / (rpsPerReplica * targetUtilization),
);
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/system-design-lab.js
сценарий79 строк
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-поток открытым до завершения сценария.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.

01

Черновой 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.
02

Идемпотентная запись

Безопасно пережить повтор 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 и срок хранения.
03

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 требуют отдельной политики.
04

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.
05

Readiness отдельно от liveness

Убрать неготовый Pod из трафика, не перезапуская его без причины.

@Get('/ready')
ready() {
  return this.dependencies.canServeTraffic()
    ? { status: 'ready' }
    : (() => { throw new ServiceUnavailableException(); })();
}
  • Readiness отвечает на вопрос о приёме нового трафика.
  • Liveness должна выявлять состояние, исправляемое restart.
  • Глубокая проверка всех зависимостей может сама создать нагрузку.
07 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

Экспорт отчёта выполнялся внутри 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.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Асинхронная граница полезна, когда пользователю не нужен готовый результат в том же response; очередь должна иметь лимиты, retry policy и наблюдаемое время ожидания.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • HTTP acceptance p95 и доля 202/ошибок.
  • Queue depth, age oldest job и число retry/dead-letter jobs.
  • Database pool saturation и export completion time.
КЕЙС 02

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.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Replication — это не только topology. Продукт должен определить, где допустимы stale reads и где требуется read-after-write consistency.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Replication lag в секундах и bytes.
  • Доля чтений, временно направленных на primary.
  • Число not-found сразу после успешной записи.
08 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

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.

09 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Какие functional и non-functional требования вы спросите до рисования архитектуры?
  2. Как из DAU получить приблизительный peak RPS и почему этого всё ещё недостаточно?
  3. Чем SLI отличается от SLO и SLA?
  4. Почему очередь сглаживает всплеск, но не исправляет постоянную нехватку capacity?
  5. Где в системе нужен idempotency key и какой scope он имеет?
  6. Когда stale read допустим, а когда чтение нужно направить на primary?
  7. Как доказать, что backup действительно укладывается в RPO и RTO?
  8. Какие метрики покажут bottleneck раньше пользовательских timeout?