Модуль 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 одновременно для всех.

И третий удар: при обновлении профиля кэш удаляется до коммита БД. Один поток читает старое значение (коммит ещё не прошёл), пишет его в кэш, перезаписывая правильную инвалидацию. В кэше оседает устаревшее значение — клиенты видят противоречивые данные.

Три задачи, которые нельзя решить «просто таймаутом»:

  1. Остановить каскад — Circuit Breaker + Bulkhead: не дать медленному сервису съесть все ресурсы.
  2. Защитить БД от лавины — Stale-While-Revalidate + атомарный лок: при Cache Miss в БД идёт ровно один SELECT.
  3. Не ломать консистентность — 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 BreakerBulkheadКэшПочему критично
Финтех (P2P)KYC-провайдерПул для платежей vs пул для балансаПрофиль пользователя (read-heavy)Каскадный отказ = деньги заморожены
Git-хостингGit Gateway clonechan struct{} для git vs APIgit 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 + SWR1 SELECT при stampede; ≥ 90% hitstale-данные до обновления кэша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.

Примерный план работы:

  1. Circuit Breaker + Fallback (декоратор KycClientWithBreaker) → CB открывается при таймауте KYC, мелкие суммы пропускаются.
  2. Bulkhead + Rate Limiter → изоляция пула воркеров + Redis Token Bucket → 429 при превышении.
  3. Cache-Aside + Stale-While-Revalidate → 1 SELECT при stampede, ≥ 90% Cache Hit.
  4. Event-Driven инвалидация через Outbox → DEL + delayed double-delete при обновлении профиля.
  5. Метрики Prometheus → circuit_breaker_state, cache_hits/misses, bulkhead_queue_length.
  6. Тест 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

Получение профиля:

  1. Чтение из Redis. Если данные свежие — отдать.
  2. Если есть stale (протухшие) — отдать сразу и запустить фоновое обновление (SWR) одним воркером.
  3. Холодный старт — атомарный лок 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)
}

Вопросы студенту:

  1. Что произойдёт с воркерами, если kyc-provider.com начнёт отвечать 30 секунд?
  2. Что произойдёт с PostgreSQL, если кэш профиля протухнет под нагрузкой 10 000 RPS без защиты от Stampede?
  3. Почему удаление кэша до коммита БД приводит к гонкам и возврату старых данных в кэш?

📝 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-логикой.

Шаблон для ориентира:

Circuit Breaker: CLOSED/OPEN/HALF_OPEN

🧩 Проверь себя

1. Ключ кэша протух под нагрузкой 10 000 RPS, защиты нет. Что будет?
2. Где и когда удалять кэш профиля при обновлении?
3. Write-Behind (Write-Back) — главный риск?
4. Cache-Aside: при Cache Miss приложение...
5. Лучший способ защититься от Cache Stampede при холодном старте ключа?
6. Circuit Breaker перешёл из OPEN в HALF_OPEN. Что происходит?
7. Как Circuit Breaker проявляется в Git-хостинге?
8. Почему локальный Rate Limiter не работает при масштабировании?

🤖 Инструкция для 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.