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

Паттерны устойчивости и проектирование сервисов

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

Здесь разберём каждый паттерн с рабочим кодом на TypeScript, затем соберём всё вместе в двух классических проектных упражнениях: URL-shortener и лидерборд. Это формат систем-дизайн-собеседования — умение пройти этот путь по шагам важнее зазубренных определений.

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

// fetch без таймаута висит вечно; AbortSignal — минимальная гигиена
async function callWithTimeout(url: string, timeoutMs = 3000): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeoutMs);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}

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

  • Экспоненциальная задержка: ждём 100 мс, 200 мс, 400 мс… Каждая следующая попытка даёт соседу больше времени на восстановление.
  • Jitter (случайное дробление): добавляем случайность, чтобы тысячи клиентов не синхронизировались в едином retry-шторме.

Формулы backoff’а и поведение при массовых ретраях инженеры Amazon разобрали в AWS Builders’ Library.

// Реализация retry с экспоненциальным backoff и полным jitter
export async function withRetry<T>(
fn: () => Promise<T>,
{ retries = 4, baseDelayMs = 100, maxDelayMs = 2000 }: {
retries?: number; baseDelayMs?: number; maxDelayMs?: number;
} = {},
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
// Ретраим только ретраибельные ошибки: таймауты, 502/503, сетевые сбои.
// 4xx (кроме 408/429) — ошибки клиента, повторять бессмысленно.
if (!isRetryable(err) || attempt === retries) throw err;
// Полный jitter: случайная задержка от 0 до base * 2^attempt
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
await sleep(Math.random() * delay);
}
}
throw lastError;
}
function isRetryable(err: unknown): boolean {
if (err instanceof Error && err.message === "timeout") return true;
const status = (err as { status?: number }).status;
return status === 408 || status === 429 || (status !== undefined && status >= 500);
}

Проблема, которую решает breaker: зависимый сервис лежит, а твой сервис упорно шлёт запросы и ждёт таймауты каждого. Соединения заняты, пулы исчерпаны, потоки заблокированы — лёгкая простуда соседа превратилась в пневмонию у тебя. Circuit breaker следит за долей ошибок и при превышении порога «размыкает цепь»: запросы к зависимости отклоняются мгновенно, без сетевого вызова, пока таймер cooldown не переведёт breaker в полуоткрытое состояние для пробного запроса.

CLOSED (норма) OPEN (авария) HALF-OPEN (проба)
───────────────── ───────────────── ───────────────────
запросы проходят быстрый отказ 1 пробный запрос
│ без вызова соседа ├─ успех → CLOSED
считаем ошибки │ └─ ошибка → OPEN
▼ ждём cooldown (снова таймер)
доля ошибок > 50%
за 10 запросов ──────► таймер истёк ──────────►
type BreakerState = "closed" | "open" | "half-open";
export class CircuitBreaker {
private state: BreakerState = "closed";
private failures = 0;
private total = 0;
private openedAt = 0;
constructor(
private readonly threshold = 0.5, // доля ошибок для размыкания
private readonly windowSize = 10, // по скольким запросам считаем
private readonly cooldownMs = 30000 // пауза перед half-open
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "open") {
if (Date.now() - this.openedAt < this.cooldownMs) {
throw new Error("circuit open"); // быстрый отказ, без сети
}
this.state = "half-open";
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private onSuccess() {
this.failures = 0; this.total = 0;
this.state = "closed";
}
private onFailure() {
this.failures++; this.total++;
if (this.state === "half-open" || this.total >= this.windowSize) {
if (this.failures / Math.max(this.total, 1) >= this.threshold) {
this.state = "open";
this.openedAt = Date.now();
}
}
}
}

Прикладная настройка: считать ошибками только «серьёзные» (таймаут, 5xx), 4xx не считать (это не болезнь соседа). Параметры — windowSize, threshold, cooldownMs — подбираются по SLO зависимости. В экосистеме Node.js готовые реализации: opossum, во фронте за запросы к API — тот же паттерн. Паттерн в его каноническом виде описан на microservices.io.

// Использование: breaker на каждую внешнюю зависимость, не один на все
const paymentsBreaker = new CircuitBreaker({ threshold: 0.5, windowSize: 10, cooldownMs: 15000 });
app.post("/api/checkout", async (req, res) => {
try {
const result = await paymentsBreaker.call(() => chargePayment(req.body));
res.json(result);
} catch (err) {
if ((err as Error).message === "circuit open") {
res.status(503).json({ error: "payments_unavailable", retryAfter: 15 });
} else {
res.status(502).json({ error: "payment_failed" });
}
}
});

Корабль делится на отсеки, чтобы пробоина одного не топила всё судно. В софте — выделение пулов ресурсов под разные виды нагрузки: отдельные пулы соединений под разные зависимости, отдельные воркеры под разные очереди, отдельные лимиты под разные эндпоинты.

Без bulkhead: С bulkhead:
┌────────────────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ общий пул 100 │ │пул 30 │ │пул 30 │ │пул 40 │
│ соединений │ │paymnts│ │search │ │other │
└───────┬────────┘ └───┬───┘ └───┬───┘ └───┬───┘
slow search затопил всё slow search затапливает
→ payments тоже лежат только свой пул

В Node.js «пул» — это concurrency-limiter для исходящих вызовов; в Java — Bulkhead из Resilience4j; в инфраструктуре — отдельные queue-пулы воркеров, rate limits на уровне per-endpoint, отдельные k8s QoS-классы. Принцип один: катастрофа в одном отсеке не распространяется на соседние.

// Минимальный semaphore-limiter: не более N параллельных вызовов
export class Semaphore {
private queue: Array<() => void> = [];
constructor(private permits: number) {}
async acquire(): Promise<void> {
if (this.permits > 0) { this.permits--; return; }
await new Promise<void>((resolve) => this.queue.push(resolve));
}
release(): void {
const next = this.queue.shift();
if (next) next(); // передали permit следующему ждущему
else this.permits++;
}
}
const searchLimiter = new Semaphore(30); // bulkhead для вызовов поиска
async function search(q: string) {
await searchLimiter.acquire();
try { return await searchClient.query(q); }
finally { searchLimiter.release(); }
}

Идемпотентность: повторный запрос — не баг

Заголовок раздела «Идемпотентность: повторный запрос — не баг»

Сети теряют ответы: клиент отправил запрос на списание денег, ответ потерялся, клиент повторяет. Без защиты — двойное списание. Идемпотентная операция при повторе даёт тот же результат и не создаёт побочных эффектов. Три уровня защиты. Идемпотентный потребитель как паттерн — на microservices.io.

1. Idempotency Key на уровне API. Клиент генерирует уникальный ключ на операцию и шлёт его заголовком. Сервер запоминает ключ и результат первой обработки; повтор с тем же ключом возвращает сохранённый результат, не выполняя операцию заново.

// Express + PostgreSQL: таблица идемпотентных ключей
// CREATE TABLE idempotency_keys (
// key TEXT PRIMARY KEY,
// response JSONB NOT NULL,
// created_at TIMESTAMPTZ DEFAULT now()
// );
app.post("/api/payments", async (req, res) => {
const key = req.header("Idempotency-Key");
if (!key) return res.status(400).json({ error: "idempotency_key_required" });
// INSERT ... ON CONFLICT DO NOTHING: атомарная «захват ключа»
const inserted = await db.query(
`INSERT INTO idempotency_keys (key, response)
VALUES ($1, NULL) ON CONFLICT (key) DO NOTHING RETURNING key`,
[key],
);
if (inserted.rows.length === 0) {
// Ключ уже есть: вернуть сохранённый ответ
const saved = await db.query(`SELECT response FROM idempotency_keys WHERE key = $1`, [key]);
if (saved.rows[0].response) return res.json(saved.rows[0].response);
// NULL response = предыдущий запрос ещё выполняется (где-то упал между вставкой и апдейтом)
return res.status(409).json({ error: "request_in_progress" });
}
try {
const result = await processPayment(req.body); // реальная работа
await db.query(`UPDATE idempotency_keys SET response = $2 WHERE key = $1`, [key, JSON.stringify(result)]);
res.json(result);
} catch (err) {
// Ключ «освобождаем», чтобы клиент мог повторить после исправления ошибки
await db.query(`DELETE FROM idempotency_keys WHERE key = $1`, [key]);
throw err;
}
});

2. Upsert и уникальные ограничения на уровне БД. Для операций вида «создать сущность с клиентским ID» — сам клиентский ID и есть ключ идемпотентности.

-- Клиент генерирует UUID заказа; повторная вставка того же UUID — no-op
INSERT INTO orders (id, user_id, amount)
VALUES ('7f3a...', 42, 1500)
ON CONFLICT (id) DO NOTHING;

3. Дедупликация на уровне очереди. Потребитель Kafka/SQS обрабатывает сообщение и падает до коммита offset’а — сообщение придёт повторно. Защита та же: ключ дедупликации (ID события) в таблицу с ON CONFLICT DO NOTHING, бизнес-операция — только внутри транзакции вместе с вставкой ключа.

BEGIN;
INSERT INTO processed_events (event_id) VALUES ('evt-9c1...') ON CONFLICT DO NOTHING;
-- если вставка не вернула строку — событие уже обрабатывалось: ROLLBACK-ветка
UPDATE accounts SET balance = balance - 1500 WHERE id = 42;
COMMIT;

В монолите с одной БД транзакция атомарна из коробки. В распределённой системе (заказ → оплата → резерв на складе → доставка) «всё или ничего» не достичь локально. Два подхода:

2PC (Two-Phase Commit) — координатор спрашивает все участников «готовы?» (phase 1), все отвечают «да» → фиксирует всех (phase 2). Строгая согласованность, но: блокировки на время обоих фаз (минимум 2 RTT + ожидание всех), координатор — единая точка отказа, при его падении участники держат блокировки неопределённо. В высоконагруженных веб-системах 2PC почти не используют: слишком дорого и хрупко.

Saga — последовательность локальных транзакций, каждая с компенсацией. Заказ создан → событие OrderCreated → оплата проведена → событие PaymentDone → склад зарезервирован. Падает оплата → компенсация: отмена заказа. Нет блокировок, каждый шаг независим, отказоустойчивость через retry событий. Плата — eventual consistency: между «заказ создан» и «оплата прошла» есть окно, в котором система в противоречивом состоянии (заказ есть, денег нет). Это нормально, если продукт умеет его показывать («заказ оформляется…»). Паттерн saga с оркестрацией и хореографией — на microservices.io.

Заказ Оплата Склад
│ create │ │
▼ ▼ ▼
┌─────┐ ┌──────┐ ┌───────┐
│T1 ok│─evt─►│T2 ok │─evt──►│ T3 ok │ ← happy path
└─────┘ └──────┘ └───────┘
T2 упал ▼
компенсация C1: заказ → cancelled (событие OrderCancelled)

Saga — рабочая лошадка микросервисной эпохи: оркестрация (координатор шлёт команды шагам) или хореография (сервисы реагируют на события сами). Хореография проще на старте, оркестрация нагляднее при росте числа шагов.

Кэширование: стратегии и два катастрофических сценария

Заголовок раздела «Кэширование: стратегии и два катастрофических сценария»

Четыре базовых паттерна (подробности кэш-паттернов — в разделе про Redis, здесь — системный взгляд):

  • Cache-aside (lazy): читаем из кэша, промах → читаем из БД, кладём в кэш. Самый частый. Простой, но первый промах платный.
  • Read-through: читаем через кэш-провайдер, он сам подгружает из БД. Менее гибкий, логика внутри кэша.
  • Write-through: пишем в кэш, кэш синхронно пишет в БД. Согласованность хорошая, латентность записи выше.
  • Write-behind (write-back): пишем в кэш, в БД — асинхронно. Молниеносная запись, риск потери данных при падении кэша. Для счётчиков, лидербордов, аналитики.

Два сценария, которые кладут production-кэши:

Cache penetration — запросы к несуществующим ключам (user_id = -1, перебор ID). Промахи бьют в БД напрямую; при атаке/баге БД ложится. Защита: кэшировать пустой результат с коротким TTL + Bloom-фильтр на слое приложения для заведомо-несуществующих ключей.

Cache breakdown (stampede) — горячий ключ протух, и сотня параллельных запросов одновременно идут в БД за его пересчётом. БД получает сотню тяжёлых запросов вместо одного. Защита: mutex на пересчёт (один воркер считает, остальные ждут), permValue (отдавать чуть устаревшее значение, пока свежее считается фоном), TTL с jitter (ключи не протухают синхронно).

// Mutex против stampede: один запрос пересчитывает, остальные ждут того же промиса
const inflight = new Map<string, Promise<unknown>>();
async function getCached<T>(key: string, ttlMs: number, loader: () => Promise<T>): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
let pending = inflight.get(key);
if (!pending) {
pending = loader().then(async (value) => {
// TTL с jitter: 900–1100 с, чтобы соседние ключи не протухали разом
await redis.set(key, JSON.stringify(value), "PX", ttlMs + Math.floor(Math.random() * 200));
return value;
}).finally(() => inflight.delete(key));
inflight.set(key, pending);
}
return pending as Promise<T>;
}

Cache avalanche — третий сценарий: массовое протухание кучи ключей одновременно (рестарт кэша, совпавшие TTL). Лечится тем же jitter’ом TTL, pre-warm после рестарта, circuit breaker на БД.

CQRS (Command Query Responsibility Segregation) — разделение моделей записи и чтения: команды меняют состояние через одну модель, запросы читают спроектированные под них представления. Когда нужно: чтение и запись сильно различаются по форме (лента читается иначе, чем пишется) или по нагрузке (чтение ×100 от записи — вынеси его на реплики/кэши/отдельные таблицы). Плата — eventual consistency между write- и read-моделью и больше кода. Паттерн — на microservices.io.

Event Sourcing — состояние хранится не как текущая строка, а как журнал событий; текущее состояние — свёртка журнала. Банковский счёт: не «баланс = 500», а [+1000, -300, -200]. Аудит из коробки, возможность перестроить проекции, time-travel. Плата — сложность свёрток, схемы событий навсегда, рост журнала. Чаще всего Event Sourcing применяют точечно (финансовые операции) вместе с CQRS, а не как универсальную модель.

LIKE '%term%' сканирует таблицу — на миллионах строк это смерть. Полнотекстовый поиск с релевантностью, опечатками, фасетами — отдельная подсистема: поисковый движок с инвертированным индексом.

Elasticsearch — распределённый поисковый кластер: индексы шардируются по узлам, реплики переживают отказы, запросы маршрутизируются по шардам, есть агрегации, гео, suggesters. Тяжёлый: JVM, куча оперативной памяти, кластерная топология, тонкая настройка маппингов и шардов. Берут для серьёзных объёмов и сложного поиска (логи, каталоги, аналитика).

Meilisearch — лёгкий поисковый движок: один бинарник/Docker-контейнер, индексы в памяти/на диске, релевантность из коробки, typo-tolerance по умолчанию. До миллионов документов — отличный выбор для pet-проектов и малого SaaS. API простой, эксплуатация почти нулевая.

Ключевой вопрос архитектуры — синхронизация между основной БД и поисковым индексом:

Путь 1 (простой, рискованный): Путь 2 (надёжный, сложнее):
app ──► Postgres app ──► Postgres ──(outbox)──► queue
app ─────► Meilisearch worker читает очередь ──► Meilisearch
писать в две системы атомарно at-least-once + идемпотентный индексатор
нельзя → рассинхрон при сбое рассинхрон самолечится retry

Путь 1 гонит «подвисшие» записи: приложение упало между Postgres и индексом — индекс устарел. Путь 2 (outbox-паттерн): событие пишется в таблицу outbox в той же транзакции, что и данные → воркер вычитывает и индексирует с ретраями. Индексатор идемпотентен (переиндексация того же документа — не страшно), поэтому at-least-once доставка — достаточно.

Классика собеседований. Требования: сокращать URL, редиректить по короткому коду, статистика переходов. Оценим: 100 млн созданных ссылок/мес, 10:1 ratio чтения к записи, редиректы — горячая нагрузка (1 млн RPS в пике), задержка редиректа < 50 мс.

Шаг 1. Модель данных. Код короткой ссылки: base62 от автоинкремента ID (aBc123, 7 символов ≈ 3.5 трлн комбинаций — хватит вечно). Таблица links(id bigint pk, code text unique, long_url text, created_by, created_at). Хранение — Postgres на старте, шардирование по id не понадобится годами: 100 млн строк/мес — миллиарды строк в год, Postgres справится с диапазонной партицией.

Шаг 2. Горячий путь — редирект. Чтение по code при каждом клике: индекс по code, кэш code → long_url в Redis с TTL 24 ч (cache-aside). 99% редиректов — из кэша, p99 < 5 мс. Запросы на несуществующий код — cache penetration: кэшируем пустышку на 60 с.

Шаг 3. Запись. Генерация кода — автоинкремент + base62, конфликтов нет. Идемпотентность: клиент шлёт свой Idempotency-Key (или хочет кастомный alias) — таблица-ключи как выше.

Шаг 4. Статистика. Каждый редирект — событие click(code, ts, ua, ip). Синхронно в БД писать нельзя: вставка на горячем пути убьёт latency. События — в очередь (Kafka/SQS), агрегатор считает счётчики в Redis (INCR по минутам) и сливает в Postgres раз в минуту. Click-события — идеальный кандидат на потерю безболезненности: можем сэмплировать 1 из 10 при перегрузке.

Шаг 5. Отказы. Redis упал → редирект идёт прямо в Postgres (breaker на кэше, degraded mode). Postgres упал → 503 для редиректа; запись — очередь на retry.

POST /api/shorten ──► API ──► Postgres (links)
└─► Idempotency-Keys
GET /r/:code ──► Redis? ──да──► 302 long_url
│нет
Postgres ──► 302 + наполнить Redis
└─► событие click ──► Queue ──► Aggregator ──► Redis INCR
└──► Postgres (часовые rollup'ы)

Требования: топ-100 игроков по очкам, позиция конкретного игрока, обновления очков в реальном времени (10k обновлений/сек в пике), задержка чтения топа < 10 мс.

Шаг 1. Структура данных. Это Sorted Set в чистом виде: Redis ZSET — score → member, упорядоченный. ZADD leaderboard 2450 "user:42" — O(log N) на обновление. Топ-100: ZREVRANGE leaderboard 0 99 WITHSCORES — O(log N + 100). Позиция игрока: ZREVRANK leaderboard "user:42" — O(log N). Все требования по latency закрыты одной структурой.

Шаг 2. Надёжность. Redis — память, сбой = потеря очков. Схема: каждое обновление очков — событие в Redis Stream (XADD scores * user 42 points 2450) + Redis AOF everysec для персистентности. Тяжёлая артиллерия: события дублируются в Kafka, свёртка в Postgres раз в минуту — источник истины на случай полной гибели Redis, перестроение ZSET из Postgres занимает минуты.

Шаг 3. Масштаб. 10k обновлений/сек — один Redis-шард тянет (ZADD — десятки тысяч ops/s). Если очки региональные — шардируем ZSET по региону (ключ leaderboard:eu). Топ-100 из N шардов: local top-100 из каждого → merge. Позиция игрока в глобальном топе точная — дорого; обычно достаточно оценки по шарду или точного подсчёта только топ-N% игроков.

Шаг 4. Веб. Обновления топа клиентам: WebSocket/SSE-канал, сервер шлёт диффы топ-100 раз в 1–2 с (подписка на keyspace notification или polling ZREVRANGE — дёшево). Очередь и backpressure: SSE — однонаправленный канал, браузер сам решает, когда читать — backpressure на транспортном уровне бесплатно.

  1. Retry без идемпотентности. Клиентский retry списания денег с исчерпанием попыток — лотерея двойного списания. Порядок всегда: сначала механизм идемпотентности, потом retry.
  2. Circuit breaker на все зависимости одним экземпляром. Общий breaker: медленный поиск размыкает цепь — и здоровые платежи отказывают. Breaker — per-dependency, иногда per-endpoint.
  3. Таймауты «на глаз» и их отсутствие в цепочках. Таймаут HTTP-клиента 30 с, при этом балансировщик рвёт на 15 с — половина запросов умирает неизвестно где. Таймауты распределяют по уровням сверху вниз: каждый внутренний ≤ внешнего с запасом.
  4. SELECT затем INSERT для идемпотентности. Гонка двух параллельных запросов с одним ключом. Только INSERT ... ON CONFLICT (или эквивалент атомарной операции СУБД/брокера).
  5. Cache stampede на горячем ключе. Все инстансы приложения увидели протухание одновременно → N тяжёлых запросов в БД вместо одного. Лечится mutex’ом и jitter’ом TTL — до первого инцидента выглядит избыточным.
  6. Saga без компенсаций. События «шаг выполнен» есть, а «шаг отменён» — нет: система застревает в противоречивом состоянии навсегда. Проектирование saga начинается с компенсаций, не с happy path.
  1. Зачем jitter в retry? Без него клиенты, пострадавшие от одного сбоя, ретраят синхронно и устраивают повторный удар в восстанавливающийся сервис. Случайная задержка рассинхронизирует волну повторов.
  2. Три состояния circuit breaker и переходы. Closed → (доля ошибок > порога) → Open → (cooldown истёк) → Half-open → (пробный запрос успешен) → Closed / (ошибка) → Open. В Open — быстрые отказы без сетевых вызовов.
  3. Как обеспечить идемпотентность платежа при повторном запросе? Клиентский idempotency key, уникальный индекс/таблица ключей, атомарный захват через ON CONFLICT, результат первой обработки сохраняется и возвращается при повторах.
  4. Чем saga отличается от 2PC и почему saga победила в микросервисах? 2PC блокирует ресурсы всех участников на обе фазы и зависит от координатора; saga — неблокирующие локальные транзакции с компенсациями, ценой eventual consistency. Веб-системы выбирают доступность и скорость.
  5. Что такое cache penetration и как защититься? Запросы к несуществующим ключам обходят кэш и бьют в БД. Защита: кэширование пустых результатов с коротким TTL, Bloom-фильтр для отсечения заведомо несуществующих ключей.
  6. Спроектируй лидерборд: структура данных и операции. Redis Sorted Set: ZADD на обновление, ZREVRANGE 0 99 на топ, ZREVRANK на позицию — всё O(log N). Надёжность — поток событий + периодическая свёртка в Postgres как источник истины.
  1. Внедри withRetry с backoff+jitter в HTTP-клиент pet-проекта. Напиши тест, имитирующий зависимость, падающую 3 раза из 5, и убедись, что клиент преуспевает, а задержки растут экспоненциально с джиттером.
  2. Добавь circuit breaker на самую внешнюю зависимость проекта (платёжный шлюз, внешний API). Симулируй 30-секундный даунтим зависимости и проверь: быстрые 503 во время даунтайма, автовосстановление после.
  3. Реализуй идемпотентную ручку создания заказа с Idempotency-Key: таблица ключей, ON CONFLICT, сохранение ответа. Прогони 10 параллельных запросов с одним ключом — должен создаться ровно один заказ, все получили один ответ.
  4. Поставь перед горячей выборкой проекта защиту от stampede (mutex или permValue). Тест: 50 параллельных запросов по протухшему ключу → в БД ушёл ровно один запрос.
  5. Спроектируй вслух (блокнот/draw.io) URL-shortener под 10× рост из примера: что шардируешь, где очередь, где breaker, что мониторишь. Запиши три узких места, которые сломаются первыми.