Повторы без лимита и джиттера усиливают аварию соседа. Устойчивый клиент сочетает идемпотентность, экспоненциальный 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.