open atlas
↑ К треку
Go с нуля до senior GO · 07 · 04

Ретраи и устойчивость: бэкофф с джиттером, бюджеты ретраев и circuit breaker

Ретраите только идемпотентное, с экспоненциальным бэкоффом и полным джиттером против синхронных волн. Ограничивайте ретраи бюджетом, иначе они умножают трафик в 3 раза в худший момент. Таймауты на попытку живут под общим дедлайном; circuit breaker не бьёт лежащего соседа.

GO Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Каталожный сервис не упал — он «потемнел». Один деградировавший узел поднял p99 до 1,2 секунды против секундного таймаута вызывающих, и часть запросов начала таймаутить. Каждый затаймаутивший вызов немедленно ретраился, дважды. Дальше работала математика: за девяносто секунд предложенная нагрузка на каталожный тир утроилась, пока его ёмкость просела на пятнадцать процентов. Здоровые узлы насытились, их латентность тоже пересекла таймаут, их вызывающие заретраили — и brownout (частичная деградация) стал blackout (полный отказ). График из постмортема стал легендой компании: ёмкость минус 15%, трафик плюс 300%, доступность — ноль. Никто ничего не деплоил; кроме одного медленного узла, железо не отказывало. Аварию целиком изготовила ретрай-политика клиентов — каждая локально разумная, коллективно — распределённый DoS против собственной зависимости. Фикс был не в каталоге. Фикс — ретраи, уважающие бюджет, отступающие с джиттером и полностью замолкающие, когда сосед тонет.

Безопасность ретрая — свойство операции

Прежде чем писать ретрай-цикл, задайте один вопрос: если операция выполнится дважды, результат будет тем же, что от одного выполнения? От ответа зависит, строите ли вы устойчивость или баг двойного списания. Таймаут — не отчёт об отказе, а отсутствие отчёта. Запрос мог умереть на проводе — а мог успеть после того, как вы перестали слушать. Поэтому ретрай безопасен только тогда, когда выполнить операцию дважды — то же самое, что один раз. GET, PUT и DELETE обещают это HTTP-контрактом (если хендлеры его соблюдают); POST не обещает ничего — ретраить затаймаутивший POST /charge, потому что «он упал», и есть способ списать с клиента дважды. Индустриальный фикс — ключ идемпотентности: клиент генерирует уникальный ключ на логическую операцию, шлёт его с каждой попыткой, а сервер хранит результат под этим ключом и на дубликаты переигрывает сохранённый ответ. Exactly-once — ложь, которую сеть не позволяет; ключи идемпотентности реально реализуют at-least-once плюс серверную дедупликацию. Классифицируйте каждое место вызова до написания любого ретрай-цикла: идемпотентно по природе, идемпотентно через ключ или неретраибельно вовсе.

Викторина

POST /charge таймаутит через 1с. Клиент ретраит; с покупателя списывают дважды. Что именно сделало ретрай небезопасным?

Бэкофф с полным джиттером

Отказы синхронизируют клиентов: рестарт зависимости, сброс кэша, деплой — и тысяча вызывающих падает в одни и те же сто миллисекунд. Если все повторяют попытки по одному расписанию, они возвращаются волной, а голый экспоненциальный бэкофф лишь раздвигает волны: пики на первой секунде, второй, четвёртой — каждый thundering herd по выздоравливающему сервису. Синхронизацию ломает джиттер. Анализ в блоге архитектуры AWS, давший этим стратегиям имена, нашёл полный джиттер — сон равномерно случайное время между нулём и экспоненциальным потолком — близким к оптимуму: клиенты размазываются по окну, стадо превращается в морось, а суммарная работа системы падает драматически по сравнению с бэкоффом без джиттера.

const (
	baseDelay = 100 * time.Millisecond
	maxDelay  = 5 * time.Second
)

// Полный джиттер: равномерно в [0, min(maxDelay, base*2^attempt)).
func backoff(attempt int) time.Duration {
	ceiling := baseDelay << attempt // 100мс, 200мс, 400мс, ...
	if ceiling > maxDelay {
		ceiling = maxDelay
	}
	return rand.N(ceiling) // math/rand/v2: равномерно в [0, ceiling)
}

Бюджеты ретраев: потолок усиления

Джиттер решает проблему синхронизации — но ничего не делает с усилением трафика. Это разные проблемы, и вот механизм, убивший каталожный сервис. При трёх попытках на запрос зависимость со 100% отказов получает втрое больше обычного трафика — ровно когда её ёмкость минимальна. Сложите ретраи по слоям — и они перемножатся: три попытки у клиента и три у гейтвея — до девяти прилётов вниз на одно действие пользователя. Дисциплина из SRE-книги — два потолка. На запрос: максимум три попытки, затем отказ наверх. На клиента: бюджет ретраев — ретраи не превышают примерно 10% запросов в скользящем окне; когда бюджет потрачен, отказы возвращаются немедленно, без ретрая. При тотальном отказе зависимости бюджетированный клиент шлёт максимум 1,1x своей нормы вместо 3x, а ретраи помогают там, где реально помогают, — на транзиентных сбоях, — оставаясь безвредными во время настоящих аварий. И ретраит один слой — тот, что владеет видимым пользователю дедлайном; всё ниже падает быстро наверх.

Викторина

Клиент и гейтвей повторяют попытки до 3 раз каждый. Зависимость отказывает на 100% пять минут при базовой нагрузке 1k rps. Сколько примерно она получает — и что меняет 10%-бюджет на каждом слое?

Таймауты: на попытку, под одним дедлайном

Ретраи и таймауты — одна задача проектирования, и дерево контекстов из первого урока — её скелет. SLO перед вызывающим становится общим дедлайном на ctx; каждая попытка выводит более короткий контекст на попытку, чтобы зависшая попытка умерла быстро, а ретрай мог попасть на более здоровую реплику; и цикл проверяет родителя между попытками, чтобы никогда не проспать дедлайн:

func callWithRetry(ctx context.Context, do func(context.Context) error) error {
	var lastErr error
	for attempt := 0; attempt < 3; attempt++ {
		attemptCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
		lastErr = do(attemptCtx)
		cancel()
		if lastErr == nil || !retryable(lastErr) {
			return lastErr
		}
		select {
		case <-time.After(backoff(attempt)):
		case <-ctx.Done():
			return errors.Join(ctx.Err(), lastErr) // дедлайн сильнее упорства
		}
	}
	return lastErr
}

Пропорции важны: общий дедлайн 2 секунды при попытках по 500 мс оставляет место трём попыткам плюс бэкоффу; дедлайн в секунду при попытках по 900 мс — ретрай-цикл только по названию. Бюджетируйте дедлайн как деньги — попытки, бэкофф и резерв на собственную работу вызывающего.

Circuit breaker: когда тишина сильнее упорства

Бюджет ретраев всё равно отправляет базовую долю трафика в полностью лежащую зависимость — и каждый такой вызов сжигает полный таймаут перед отказом. Circuit breaker (автоматический выключатель) добавляет недостающий конечный автомат. Closed: трафик идёт, отказы считаются в окне. Open: порог отказов сработал; вызовы падают за микросекунды, не трогая сеть, а зависимость получает единственное, что реально помогает ей выздороветь, — тишину. Half-open: после паузы несколько пробных запросов зондируют; успех закрывает брейкер, отказ снова открывает. Брейкер бьёт бюджет, когда зависимость мертва бинарно и когда важна ваша собственная латентность: отказ за микросекунды вместо сжигания 500 мс на обречённый вызов сохраняет ваши горутины, пулы соединений и вызывающих. Честные издержки: состояние на зависимость, два порога и пауза для настройки, ложные срабатывания на малом трафике, где три отказа из пяти запросов — шум, и обязанность явно решить, что является fallback-ом при открытом брейкере.

Почему это работает

Что не надо писать руками — и что не надо импортировать? Джиттерный, бюджетированный, знающий о дедлайнах ретрай-цикл выше — тридцать строк: скопируйте, протестируйте, владейте; внешний фреймворк ради этого — поверхность зависимостей без рычага. Брейкер — противоположный случай: конкурентно-безопасный конечный автомат со скользящими окнами и координацией зондов достаточно тонок, чтобы маленькая обкатанная библиотека (в духе gobreaker) била ваши первые три попытки написать свой. Граница — рычаг на зависимость: импортируйте механизмы, которые трудно сделать правильно; копируйте политики, которые легко прочитать.

Вспомните перед уходом
  1. 01
    Объясни механизм усиления ретраями и два потолка, которые его сдерживают.
  2. 02
    Разбери полный джиттер, разделение «попытка/общий дедлайн» и конечный автомат брейкера с его трейдоффом против бюджета.
Итог

Устойчивость начинается с классификации, а не с цикла: какие операции идемпотентны по контракту, какие становятся идемпотентными через клиентский ключ, по которому сервер дедуплицирует, а какие нельзя ретраить вовсе — потому что таймаут есть отсутствие ответа, и первая попытка могла успеть после того, как вы повесили трубку. Двойное списание — вот как выглядит пропуск этого шага. Затем цикл: экспоненциальный бэкофф задаёт шаг, полный джиттер — равномерная выборка между нулём и экспоненциальным потолком — растворяет синхронные волны, рождённые общим моментом отказа, а math/rand/v2 делает это тремя строками. Бюджет — деталь, которую большинство команд упускает до первой аварии усиления: три попытки на запрос и ретраи не выше десяти процентов объёма на клиента, чтобы зависимость с нулевой доступностью видела 1,1x нагрузки вместо 3x — или 9x, когда два слоя одновременно повторяют попытки, поэтому ретраями владеет ровно один слой. Таймауты замыкают геометрию: видимый пользователю SLO — дедлайн родительского контекста, каждая попытка выводит 500-миллисекундного ребёнка, а цикл селектится на Done между попытками, чтобы бэкофф не пережил запрос. Circuit breaker закрывает случай, который бюджету не по силам: бинарно мёртвую зависимость, где микросекундные отказы открытого состояния защищают ваши пулы и дарят соседу тишину для выздоровления — ценой порогов, ложных срабатываний на тонком трафике и явного fallback. Тридцатистрочный цикл копируйте в кодбазу; импортируйте только брейкер. Теперь, когда brownout превращается в blackout без единого нового деплоя, — проверьте математику ретраев: просадка ёмкости на X процентов часто означает рост трафика на 300 процентов, если клиенты перемножают попытки по слоям.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.