Паттерны устойчивости и проектирование сервисов
Распределённая система из предыдущей главы — это десятки способов упасть: зависимый сервис тормозит, сеть теряет пакеты, сообщение доставляется дважды, кэш умирает всем скопом. Паттерны устойчивости — это готовые решения этих классов проблем, отточенные индустрией. Знать их имена и когда применять — значит проектировать системы, которые деградируют управляемо, а не падают лавиной.
Здесь разберём каждый паттерн с рабочим кодом на TypeScript, затем соберём всё вместе в двух классических проектных упражнениях: URL-shortener и лидерборд. Это формат систем-дизайн-собеседования — умение пройти этот путь по шагам важнее зазубренных определений.
Таймауты и retry: джентльменский набор
Заголовок раздела «Таймауты и retry: джентльменский набор»Правило номер один распределённых систем: у каждого сетевого вызова должен быть таймаут. Вызов без таймаута — потенциально вечно висящий поток, который съест пул соединений и потянет за собой соседние запросы.
// 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 и полным jitterexport 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);}Circuit breaker: автоматический предохранитель
Заголовок раздела «Circuit breaker: автоматический предохранитель»Проблема, которую решает 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: переборки, как на корабле»Корабль делится на отсеки, чтобы пробоина одного не топила всё судно. В софте — выделение пулов ресурсов под разные виды нагрузки: отдельные пулы соединений под разные зависимости, отдельные воркеры под разные очереди, отдельные лимиты под разные эндпоинты.
Без 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-opINSERT 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;Распределённые транзакции: Saga против 2PC
Заголовок раздела «Распределённые транзакции: Saga против 2PC»В монолите с одной БД транзакция атомарна из коробки. В распределённой системе (заказ → оплата → резерв на складе → доставка) «всё или ничего» не достичь локально. Два подхода:
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 и Event Sourcing: кратко и по делу
Заголовок раздела «CQRS и Event Sourcing: кратко и по делу»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)──► queueapp ─────► Meilisearch worker читает очередь ──► Meilisearch писать в две системы атомарно at-least-once + идемпотентный индексатор нельзя → рассинхрон при сбое рассинхрон самолечится retryПуть 1 гонит «подвисшие» записи: приложение упало между Postgres и индексом — индекс устарел. Путь 2 (outbox-паттерн): событие пишется в таблицу outbox в той же транзакции, что и данные → воркер вычитывает и индексирует с ретраями. Индексатор идемпотентен (переиндексация того же документа — не страшно), поэтому at-least-once доставка — достаточно.
Проектирование по шагам: URL-shortener
Заголовок раздела «Проектирование по шагам: URL-shortener»Классика собеседований. Требования: сокращать 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 на транспортном уровне бесплатно.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Retry без идемпотентности. Клиентский retry списания денег с исчерпанием попыток — лотерея двойного списания. Порядок всегда: сначала механизм идемпотентности, потом retry.
- Circuit breaker на все зависимости одним экземпляром. Общий breaker: медленный поиск размыкает цепь — и здоровые платежи отказывают. Breaker — per-dependency, иногда per-endpoint.
- Таймауты «на глаз» и их отсутствие в цепочках. Таймаут HTTP-клиента 30 с, при этом балансировщик рвёт на 15 с — половина запросов умирает неизвестно где. Таймауты распределяют по уровням сверху вниз: каждый внутренний ≤ внешнего с запасом.
SELECTзатемINSERTдля идемпотентности. Гонка двух параллельных запросов с одним ключом. ТолькоINSERT ... ON CONFLICT(или эквивалент атомарной операции СУБД/брокера).- Cache stampede на горячем ключе. Все инстансы приложения увидели протухание одновременно → N тяжёлых запросов в БД вместо одного. Лечится mutex’ом и jitter’ом TTL — до первого инцидента выглядит избыточным.
- Saga без компенсаций. События «шаг выполнен» есть, а «шаг отменён» — нет: система застревает в противоречивом состоянии навсегда. Проектирование saga начинается с компенсаций, не с happy path.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Зачем jitter в retry? Без него клиенты, пострадавшие от одного сбоя, ретраят синхронно и устраивают повторный удар в восстанавливающийся сервис. Случайная задержка рассинхронизирует волну повторов.
- Три состояния circuit breaker и переходы. Closed → (доля ошибок > порога) → Open → (cooldown истёк) → Half-open → (пробный запрос успешен) → Closed / (ошибка) → Open. В Open — быстрые отказы без сетевых вызовов.
- Как обеспечить идемпотентность платежа при повторном запросе? Клиентский idempotency key, уникальный индекс/таблица ключей, атомарный захват через
ON CONFLICT, результат первой обработки сохраняется и возвращается при повторах. - Чем saga отличается от 2PC и почему saga победила в микросервисах? 2PC блокирует ресурсы всех участников на обе фазы и зависит от координатора; saga — неблокирующие локальные транзакции с компенсациями, ценой eventual consistency. Веб-системы выбирают доступность и скорость.
- Что такое cache penetration и как защититься? Запросы к несуществующим ключам обходят кэш и бьют в БД. Защита: кэширование пустых результатов с коротким TTL, Bloom-фильтр для отсечения заведомо несуществующих ключей.
- Спроектируй лидерборд: структура данных и операции. Redis Sorted Set:
ZADDна обновление,ZREVRANGE 0 99на топ,ZREVRANKна позицию — всё O(log N). Надёжность — поток событий + периодическая свёртка в Postgres как источник истины.
Практика
Заголовок раздела «Практика»- Внедри
withRetryс backoff+jitter в HTTP-клиент pet-проекта. Напиши тест, имитирующий зависимость, падающую 3 раза из 5, и убедись, что клиент преуспевает, а задержки растут экспоненциально с джиттером. - Добавь circuit breaker на самую внешнюю зависимость проекта (платёжный шлюз, внешний API). Симулируй 30-секундный даунтим зависимости и проверь: быстрые 503 во время даунтайма, автовосстановление после.
- Реализуй идемпотентную ручку создания заказа с
Idempotency-Key: таблица ключей,ON CONFLICT, сохранение ответа. Прогони 10 параллельных запросов с одним ключом — должен создаться ровно один заказ, все получили один ответ. - Поставь перед горячей выборкой проекта защиту от stampede (mutex или permValue). Тест: 50 параллельных запросов по протухшему ключу → в БД ушёл ровно один запрос.
- Спроектируй вслух (блокнот/draw.io) URL-shortener под 10× рост из примера: что шардируешь, где очередь, где breaker, что мониторишь. Запиши три узких места, которые сломаются первыми.
Что почитать
Заголовок раздела «Что почитать»- Release It! (Найгард) — родоначальник паттернов устойчивости: breaker, bulkhead, timeout. Книга, из которой они все вышли.
- Microservices Patterns (Ричардсон) — saga, outbox, CQRS — с примерами кода.
- AWS Architecture Blog — Retry behaviour — джиттер и backoff от практиков Amazon.
- Redis Sorted Set docs — лидерборды и range-запросы.
- Meilisearch vs Elasticsearch — когда лёгкий поисковик достаточен.