4.3Паттерны надёжности
Выберем retry, timeout, breaker и fallback с учётом идемпотентности и общего бюджета запроса.
Семь приёмов и что каждый делает
Зависимость сломалась или затормозила. Что должен сделать твой сервис, чтобы не упасть следом и заодно не добить её окончательно? Ответ собирается из семи приёмов.
1. Транзиентная ошибка - та, которая может пройти сама
Транзиентной называют ошибку, причина которой лежит снаружи и меняется со временем: сеть моргнула, соединение оборвали, сервер перегружен и ответил 503, база поймала deadlock. Повтори ту же операцию через секунду - и она вполне может пройти. На другом полюсе стоит детерминированная ошибка: невалидный JSON, нет прав, нет такого пользователя. Сколько её ни повторяй, ответ будет тот же.
Отсюда первое правило: ретраить (повторять) имеет смысл только транзиентные ошибки. Повторяя детерминированную, ты гарантированно впустую тратишь свои и чужие ресурсы.
2. Идемпотентность - повтор не меняет результат
Операция идемпотентна, если состояние системы не зависит от того, выполнил ты её один раз или пять раз подряд. «Поставить статус = banned» идемпотентно: сколько ни ставь, статус один. А вот «списать 100 рублей» - нет, пять повторов спишут 500.
Критерий строгий: операция не меняет состояние второй раз. Если она всего лишь не падает при повторе, этого мало. Причём это свойство самой операции, а не кода, который её повторяет. Ретрай безопасен только для идемпотентной операции, потому что таймаут не говорит «не сработало», он говорит «я не знаю». Запрос мог дойти, выполниться и потеряться уже на обратном пути.
3. Backoff - растущая пауза между попытками
При backoff клиент ждёт перед следующей попыткой, и пауза растёт с каждым разом. Обычно рост экспоненциальный: 100 мс, 200, 400, 800 - и так до потолка. Упавшую зависимость не долбят в полную силу, ей дают время подняться. Ретрай без backoff, сразу и в лоб, мгновенно удваивает нагрузку на то, что и так не справляется.
4. Jitter - случайный разброс этих пауз
Jitter подмешивает к паузе backoff случайность: вместо ровно 400 мс ждём случайное время где-то от нуля до 400 мс. Backoff разносит по времени попытки одного клиента, а клиентов между собой никак не разводит. Тысяча клиентов получила отказ в одну и ту же секунду и поставила одинаковые таймеры - через 400 мс на зависимость придёт ровно тысяча одновременных запросов и снова её уронит. Jitter размазывает эту волну.
5. Bulkhead - отдельная квота ресурсов на каждую зависимость
Bulkhead переводится как «переборка», глухая перегородка между отсеками корабля: пробили один отсек - затопило только его, а не весь корпус. В коде это лимит на число одновременных вызовов, свой для каждой зависимости: платёжный API получает не больше 50 горутин, поиск не больше 200. Тогда одна медленная зависимость не съест все горутины и соединения сервиса и не утащит за собой всё остальное, что вообще-то работает.
6. Load shedding - сознательный отказ части запросов при перегрузке
Load shedding, «сброс нагрузки», пришёл из энергетики: там так называют веерные отключения. Идея контринтуитивная: если входящих запросов больше, чем сервис способен переварить, правильнее честно отказать части из них сразу, чем принять все и всех обслужить плохо. Приняв всё, ты получишь растущую очередь, растущие задержки и в итоге таймаут у всех - то есть 100 % отказов вместо 10 %. И отказывать надо быстро: 429 или 503 в первую миллисекунду, а не после тридцати секунд ожидания в очереди.
7. Circuit breaker - предохранитель, который сам отключает вызовы к упавшей зависимости
Circuit breaker дословно значит «автоматический выключатель», тот самый автомат в электрощитке: пошёл ток выше нормы - он щёлкнул и разорвал цепь, чтобы не сгорела проводка.
В коде это обёртка вокруг вызова зависимости, которая считает отказы. Пока всё хорошо, она просто пропускает вызовы насквозь. Когда доля отказов переваливает порог, она перестаёт пускать вызовы вообще и на какое-то время отвечает ошибкой мгновенно, никуда не ходя. Потом пробует пропустить один-два пробных запроса: ответили нормально - трафик возвращается, снова отказ - пауза продлевается.
Пока зависимость лежит, каждый вызов к ней впустую держит горутину и соединение на полный таймаут. Брейкер экономит их тебе и заодно снимает нагрузку с того, кто и так не справляется.
Транзиентность и идемпотентность задают условия допуска: можно ли вообще повторять. Backoff и jitter определяют, как повторять, чтобы повтор помогал, а не добивал. Circuit breaker подсказывает, когда перестать повторять совсем, и следит при этом не за одним запросом, а за потоком. Bulkhead и load shedding заходят с другой стороны и защищают тебя от чужой медленности и от собственной перегрузки.
Ретраить или нет: классификация ошибок
Ретраить стоит, только когда одновременно выполнены два условия: ошибка транзиентная (повтор той же операции может дать другой результат) и операция идемпотентна (повтор безопасен, даже если первая попытка на самом деле прошла, а ответ потерялся). Нарушишь любое - и ретрай станет либо бесполезным, либо вредным.
Коварнее всего таймаут. Запрос мог дойти и выполниться, а ответ застрять на обратном пути, и клиент этого не узнает. Ретрай после таймаута и есть тот случай, ради которого нужна идемпотентность.
Что ретраить: конкретика по транспортам
| Слой | Ретраить | Не ретраить |
|---|---|---|
| HTTP | 408, 425, 429 (с уважением Retry-After), 500 (осторожно, 1–2 попытки),
502, 503, 504 |
400, 401, 403, 404, 405, 409, 410, 422, 501 - детерминированные, повтор даст то же самое |
| Сеть / syscall | ECONNREFUSED, ECONNRESET, EPIPE,
ETIMEDOUT, io.ErrUnexpectedEOF,
net.Error с Timeout() == true |
TLS-ошибки (x509: certificate signed by unknown authority),
*net.DNSError с IsNotFound - это конфигурация, а не сбой |
| gRPC | Unavailable (14), ResourceExhausted (8),
Aborted (10 - конфликт транзакции), DeadlineExceeded (4,
только если остался бюджет) |
InvalidArgument (3), NotFound (5),
AlreadyExists (6), PermissionDenied (7),
FailedPrecondition (9), Unimplemented (12),
Unauthenticated (16) |
| PostgreSQL | 40001 serialization_failure, 40P01 deadlock_detected,
55P03 lock_not_available, 53300 too_many_connections,
08006/08003 обрыв соединения |
23505 unique_violation, 23503 foreign_key_violation,
22P02 invalid_text_representation, 42601 syntax_error |
| Kafka | NotLeaderForPartition, LeaderNotAvailable,
RequestTimedOut, NotEnoughReplicas - брокер сам помечает
ошибки как retriable |
RecordTooLarge, InvalidTopic,
TopicAuthorizationFailed |
23505unique_violation после ретрая часто означает не «дубль», а «предыдущая попытка на самом деле прошла». Если у операции есть естественный ключ, такую ошибку надо считать успехом, а не отказом.context.DeadlineExceededбывает двух видов: истёк твой таймаут на конкретный вызов (можно ретраить, если общий бюджет остался) или истёк дедлайн всего запроса (ретраить нельзя - клиент уже ушёл). Различают их поctx.Err()родительского контекста, а не по тексту ошибки.- С 500 однозначного ответа нет. Если это NullPointerException в чужом сервисе, ретрай ничего не даст, а нагрузку удвоит. Разумный компромисс: одна повторная попытка и обязательный circuit breaker, который отсечёт серию.
Идемпотентность - фундамент, без которого ретрай опасен
Идемпотентная операция не меняет состояние второй раз. Обеспечивать это свойство надо первым, и способов для этого пять.
| Тип | Как достигается | Примеры |
|---|---|---|
| Естественная | Операция по своей природе присваивает, а не изменяет | UPDATE users SET status='banned' WHERE id=42, DELETE ... WHERE id=,
SET key value в Redis, любой GET |
| Через уникальный ключ | Уникальный индекс + ON CONFLICT DO NOTHING: вторая вставка не делает ничего |
INSERT INTO payments(order_id, ...) VALUES(...) ON CONFLICT (order_id) DO NOTHING |
| Ключ идемпотентности | Клиент присылает Idempotency-Key; сервер запоминает ключ вместе с ответом
и на повтор возвращает сохранённый ответ, ничего не выполняя |
Stripe, YooKassa, любые платёжные API. Именно так делают неидемпотентный POST безопасным |
| Условное обновление | Версия/ревизия в условии: повтор не пройдёт, потому что версия уже другая | UPDATE ... SET v = v+1 WHERE id=? AND v=?, ETag + If-Match,
CAS в etcd |
| Дедупликация на приёме | Таблица обработанных message_id в той же транзакции, что и бизнес-эффект |
Inbox-паттерн для Kafka/RabbitMQ: at-least-once снаружи, ровно один эффект внутри |
RFC 9110 объявляет GET, HEAD, PUT,
DELETE, OPTIONS, TRACE идемпотентными. Это
требование к разработчику, а не гарантия транспорта. PUT /counter,
который внутри делает counter = counter + 1, идемпотентным не станет от того,
что метод называется PUT. Проверять надо реализацию, а не глагол.
Ключ идемпотентности: рабочая схема
CREATE TABLE idempotency_keys (
key text PRIMARY KEY, -- то, что прислал клиент
endpoint text NOT NULL, -- один ключ на один эндпоинт
request_hash bytea NOT NULL, -- защита от «тот же ключ, другое тело»
state text NOT NULL, -- in_progress | done
status_code int,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Ключи живут ограниченное время: сутки для платежей - разумный дефолт.
CREATE INDEX idempotency_keys_created_at_idx ON idempotency_keys (created_at);
func (s *Service) CreatePayment(ctx context.Context, key string, req Request) (Response, error) {
hash := sha256.Sum256(req.Canonical())
// 1. Атомарно пытаемся занять ключ. Гонку решает сама БД:
// выигрывает ровно один INSERT, остальным вернётся 0 строк.
claimed, err := s.repo.ClaimKey(ctx, key, "POST /payments", hash[:])
if err != nil {
return Response{}, err
}
if !claimed {
rec, err := s.repo.LoadKey(ctx, key)
if err != nil {
return Response{}, err
}
// 2. Тот же ключ с другим телом - это ошибка клиента, а не повтор.
if !bytes.Equal(rec.RequestHash, hash[:]) {
return Response{}, ErrIdempotencyKeyReuse // 422
}
// 3. Первый запрос ещё в работе: не выполняем параллельно.
// Клиенту честно говорим «повтори позже».
if rec.State == StateInProgress {
return Response{}, ErrInProgress // 409 + Retry-After: 1
}
return rec.Response, nil // 4. Отдаём готовый ответ
}
resp, err := s.charge(ctx, req)
if err != nil {
// Деньги точно не списаны - отпускаем ключ, и повтор пройдёт заново:
// иначе ключ навсегда залипнет в in_progress. Исход неизвестен
// (таймаут после отправки) - не отпускаем: повтор может списать
// второй раз, такой ключ ждёт сверки с платёжкой.
if !errors.Is(err, ErrOutcomeUnknown) {
_ = s.repo.ReleaseKey(ctx, key)
}
return Response{}, err
}
// 5. Сохраняем ответ под ключом: повтор получит его, а не второе списание.
if err := s.repo.CompleteKey(ctx, key, resp); err != nil {
return Response{}, err
}
return resp, nil
}
- Ключ генерирует клиент, а не сервер: только так повтор после потерянного ответа придёт с тем же ключом. Один UUIDv4 на попытку операции, а не на каждый HTTP-запрос.
- Хеш тела обязателен. Иначе клиент, который по ошибке переиспользовал ключ, молча получит чужой ответ.
- Состояние
in_progressзакрывает гонку двух одновременных повторов: второй не выполняет операцию, а получает 409. - Ключ и бизнес-эффект пишутся одной транзакцией, если эффект в той же базе.
Иначе возможен разрыв: деньги списаны, ключ не сохранён, и повтор спишет снова.
Когда эффект во внешнем API, как в коде выше, одной транзакции не выйдет: разрыв
закрывает
in_progress, а ключ с неизвестным исходом ждёт сверки. - TTL и чистка. Таблица растёт линейно вместе с трафиком и без ретеншена станет
самой большой в базе. Партиции по дате и
DROP PARTITIONобходятся дешевле, чемDELETE.
Экспоненциальный backoff и jitter
Хуже всего ретраить сразу: зависимость упала под нагрузкой, а ты мгновенно удваиваешь ей
нагрузку. Экспоненциальный backoff растягивает попытки:
base * 2^attempt с потолком cap. Но одного backoff мало, потому
что все клиенты падают одновременно - а значит, одновременно и просыпаются.
Retry storm (он же thundering herd) выглядит так. В 12:00:00 сервис зависимости отвечает 503 всей тысяче клиентов. Все ставят таймер на 1 секунду. В 12:00:01 приходит ровно тысяча одновременных запросов - сервис, который только начал вставать, снова ложится. Все ставят таймер на 2 секунды. В 12:00:03 - снова тысяча. Система входит в устойчивый автоколебательный режим и сама из него не выходит: каждая волна валит зависимость заново. Ретрай тут не помогает восстановиться, а не даёт восстановиться.
Четыре стратегии jitter
| Стратегия | Формула | Когда брать |
|---|---|---|
| Без jitter | sleep = min(cap, base * 2^n) |
Только когда клиент один (миграции, CLI). В сервисе - источник retry storm |
| Full jitter | sleep = rand(0, min(cap, base * 2^n)) |
Дефолт. По замерам AWS даёт наименьшую суммарную нагрузку и наименьшее число попыток. Разброс максимальный |
| Equal jitter | t = min(cap, base * 2^n);sleep = t/2 + rand(0, t/2) |
Когда важно, чтобы попытка гарантированно не случилась «слишком рано» - например, известно время рестарта зависимости |
| Decorrelated jitter | sleep = min(cap, rand(base, prev * 3)) |
Долгие серии попыток: растёт быстрее full jitter, но не залипает на потолке. Её предложили в блоге AWS о backoff; в самих AWS SDK по умолчанию full jitter |
Внутренний RPC: base = 20..50 мс, cap = 1 с,
3 попытки (то есть 2 ретрая), full jitter. Внешний API:
base = 200 мс, cap = 10 с, 4–5 попыток. Фоновая обработка
без клиента на проводе: cap до минут и десятки попыток.
Смотреть надо не на количество попыток, а на бюджет времени: все ретраи вместе
не должны выходить за дедлайн запроса.
Retry, который уважает context и Retry-After
type Policy struct {
MaxAttempts int // всего попыток, включая первую
Base time.Duration // 100ms
Cap time.Duration // 5s
Budget time.Duration // потолок на всю серию; 0 - ограничивает только ctx
}
// Classifier отвечает на два вопроса сразу: повторять ли и сколько ждать,
// если сервер сам назвал паузу (Retry-After, gRPC RetryInfo).
type Classifier func(error) (retry bool, after time.Duration)
func Do(ctx context.Context, p Policy, cls Classifier, op func(context.Context) error) error {
if p.MaxAttempts <= 0 { p.MaxAttempts = 3 }
if p.Base <= 0 { p.Base = 100 * time.Millisecond }
if p.Cap <= 0 { p.Cap = 5 * time.Second }
if p.Budget > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, p.Budget)
defer cancel()
}
var last error
for attempt := 0; attempt < p.MaxAttempts; attempt++ {
// Не ходим в сеть с уже истёкшим дедлайном: это чистая трата
// соединения и лишняя нагрузка на зависимость.
if err := ctx.Err(); err != nil {
return errors.Join(last, err)
}
last = op(ctx)
if last == nil {
return nil
}
// Отмена самого контекста - не транзиентная ошибка. Клиент ушёл.
if ctx.Err() != nil {
return errors.Join(last, ctx.Err())
}
retry, after := cls(last)
if !retry || attempt == p.MaxAttempts-1 {
return last
}
delay := after // сервер назвал паузу - уважаем
if delay <= 0 {
delay = fullJitter(p.Base, p.Cap, attempt)
}
// Спать дольше, чем осталось до дедлайна, бессмысленно:
// лучше вернуть ошибку сразу, чем разбудить таймер в пустоту.
if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= delay {
return errors.Join(last, context.DeadlineExceeded)
}
t := time.NewTimer(delay)
select {
case <-ctx.Done():
t.Stop() // освобождаем таймер сразу, не ждём GC
return errors.Join(last, ctx.Err())
case <-t.C:
}
}
return last
}
// math/rand/v2 (Go 1.22+): глобальный генератор без блокировок и без ручного Seed.
func fullJitter(base, cap time.Duration, attempt int) time.Duration {
backoff := base << attempt // base * 2^attempt
if backoff > cap || backoff <= 0 { // <=0 - защита от переполнения int64
backoff = cap
}
return time.Duration(rand.Int64N(int64(backoff)) + 1)
}
// Классификатор для HTTP. Заодно решает вопрос с Retry-After.
func HTTPClassifier(err error) (bool, time.Duration) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false, 0
}
var he *HTTPError
if errors.As(err, &he) {
switch he.Code {
case 408, 425, 429, 500, 502, 503, 504:
return true, he.RetryAfter // распарсен из заголовка
default:
return false, 0
}
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return true, 0
}
return errors.Is(err, syscall.ECONNRESET) ||
errors.Is(err, syscall.ECONNREFUSED) ||
errors.Is(err, io.ErrUnexpectedEOF), 0
}
func parseRetryAfter(h http.Header) time.Duration {
v := h.Get("Retry-After")
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(v); err == nil { // HTTP-date - тоже валидная форма
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}
Первое: тело ответа. Перед повтором его надо дочитать и закрыть
(io.Copy(io.Discard, resp.Body); resp.Body.Close()), иначе соединение
не вернётся в пул keep-alive - и на серии ретраев ты откроешь тысячи сокетов и упрёшься
в too many open files. Второе: тело запроса. Если оно
io.Reader, второй раз его не прочитать: Read уже вернул EOF.
Нужен либо буфер в памяти, либо Request.GetBody (его
http.NewRequest заполняет автоматически для bytes.Reader,
strings.Reader и bytes.Buffer).
Если ретраит клиент (3), потом сервис-прокси (3), потом бэкенд (3) - на дальнюю зависимость прилетит 27 запросов вместо одного. Это retry amplification, и он убивает системы надёжнее, чем исходный сбой. Правило: ретраит один слой, обычно самый близкий к зависимости; остальные пробрасывают ошибку. Сверху страхует retry budget в духе gRPC: ретраи разрешены, только пока их доля не превышает 10–20% от общего трафика на эту зависимость, а при превышении выключаются целиком.
Circuit breaker
Ретраи с backoff вытаскивают один запрос. Circuit breaker имеет дело с потоком запросов к упавшей зависимости. Пока сервис лежит, каждый вызов к нему занимает горутину и соединение из пула на весь таймаут. Тысяча запросов в секунду × 3 секунды таймаута дают три тысячи горутин, висящих в ожидании, и исчерпанный пул соединений - после чего перестают проходить вызовы и к здоровым зависимостям. Так локальный отказ становится общим.
Брейкер даёт две вещи. Вызывающий получает fail fast: ошибку за 0,1 мс вместо таймаута на 3 секунды, ресурсы свободны, можно сразу уходить в fallback. Зависимость получает передышку: трафик срезан почти в ноль, и у неё появляется шанс встать. Поэтому брейкер помогает там, где голый retry делает только хуже.
Разбор sony/gobreaker
// gobreaker v2 - дженерик по типу результата.
var billingCB = gobreaker.NewCircuitBreaker[*Receipt](gobreaker.Settings[*Receipt]{
Name: "billing",
// Сколько пробных запросов пропустить в half-open одновременно.
// 0 означает 1. Это и есть ограничение пробного трафика.
MaxRequests: 3,
// Как часто обнулять счётчики, пока состояние CLOSED.
// Без этого одиночные отказы за час накопятся и откроют брейкер на ровном месте.
Interval: 60 * time.Second,
// Сколько держать OPEN до перехода в HALF-OPEN.
Timeout: 30 * time.Second,
ReadyToTrip: func(c gobreaker.Counts) bool {
// Порог по доле, а не по числу: защищает от ложных срабатываний
// на низком трафике, когда 3 отказа - это статистический шум.
return c.Requests >= 20 &&
float64(c.TotalFailures)/float64(c.Requests) >= 0.5
},
// Главная настройка, и её чаще всего забывают.
IsSuccessful: func(err error) bool {
if err == nil {
return true
}
// Клиент ушёл - это не отказ зависимости.
if errors.Is(err, context.Canceled) {
return true
}
// 4xx, кроме 429, - вина запроса, а не сервиса. Иначе один бот с кривыми
// запросами откроет брейкер всем остальным. 429 - зависимость сама
// просит отступить, это отказ.
var he *HTTPError
if errors.As(err, &he) && he.Code < 500 && he.Code != 429 {
return true
}
return false
},
OnStateChange: func(name string, from, to gobreaker.State) {
log.Warn("circuit breaker state changed",
"name", name, "from", from.String(), "to", to.String())
breakerState.WithLabelValues(name).Set(float64(to))
},
})
func chargeWithBreaker(ctx context.Context, req Request) (*Receipt, error) {
r, err := billingCB.Execute(func() (*Receipt, error) {
return billing.Charge(ctx, req)
})
if errors.Is(err, gobreaker.ErrOpenState) ||
errors.Is(err, gobreaker.ErrTooManyRequests) {
// Брейкер отказал мгновенно - самое время для fallback,
// а не для ретрая: ретрай получит ровно тот же отказ.
return nil, ErrBillingUnavailable
}
return r, err
}
Минимальная своя реализация - чтобы понимать, что внутри
type State int
const (
Closed State = iota
Open
HalfOpen
)
var (
ErrOpen = errors.New("circuit breaker is open")
ErrTooManyProbes = errors.New("circuit breaker: probe limit reached")
)
type Breaker struct {
mu sync.Mutex
state State
fails int
successes int
openedAt time.Time
probes int // пробных сейчас в полёте
threshold int // отказов подряд до открытия
cooldown time.Duration // сколько держать Open
maxProbes int // сколько пробных пускать в HalfOpen
needSucc int // успехов подряд для закрытия
}
// allow решает, пускать ли вызов, и переводит состояние по времени.
func (b *Breaker) allow() error {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case Closed:
return nil
case Open:
if time.Since(b.openedAt) < b.cooldown {
return ErrOpen
}
// Переход ленивый: его делает первый запрос после cooldown.
b.state, b.probes, b.successes = HalfOpen, 0, 0
fallthrough
case HalfOpen:
if b.probes >= b.maxProbes {
return ErrTooManyProbes // пробный трафик ограничен
}
b.probes++
return nil
}
return nil
}
func (b *Breaker) report(err error, success bool) {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case Closed:
if success {
b.fails = 0
return
}
b.fails++
if b.fails >= b.threshold {
b.state, b.openedAt = Open, time.Now()
}
case HalfOpen:
b.probes--
if !success {
// Один упавший пробный возвращает нас в Open.
b.state, b.openedAt, b.successes = Open, time.Now(), 0
return
}
b.successes++
if b.successes >= b.needSucc {
b.state, b.fails, b.successes = Closed, 0, 0
}
}
}
func (b *Breaker) Do(ctx context.Context, fn func(context.Context) error) error {
if err := b.allow(); err != nil {
return err
}
err := fn(ctx)
// context.Canceled - уход клиента, а не отказ зависимости.
b.report(err, err == nil || errors.Is(err, context.Canceled))
return err
}
- Считать отказом 4xx. Один клиент с кривыми запросами открывает брейкер всем остальным. Отказом считаются 5xx, 429, таймауты и сетевые ошибки.
- Один брейкер на всё приложение. Заводить его нужно на пару «зависимость + операция», а лучше ещё и на инстанс/шард. Медленный поиск не должен рубить быстрый профиль того же сервиса.
- Порог по абсолютному числу отказов на низком трафике. Ночью три запроса в минуту, три упали - брейкер открыт на полчаса. Нужен минимум запросов в окне.
- HALF-OPEN без ограничения. Если после cooldown пропустить весь трафик, зависимость ляжет ровно в момент восстановления. Пробных должно быть единицы.
«Внешний платёжный шлюз начал отвечать 504 с латентностью 30 с. Без брейкера: наши 200 RPS к нему висят по 30 секунд - это 6000 занятых горутин и исчерпанный пул HTTP-соединений; через минуту деградируют и эндпоинты, которые в платёжку не ходят, потому что заняты воркеры. С брейкером: за 20 запросов доля отказов переваливает 50%, брейкер открывается, все последующие вызовы падают за микросекунды, ресурсы свободны. Пользователь видит честное оплата временно недоступна вместо тридцатисекундного спиннера, остальной сайт работает. Каждые 30 секунд уходят 3 пробных запроса; как только шлюз ответил трижды подряд - трафик восстанавливается сам, без нашего вмешательства.»
Таймауты и бюджет запроса
Правило без исключений: у каждого вызова, который уходит за пределы процесса, должен быть таймаут. Сеть, БД, кэш, файловая система на NFS, вызов внешнего API - всё. Страшно не то, что вызов «зависнет надолго», а то, что всё это время он держит ресурсы: горутину, соединение из пула, память запроса, слот в семафоре. При 1000 RPS хватит временной деградации зависимости, чтобы вызов без таймаута за считанные секунды исчерпал пул соединений. Дальше падает всё, включая то, что к этой зависимости отношения не имеет.
У http.Client{} из коробки таймаута нет вообще: ноль означает
«ждать бесконечно». У http.Server{} то же самое. Без
ReadHeaderTimeout сервер уязвим к Slowloris: соединение, которое шлёт заголовки
по байту в минуту, держится вечно. У net.Dialer по умолчанию только
системный таймаут TCP (на Linux по умолчанию больше двух минут). И отдельный сюрприз:
Transport.MaxIdleConnsPerHost по умолчанию 2, из-за чего под нагрузкой
соединения постоянно пересоздаются, а не переиспользуются.
// Клиент: таймауты на каждой фазе, а не один общий.
tr := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 2 * time.Second, // установка TCP-соединения
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 2 * time.Second,
ResponseHeaderTimeout: 3 * time.Second, // ждём первый байт заголовков ответа
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100, // дефолт 2 - главное узкое место
MaxConnsPerHost: 200, // потолок параллелизма = грубый bulkhead
}
client := &http.Client{
Transport: tr,
Timeout: 5 * time.Second, // весь цикл, включая чтение тела
}
// Сервер: без этих четырёх полей сервис уязвим к медленным клиентам.
srv := &http.Server{
Addr: ":8080",
ReadHeaderTimeout: 5 * time.Second, // защита от Slowloris
ReadTimeout: 15 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 60 * time.Second,
Handler: http.TimeoutHandler(mux, 10*time.Second, `{"error":"timeout"}`),
}
// Слой берёт себе меньше, чем ему осталось, и оставляет резерв
// на обработку результата и запись ответа.
func childCtx(ctx context.Context, want, reserve time.Duration) (context.Context, context.CancelFunc) {
dl, ok := ctx.Deadline()
if !ok {
return context.WithTimeout(ctx, want) // бюджета сверху нет - берём свой
}
left := time.Until(dl) - reserve
if left <= 0 {
// Времени не осталось: идти в сеть бессмысленно, только зря
// займём соединение у зависимости, которой и так тяжело.
cancelled, cancel := context.WithDeadline(ctx, time.Now())
return cancelled, cancel
}
if left < want {
want = left
}
return context.WithTimeout(ctx, want)
}
// Использование: у платежа свой потолок 1800 мс, но если запросу
// осталось 600 - возьмём 600 минус резерв.
ctxPay, cancel := childCtx(ctx, 1800*time.Millisecond, 100*time.Millisecond)
defer cancel()
receipt, err := billing.Charge(ctxPay, req)
Таймаут ставят исходя из наблюдаемой латентности: примерно p99.9 нормального времени ответа плюс запас. Среднее для таймаута не годится: его перекашивают как раз те редкие медленные запросы, ради которых таймаут и ставится. Слишком большой таймаут ни от чего не защищает (вызов всё равно держит ресурсы). Со слишком маленьким ты сам себе генерируешь отказы и ретраи там, где зависимость просто чуть медленнее обычного. Дальше проверяй арифметику: если у запроса бюджет 3 с, а внутри стоит вызов с таймаутом 5 с и тремя ретраями - таймаут вызова не сработает никогда. Первым всегда сработает внешний дедлайн, и все внутренние настройки теряют смысл.
При отмене контекста драйвер Postgres шлёт серверу CancelRequest, то есть
открывает отдельное соединение с запросом на отмену. Это работает, но не мгновенно и не гарантированно:
если сеть до БД деградировала, отмена может не дойти, и тяжёлый запрос продолжит жечь
CPU базы, хотя клиент уже ушёл. Поэтому клиентский таймаут дублируют серверным:
SET LOCAL statement_timeout = '500ms' в транзакции или
statement_timeout на уровне роли. То же с
idle_in_transaction_session_timeout: он ловит транзакции, брошенные
упавшим приложением.
Graceful degradation и fallback
Деградация нужна, когда зависимость всё-таки недоступна, а ответить надо. Но допустима она только для некритичных зависимостей. Вместо рекомендаций разумно отдать топ популярного. С проверкой баланса перед списанием так нельзя: там корректен только отказ.
| Уровень | Что делаем | Когда подходит |
|---|---|---|
| 1. Протухший кэш | Отдаём последнее известное значение, помечая ответ как stale
(stale-while-revalidate) |
Данные меняются медленно: каталог, курсы, настройки, фичефлаги |
| 2. Дефолт | Заранее посчитанная константа или общий вариант | Рекомендации, персональные баннеры, ранжирование - «неперсонально» лучше, чем «пусто» |
| 3. Урезанный ответ | 200 без части полей + признак partial: true и метрика |
Композитный ответ, где отвалился один из источников: карточка товара без блока «с этим покупают» |
| 4. Отложить | Положить в очередь/outbox, ответить 202 Accepted |
Операция допускает асинхронность: отправка письма, начисление бонусов, индексация |
| 5. Read-only | Запись отключена, чтение работает с реплики | Отказ мастера БД. Лучше, чем полный 503 на весь сервис |
| 6. Честный отказ | Явная ошибка с понятным текстом и Retry-After |
Деньги, права доступа, всё, где выдумать ответ означает соврать |
func (s *Service) Recommendations(ctx context.Context, userID int64) (Recs, bool) {
// Некритичная зависимость - жёсткий короткий таймаут,
// чтобы она не съедала бюджет основного ответа.
ctx, cancel := childCtx(ctx, 150*time.Millisecond, 20*time.Millisecond)
defer cancel()
recs, err := s.mlCB.Do(ctx, func(ctx context.Context) (Recs, error) {
return s.ml.Get(ctx, userID)
})
if err == nil {
s.cache.Set(userID, recs, 10*time.Minute)
return recs, false
}
// Каждая деградация обязана быть видна: без метрики сервис будет
// месяцами отдавать дефолты, и никто об этом не узнает.
degraded.WithLabelValues("recs", reasonOf(err)).Inc()
s.log.WarnContext(ctx, "recommendations degraded", "err", err)
if stale, ok := s.cache.GetStale(userID); ok { // допускаем протухшее
return stale, true
}
return s.topPopular(), true // последний рубеж
}
- Деградировать или нет, решает продукт, а не код на месте. Для каждой зависимости заранее записано: критичная или нет и что отдавать при отказе.
- Деградация должна быть видимой: метрика, поле в ответе, заголовок
Warning. Иначе она превращается в тихую поломку. - Некритичной зависимости нужны свой короткий таймаут и свой брейкер. Блок рекомендаций не имеет права занимать 3 секунды бюджета основного ответа.
- Fallback тоже надо тестировать. Иначе в момент инцидента выяснится, что путь деградации сам падает. Классика: в обычной жизни этот код не выполняется никогда. Лечится chaos-тестами и явными тестами на путь с отказом.
- Кэш для деградации и кэш для скорости держат раздельно. У первого TTL длинный и можно отдавать просроченное, у второго TTL короткий.
Fail-fast vs fault-tolerant
Fail-fast и fault-tolerance живут на разных уровнях системы и друг другу не противоречат. Внутри одной операции почти всегда правильнее fail-fast: не тянуть, а быстро освободить ресурсы. На уровне системы нужна fault-tolerance: отказ одного компонента не должен ронять целое. Вопрос закрывает одна формулировка: «fail-fast на уровне вызова, fault-tolerance на уровне архитектуры».
| Ситуация | Подход | Почему |
|---|---|---|
| Невалидная конфигурация при старте | fail-fast | Упасть на деплое дешевле, чем работать неправильно. Заодно выкатка застрянет на новых подах, а старые продолжат работать |
| Деньги, права, целостность данных | fail-fast | Выдуманный ответ хуже отказа. «Наверное, оплачено» - это инцидент, а не деградация |
| Детерминированная ошибка (400, невалидный вход) | fail-fast | Повтор даст тот же результат: ретрай только жжёт ресурсы |
| Зависимость недоступна, есть разумный дефолт | fault-tolerant | Частичный ответ полезнее пустого. Пользователь чаще всего разницы не заметит |
| Транзиентная сетевая ошибка | fault-tolerant | Ретрай с backoff решает 90% таких случаев, и это дешевле, чем отказ пользователю |
| Фоновая обработка, лента, аналитика | fault-tolerant | Никто не ждёт ответа на проводе: можно ретраить долго и настойчиво |
| Перегрузка, очередь растёт | fail-fast | Load shedding: лучше честно отказать 10% сразу, чем деградировать всем и всё равно не успеть |
Отдельно стоит самому упомянуть bulkhead (те самые «переборки» из начала главы): каждой зависимости свой семафор, который ограничивает параллелизм, чтобы одна медленная не съела все горутины и все соединения. Он дешевле брейкера и срабатывает раньше него - в момент замедления, а не в момент отказа.
type Bulkhead struct{ sem chan struct{} }
var ErrOverloaded = errors.New("dependency at capacity")
func (b *Bulkhead) Do(ctx context.Context, fn func(context.Context) error) error {
select {
case b.sem <- struct{}{}:
// Место есть - работаем.
case <-ctx.Done():
return ctx.Err()
default:
// Очередь не растим: копить горутины в ожидании слота -
// это отложенный отказ вместо мгновенного. Лучше отказать сразу.
return ErrOverloaded
}
defer func() { <-b.sem }()
return fn(ctx)
}
Как всё это складывается в одну цепочку
По отдельности паттерны выглядят безобидно, а вместе легко вредят друг другу: ретрай внутри ретрая даёт умножение, брейкер снаружи ретрая не видит отдельных попыток, таймаут внутри backoff не срабатывает никогда. Рабочий порядок обёрток снаружи внутрь (retry снаружи брейкера - так же по умолчанию и в resilience4j):
| # | Слой | Что делает и почему именно здесь |
|---|---|---|
| 1 | Дедлайн запроса | Самый внешний. Задаёт общий бюджет, за который не может выйти ничего внутри |
| 2 | Retry | Снаружи брейкера, чтобы серия попыток укладывалась в один логический вызов.
Обязан считать ErrOpenState неретраибельной ошибкой |
| 3 | Circuit breaker | Внутри retry - тогда он видит каждую попытку и открывается по реальной статистике, а не по одной «серии» |
| 4 | Bulkhead | Ограничивает параллелизм к зависимости. Работает раньше брейкера: ловит замедление, а не отказ |
| 5 | Таймаут вызова | Самый внутренний: он должен ограничивать одну попытку, иначе брейкер и ретрай считают неправильные вещи |
// Читается снаружи внутрь: бюджет -> ретраи -> брейкер -> семафор -> таймаут попытки.
func (c *BillingClient) Charge(ctx context.Context, req Request) (*Receipt, error) {
ctx, cancel := childCtx(ctx, 2*time.Second, 100*time.Millisecond) // 1. бюджет
defer cancel()
var receipt *Receipt
err := retry.Do(ctx, c.policy, billingClassifier, func(ctx context.Context) error { // 2
return c.breaker.Do(ctx, func(ctx context.Context) error { // 3
return c.bulkhead.Do(ctx, func(ctx context.Context) error { // 4
attemptCtx, cancel := context.WithTimeout(ctx, 600*time.Millisecond) // 5
defer cancel()
r, err := c.raw.Charge(attemptCtx, req) // req несёт Idempotency-Key
receipt = r
return err
})
})
})
return receipt, err
}
func billingClassifier(err error) (bool, time.Duration) {
// Брейкер открыт - ретраить бессмысленно: получим тот же мгновенный отказ,
// только сожжём попытки и время.
if errors.Is(err, ErrOpen) || errors.Is(err, ErrOverloaded) {
return false, 0
}
return HTTPClassifier(err)
}
- Ретрай на каждом слое. 3 попытки в клиенте × 3 в gateway × 3 в сервисе = 27 запросов на дальнюю зависимость. Ретраит ровно один слой.
- Ретрай на
ErrOpenState. Брейкер отказывает мгновенно, поэтому ретрай мгновенно сожжёт все попытки и вернёт ту же ошибку - только шумнее. Открытый брейкер сигналит, что пора уходить в fallback. - Таймаут снаружи ретраев вместо таймаута на попытку. Тогда одна зависшая попытка съедает весь бюджет, и ретраев фактически не будет никогда.
Вопросы
7Формулировка «ретраим 5xx, не ретраим 4xx» закрывает только половину ответа, и на ней ловят. Вторая половина касается операции, а не ошибки. Ошибка говорит «может, повезёт со второй попытки», идемпотентность говорит «второй попыткой я ничего не сломаю». Без второго условия ретрай на таймауте списания превращается в двойное списание, и никакой backoff тут не поможет.
Ретраить можно
- Сетевые сбои до отправки запроса:
ECONNREFUSED,EHOSTUNREACH, ошибка DNS, отказ на этапе dial. Исход здесь однозначный: сервер запрос точно не видел, так что повтор безопасен даже для неидемпотентной операции. - Явные сигналы «попробуй позже»: HTTP 429, 503, 502, 504, gRPC
UNAVAILABLE,RESOURCE_EXHAUSTED. Если пришёлRetry-AfterилиRetryInfo- паузу назначает сервер, а не твой backoff. - Транзиентные ошибки БД: в Postgres это класс
40xxx-40001 serialization_failureи40P01 deadlock_detected. Транзакция откатилась целиком, побочных эффектов нет, и дляSERIALIZABLEиREPEATABLE READповтор считается штатным сценарием. - Гонка на выборе лидера / смена мастера:
read-only transactionпосле failover,MOVEDв Redis Cluster,NotLeaderв Kafka. Повтор попадёт уже на нового лидера. - Оптимистические конфликты:
UPDATE ... WHERE version = ?вернул 0 строк. Здесь ретрай обязан перечитать данные и пересчитать, а не повторить тот жеUPDATE.
Ретраить нельзя
- Детерминированные ошибки: 400, 401, 403, 404, 422, невалидный JSON, нарушение бизнес-правила. Повтор даст ровно тот же ответ и впустую потратит время пользователя и ресурсы зависимости.
- Неидемпотентная запись с неопределённым исходом. Таймаут на
POST /paymentsбез ключа идемпотентности: ты не знаешь, прошло списание или нет. Ретраить тут значит играть в лотерею деньгами клиента. - Отменённый контекст.
context.Canceledозначает, что клиент ушёл и ответ уже некому получать;DeadlineExceeded- что бюджет исчерпан. Обе ошибки говорят: прекращай, а не повторяй. - Открытый circuit breaker. Брейкер отказывает мгновенно, значит серия ретраев сгорит за микросекунды и вернёт ту же ошибку. Открытый брейкер означает одно: уходи в fallback.
- Ошибка после частичного эффекта. Типичный случай: batch-вставка, где половина строк прошла, а потом соединение отвалилось. Пока не сделаешь операцию атомарной или идемпотентной, ретраить нечего.
Серая зона: ambiguous outcome
Интереснее всего класс с неопределённым исходом: таймаут или разрыв соединения уже после отправки запроса. Клиент не знает, дошёл ли запрос, выполнился ли он и просто потерялся ответ. На собесе правильный разбор звучит так: «такая ошибка транзиентная, но ретраить её можно только при идемпотентной операции; если операция неидемпотентна - вариантов два: сделать её идемпотентной ключом идемпотентности либо не ретраить и вернуть пользователю честное статус неизвестен, проверьте позже».
// Классификатор - единственное место, где живёт знание «что ретраибельно».
// Разносить эти проверки по коду нельзя: они разъедутся в первый же месяц.
type Outcome int
const (
Fatal Outcome = iota // повтор бессмыслен
Transient // запрос точно не выполнился - повтор безопасен всегда
Ambiguous // исход неизвестен - повтор, только если операция идемпотентна
)
func classify(err error) Outcome {
switch {
case err == nil:
return Fatal
// Уход клиента и исчерпанный бюджет - это не сбой зависимости.
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return Fatal
// Брейкер открыт: ретрай только сожжёт попытки.
case errors.Is(err, ErrOpen), errors.Is(err, ErrOverloaded):
return Fatal
}
// Не доехали до сервера - исход однозначный.
var de *net.DNSError
if errors.As(err, &de) || errors.Is(err, syscall.ECONNREFUSED) ||
errors.Is(err, syscall.EHOSTUNREACH) {
return Transient
}
var pg *pgconn.PgError
if errors.As(err, &pg) {
switch pg.Code {
case "40001", "40P01": // serialization_failure, deadlock_detected
return Transient // транзакция откатилась целиком
case "23505": // unique_violation - это уже дедупликация, а не сбой
return Fatal
}
return Fatal
}
var he *HTTPError
if errors.As(err, &he) {
switch he.Code {
case 408, 425, 429, 503:
return Transient // запрос не обработан - повтор безопасен
case 500, 502, 504:
return Ambiguous // сервер мог успеть выполнить операцию
}
return Fatal // 4xx - ошибка запроса, повтор не поможет
}
// Таймаут или обрыв на середине - самый опасный случай.
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return Ambiguous
}
if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, io.ErrUnexpectedEOF) {
return Ambiguous
}
return Fatal // неизвестное по умолчанию не ретраим
}
// Решение зависит от пары «класс ошибки + свойство операции».
func shouldRetry(err error, idempotent bool) bool {
switch classify(err) {
case Transient:
return true
case Ambiguous:
return idempotent
default:
return false
}
}
- «Ретраим всё, что не 200». Тогда сервис сам себе устраивает трёхкратную нагрузку на каждой волне клиентских ошибок валидации.
- Ретрай по умолчанию в SDK. У многих клиентов (AWS SDK, gRPC с
retryPolicyв service config) ретраи включены из коробки. Твой собственный ретрай сверху даёт умножение, о котором ты не знаешь. Первым делом надо посмотреть, что уже ретраит библиотека. - Ретрай без ограничения по времени.
MaxAttemptsбез общего бюджета: три попытки по 30 секунд дают 90 секунд, а пользователь ушёл ещё на первой. - Забыть про
Request.GetBody. Тело запроса изio.Readerвторой раз не читается - повтор уйдёт с пустым телом и получит 400, а в логах это будет выглядеть как «сервер сломался».
«Ретраит ровно один слой - тот, что ближе всего к зависимости; остальные пробрасывают ошибку наверх. Иначе 3 попытки на трёх слоях дают 27 запросов. И сверху я бы поставил retry budget в духе gRPC: ретраи разрешены, пока их доля не превышает 10–20% от трафика на эту зависимость. Как только зависимость лежит целиком, ретраи сами отключаются, а ведь именно они обычно и мешают ей встать.»
Почему одного backoff мало
Зависимость упала или перегрузилась - значит, все клиенты получили ошибку почти
одновременно. Дальше каждый честно ждёт base * 2^attempt. Формула
детерминированная, стартовая точка у всех общая, поэтому вторая попытка у всей тысячи
придётся на один и тот же момент. Получается thundering herd: ровно в секунду
восстановления зависимость получает пиковый залп и падает снова. Такой цикл
держится часами - это классический metastable failure: исходной
причины давно нет, а система из ямы не выходит, потому что её держит собственный
ретрай-трафик.
Jitter добавляет в паузу случайность и размазывает попытки по интервалу: вместо пика зависимость получает ровный поток, который способна переварить. Коротко: backoff уменьшает частоту повторов, jitter уменьшает их корреляцию. Нужны оба.
| Вид | Формула | Свойства |
|---|---|---|
| Без jitter | min(cap, base*2^n) |
Максимальная синхронизация клиентов. Годится только для одного клиента в системе |
| Full jitter | rand(0, min(cap, base*2^n)) |
Лучшее разнесение и наименьшее суммарное число запросов. Дефолт. Минус - иногда спит почти ноль |
| Equal jitter | h + rand(0, h), где h = min(cap,base*2^n)/2 |
Гарантирует минимальную паузу. Хорош, когда мгновенный повтор вреден сам по себе |
| Decorrelated | min(cap, rand(base, prev*3)) |
Разброс шире, сходимость быстрее. Рекомендация AWS, если нужен агрессивный поиск момента восстановления |
В замерах AWS (статья Exponential Backoff and Jitter) при равном времени восстановления full jitter даёт примерно втрое меньше суммарных вызовов, чем экспонента без jitter, и заметно меньший пик конкурентности на сервере. Так что jitter экономит вполне реальный ресурс зависимости, а ровный график здесь только следствие.
Retry с уважением к context
Рабочий ретрай прерывает паузу по отмене, не спит дольше оставшегося дедлайна, закрывает таймер и даёт каждой попытке свой таймаут, иначе первая зависшая съест весь бюджет.
package retry
import (
"context"
"errors"
"math/rand/v2" // Go 1.22+: глобальный генератор без блокировок и без ручного Seed
"time"
)
type Policy struct {
MaxAttempts int // всего попыток, включая первую
Base time.Duration // 100ms
Cap time.Duration // 5s - потолок одной паузы
PerAttempt time.Duration // таймаут одной попытки; 0 - без своего таймаута
Budget time.Duration // потолок на всю серию; 0 - ограничивает только ctx
}
// Классификатор отвечает сразу на два вопроса: повторять ли,
// и не назвал ли сервер паузу сам (Retry-After, gRPC RetryInfo).
type Classifier func(err error) (retry bool, after time.Duration)
func Do(ctx context.Context, p Policy, cls Classifier, op func(context.Context) error) error {
if p.MaxAttempts <= 0 { p.MaxAttempts = 3 }
if p.Base <= 0 { p.Base = 100 * time.Millisecond }
if p.Cap <= 0 { p.Cap = 5 * time.Second }
if p.Budget > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, p.Budget)
defer cancel() // обязателен: иначе течёт таймер до конца родительского ctx
}
var last error
for attempt := 0; attempt < p.MaxAttempts; attempt++ {
// Не ходим в сеть с уже истёкшим дедлайном: соединение зависимости
// дороже, чем наша попытка «а вдруг успеем».
if err := ctx.Err(); err != nil {
return errors.Join(last, err)
}
last = attemptOnce(ctx, p.PerAttempt, op)
if last == nil {
return nil
}
// Отмена родительского контекста - не транзиентная ошибка: клиент ушёл.
// Проверяем до классификатора, иначе таймаут попытки спутаем с отменой запроса.
if ctx.Err() != nil {
return errors.Join(last, ctx.Err())
}
retry, after := cls(last)
if !retry || attempt == p.MaxAttempts-1 {
return last
}
delay := after // сервер назвал паузу - уважаем
if delay <= 0 {
delay = fullJitter(p.Base, p.Cap, attempt)
}
if err := sleepCtx(ctx, delay); err != nil {
return errors.Join(last, err)
}
}
return last
}
// Своя граница времени на каждую попытку. Без неё одна зависшая
// попытка съедает весь бюджет, и ретраев фактически не происходит.
func attemptOnce(ctx context.Context, per time.Duration, op func(context.Context) error) error {
if per <= 0 {
return op(ctx)
}
actx, cancel := context.WithTimeout(ctx, per)
defer cancel()
return op(actx)
}
func sleepCtx(ctx context.Context, d time.Duration) error {
// Спать дольше, чем осталось до дедлайна, незачем: лучше вернуть ошибку
// сразу, чем разбудить таймер в уже мёртвый запрос.
if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= d {
return context.DeadlineExceeded
}
t := time.NewTimer(d)
defer t.Stop() // до Go 1.23 неостановленный таймер живёт до срабатывания
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
func fullJitter(base, capD time.Duration, attempt int) time.Duration {
backoff := base << attempt // base * 2^attempt
if backoff > capD || backoff <= 0 { // <=0 - защита от переполнения int64
backoff = capD
}
return time.Duration(rand.Int64N(int64(backoff)) + 1)
}
Как это вызывается
policy := retry.Policy{
MaxAttempts: 4,
Base: 50 * time.Millisecond,
Cap: 1 * time.Second,
PerAttempt: 400 * time.Millisecond, // одна попытка
Budget: 2 * time.Second, // вся серия
}
var receipt *Receipt
err := retry.Do(ctx, policy, HTTPClassifier, func(ctx context.Context) error {
req = req.WithIdempotencyKey(key) // ключ один на всю серию, а не на попытку
r, err := client.Charge(ctx, req)
receipt = r
return err
})
time.Sleep(delay)вместоselectсctx.Done(). Горутина продолжает спать после ухода клиента, и под нагрузкой таких набираются тысячи, вместе с памятью под них. Ретрай обязан быть прерываемым.- Новый ключ идемпотентности на каждую попытку. Тогда идемпотентности нет: сервер видит три разные операции. Ключ генерируют один раз на логическую операцию.
- Таймаут снаружи вместо таймаута на попытку. Первая попытка виснет на весь бюджет - до второй и третьей дело не доходит, хотя в метриках «настроено 3 ретрая».
- Классификация до проверки
ctx.Err(). Тогда собственный таймаут попытки выглядит как транзиентная ошибка зависимости, и цикл честно ретраит уже отменённый запрос.
Писать самому обычно не надо. Есть cenkalti/backoff/v5 и
avast/retry-go; gRPC умеет декларативную retryPolicy
в service config (там же hedging); в AWS SDK и Google Cloud SDK ретраи
встроены. На собесе этот вопрос задают ради другого: понимаешь ли ты
отмену, бюджет, таймаут на попытку и ключ идемпотентности. Эти четыре вещи
библиотеки за тебя не решат.
От чего он спасает на самом деле
Упавшая зависимость опасна не столько ошибками, сколько ожиданием. Пока вызов висит на таймауте, он держит горутину, соединение из пула, слот в семафоре и кусок бюджета запроса. При 200 RPS и таймауте 30 секунд к моменту первого отказа у тебя уже 6000 горутин в ожидании и пустой пул HTTP-соединений. Дальше начинают отваливаться эндпоинты, которые к этой зависимости вообще не ходят. Так выглядит каскадный отказ. Брейкер разрывает цепочку: вместо 30 секунд ожидания вызов возвращает ошибку за микросекунды, ресурсы остаются свободными.
Второй эффект достаётся самой зависимости. Сервис, который лежит под нагрузкой, не встанет, пока в него продолжают долбить. Открытый брейкер снимает трафик, и у зависимости появляется окно на восстановление. Здесь «перестать пытаться» полезнее для обеих сторон.
Три состояния
| Состояние | Что делает | Когда уходит дальше |
|---|---|---|
| CLOSED | Пропускает весь трафик, считает отказы в скользящем окне | Доля (или число подряд) отказов превысила порог при достаточном числе запросов в окне → OPEN |
| OPEN | Ничего не пропускает, мгновенно возвращает ErrOpen. В сеть
не ходит вообще |
Прошёл cooldown (обычно 5–60 с) → HALF-OPEN.
Переход ленивый: его делает первый запрос после паузы |
| HALF-OPEN | Пропускает единицы пробных запросов, остальным отказывает | N успехов подряд → CLOSED; хотя бы один отказ → сразу OPEN и новый отсчёт cooldown |
Без него брейкер вырождается в «подождали и снова уронили»: после cooldown весь накопленный трафик разом бьёт в едва вставшую зависимость. HALF-OPEN работает как контролируемая разведка: пропускает 1–3 запроса, остальным по-прежнему отказывает и так за минимальную цену проверяет гипотезу «зависимость жива». Считать надо и успехи, и число пробных в полёте, иначе под конкурентной нагрузкой в half-open проскочат сотни запросов, пока первый ещё не ответил.
// Ядро автомата - вход и отчёт. Полная реализация со счётчиками
// разбиралась выше; здесь важна логика переходов.
func (b *Breaker) allow() error {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case Closed:
return nil
case Open:
if time.Since(b.openedAt) < b.cooldown {
return ErrOpen // отказ за наносекунды, в сеть не идём
}
b.state, b.probes, b.successes = HalfOpen, 0, 0
fallthrough
case HalfOpen:
if b.probes >= b.maxProbes { // пробных - единицы, не весь трафик
return ErrTooManyProbes
}
b.probes++
return nil
}
return nil
}
// Обёртка вызова. Тонкое место - что считать отказом.
func (b *Breaker) Do(ctx context.Context, fn func(context.Context) error) error {
if err := b.allow(); err != nil {
return err // fallback решает вызывающий
}
err := fn(ctx)
b.report(err, isSuccess(err))
return err
}
func isSuccess(err error) bool {
if err == nil {
return true
}
// Уход клиента - не вина зависимости, иначе массовая отмена запросов
// сама откроет брейкер.
if errors.Is(err, context.Canceled) {
return true
}
// 4xx - ошибка запроса, а не сбой зависимости.
var he *HTTPError
if errors.As(err, &he) && he.Code < 500 && he.Code != 429 {
return true
}
return false
}
Сценарий: внешний платёжный API лёг
Рассказывать его удобнее по шагам: интервьюер ждёт цифр и того, что происходит с ресурсами, а не определения из Википедии.
| Время | Без брейкера | С брейкером |
|---|---|---|
| T+0 | Шлюз начинает отвечать 504 с латентностью 30 с | То же самое: первые запросы честно уходят и висят |
| T+10 с | Висят уже ~2000 вызовов (200 RPS × 10 с); к T+30 с их будет 6000, и пул HTTP-соединений исчерпан | Таймаут попытки (600 мс) режет зависшие вызовы: уже в первую секунду 20 подряд упали, доля отказов > 50% → брейкер OPEN |
| T+30 с | Латентность растёт на всех эндпоинтах: заняты горутины и соединения. Деградируют ручки, которые в платёжку не ходят | Вызовы падают за микросекунды с ErrOpen. Ресурсы свободны,
остальной сайт работает штатно |
| T+60 с | Балансировщик выкидывает поды по health-check, оставшиеся получают ещё больше трафика → каскад | Пользователь видит честное оплата временно недоступна, заказ
сохраняется в статусе pending |
| Восстановление | Шлюз встал, но получает весь накопленный трафик разом и падает снова | Каждые 30 с уходят 3 пробных запроса. Три успеха подряд → CLOSED, трафик возвращается сам |
- Считать отказом 4xx. Один клиент с кривыми запросами открывает брейкер всем. В отказы записывают 5xx, 429, таймауты и сетевые ошибки.
- Один брейкер на всё приложение. Нужна гранулярность «зависимость + операция», а часто ещё и инстанс. Медленный поиск не должен рубить быстрый профиль.
- Порог по абсолютному числу отказов. Ночью три запроса в минуту, все три
упали, и брейкер открыт на полчаса. Нужен минимальный объём запросов в окне
(
MinRequests), иначе на низком трафике он бесполезен и вреден. - Ретрай поверх открытого брейкера. Три попытки сгорают мгновенно, ошибка
та же, зато метрики и логи зашумлены.
ErrOpenобязана быть неретраибельной. - Брейкер без fallback. Он превращает медленный отказ в быстрый, но отказ остаётся отказом. Толк от него появляется в паре с деградацией: кэш, дефолт, очередь.
Классический брейкер бинарен и потому груб: он либо пропускает всё, либо ничего.
Тоньше работает adaptive throttling (Google SRE, глава
Handling Overload): клиент считает requests и
accepts за скользящее окно и отбрасывает запрос с вероятностью
max(0, (requests - K*accepts) / (requests + 1)), где K
около 2. Трафик снижается плавно, без ступеньки, и восстановление проходит мягче.
В Go готовые реализации: sony/gobreaker
(окно по счётчикам, простой и надёжный) и slok/goresilience. Рядом
стоит упомянуть hedged requests: если ответ не пришёл за p95,
отправить дублирующий запрос на другую реплику и взять первый ответивший. Работает
только для идемпотентных чтений и режет длинный хвост латентности.
Почему нельзя без таймаута
TCP сам по себе не заметит зависшего собеседника: если сервер принял соединение и молчит, клиент будет ждать бесконечно - не «долго», а именно бесконечно. Каждый такой вызов держит горутину (2 КБ стека на старте, обычно больше), соединение из пула, слот в семафоре и открытый файловый дескриптор. При 200 RPS и часовой недоступности набегают сотни тысяч горутин: сервис умирает не от ошибок зависимости, а от собственной памяти. Таймаут превращает неопределённое ожидание в предсказуемую ошибку, а предсказуемую ошибку уже можно обработать: ретраем, брейкером, деградацией.
- У
http.DefaultClientи&http.Client{}таймаута нет вообще. Защитить могут только таймауты на уровнеTransport, а в дефолтном транспорте их для чтения тела тоже нет.http.Getв проде почти всегда означает баг. http.Server{}безReadHeaderTimeoutуязвим к Slowloris: клиент шлёт заголовки по байту и держит соединение сколько угодно.ReadTimeout,WriteTimeout,IdleTimeoutпо умолчанию тоже нулевые.MaxIdleConnsPerHostпо умолчанию 2. При высокой параллельности соединения не переиспользуются: каждый запрос платит за новый TCP+TLS handshake, и это выглядит как «зависимость тормозит».net.DialerбезTimeoutждёт таймаута ОС - на blackhole-адресе на Linux это больше двух минут.sql.DB:SetConnMaxLifetimeиSetConnMaxIdleTimeпо умолчанию бесконечны, аSetMaxOpenConnsне ограничен - при зависшей БД пул растёт, пока не упрётся в лимиты сервера.
Бюджет запроса и каскад
Таймаут на вызов необходим, но его недостаточно. Если каждый слой ставит свой таймаут «на глаз», арифметика ломается. У запроса бюджет 3 секунды, а внутри вызов с таймаутом 5 секунд и тремя ретраями: его таймаут не сработает ни разу, раньше всегда успеет внешний дедлайн, и внутренние настройки ничего не значат. Правильная модель называется бюджет: верхнюю границу задаёт вход (ingress, gateway), а дальше каждый слой берёт строго меньше, чем ему осталось, и оставляет резерв на обработку результата и запись ответа.
// В Go бюджет уже встроен в context: WithTimeout не добавляет времени,
// если дедлайн родителя ближе - побеждает родитель. Глубокий вызов
// физически не может пережить запрос.
func childCtx(ctx context.Context, want, reserve time.Duration) (context.Context, context.CancelFunc) {
dl, ok := ctx.Deadline()
if !ok {
return context.WithTimeout(ctx, want) // бюджета сверху нет - берём свой
}
left := time.Until(dl) - reserve
if left <= 0 {
// Времени не осталось: идти в сеть бессмысленно - только займём
// соединение у зависимости, которой и так тяжело. Отказываем сразу.
c, cancel := context.WithDeadline(ctx, time.Now())
return c, cancel
}
if left < want {
want = left
}
return context.WithTimeout(ctx, want)
}
// Пример арифметики: ingress даёт 3 с.
// auth 200 мс
// профиль 400 мс
// платёж 3 попытки по 600 мс + backoff ~200 мс = 2000 мс
// резерв 100 мс на сериализацию и запись ответа
// итого 2700 мс < 3000 мс - влезает
func Handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // дедлайн сюда приносит http.TimeoutHandler или клиент
aCtx, cancel := childCtx(ctx, 200*time.Millisecond, 0)
defer cancel()
user, err := auth.Verify(aCtx, r.Header.Get("Authorization"))
if err != nil { writeErr(w, err); return }
pCtx, cancel2 := childCtx(ctx, 2*time.Second, 100*time.Millisecond)
defer cancel2()
receipt, err := billing.Charge(pCtx, user.ID) // внутри ретраи и брейкер
if err != nil { writeErr(w, err); return }
writeJSON(w, receipt)
}
Таймаут берут из наблюдаемой латентности: примерно p99.9 нормального времени ответа плюс запас, а не «поставлю побольше, чтобы не мешало». С большим таймаутом защиты нет, а маленький сам наплодит отказов там, где зависимость просто чуть медленнее обычного. Потом обязательно проверь арифметику по всей цепочке: для каждой ветки сумма (таймаут на попытку × число попыток + паузы backoff) должна целиком влезать в бюджет.
context не пересекает границу процесса сам. gRPC передаёт дедлайн
в метаданных grpc-timeout автоматически - поэтому в gRPC-цепочке бюджет
распространяется бесплатно. В HTTP это делают руками: заголовок вроде
x-envoy-expected-rq-timeout-ms или свой
X-Request-Deadline, который сервер превращает в
context.WithDeadline. Причём клиентский таймаут
не останавливает работу на стороне сервера: отмена контекста для Postgres шлёт
CancelRequest отдельным соединением, а это не мгновенно и без
гарантий. Поэтому клиентский таймаут дублируют серверным:
statement_timeout в Postgres, maxTimeMS в MongoDB,
http.TimeoutHandler у себя.
Лестница деградации
Вопрос не сводится к «есть fallback или нет»: уровней несколько, и выбирают их осознанно. На собесе полезно перечислить уровни по убыванию качества ответа.
- Протухший кэш - отдаём последнее известное значение и помечаем его
stale(stale-while-revalidate). Годится для медленно меняющихся данных: каталог, курсы, настройки, фичефлаги. - Дефолт, то есть заранее посчитанная константа или неперсональный вариант. В рекомендациях, баннерах, ранжировании «топ популярного» лучше пустого блока.
- Урезанный ответ: 200 без части полей плюс признак
partial: true. Карточка товара без блока «с этим покупают» вполне годится, а 500 нет. - Можно отложить работу: положить в очередь или outbox и ответить
202 Accepted. Так делают там, где операция допускает асинхронность: письма, бонусы, индексация. - Read-only режим - запись отключена, чтение идёт с реплики. При отказе мастера это заметно лучше полного 503.
- И последний уровень, честный отказ: явная ошибка с понятным текстом и
Retry-After. Для денег, прав доступа и целостности данных это единственный корректный вариант: выдуманный ответ там означает соврать.
// Второй результат говорит вызывающему, что ответ деградировал.
// Возвращать «как будто всё хорошо» - это тихая поломка.
func (s *Service) Recommendations(ctx context.Context, userID int64) (Recs, bool) {
// У некритичной зависимости - свой короткий таймаут, чтобы она
// не съедала бюджет основного ответа.
ctx, cancel := childCtx(ctx, 150*time.Millisecond, 20*time.Millisecond)
defer cancel()
recs, err := s.mlCB.Do(ctx, func(ctx context.Context) (Recs, error) {
return s.ml.Get(ctx, userID)
})
if err == nil {
s.cache.Set(userID, recs, 10*time.Minute)
return recs, false
}
// Каждая деградация обязана быть видна. Без метрики сервис будет
// месяцами отдавать дефолты, и никто об этом не узнает.
degraded.WithLabelValues("recs", reasonOf(err)).Inc()
s.log.WarnContext(ctx, "recommendations degraded", "err", err, "user", userID)
if stale, ok := s.cache.GetStale(userID); ok { // допускаем просроченное
return stale, true
}
return s.topPopular(), true // последний рубеж
}
// В HTTP-слое деградация становится видимой снаружи.
func (h *Handler) Feed(w http.ResponseWriter, r *http.Request) {
recs, degraded := h.svc.Recommendations(r.Context(), userID(r))
if degraded {
w.Header().Set("X-Degraded", "recommendations")
w.Header().Set("Cache-Control", "no-store") // не кэшируем ухудшенный ответ
}
writeJSON(w, FeedResponse{Recs: recs, Partial: degraded})
}
Композитный ответ: параллельные источники с разной критичностью
func (s *Service) ProductPage(ctx context.Context, id int64) (Page, error) {
var page Page
g, gctx := errgroup.WithContext(ctx)
// Критичный источник: его отказ - отказ всей ручки.
g.Go(func() error {
p, err := s.catalog.Get(gctx, id)
page.Product = p
return err // вернётся из g.Wait; блоки на ctx её не заметят
})
// Некритичные: ошибку не возвращаем в группу, иначе один упавший
// блок отменит контекст и убьёт весь ответ.
// Флаги деградации - у каждого свой: общий page.Partial из двух горутин -
// это гонка.
var recsDeg, reviewsDeg bool
g.Go(func() error {
recs, deg := s.Recommendations(ctx, id) // именно ctx, не gctx
page.Recs, recsDeg = recs, deg
return nil
})
g.Go(func() error {
rv, err := s.reviews.Top(ctx, id)
if err != nil {
degraded.WithLabelValues("reviews", reasonOf(err)).Inc()
reviewsDeg = true
return nil // блок отзывов просто не покажем
}
page.Reviews = rv
return nil
})
if err := g.Wait(); err != nil {
return Page{}, err // упал критичный источник
}
page.Partial = recsDeg || reviewsDeg
return page, nil
}
- Какие зависимости критичны, решают заранее, на уровне продукта. Для каждой записано: критичная или нет и что отдавать при отказе. Если решать это в коде «на месте», однажды гарантированно отдашь нули вместо баланса.
- Деградация должна быть видимой: метрика, поле в ответе, заголовок. Невидимую деградацию, как любую тихую поломку, находят через месяц по жалобам.
- У некритичной зависимости свой короткий таймаут и свой брейкер. Блок рекомендаций не имеет права занимать 3 секунды бюджета основного ответа.
- Fallback надо тестировать. Этот код не выполняется в обычной жизни, поэтому в момент инцидента регулярно выясняется, что сам путь деградации падает. Лечится явными тестами на отказ и chaos-экспериментами.
- Кэш для скорости и кэш для деградации не смешивают. У первого короткий TTL, у второго длинный, и отдавать просроченное ему явно разрешено.
- Не кэшировать ухудшенный ответ на CDN. Иначе один инцидент застынет в кэше на часы после восстановления.
Всё, где «наверное» равно «инцидент»: проверка баланса перед списанием, проверка прав доступа, проверка остатка на складе перед резервом, антифрод. Если сервис прав недоступен, отвечай 503, а не «пропустим на всякий случай» (это fail-open в безопасности и почти всегда уязвимость). Хорошая формулировка для собеса: деградировать можно полноту ответа, но не его корректность.
Почему они не противоречат друг другу
Медленный отказ собирает худшее из обоих миров: пользователь всё равно не получит ответа,
но ресурсы будут заняты до конца таймаута, а ретраи придут уже на исчерпанный пул.
Fault-tolerance строится ровно из быстрых отказов: брейкер даёт мгновенный
ErrOpen, и только поэтому у вызывающего остаются время и ресурсы уйти
в fallback. Если внизу «терпеливо ждём», наверху терпеть уже нечем.
| Ситуация | Подход | Почему |
|---|---|---|
| Невалидный конфиг или отсутствующий секрет при старте | fail-fast | Упасть на деплое дешевле, чем работать неправильно: выкатка застрянет на новых подах, старые продолжат отвечать, пользователи ничего не увидят (а откат Kubernetes сам не делает, его запускают отдельно) |
| Деньги, права доступа, целостность данных | fail-fast | Выдуманный ответ хуже отказа. «Наверное, оплачено» - это инцидент, а не деградация |
| Детерминированная ошибка (400, невалидный вход) | fail-fast | Повтор даст тот же результат - ретрай только жжёт ресурсы и время |
| Перегрузка: очередь растёт, латентность ползёт вверх | fail-fast | Load shedding: честно отказать 10% сразу лучше, чем деградировать всем и всё равно не успеть |
| Транзиентная сетевая ошибка | fault-tolerant | Ретрай с backoff решает большинство таких случаев дешевле, чем отказ |
| Некритичная зависимость и есть разумный дефолт | fault-tolerant | Частичный ответ полезнее пустого - чаще всего пользователь разницы не заметит |
| Фоновая обработка, очередь, аналитика | fault-tolerant | Никто не ждёт ответа на проводе: ретраить можно долго, вплоть до DLQ |
| Инициализация ленивых ресурсов в рантайме | зависит | При старте - fail-fast; при рантайм-переподключении - терпеливо с backoff, иначе одно сетевое моргание уронит весь под |
Fail-fast как защита от перегрузки
Чаще всего недооценивают отказ до начала работы. Если бюджет запроса уже истёк или очередь на обработку длиннее, чем ты успеешь разгрести, полезно только одно: мгновенно отказать. Если копить запросы в ожидании, отказ просто откладывается: ты потратишь ресурсы и всё равно ответишь ошибкой, только позже и всем сразу.
// Приёмный контроль: три быстрых проверки до любой полезной работы.
func (s *Server) admit(ctx context.Context) error {
// 1. Клиент уже не ждёт - работать не для кого.
if err := ctx.Err(); err != nil {
return err
}
// 2. Бюджета не хватит даже на нормальный путь - лучше отказать сразу.
if dl, ok := ctx.Deadline(); ok && time.Until(dl) < s.minWorkTime {
sheddedTotal.WithLabelValues("no_budget").Inc()
return ErrNoTimeBudget // 503 + Retry-After
}
// 3. Перегрузка: очередь длиннее, чем мы разгребём за отведённое время.
if s.inflight.Load() > s.maxInflight {
sheddedTotal.WithLabelValues("overload").Inc()
return ErrOverloaded // 429 или 503
}
return nil
}
// Bulkhead: ограничить параллелизм на каждую зависимость отдельно,
// чтобы одна медленная не съела все горутины и соединения.
type Bulkhead struct{ sem chan struct{} }
func (b *Bulkhead) Do(ctx context.Context, fn func(context.Context) error) error {
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
default:
// Очередь не растим: ожидание слота - это отложенный отказ.
return ErrOverloaded
}
defer func() { <-b.sem }()
return fn(ctx)
}
«Fail-fast на уровне вызова, fault-tolerance на уровне архитектуры. Внутри операции я хочу узнать об отказе как можно раньше и освободить ресурсы; на уровне системы я хочу, чтобы пользователь этого отказа не увидел - за счёт ретраев, реплик, деградации и очередей. Медленный отказ не даёт ни того, ни другого.»
- Fail-fast на старте применили к рантайму. Паниковать, если БД недоступна
при старте, нормально. Паника при обрыве соединения через час работы перезапустит
под как раз тогда, когда БД и так тяжело. В рантайме нужен backoff
и readiness-пробы, а не
log.Fatal. - Fault-tolerance там, где нужна корректность. «Антифрод не ответил - пропустим» создаёт дыру, а не устойчивость.
- Путаница
livenessиreadiness. Если liveness-проба зависит от внешней БД, её недоступность перезапустит все поды разом и добьёт систему. Внешние зависимости проверяют только в readiness. - Ретраить
ErrOverloadedиErrOpen. Быстрый отказ, в который тут же долбят ретраем, перестаёт быть защитой.
Почему без неё ретрай опасен
Сеть не даёт узнать, что произошло на другой стороне. Таймаут на
POST /payments означает одно из трёх: запрос не дошёл; запрос выполнился,
а ответ потерялся; запрос выполняется прямо сейчас. Различить эти случаи снаружи
нельзя в принципе - это и есть ambiguous outcome. С идемпотентностью различать
их и не нужно: повтор либо выполнит операцию (если первая не прошла), либо вернёт
результат первой, ничего не выполняя.
Отсюда же следует, что «ровно один раз» в распределённой системе недостижим на уровне доставки. Достижимо другое: at-least-once доставка плюс идемпотентный обработчик дают ровно один эффект. Это стандартный ответ и по ретраям, и по Kafka с RabbitMQ.
Пять способов сделать операцию идемпотентной
| Способ | Как | Где применяется |
|---|---|---|
| Естественная | Операция присваивает, а не изменяет | UPDATE users SET status='banned' WHERE id=42, SET key value
в Redis, любой GET |
| Уникальный ключ | Уникальный индекс + ON CONFLICT DO NOTHING: вторая вставка не делает
ничего |
Заказы, транзакции, любые сущности с естественным бизнес-ключом |
| Ключ идемпотентности | Клиент шлёт Idempotency-Key, сервер хранит ключ вместе с ответом
и на повтор возвращает сохранённый ответ |
Единственный способ сделать безопасным неидемпотентный POST. Так работают Stripe и все платёжные API |
| Условное обновление | Версия в условии: WHERE id=? AND version=? |
Оптимистические блокировки, ETag + If-Match, CAS в etcd |
| Дедупликация на приёме | Таблица обработанных message_id в той же транзакции,
что и бизнес-эффект |
Inbox-паттерн для Kafka и RabbitMQ: at-least-once снаружи, один эффект внутри |
-- Дедупликация вставки: повтор не создаёт вторую запись и не падает.
INSERT INTO payments (order_id, amount, status)
VALUES ($1, $2, 'pending')
ON CONFLICT (order_id) DO NOTHING
RETURNING id;
-- Пустой результат = запись уже была. Это не ошибка, а штатная ветка:
-- читаем существующую строку и возвращаем её как результат.
-- Inbox: факт обработки и бизнес-эффект фиксируются одной транзакцией.
BEGIN;
INSERT INTO processed_messages (message_id) VALUES ($1); -- PK, повтор упадёт 23505
UPDATE accounts SET balance = balance - $2 WHERE id = $3;
COMMIT;
-- Если вторая команда упала - откатится и первая, сообщение приедет снова.
-- Если сообщение дубль - упадёт первая, транзакция откатится, эффекта нет.
// Обработчик с ключом идемпотентности. Порядок шагов важен:
// сначала атомарно занимаем ключ, только потом делаем работу.
func (s *Service) CreatePayment(ctx context.Context, key string, req Request) (Response, error) {
if key == "" {
return Response{}, ErrIdempotencyKeyRequired // 400
}
hash := sha256.Sum256(req.Canonical())
// 1. Гонку решает сама БД: выигрывает ровно один INSERT.
claimed, err := s.repo.ClaimKey(ctx, key, "POST /payments", hash[:])
if err != nil {
return Response{}, err
}
if !claimed {
rec, err := s.repo.LoadKey(ctx, key)
if err != nil {
return Response{}, err
}
// 2. Тот же ключ с другим телом - баг клиента, а не повтор.
if !bytes.Equal(rec.RequestHash, hash[:]) {
return Response{}, ErrIdempotencyKeyReuse // 422
}
// 3. Первый запрос ещё в работе - параллельно не выполняем.
if rec.State == StateInProgress {
return Response{}, ErrInProgress // 409 + Retry-After: 1
}
return rec.Response, nil // 4. Отдаём сохранённый ответ
}
resp, err := s.charge(ctx, req)
if err != nil {
// Деньги точно не списаны - отпускаем ключ, и повтор пройдёт заново:
// иначе ключ навсегда залипнет в in_progress. Исход неизвестен
// (таймаут после отправки) - не отпускаем: повтор может списать
// второй раз, такой ключ ждёт сверки с платёжкой.
if !errors.Is(err, ErrOutcomeUnknown) {
_ = s.repo.ReleaseKey(ctx, key)
}
return Response{}, err
}
// 5. Сохраняем ответ под ключом: повтор получит его, а не второе списание.
if err := s.repo.CompleteKey(ctx, key, resp); err != nil {
return Response{}, err
}
return resp, nil
}
- «PUT идемпотентен по спецификации». RFC 9110 объявляет
GET,PUT,DELETEидемпотентными, но это требование к разработчику, а не гарантия транспорта.PUT /counter, который внутри делаетcounter = counter + 1, идемпотентным не станет. Проверять надо реализацию, а не глагол. - Ключ генерирует сервер. Всё ровно наоборот: ключ создаёт клиент, один раз на логическую операцию, и шлёт его во всех повторах. Новый ключ на каждую попытку убивает всю схему.
- Нет хеша тела. Клиент случайно переиспользовал ключ - и получил чужой сохранённый ответ. По логам такую ошибку почти невозможно найти.
- Ключ и эффект в разных транзакциях. Деньги списаны, ключ не сохранён, повтор списывает снова - то самое двойное списание, от которого всё и затевалось.
- Нет TTL и чистки. Таблица ключей растёт линейно по трафику и однажды
становится крупнейшей в базе. Партиционирование по дате и
DROP PARTITIONдешевле, чемDELETE. - Идемпотентность на уровне HTTP, но не на уровне бизнес-операции. Повтор не создал второй платёж, но второй раз отправил письмо и второй раз начислил бонусы - потому что побочные эффекты живут вне транзакции. Их место в outbox.
«Идемпотентность нужна для корректности при дублировании, а ретрай лишь частный случай. Дубликаты порождает не один мой ретрай: их создаёт балансировщик, sidecar, нетерпеливый пользователь, дважды нажавший кнопку, и at-least-once брокер. Поэтому сначала делаем операцию идемпотентной и только потом включаем ретраи. В обратном порядке мы, по сути, уже проектируем двойные списания.»
Что стоит запомнить
- Retry требует идемпотентной операции или idempotency key.
- Backoff с jitter уменьшает синхронные повторы.
- Timeout должен укладываться в общий budget запроса.
Частые ошибки
- Не повторяй неидемпотентную запись после неопределённого результата.
- Fallback должен иметь определённую политику и срок.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи цепочку ошибки
Скриптированный GET возвращает временную ошибку, затем успех. Общий deadline моделируется бюджетом 100 единиц; backoff стоит 10 за первую паузу. Что напечатает loop и сколько попыток будет?
results := []error{errors.New("temporary"), nil}
remainingBudget := 100 // virtual time units for the whole request
backoffCost := 10
attempts := 0
for attempts < len(results) {
if remainingBudget <= 0 { fmt.Println("deadline", attempts); return }
attempts++
err := results[attempts-1]
if err == nil { fmt.Println("ok", attempts, remainingBudget); return }
remainingBudget -= backoffCost
backoffCost *= 2
}
fmt.Println("exhausted", attempts)
Ответ и разбор
Печатается ok 2 90: первая попытка получает временную ошибку и списывает 10 единиц общего бюджета; вторая получает nil. Это виртуальные единицы времени, поэтому результат не зависит от планировщика или системных часов. В реальном клиенте тот же общий deadline задаётся context и передаётся в каждую попытку/ожидание.
Код-ревью
2. Найди дефект и исправь
charge может списать деньги, а ответ потеряться. Код без ограничения повторяет операцию. Исправь retry policy.
for {
err := charge(ctx, account, amount)
if err == nil { return nil }
time.Sleep(time.Second)
}
Ответ и разбор
Сначала обеспечь idempotency key, который сервер атомарно связывает с результатом списания. Затем ограничь попытки общим context/deadline, классифицируй retryable ошибки и применяй exponential backoff с jitter. Без идемпотентности не повторяй операцию после неопределённого результата: timeout не доказывает, что списание не произошло.
Применение
3. Разбери сценарий
Провайдер не отвечает 20 секунд и исчерпывает deadline входящих запросов. Как circuit breaker меняет поведение и чего он не гарантирует?
Ответ и разбор
После порога ошибок breaker открывается и быстро отклоняет новые вызовы, снижая нагрузку на провайдера. После cooldown он допускает ограниченную пробную попытку. Breaker не повторяет операцию, не делает запись идемпотентной и не заменяет deadline; при открытой цепи приложение должно вернуть согласованную ошибку или fallback только там, где он безопасен.
Применение
4. Выбери границу обработки
Чтение каталога справочника может временно обслуживаться из локальной копии, но неуспешный платёж нельзя считать успешным по кэшу. Где допустим fallback и как обозначить его?
Ответ и разбор
Fallback подходит для чтения каталога, если копия имеет понятный срок свежести и клиент может принять устаревшие данные. Для платежа fallback не должен выдумывать успех: верни ошибку/недоступность и сохрани возможность безопасного повтора по idempotency key. Метрики и логи должны показывать, что запрос обслужен fallback-данными.