Модуль 5. Resilience и Highload: отказоустойчивость и кэширование
Цель модуля: Научить студента защищать систему от каскадных отказов при частичных сбоях (медленные внешние сервисы) и проектировать кэширование для радикального снижения Latency и защиты БД. Освоить Circuit Breaker, Bulkhead, Rate Limiting, Exponential Backoff и стратегии борьбы с Cache Stampede. Связать Resilience с Saga (Модуль 4) и заложить базу для Observability (Модуль 6).
Стадия роста проекта: Внешние сервисы тормозят. Проект пережил интеграции, Outbox и Saga. Теперь система взаимодействует с внешними сервисами (KYC, платёжные шлюзы, поисковые индексы), и первое, что ломается — медленный сосед съедает все ресурсы. Один тормозящий сервис кладёт всю систему.
Перед началом: Модули 1–4 (Trade-offs, Идемпотентность, Outbox, Saga) Инструменты: Redis (
go-redis), PostgreSQL,sony/gobreaker, Toxiproxy, k6, Prometheus Трудоёмкость: ~10 ч (теория 2 ч · практика 7 ч · ADR 1 ч) — самый объёмный модуль
🔥 Инцидент: когда 5 секунд задержки ломают весь сервис
Saga работает (Модуль 4), переводы не теряются. Но новый алерт: «Сервис недоступен, 100% ошибок, p99 > 30 с». В Grafana: пул воркеров полностью исчерпан, все висят в ожидании ответа от KYC-провайдера. Тот начал отвечать не за 50 мс, а за 30–40 секунд — не полный отказ, а тихий тормоз. Ошибок HTTP 5xx почти нет — только таймауты. Но они съели весь пул: каждый воркер ждёт по 30 секунд, новые запросы не принимаются. Система легла не от падения, а от медлительности соседа.
В тот же момент — взрыв нагрузки на PostgreSQL ×5. Кэш профилей протух ровно в 10:00, и 10 000 RPS ударили в БД: каждый запрос увидел Cache Miss, каждый пошёл за данными. Это Cache Stampede — лавина SELECT-ов сгорает от того, что кэш expired одновременно для всех.
И третий удар: при обновлении профиля кэш удаляется до коммита БД. Один поток читает старое значение (коммит ещё не прошёл), пишет его в кэш, перезаписывая правильную инвалидацию. В кэше оседает устаревшее значение — клиенты видят противоречивые данные.
Три задачи, которые нельзя решить «просто таймаутом»:
- Остановить каскад — Circuit Breaker + Bulkhead: не дать медленному сервису съесть все ресурсы.
- Защитить БД от лавины — Stale-While-Revalidate + атомарный лок: при Cache Miss в БД идёт ровно один SELECT.
- Не ломать консистентность — Event-Driven инвалидация через Outbox (Модуль 3):
DELпосле коммита + delayed double-delete от гонки.
Resilience — это не «чтобы не падало», а осознанная деградация: что именно ухудшается при сбое и как быстро восстанавливается. CB жертвует точностью ради доступности; кэш — консистентностью ради скорости. Каждый выбор — в ADR с цифрами.
📖 Теория: Раскрытые термины
Глоссарий модуля:
- Cascading Failure (каскадный отказ) — сбой одного сервиса вытягивает ресурсы (потоки/память) из вызывающего, пока не падает всё целиком.
- Token Bucket — алгоритм Rate Limiting: «ведро» токенов пополняется с фиксированной скоростью; запрос тратит токен, нет токенов — отказ (
429). - Exponential Backoff + Jitter — ретраи с экспоненциально растущей задержкой плюс случайный сдвиг (jitter), чтобы избежать синхронной лавины («thundering herd»).
- Fallback (деградация) — осмысленный запасной ответ/действие при отказе: кэш, упрощённый ответ или ошибка с компенсацией.
- Cache-Aside (Lazy Loading) — приложение само читает кэш, при промахе идёт в БД и заполняет кэш.
- Write-Through / Write-Behind — запись в кэш синхронно с БД / асинхронно (воркер сбрасывает в БД позже).
- Cache Stampede (Dogpile) — массовый одновременный промах (Cache Miss) по протухшему ключу → лавина запросов в БД.
- Stale-While-Revalidate (SWR) — отдавать протухшие (stale) данные сразу, а кэш обновлять в фоне одним воркером.
- TTL (Time To Live) — время жизни записи в кэше; Cache Miss — отсутствие записи (промах), Cache Hit — попадание.
5.1. Каскадный отказ (Cascading Failure)
Внешний сервис (например, KYC-провайдер) начал отвечать не за 50 мс, а за 30 секунд. Приложение использует пул из 50 горутин. Все 50 уходят в ожидание. Приложение перестаёт отвечать на любые другие запросы — оно «лежит» из-за чужого тормоза. Одного таймаута недостаточно: пул всё равно исчерпается, просто за N секунд вместо N минут.
5.2. Паттерны Resilience
Circuit Breaker (Предохранитель): При достижении порога ошибок цепь «размыкается» (Open). Запросы прекращаются на N секунд.
Probe-логика (Half-Open): После таймаута пропускается ровно 1 пробный запрос с жёстким таймаутом. Успех → Closed; неудача → немедленно обратно в Open (без накопления ошибок).
go// Декоратор KYC с бизнес-фолбэком и Circuit Breaker type KycClientWithBreaker struct { inner KycClient breaker *gobreaker.CircuitBreaker } func (k *KycClientWithBreaker) CheckKyc(ctx context.Context, userID string, amount float64) (bool, error) { ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) // жёсткий таймаут (и для probe) defer cancel() r, err := k.breaker.Execute(func() (interface{}, error) { return k.inner.CheckKyc(ctx, userID, amount) }) if err != nil { if errors.Is(err, gobreaker.ErrOpenState) { // CB разомкнут if amount > highRiskThreshold { return false, ErrKycUnavailable // -> Saga запустит компенсацию } return true, nil // пропускаем KYC для мелких сумм } return false, err } return r.(bool), nil }
Bulkhead (Переборка): Изоляция ресурсов на разных уровнях стека.
- Go: явные worker pools (
chan struct{}, bounded channels). «Просто таймаут» Bulkhead не даёт. - Serverless MVP:
concurrency_limitна Cloud Function в Terraform.
go// Bulkhead через chan struct{} func (h *GitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { select { case h.gitSem <- struct{}{}: defer func() { <-h.gitSem }() case <-r.Context().Done(): http.Error(w, "503 git pool overloaded", http.StatusServiceUnavailable) return } // дальше — обычная обработка }
Rate Limiting: Распределённый Token Bucket. Важно: локальный лимитер в каждом поде не работает при масштабировании (10 подов = 10× лимит). Состояние токенов хранится в Redis, а check&decrement — атомарный (Lua-скрипт).
go// Lua-скрипт Token Bucket в Redis func (h *Handler) RateLimit(w http.ResponseWriter, r *http.Request) { key := "rate:" + r.RemoteAddr now := time.Now().UnixMilli() // Lua: атомарно обновляет last_time и current result, err := h.redis.Eval(r.Context(), tokenBucketScript, []string{key}, maxTokens, refillRate, now).Result() if err != nil || result.(int64) < 0 { w.Header().Set("Retry-After", "1") http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests) return } // пропускаем запрос }
Retry: Exponential Backoff + Jitter — повторять запросы с растущей задержкой и случайным сдвигом; без jitter параллельные ретраи дают синхронную лавину на восстанавливающийся сервис.
go// Exponential Backoff + Jitter func (w *Worker) retryWithBackoff(ctx context.Context, fn func() error) error { base := 100 * time.Millisecond for attempt := 0; attempt < maxRetries; attempt++ { if err := fn(); err != nil { backoff := base * time.Duration(1<<attempt) // экспоненциальный рост jitter := time.Duration(rand.Int63n(int64(backoff) / 2)) // случайный сдвиг select { case <-ctx.Done(): return ctx.Err() case <-time.After(backoff + jitter): continue // ретрай } } return nil // успех } return fmt.Errorf("max retries exceeded") }
5.3. Стратегии Fallback и интеграция с Saga
Размыкание цепи — половина дела. Что ответить клиенту?
Бизнес-правило: Для мелких сумм — пропустить KYC (вернуть true). Для крупных — сразу выбросить ошибку.
Интеграция с Saga: Если Оркестратор (Модуль 4) вызывает сервис, и CB в состоянии Open, нет смысла ждать таймаута. Saga немедленно переходит в статус COMPENSATING. Компенсация обязана быть идемпотентной (через compensation_id), чтобы избежать двойного возврата денег при ретраях.
5.4. Стратегии кэширования (Write Strategies)
Cache-Aside (Lazy Loading): Приложение спрашивает кэш; при Cache Miss идёт в БД и обновляет кэш. Риск: данные могут быть устаревшими (stale), пока не истечёт TTL.
Write-Through: Пишем в БД и синхронно обновляем кэш. Плюс: данные всегда консистентны. Минус: высокая Latency на запись.
Write-Behind (Write-Back): Пишем только в кэш, асинхронный воркер сбрасывает в БД. Плюс: максимальная производительность. Минус: риск потери данных при падении Redis (частично закрывается AOF-персистентностью и репликацией).
5.5. Скрытый риск: Cache Stampede (Dogpile Effect)
Кэш по ключу user:100:profile протух. В эту же миллисекунду 1000 запросов стучат в API. Все видят Cache Miss, все идут в БД — БД падает (OOM).
Решения:
- Mutex (
SET NX EX): первый запрос берёт лок в Redis, идёт в БД, обновляет кэш; остальные ждут. Минус: ухудшает UX (клиенты ждут). Важно:SET lock N EX 5 NX(атомарный лок+TTL), а не отдельныеSETNX+EXPIRE— иначе зависший лок без TTL не снимется. - Stale-While-Revalidate: при наличии stale-данных отдавать их клиентам сразу, а в фоне (одним воркером через лок) обновлять кэш. Снижает perceived latency и защищает БД.
go// Cache-Aside + Stale-While-Revalidate + SET NX EX func (s *Service) GetUserProfile(ctx context.Context, id string) (*Profile, error) { key := "user:" + id if raw, _ := s.cache.Get(ctx, key).Result(); raw != "" { e := decode(raw) if e.FreshUntil.After(time.Now()) { return e.Data, nil // свежие } go s.refresh(ctx, id) // SWR: отдали stale выше, в фоне обновляем return e.Data, nil } // холодный старт: атомарный лок на 5с (один запрос в БД) ok, _ := s.cache.SetNX(ctx, "lock:"+key, 1, 5*time.Second).Result() if !ok { time.Sleep(50 * time.Millisecond) // другой воркер обновляет return s.reread(ctx, key) } defer s.cache.Del(ctx, "lock:"+key) prof, err := s.repo.Find(ctx, id) if err != nil { return nil, err } s.cache.Set(ctx, key, encode(prof, time.Now().Add(5*time.Minute)), 10*time.Minute) return prof, nil }
5.6. Инвалидация кэша и Outbox (Event-Driven Invalidation)
Удалять кэш синхронно в том же запросе — антипаттерн (связывает Latency с Redis и даёт гонки). Правильно — event-driven инвалидация через Outbox (Модуль 3):
При обновлении профиля persist(профиль) + persist(событие UserUpdated в outbox_events) в одной транзакции (один COMMIT). Это и есть Outbox — без Dual-Write.
Отдельный консьюмер читает событие из Kafka и делает DEL user:<id> в Redis. Поскольку событие попадает в Kafka уже после коммита, DEL всегда выполняется после сохранения данных.
Гонка stale-after-invalidate (важно). Cache-Aside подвержён классической гонке: читатель загрузил старое значение из БД (до коммита писателя) и записал его в кэш после того, как консьюмер уже сделал DEL. В кэше осядет устаревшее значение, и оно не перезапишется. Решение — delayed double-delete: после DEL консьюмер ждёт короткую паузу (больше времени жизни read-запроса) и делает повторный DEL. Альтернативы — версионные ключи (user:<id>:v<version>) или короткий TTL как страховка.
go// Консьюмер Kafka: инвалидация кэша через Outbox func (c *Consumer) onUserUpdated(ctx context.Context, e UserUpdated) error { key := "user:" + e.UserID // (1) Первый DEL — после коммита (событие из Outbox) if err := c.cache.Del(ctx, key).Err(); err != nil { return err } // (2) Delayed double-delete — повторный DEL через 500 мс go func() { time.Sleep(500 * time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() c.cache.Del(ctx, key) }() return nil }
Не путать: и профиль, и событие сохраняются атомарно в одной транзакции, а не «коммит, затем запись события» — иначе это рассинхрон (Dual-Write), который Outbox как раз устраняет.
5.7. Этот паттерн в архетипах
Resilience проявляется по-разному в четырёх архетипах проектов:
| Архетип | Circuit Breaker | Bulkhead | Кэш | Почему критично |
|---|---|---|---|---|
| Финтех (P2P) | KYC-провайдер | Пул для платежей vs пул для баланса | Профиль пользователя (read-heavy) | Каскадный отказ = деньги заморожены |
| Git-хостинг | Git Gateway clone | chan struct{} для git vs API | git ls-tree (immutable SHA) | 100 clone'ов убивают API |
| Мессенджер | Push-сервис | WebSocket пул vs REST пул | Последние сообщения чата | Недоставка = жалоба |
| Лента / чтение | API рекомендаций | Пул записи vs пул чтения | Лента (TTL 5 мин) | Лавина на БД при протухшем кэше |
Общий принцип: Circuit Breaker защищает от каскадных отказов, Bulkhead изолирует ресурсы, кэш снимает нагрузку с БД. Различается только внешний сервис, который тормозит, и стратегия Fallback.
5.8. Эволюция (v2): от кэша к CQRS
Cache-Aside + SWR — решение для MVP. При 50k+ RPS инвалидация кэша начнёт «моргать» (данные часто протухают, delayed double-delete не спасает от гонок). Следующий шаг — CQRS Read Models (Модуль 9, §9.6): консьюмеры Kafka строят оптимизированные проекции в отдельной БД для чтения. Это убирает Cache Stampede навсегда — нет кэша, нет проблемы. Но добавляет eventual consistency и сложность поддержки проекций.
5.9. Компромиссы (Trade-offs)
| Паттерн | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| Circuit Breaker | защита от каскадного отказа; быстрый fallback | ложные срабатывания при кратковременных колебаниях latency (flapping) | внешние вызовы (KYC, 3rd-party API) |
| Bulkhead (пул изоляции) | отказ в одном пуле не убивает остальной сервис | недоиспользование ресурсов (пулы фиксированного размера) | разные по приоритету операции |
| Rate Limiter (Token Bucket) | защита от перегрузки; честность между клиентами | 429 может потерять клиента, если лимит подобран неверно | публичные API, multi-tenant |
| Cache-Aside + SWR | 1 SELECT при stampede; ≥ 90% hit | stale-данные до обновления кэша | read-heavy (90% чтения) |
| Event-Driven Invalidation | консистентность БД и кэша без Dual-Write | задержка инвалидации (Outbox → Kafka → DEL) | обновляемые данные в кэше |
| Fallback (пропуск KYC для мелких сумм) | доступность выше при отказе внешнего сервиса | риск: мошенническая операция пройдёт без проверки | согласовано с бизнесом (риск < стоимость простоя) |
Вывод: resilience — это не «сделать чтобы не падало», а осознанный выбор: что деградирует и как при сбое. Circuit Breaker жертвует точностью ради доступности; кэш — консистентностью ради скорости. Каждый выбор — в ADR с цифрами.
🛠 Практические задания
Сценарий: Сервис P2P-переводов перед списанием вызывает внешний сервис KYC. Нагрузка на API: 10 000 RPS, из них ~90% — чтение профиля пользователя. Цель: удержать p99 < 500 мс и снять нагрузку с PostgreSQL.
Примерный план работы:
- Circuit Breaker + Fallback (декоратор
KycClientWithBreaker) → CB открывается при таймауте KYC, мелкие суммы пропускаются. - Bulkhead + Rate Limiter → изоляция пула воркеров + Redis Token Bucket →
429при превышении. - Cache-Aside + Stale-While-Revalidate → 1 SELECT при stampede, ≥ 90% Cache Hit.
- Event-Driven инвалидация через Outbox →
DEL+ delayed double-delete при обновлении профиля. - Метрики Prometheus →
circuit_breaker_state,cache_hits/misses,bulkhead_queue_length. - Тест Toxiproxy + k6 → p99 < 500 мс, CB → Open, ни один воркер не блокируется.
Задача 1: Circuit Breaker с Fallback-стратегией и Saga
Реализовать декоратор KycClientWithBreaker. В Half-Open — жёсткий таймаут на probe-запрос. При isOpen() для мелких сумм вернуть true (пропуск), для крупных — выбросить ошибку → Saga (Модуль 4) компенсирует.
✅ Готово, когда:
- Декоратор
KycClientWithBreakerобёрнут вокругKycClient(декоратор, не замена) - В Half-Open — жёсткий таймаут на probe-запрос (200 мс)
- При
isOpen(): мелкие суммы →true(пропуск), крупные суммы →ErrKycUnavailable→ Saga компенсирует - ADR-5 «Circuit Breaker + Fallback» написан
Задача 2: Bulkhead и атомарный Rate Limiter
Go: Worker Pool (chan struct{} размером 5); при заполненном канале — ErrBulkheadFull.
Rate Limiter: Redis-backed Token Bucket через Lua-скрипт (атомарность). При превышении — 429 Too Many Requests + Retry-After.
✅ Готово, когда:
- Bulkhead:
chan struct{}размером 5 (Go); при переполнении —ErrBulkheadFull - Rate Limiter: Redis Token Bucket через Lua-скрипт (атомарный
INCR + EXPIRE) - При превышении лимита — HTTP
429+ заголовокRetry-After
Задача 3: Cache-Aside + Stale-While-Revalidate
Получение профиля:
- Чтение из Redis. Если данные свежие — отдать.
- Если есть stale (протухшие) — отдать сразу и запустить фоновое обновление (SWR) одним воркером.
- Холодный старт — атомарный лок
SET lock:user:<id> NX EX 5, чтобы в БД пошёл ровно один запрос.
Тест (k6): 1000 параллельных запросов на один профиль после очистки кэша. В БД должен уйти только 1 SELECT.
✅ Готово, когда:
- Свежие данные (
fresh_until > now) — отдаются сразу - Stale-данные — отдаются сразу + запускается фоновое обновление (SWR)
- Холодный старт: атомарный лок
SET NX EX— в БД идёт ровно 1 SELECT - k6: 1000 параллельных запросов на один профиль после очистки кэша → 1 SELECT в БД
Задача 4: Event-Driven инвалидация через Outbox
UpdateUserUseCase делает persist(профиль) + persist(UserUpdated → outbox_events) в одной транзакции. Консьюмер читает событие из Kafka, делает DEL user:<id> и (от гонки stale-after-invalidate) — delayed double-delete.
✅ Готово, когда:
-
persist(профиль)+persist(UserUpdated → outbox_events)в одной транзакции - Консьюмер Kafka:
DEL user:<id>после получения события - Delayed double-delete: повторный
DELпосле паузы (защита от stale-race после инвалидации)
Задача 5: Метрики (подготовка для Модуля 6)
Выставить в Prometheus: circuit_breaker_state, fallback_count_total, bulkhead_queue_length, а для кэша — cache_hits_total, cache_misses_total, cache_refresh_total.
✅ Готово, когда:
- Метрики resilience:
circuit_breaker_state,fallback_count_total,bulkhead_queue_length - Метрики кэша:
cache_hits_total,cache_misses_total,cache_refresh_total - Endpoint
/metricsдоступен для scrape Prometheus
Задача 6: Тесты с Toxiproxy и k6
Toxiproxy добавляет задержку 5 секунд к сервису KYC. SLO: p99 latency для клиента остаётся < 500 мс (благодаря CB); circuit_breaker_state → 1 (Open); ни один воркер основного пула не блокируется. Для кэша — нагрузочный тест профильного чтения показывает ≥ 90% Cache Hit и 1 SELECT в БД при stampede.
✅ Готово, когда:
- Toxiproxy: задержка 5 с к сервису KYC
- p99 latency для клиента < 500 мс (благодаря CB)
-
circuit_breaker_state→1 (Open); воркеры основного пула не блокируются - Cache: ≥ 90% Cache Hit и 1 SELECT в БД при stampede
⚠️ Антипаттерн для разбора
Студенту даётся код — нужно найти уязвимость (нет таймаута, нет CB, нет Bulkhead; инвалидация до коммита).
go// Go (Антипаттерн) func CheckKyc(ctx context.Context, userID string, amount float64) bool { // ❌ Нет таймаута, нет Circuit Breaker, нет Bulkhead resp, _ := http.Get("https://kyc-provider.com/check?user=" + userID) return resp.StatusCode == http.StatusOK }
go// Go (Антипаттерн, кэширование) func (s *Service) GetUserProfile(ctx context.Context, id string) (*Profile, error) { raw, _ := s.cache.Get(ctx, "user:"+id).Result() if raw == "" { // ❌ Нет защиты от Stampede: 1000 запросов одновременно пойдут в БД prof, err := s.repo.Find(ctx, id) if err == nil { s.cache.Set(ctx, "user:"+id, encode(prof), 5) // ❌ Короткий TTL, единицы неясны } return prof, err } return decode(raw), nil } // ❌ Инвалидация ДО коммита БД — гонка func (s *Service) UpdateUser(ctx context.Context, p *Profile) error { s.cache.Del(ctx, "user:"+p.ID) return s.repo.Save(ctx, p) }
Вопросы студенту:
- Что произойдёт с воркерами, если
kyc-provider.comначнёт отвечать 30 секунд? - Что произойдёт с PostgreSQL, если кэш профиля протухнет под нагрузкой 10 000 RPS без защиты от Stampede?
- Почему удаление кэша до коммита БД приводит к гонкам и возврату старых данных в кэш?
📝 ADR-5: Кэширование профилей пользователей
Контекст: 10 000 RPS, ~90% — чтение профиля. Цель: снизить нагрузку на PostgreSQL и удержать p99 < 500 мс. Бизнес допускает задержку обновления данных до 5 минут.
Альтернативы:
- Write-Through — синхронная запись в кэш и БД. Плюс: данные всегда консистентны. Минус: высокая Latency на запись.
- Cache-Aside + TTL — ленивая загрузка. Плюс: низкая Latency чтения. Минус: возможны stale-данные.
- Write-Behind — асинхронная запись. Плюс: максимальная производительность. Минус: риск потери данных при падении Redis.
Решение: Cache-Aside с TTL 5 минут + защита от Cache Stampede через SET NX EX и Stale-While-Revalidate; инвалидация — event-driven через Outbox + delayed double-delete.
Последствия (Trade-offs):
| Критерий | Значение | Комментарий |
|---|---|---|
| p99 Latency | < 500 мс | по данным k6 при 10 000 RPS |
| Нагрузка на БД | −90% | за счёт кэширования чтений |
| Актуальность данных | до 5 мин задержки | укладывается в SLA бизнеса |
| Риск Cache Stampede | низкий | SET NX EX + SWR |
| Риск stale-after-invalidate | низкий | delayed double-delete через Outbox |
📐 Задание по визуализации (Mermaid)
Студенту необходимо нарисовать диаграмму состояний (State Diagram) для Circuit Breaker с явной probe-логикой.
Шаблон для ориентира:
🧩 Проверь себя
🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 5
Привет! Я начинаю Модуль 5 курса «Архитектура распределённых систем».
Дай короткий ответ (2–3 предложения): чем Bulkhead отличается от таймаута, и почему в Highload-системе одного таймаута недостаточно?
Объясни механику Cache Stampede и разницу между блокировкой (
SET NX EX) и Stale-While-Revalidate: когда что применять?Покажи код Антипаттерна (синхронная инвалидация ДО коммита БД) и попроси меня объяснить, какие гонки и проблемы с консистентностью это создаёт.
Дай описание задания: спроектировать Circuit Breaker с probe-логикой, Bulkhead (
chan struct{}), Rate Limiter (Redis Lua), Cache-Aside с защитой от Stampede и event-driven инвалидацию через Outbox. Свяжи с Saga для компенсации при отказе KYC.НЕ ПИШИ за меня реализацию декоратора/воркера. Дождись моего архитектурного плана. После этого дай фидбек по чек-листу ментора и укажи на слепые зоны (например, «нет защиты probe-запроса», «Rate Limiter не атомарный», «нет SWR», «инвалидация до коммита»).
📝 Чек‑лист ментора
-
Таймауты: везде жёсткие (connection + read), бесконечных ожиданий нет; probe-запрос тоже с таймаутом.
-
Circuit Breaker: три состояния (Closed/Open/Half-Open), корректная probe-логика (1 пробный запрос, немедленный возврат в Open при ошибке).
-
Fallback: бизнес-стратегия деградации (пропуск для мелких сумм, ошибка → компенсация Saga для крупных); компенсация идемпотентна.
-
Bulkhead: изоляция пулов (
chan struct{}) на правильном уровне стека. -
Rate Limiting: распределённый (Redis + Lua), без race conditions;
429+Retry-After. -
Retry: Exponential Backoff + Jitter.
-
Cache Stampede:
SET NX EXили Stale-While-Revalidate; в тесте k6 в БД уходит только 1 SELECT. -
Инвалидация кэша: событие
UserUpdatedв Outbox в одной транзакции с коммитом; консьюмер делаетDELпосле + delayed double-delete. -
Метрики:
circuit_breaker_state,fallback_count_total,cache_hits_total,cache_misses_total. -
Тесты Toxiproxy + k6: при задержке 5 с p99 в норме, CB → Open; профильное чтение — ≥ 90% Cache Hit.
-
Mermaid-диаграмма: корректно отображает переходы CB (включая Half-Open и probe).
-
Студент понимает, как Resilience проявляется в разных архетипах (финтех, Git, мессенджер, лента).
-
ADR-5: Student сформулировал ADR «Circuit Breaker + Fallback» (контекст, альтернативы, решение, последствия).
🔗 Мостик к Модулю 6 (Observability)
В конце ревью скажи студенту:
- «Отлично, мы научились не давать системе падать при перегрузках и быстро отдавать данные через кэш. Но как команда эксплуатации узнает, что Circuit Breaker перешёл в Open? Или что кэш начал промахиваться (Cache Miss rate > 50%)? Без метрик и логов мы летим вслепую. В Модуле 6 мы внедрим OpenTelemetry, Prometheus и Grafana, чтобы сделать систему прозрачной: увидим состояние CB, hit/miss rate кэша, распределение TTL и связать это с SLO.»
Если ты прошёл курс «Git Hosting: Serverless MVP», ты уже видел этот паттерн на упрощённом стеке: concurrency_limit на Cloud Function в Terraform вместо chan struct{} в коде, Rate Limiter API Gateway вместо Redis Token Bucket. Паттерн тот же — инфраструктура разная. Здесь мы разбираем его на полном стеке, с глубиной и trade-offs.
Как те же защиты выглядят в коде сервиса — «Мультипоточность в Go», модуль 12 и модуль 15 (singleflight). Потренировать отказ зависимости на живом стенде — «Архитектурный полигон», модуль 7.