Отказоустойчивость¶
Любой 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 себе.
⚙️ Формула.
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 → дубли (двойные списания, двойные заказы).
⚙️ Реализация.
- Клиент генерирует
idempotency_key(UUID на запрос). - Сервер сохраняет
(key, result)в БД. - Повтор с тем же 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)
}
История из проды¶
Однажды 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 (намеренно убиваешь зависимости и смотришь, что выживает).
📝 Подумай¶
- У тебя сервис заказов вызывает payment. Какой timeout, retry-policy и circuit breaker настроишь?
- Клиент сделал POST /charge, не получил ответ (network timeout). Повторил. Как защититься от двойного списания?
- Почему «retry с фиксированной задержкой 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 «принят».
- Idempotency-Key в header. Сервер хранит
(key, response)в БД. Повтор с тем же key → возвращает первый ответ, не делает новую операцию. - Все клиенты при одновременном blink повторят синхронно → thundering herd на downstream. Нужен exponential backoff, чтобы развести во времени, и jitter, чтобы не было идеально синхронных пиков.
Дальше: observability.md — без неё ты не увидишь, как твой circuit breaker открылся в 03:00.