Перейти к содержанию

Отказоустойчивость

Любой downstream упадёт. Любой network blink случится. Любая база разок встанет. Вопрос только — что произойдёт с твоим сервисом, когда это произойдёт. Здесь — паттерны, которые не дают одному сбою обрушить всё.

Главный принцип: graceful degradation

🧩 Простыми словами. Если что-то сломалось — не падай целиком, отдай меньше функциональности. Лента без рекомендаций лучше, чем 500. Заказ без email лучше, чем потерянный заказ.

⚙️ Под капотом. Делишь функциональность на критичную и опциональную. Для опциональной — fallback (заглушка, кэш, дефолт). Для критичной — retry, circuit breaker, queue.

Timeouts — самый важный

🧩 Простыми словами. Без таймаута ты ждёшь ответа от downstream бесконечно. Один медленный сервис → накопил все горутины → кончились → 503 на всём API.

⚙️ В Go это всегда context.Context.

ctx, cancel := context.WithTimeout(parentCtx, 200*time.Millisecond)
defer cancel()

resp, err := http.NewRequestWithContext(ctx, "GET", "...", nil)

📌 Иерархия таймаутов. Чем глубже в стеке, тем меньше таймаут.

HTTP server timeout:   30s  (общий лимит)
Handler timeout:       5s   (один пользовательский запрос)
Downstream call:       1s   (вызов другого сервиса)
DB query:              500ms
Cache:                 50ms

Важно: не делай downstream timeout > parent timeout — будешь ждать дольше, чем имеет смысл.

Ошибка №1 в Go-сервисах. Вызов http.Get(url) без таймаута. По дефолту — бесконечный. Используй http.Client{Timeout: 5*time.Second} или Request.Context.

Retry с backoff и jitter

🧩 Простыми словами. Network blink — повтори. Но без backoff — добьёшь downstream. Без jitter — все клиенты одновременно повторят и устроят DDoS себе.

⚙️ Формула.

delay = min(maxDelay, base * 2^attempt) + random_jitter
func withRetry(ctx context.Context, op func() error) error {
    var err error
    base := 100 * time.Millisecond
    max := 5 * time.Second
    for attempt := 0; attempt < 5; attempt++ {
        err = op()
        if err == nil {
            return nil
        }
        if !isRetriable(err) {
            return err
        }
        delay := time.Duration(math.Min(
            float64(max),
            float64(base)*math.Pow(2, float64(attempt)),
        ))
        jitter := time.Duration(rand.Int63n(int64(delay) / 2))
        select {
        case <-time.After(delay + jitter):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return err
}

📌 Что повторять. Только идемпотентные операции. POST /charge — нельзя без дополнительной защиты (см. идемпотентность ниже).

📌 Что НЕ повторять. 4xx (твой баг), context.DeadlineExceeded от parent (глобальный лимит вышел).

Circuit breaker

🧩 Простыми словами. Если downstream падает 50 раз подряд — перестань звонить, дай ему отдохнуть. Открываешь «прерыватель» — все запросы фейлятся мгновенно с fallback'ом, downstream не получает удар.

⚙️ Состояния.

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: failure threshold (e.g. 50% over 1min)
    Open --> HalfOpen: cooldown (e.g. 30s)
    HalfOpen --> Closed: первые N успешные
    HalfOpen --> Open: первый fail
  • Closed — нормальная работа, все запросы идут к downstream.
  • Open — downstream «считается мёртвым», все запросы fail-fast (не идут в сеть).
  • Half-open — после cooldown пробуем 1–N запросов. Успех → закрываем.

🛠 В Go. github.com/sony/gobreaker — простой и проверенный.

import "github.com/sony/gobreaker"

cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name:        "payment-service",
    MaxRequests: 3,
    Interval:    60 * time.Second,
    Timeout:     30 * time.Second,
    ReadyToTrip: func(c gobreaker.Counts) bool {
        return c.ConsecutiveFailures > 5 ||
            (c.Requests >= 20 && float64(c.TotalFailures)/float64(c.Requests) > 0.5)
    },
})

result, err := cb.Execute(func() (any, error) {
    return paymentClient.Charge(ctx, req)
})

Bulkhead (изоляция отсеков)

🧩 Простыми словами. Корабль делят на отсеки: пробьют один — остальные держат на плаву. В сервисе — раздели ресурсы (горутины, connection pools) по типам операций. Один зависший downstream не должен съесть пул всех остальных.

⚙️ Реализация в Go. Отдельные pgxpool для разных классов запросов; семафоры для ограничения concurrent calls на каждый downstream.

type Resilient struct {
    sem chan struct{}
}

func NewResilient(maxInFlight int) *Resilient {
    return &Resilient{sem: make(chan struct{}, maxInFlight)}
}

func (r *Resilient) Do(ctx context.Context, op func() error) error {
    select {
    case r.sem <- struct{}{}:
        defer func() { <-r.sem }()
        return op()
    case <-ctx.Done():
        return ctx.Err()
    }
}

→ payment не сможет занять более 50 горутин, даже если все запросы тормозят.

Rate limiting

🧩 Простыми словами. Не давай клиенту утопить тебя запросами. И не давай себе утопить downstream.

⚙️ Алгоритмы.

  • Token bucket. Bucket пополняется N токенов/сек, каждый запрос забирает токен. Если пусто — отказ. Хорошо для burst-allowance.
  • Leaky bucket. Похоже, но фиксированный rate выхода. Сглаживает burst.
  • Fixed window. «100 req per minute». Просто, но даёт всплеск на границе.
  • Sliding window log. Точно, но дорого по памяти.

🛠 В Go. Стандартный golang.org/x/time/rate.

limiter := rate.NewLimiter(rate.Every(time.Second/100), 200) // 100rps, burst 200

if !limiter.Allow() {
    http.Error(w, "rate limited", http.StatusTooManyRequests)
    return
}

Для распределённого rate-limit — Redis с скриптом INCR + EXPIRE, либо готовое (go-redis/redis_rate).

Fallback

🧩 Простыми словами. Когда основное сломалось — что отдать?

Варианты:

  • Stale cache. Отдай данные из последнего успешного ответа.
  • Default value. «нет рекомендаций — отдай топ-10 общих».
  • Graceful empty. «не смогли загрузить ленту — пока пусто, потяните позже».
  • Fail-fast. Иногда лучше быстро 503, чем medlennый ответ-кашка.
items, err := s.recommend(ctx, userID)
if err != nil {
    // fallback: топ-10 общих популярных
    items = s.popularFallback()
    log.Warnf("recommend failed: %v, using fallback", err)
}

Идемпотентность

🧩 Простыми словами. Один и тот же запрос можно повторять — результат одинаковый. Без идемпотентности retry → дубли (двойные списания, двойные заказы).

⚙️ Реализация.

  1. Клиент генерирует idempotency_key (UUID на запрос).
  2. Сервер сохраняет (key, result) в БД.
  3. Повтор с тем же key → возвращает закэшированный result, не выполняет операцию снова.
CREATE TABLE idempotency (
    key UUID PRIMARY KEY,
    response JSONB,
    created_at TIMESTAMPTZ DEFAULT now()
);
func Charge(ctx context.Context, key string, req Request) (*Response, error) {
    // 1. Check
    if cached, err := repo.GetIdempotency(ctx, key); err == nil && cached != nil {
        return cached, nil
    }
    // 2. Execute (в транзакции с записью результата!)
    tx, _ := db.Begin(ctx)
    resp, err := doCharge(ctx, tx, req)
    if err != nil {
        tx.Rollback(ctx)
        return nil, err
    }
    repo.SaveIdempotency(ctx, tx, key, resp)
    tx.Commit(ctx)
    return resp, nil
}

📌 Stripe / PayPal так делают. Header Idempotency-Key, ответ кешируется 24 часа.

🔥 Пример: API заказа с полным набором паттернов

graph LR
    Client -->|Idempotency-Key| API
    API --> RL[Rate limit]
    RL --> CB[Circuit breaker]
    CB --> Pay[Payment]
    Pay -->|fail| FB[Fallback:<br/>queue for retry]
    Pay -->|ok| DB[(DB tx +<br/>outbox)]
    API --> M[Metrics, logs, traces]
func (h *Handler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()

    if !h.limiter.Allow() {
        http.Error(w, "rate limited", 429); return
    }
    key := r.Header.Get("Idempotency-Key")
    if key == "" {
        http.Error(w, "missing key", 400); return
    }
    if cached, _ := h.idem.Get(ctx, key); cached != nil {
        writeJSON(w, cached); return
    }

    var resp Response
    err := withRetry(ctx, func() error {
        return h.cb.Execute(func() (any, error) {
            return h.svc.Place(ctx, key, parseReq(r))
        })
    })
    if err != nil {
        // fallback: положили в очередь, отдали 202
        h.queue.Enqueue(parseReq(r))
        w.WriteHeader(202); return
    }
    h.idem.Save(ctx, key, &resp)
    writeJSON(w, resp)
}

История из проды

Service A → Service B → Service C
              ↑            ↓
              ←── retry ───┘

Однажды C начал отвечать через 5 секунд вместо 50ms. У B retry без backoff → B бомбит C. У A нет timeout → A копит горутины → 503 на всём API. Чинили час.

После: timeout 200ms на всех вызовах + circuit breaker + retry с jitter → такой же сбой стал «10% запросов получили fallback, остальное работает».

❌ Типичные ошибки

  • Отсутствие таймаутов. Главная причина каскадных сбоев.
  • Retry без backoff/jitter. DDoS себе и downstream.
  • Retry на не-идемпотентных операциях. Дубли в проде.
  • Circuit breaker без fallback. Open → fail-fast → пользователь видит 500.
  • Один пул на всё. Один тормозной downstream съел все горутины.
  • Idempotency keys без TTL. Таблица растёт бесконечно.

🤖 Что спрашивает AI-ментор

  • Что такое circuit breaker и какие у него состояния?
  • Зачем нужен jitter при retry?
  • Как сделать идемпотентный POST в HTTP?
  • В чём разница между bulkhead и rate limit?
  • Какой timeout правильно ставить на gRPC вызов между сервисами?

📊 Уровни глубины

L1. Знаешь, что нужны таймауты. Видел context.WithTimeout. Слышал про retry.

L2. Делал retry с exponential backoff + jitter. Понимаешь, зачем circuit breaker. Имплементировал idempotency-key.

L3. Жил с production-инцидентом, где спас circuit breaker (или его отсутствие убило). Делал bulkhead через семафоры. Знаешь tradeoff fail-fast vs fail-slow. Считал error budget. Видел chaos engineering (намеренно убиваешь зависимости и смотришь, что выживает).

📝 Подумай

  1. У тебя сервис заказов вызывает payment. Какой timeout, retry-policy и circuit breaker настроишь?
  2. Клиент сделал POST /charge, не получил ответ (network timeout). Повторил. Как защититься от двойного списания?
  3. Почему «retry с фиксированной задержкой 1 секунда» — плохая идея?
Ответ
  1. Timeout 1–2 секунды (платежи медленнее обычного API). Retry 2–3 раза с backoff 100ms → 300ms → 1s + jitter, только при 5xx/timeout (не при 4xx). Circuit breaker: open after 50% errors over 30s, cooldown 30s. Idempotency-Key обязательно. Fallback: положить в очередь и отдать 202 «принят».
  2. Idempotency-Key в header. Сервер хранит (key, response) в БД. Повтор с тем же key → возвращает первый ответ, не делает новую операцию.
  3. Все клиенты при одновременном blink повторят синхронно → thundering herd на downstream. Нужен exponential backoff, чтобы развести во времени, и jitter, чтобы не было идеально синхронных пиков.

Дальше: observability.md — без неё ты не увидишь, как твой circuit breaker открылся в 03:00.