← назад к разделу

Когда приложение обращается к одному-единственному сервису или базе данных, сбои редки. Но в распределённых системах вызовы к другим сервисам — норма. А значит, норма и то, что что-то упадёт: сеть потеряет пакеты, сервис перезапустится, база данных уйдёт на переключение.

Вопрос не «упадёт ли?», а «что произойдёт с остальной системой, когда это случится?».

Паттерны отказоустойчивости не предотвращают сбои — они не дают одному сбою каскадно уронить всё вокруг.

Retry — повторить при временной ошибке

Сетевой вызов упал. Причина может быть случайной: перезапуск пода, кратковременная перегрузка, пауза при сборке мусора. Если попробовать ещё раз через секунду — сработает.

Но если ретраить моментально, сто клиентов одновременно обрушиваются на уже больной сервис. Если не ретраить совсем — теряем запрос из-за случайного сбоя.

Решение — повторять с нарастающей задержкой (Exponential Backoff):

Попытка 1: сразу
Попытка 2: через 1 сек
Попытка 3: через 2 сек
Попытка 4: через 4 сек
Попытка 5: через 8 сек (не больше 30 сек)
func retry[T any](ctx context.Context, maxAttempts int, base, max time.Duration,
	call func(ctx context.Context) (T, error)) (T, error) {

	var zero T
	delay := base
	for attempt := 1; ; attempt++ {
		result, err := call(ctx)
		if err == nil {
			return result, nil
		}
		if attempt == maxAttempts || !isRetryable(err) {
			return zero, err
		}
		select {
		case <-ctx.Done():
			return zero, ctx.Err()
		case <-time.After(delay):
		}
		delay = min(delay*2, max)
	}
}

Jitter — чтобы не долбить сервис одновременно

Все сто клиентов получили ошибку в один момент. Все ждут одну секунду. Все ретраят одновременно. Сервис снова падает. Это называется «эффект толпы» (thundering herd).

Jitter добавляет случайный разброс к задержке — клиенты рассредоточиваются во времени:

delay := min(maxDelay, baseDelay*time.Duration(math.Pow(multiplier, float64(attempt))))
jitter := time.Duration(rand.Int64N(int64(delay / 2)))
time.Sleep(delay + jitter)

Не все запросы безопасно повторять

  • Безопасно: операции, которые можно повторить без побочных эффектов — GET, PUT с фиксированным ключом, DELETE по идентификатору.
  • Опасно: POST без ключа идемпотентности. Двойная отправка может создать два заказа.

Для неидемпотентных операций используют Idempotency Key — клиент генерирует UUID, сервер проверяет: если запрос с таким ключом уже обработан, возвращает сохранённый результат вместо повторного выполнения.

Circuit Breaker — «автомат» на случай падения сервиса

Платёжный шлюз лёг. Сто горутин ждут ответа по тридцать секунд. Через минуту все ресурсы заняты ожиданием. Ваш сервис перестаёт отвечать на любые запросы — даже те, что с оплатой не связаны. Это каскадный отказ.

Circuit Breaker — буквально «выключатель». Он следит за процентом ошибок и, если их слишком много, перестаёт отправлять запросы: сразу возвращает ошибку, не тратя время на ожидание.

diagram

Три состояния:

  • CLOSED — нормальная работа. Запросы проходят, считаем процент ошибок в скользящем окне.
  • OPEN — выключатель сработал. Запросы мгновенно отклоняются без ожидания. Ждём таймаут.
  • HALF_OPEN — пропускаем несколько пробных запросов. Успешны — переходим в CLOSED. Нет — обратно в OPEN.

Пример конфигурации (sony/gobreaker):

breaker := gobreaker.NewCircuitBreaker[PaymentResult](gobreaker.Settings{
	Name: "paymentService",
	ReadyToTrip: func(counts gobreaker.Counts) bool {
		return counts.Requests >= 10 && // окно из 10 запросов
			float64(counts.TotalFailures)/float64(counts.Requests) >= 0.5 // 50% ошибок → OPEN
	},
	Timeout:     30 * time.Second, // через 30 сек → HALF_OPEN
	MaxRequests: 3,                // пробные запросы в HALF_OPEN
	IsSuccessful: func(err error) bool {
		return err == nil || errors.Is(err, ErrOrderNotFound) // бизнес-ответ — не сбой
	},
})
func (s *PaymentService) Charge(ctx context.Context, userID int64, amount Money) (PaymentResult, error) {
	result, err := s.breaker.Execute(func() (PaymentResult, error) {
		return s.client.Charge(ctx, userID, amount.Kopecks())
	})
	if errors.Is(err, gobreaker.ErrOpenState) {
		return PaymentResult{}, ErrServiceTemporarilyUnavailable
	}
	return result, err
}

Важный нюанс: ErrOrderNotFound (404) — это бизнес-ответ, не сбой. Если Circuit Breaker будет считать 404 ошибкой, то при серии «заказ не найден» он заблокирует все запросы к платёжному шлюзу. Поэтому IsSuccessful — обязательная настройка.

Timeout — не ждать вечно

Сервис не отвечает. Без таймаута горутина висит бесконечно, занимая память и ресурсы. Сто таких горутин — и всё останавливается.

Любой сетевой вызов должен ограничиваться по времени:

func (s *PaymentService) ChargeWithTimeout(ctx context.Context, userID int64, amount Money) (PaymentResult, error) {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	return s.client.Charge(ctx, userID, amount.Kopecks())
}

Плюс страховка на уровне самого HTTP-клиента:

client := &http.Client{Timeout: 5 * time.Second}

Это касается не только HTTP. Любой вызов к базе данных, Redis, gRPC, очереди сообщений — везде должен быть таймаут (в Go его несёт context.Context, который передаётся первым аргументом в каждый такой вызов).

Как сочетаются Retry, Circuit Breaker и Timeout

Три паттерна работают вместе, и порядок важен:

Circuit Breaker → Retry → Timeout → Внешний сервис
  • Circuit Breaker — снаружи. Если цепь разомкнута, не тратим время на ретраи.
  • Retry — в середине. Повторяет при временном сбое.
  • Timeout — внутри. Каждая отдельная попытка ограничена по времени.

Bulkhead — изоляция ресурсов

Сервис вызывает три внешних системы: платёжный шлюз, склад, страховую. Платёжный шлюз тормозит — все двести горутин заняты ожиданием. Запросы к складу и страховой тоже встают в очередь, хотя эти системы работают нормально.

Bulkhead — по аналогии с переборками в корабле. Каждый отсек герметичен: затопление одного не топит остальные.

diagram

Два варианта реализации:

Пул воркеров — каждый внешний вызов идёт через отдельный пул горутин-воркеров фиксированного размера с очередью заданий. Зависший вызов не занимает воркеров других систем, ёмкость очереди управляется явно — но кода больше.

Семафор — ограничение числа одновременных вызовов через буферизированный канал или semaphore.Weighted, без отдельного пула. Легче по ресурсам и проще в коде — в Go это основной вариант.

type Bulkhead struct {
	sem chan struct{}
}

func NewBulkhead(maxConcurrent int) *Bulkhead {
	return &Bulkhead{sem: make(chan struct{}, maxConcurrent)}
}

func (b *Bulkhead) Execute(ctx context.Context, maxWait time.Duration, call func() error) error {
	waitCtx, cancel := context.WithTimeout(ctx, maxWait)
	defer cancel()
	select {
	case b.sem <- struct{}{}:
		defer func() { <-b.sem }()
		return call()
	case <-waitCtx.Done():
		return ErrBulkheadFull
	}
}

paymentBulkhead := NewBulkhead(20)   // максимум 20 одновременных вызовов
inventoryBulkhead := NewBulkhead(20)
insuranceBulkhead := NewBulkhead(10)

Для HTTP-вызовов с таймаутами обычно достаточно семафора. Отдельный пул воркеров нужен, когда таймауты ненадёжны или не контролируются.

Fallback — запасной ответ

Если основной путь недоступен — используем запасной. Не всегда нужен идеальный ответ, иногда «приемлемый» лучше, чем ошибка.

Три варианта запасного ответа:

Значение по умолчанию — когда точные данные недоступны, отдаём разумную замену:

func (s *RateService) GetRate(ctx context.Context, currency string) (ExchangeRate, error) {
	rate, err := s.breaker.Execute(func() (ExchangeRate, error) {
		return s.client.GetRate(ctx, currency)
	})
	if err != nil {
		if cached, ok := s.cache.Latest(currency); ok {
			return cached, nil
		}
		return FallbackRate(currency), nil
	}
	return rate, nil
}

Кэшированный ответ — при сбое отдаём последние известные данные:

func (s *CatalogService) GetProducts(ctx context.Context, category string) ([]Product, error) {
	products, err := s.breaker.Execute(func() ([]Product, error) {
		return s.client.GetProducts(ctx, category)
	})
	if err != nil {
		return s.cache.Get(category), nil // последние известные данные или пустой список
	}
	s.cache.Put(category, products)
	return products, nil
}

Упрощённая логика — вместо персонализированных рекомендаций отдаём популярные товары:

func (s *RecommendationService) GetRecommendations(ctx context.Context, userID int64) ([]Product, error) {
	products, err := s.breaker.Execute(func() ([]Product, error) {
		return s.client.GetPersonalized(ctx, userID)
	})
	if err != nil {
		return s.products.TopByPopularity(ctx, 10)
	}
	return products, nil
}

Когда fallback не подходит: если платёжный шлюз недоступен, нельзя «примерно» списать деньги. Здесь правильный ответ — честная ошибка «сервис временно недоступен, попробуйте позже».

Rate Limiter — защита от перегрузки

Внешний API ограничивает вас: 100 запросов в секунду. Превышение — бан на пять минут. Или ваш собственный сервис получает всплеск трафика — нужно защититься.

limiter := rate.NewLimiter(rate.Limit(50), 50) // 50 запросов в секунду (golang.org/x/time/rate)

func (c *PaymentClient) Register(ctx context.Context, req PaymentRegisterRequest) (PaymentResponse, error) {
	waitCtx, cancel := context.WithTimeout(ctx, 2*time.Second) // ждём токен максимум 2 сек
	defer cancel()
	if err := c.limiter.Wait(waitCtx); err != nil {
		return PaymentResponse{}, ErrRateLimitExceeded
	}
	return c.do(ctx, req)
}

Основные алгоритмы:

  • Fixed Window — счётчик сбрасывается в начале каждого окна. Простой, но на границе окон может пропустить вдвое больше лимита.
  • Sliding Window — скользящее окно, более точное ограничение.
  • Token Bucket — токены накапливаются с постоянной скоростью. Позволяет кратковременные всплески, если есть накопленные токены. Именно его реализует golang.org/x/time/rate.
  • Leaky Bucket — запросы обрабатываются с постоянной скоростью. Сглаживает всплески, но добавляет задержку.

Dead Letter Queue — что делать с необрабатываемыми сообщениями

Сообщение в Kafka не обрабатывается: битые данные, неизвестный формат, бизнес-валидация не проходит. Бесконечные повторы бессмысленны — сообщение «отравлено» и блокирует обработку следующих.

Dead Letter Queue (DLQ, очередь мёртвых писем) — после нескольких неудачных попыток сообщение перекладывается в отдельную очередь для разбора вручную.

diagram
func (c *OrderEventsConsumer) Run(ctx context.Context) error {
	for {
		msg, err := c.reader.FetchMessage(ctx) // segmentio/kafka-go
		if err != nil {
			return err
		}
		if err := c.processWithRetry(ctx, msg); err != nil {
			c.sendToDLQ(ctx, msg, err)
		}
		c.reader.CommitMessages(ctx, msg)
	}
}

func (c *OrderEventsConsumer) processWithRetry(ctx context.Context, msg kafka.Message) error {
	var lastErr error
	for attempt := 0; attempt < 3; attempt++ {
		lastErr = c.process(ctx, msg)
		if lastErr == nil {
			return nil
		}
		if !isRetryable(lastErr) { // ValidationError, *json.SyntaxError — без повторов
			return lastErr
		}
		time.Sleep(time.Second)
	}
	return lastErr
}

func (c *OrderEventsConsumer) sendToDLQ(ctx context.Context, msg kafka.Message, cause error) {
	c.dlqWriter.WriteMessages(ctx, kafka.Message{
		Topic: msg.Topic + ".dlq",
		Key:   msg.Key,
		Value: msg.Value,
		Headers: append(msg.Headers, kafka.Header{
			Key: "x-error", Value: []byte(cause.Error()),
		}),
	})
}

Что делать с DLQ:

  • Мониторинг — настроить алерт, когда в DLQ появляются сообщения.
  • Повторная отправка — после устранения причины переотправить в основной топик. Осторожно: если причина не устранена, сообщение снова попадёт в DLQ.
  • Ручной разбор — для бизнес-ошибок, которые нельзя автоматизировать.

Как паттерны работают вместе

На практике паттерны комбинируются. Полный стек для одного внешнего вызова выглядит так (порядок снаружи → внутрь):

  1. Rate Limiter — если лимит исчерпан, не тратим ресурсы дальше.
  2. Bulkhead — если семафор заполнен, не создаём новые вызовы.
  3. Circuit Breaker — если сервис недоступен, мгновенный отказ или fallback.
  4. Retry — если вызов упал, повторяем с задержкой.
  5. Timeout — каждая попытка ограничена по времени.
func (s *PaymentService) Charge(ctx context.Context, req ChargeRequest) (PaymentResult, error) {
	if err := s.limiter.Wait(ctx); err != nil { // 1. Rate Limiter
		return PaymentResult{}, err
	}

	select { // 2. Bulkhead — максимум 25 одновременных вызовов
	case s.sem <- struct{}{}:
		defer func() { <-s.sem }()
	case <-ctx.Done():
		return PaymentResult{}, ctx.Err()
	}

	return s.breaker.Execute(func() (PaymentResult, error) { // 3. Circuit Breaker
		return retry(ctx, 3, time.Second, 30*time.Second, // 4. Retry
			func(ctx context.Context) (PaymentResult, error) {
				callCtx, cancel := context.WithTimeout(ctx, 5*time.Second) // 5. Timeout
				defer cancel()
				return s.client.Charge(callCtx, req)
			})
	})
}

Коротко

  • Retry + Exponential Backoff — переживаем кратковременные сбои; Jitter предотвращает одновременный шквал повторов.
  • Circuit Breaker — не тратим ресурсы на недоступный сервис; три состояния: CLOSED / OPEN / HALF_OPEN.
  • Timeout — любой сетевой вызов без таймаута — потенциальная утечка горутин.
  • Bulkhead — изолируем ресурсы; один проблемный сервис не блокирует остальные.
  • Fallback — деградируем, но не падаем; не всегда применим (платёж — честная ошибка, не «примерный» ответ).
  • Rate Limiter — защищаем от перегрузки себя и внешние API.
  • DLQ — не теряем и не зацикливаемся на «отравленных» сообщениях.
  • Порядок обёртки: Rate Limiter → Bulkhead → Circuit Breaker → Retry → Timeout.

Что почитать дальше

  • Распределённые паттерны — Saga и Outbox для согласованности данных между сервисами.
  • Apache Kafka — DLQ и идемпотентный потребитель в деталях.