Retry storm, backoff, breaker, bulkhead

О лекции

Повторы без лимита и джиттера усиливают аварию соседа. Устойчивый клиент сочетает идемпотентность, экспоненциальный backoff с jitter, circuit breaker и изоляцию пулов — bulkhead.

Распределённые системы architecture собеседование

Сосед отвечает 500 или не отвечает вовсе. Все клиенты ретраят сразу. Нагрузка умножается на число реплик и на число попыток. Это retry storm, а не надёжность.

Больной сервис не поднимается, потому что его продолжают долбить. Вызывающие жгут свои пулы соединений и горутины.

Ретраи имеют смысл только там, где повтор безопасен и может помочь: сеть, 429, 503, таймаут идемпотентного чтения. 400, 404, 409 и ошибка валидации от повтора не исцелятся.

Пример. Таймаут не значит «сервер ничего не записал». Часто он записал, а ответ потерялся. Без ключа идемпотентности повтор POST спишет карту второй раз.

Backoff и jitter

Экспоненциальный backoff: 50 мс, 100, 200, с потолком (например, 2 с) и ограниченным числом попыток. Без jitter все таймеры клиентов совпадают. Все бьют в одну миллисекунду. Это thundering herd.

Jitter — случайный разброс. Полный или «плюс-минус половина от задержки».

Лимит ретраев должен быть сквозным. Клиент, sidecar и брокер не должны каждый умножать попытки. Иначе «по три раза» на трёх слоях — 27 ударов по уже мёртвому соседу.

Circuit breaker

Автомат с тремя состояниями защищает вызывающего. Больному даёт передышку. Порог считают по вашей SLO, а не «пять ошибок навсегда». Fallback — часть дизайна: кэш, дефолт, упрощённый ответ.

  • Closed. Запросы идут. Считаем ошибки в скользящем окне.
  • Open. Сразу fail, не долбим больного. По таймеру пробуем half-open.
  • Half-open. Пробный запрос. Успех — закрыть. Неудача — снова open.
package circuit

type State int

const (
	Closed State = iota
	Open
	HalfOpen
)

func (b *Breaker) Call(ctx context.Context, fn func() error) error {
	switch b.state() {
	case Open:
		return ErrOpen // fail fast, не трогаем downstream
	case HalfOpen:
		if err := fn(); err != nil {
			b.trip()
			return err
		}
		b.reset()
		return nil
	default:
		err := fn()
		b.record(err)
		return err
	}
}

Breaker считает ошибки и перестаёт слать трафик, пока не истечёт cooldown.

Bulkhead

Переборка: отдельный лимит соединений, семафор горутин или отдельный HTTP-клиент на каждую зависимость. Падение поиска не должно съесть пул оплаты.

В Go это http.Transport с MaxConnsPerHost, семафор на исходящие вызовы или отдельная очередь воркеров. Без bulkhead breaker одного соседа уже не спасёт: слоты заняты ожиданием другого.

Пример. Оплата лежит. Поиск жив. Если у них общий пул из 50 соединений, checkout занимает все слоты ретраями оплаты. Поиск тоже перестаёт отвечать, хотя сам здоров.

Типичная ошибка

Ретраить неидемпотентный POST «на всякий случай». И ставить liveness-пробу на ту же зависимость, которую ретраите. Получаете двойные списания и рестарт-петлю подов. Вторая классика — backoff без jitter: все клиенты просыпаются хором.

На собеседовании

Цепочка: идемпотентность, backoff с jitter и потолком, лимит попыток, breaker, bulkhead. Breaker спасает вызывающего. Он не чинит downstream.