golang

Retry в Go: От граблей к дзену отказоустойчивости

  • четверг, 24 апреля 2025 г. в 00:00:05
https://habr.com/ru/articles/903576/

В мире распределенных систем и микросервисов сетевые сбои, временная недоступность сервисов или всплески нагрузки — не исключение, а скорее правило. Отказ одного компонента не должен каскадом обрушить всю систему. Здесь на помощь приходит механизм retry (повторные попытки).

Грамотно реализованный retry позволяет системе самоисцеляться от временных проблем, делая ее более надежной и отказоустойчивой. Однако, неправильное использование ретраев может усугубить ситуацию, замаскировав серьезные проблемы.

В этой статье мы пройдем путь от самых наивных и опасных способов реализации ретраев в Go до построения гибкого и надежного механизма, который можно использовать в production каждый день. Мы рассмотрим ключевые паттерны, антипаттерны и готовые решения.

Если вам интересен процесс и вы хотите следить за дальнейшими материалами, буду признателен за подписку на мой телеграмм-канал. Там я публикую полезныe материалы по разработке, Go, советы как быть продуктивным и конечно же отборные мемы: https://t.me/nullPointerDotEXE.

Самый наивный подход: Бесконечный цикл

Начнем с простого. У нас есть функция, которая может вернуть ошибку. Как сделать повтор? Первое, что приходит в голову, — простой цикл for.

package main

import (
	"errors"
	"fmt"
	"math/rand"
	"time"
)

// Имитируем операцию, которая иногда падает
func doSomethingUnreliable() error {
	if rand.Intn(10) < 7 {
		fmt.Println("Operation failed, retrying...")
		return errors.New("temporary failure")
	}
	fmt.Println("Operation succeeded!")
	return nil
}

func main() {
	var err error
	for { 
		err = doSomethingUnreliable()
		if err == nil {
			break // Успех, выходим
		}
		// Ошибка, просто пробуем снова немедленно
	}

	if err != nil {
		fmt.Printf("Failed after multiple retries: %v\n", err)
	}
}

Что здесь не так? Почти всё.

Во-первых, нет никакой задержки между попытками. Если ошибка происходит мгновенно (например, сетевое соединение сразу же отклоняется), цикл будет молотить с максимальной скоростью, сжирая проц и потенциально усугубляя проблему.

Во-вторых, нет ограничения на количество попыток. Если ошибка не является временной, этот цикл будет выполняться вечно. Это классический антипаттерн.

Добавляем паузу: Фиксированная задержка

Хорошо, добавим паузу с помощью time.Sleep.

func main() {
	var err error
	const maxRetries = 5
	const delay = 1 * time.Second

	for attempt := 0; attempt < maxRetries; attempt++ {
		err = doSomethingUnreliable()
		if err == nil {
			break // Успех
		}

		fmt.Printf("Attempt %d failed, waiting %v before next retry...\n", attempt+1, delay)
		if attempt < maxRetries-1 { // Не спим после последней попытки
			time.Sleep(delay)
		}
	}

	if err != nil {
		fmt.Printf("Failed after %d attempts: %v\n", maxRetries, err)
	} else {
		fmt.Println("Succeeded within retry limit.")
	}
}

Мы ограничили количество попыток и добавили фиксированную задержку. Но и тут есть нюансы. Фиксированная задержка может быть неоптимальной. Если проблема решилась быстро, мы все равно ждем полный интервал. Если проблема требует больше времени, короткая задержка может быть недостаточной, и мы продолжаем нагружать систему. А что, если несколько экземпляров нашего приложения начнут повторять запросы одновременно с одной и той же задержкой? Они будут стучаться в проблемный сервис синхронно, создавая эффект "громоподобной толпы" (Thundering Herd).

Exponential Backoff: Растем экспоненциально

Чтобы решить проблемы фиксированной задержки и синхронизации, используют стратегию Exponential Backoff (Экспоненциальная выдержка). Идея проста: с каждой неудачной попыткой мы увеличиваем время ожидания, обычно в два раза.

// ... (doSomethingUnreliable остается тем же) ...

func main() {
	var err error
	const maxRetries = 5
	baseDelay := 100 * time.Millisecond
	maxDelay := 5 * time.Second

	for attempt := 0; attempt < maxRetries; attempt++ {
		err = doSomethingUnreliable()
		if err == nil {
			break // Успех
		}

		if attempt == maxRetries-1 {
			break // Не считаем задержку после последней попытки
		}

		// Рассчитываем экспоненциальную задержку
		backoffTime := baseDelay * time.Duration(math.Pow(2, float64(attempt)))
		// Ограничиваем максимальную задержку
		if backoffTime > maxDelay {
			backoffTime = maxDelay
		}

		fmt.Printf("Attempt %d failed, waiting %v before next retry...\n", attempt+1, backoffTime)
		time.Sleep(backoffTime)
	}
	// дальнейшая логика
}

Cтало значительно лучше. Задержка адаптируется. Начинаем с малого, но если проблемы продолжаются, даем системе все больше времени на восстановление. Ограничение maxDelay не дает задержке расти до абсурдных значений. Однако проблема синхронизации все еще может возникнуть, хоть и с меньшей вероятностью.

Jitter: Добавляем случайности

Когда реализуешь экспоненциальный бэкофф, всё вроде бы красиво, но в случае если несколько клиентов или горутин одновременно начинают ретраить, они будут делать это синхронно — через одну и ту же паузу. Это приводит к эффекту thundering herd — одновременно приходят десятки или сотни запросов, что добивает сервис, особенно если он и так не справляется. Чтобы этого избежать, в задержку добавляют случайность — это и называется джиттер. Виды джиттеров:

Full jitter — полная случайность: задержка выбирается случайно от 0 до текущего бэкоффа. Максимально рассеивает нагрузку.

Equal jitter — умеренная случайность: половина задержки фиксирована, вторая половина — случайная. Баланс между стабильностью и случайностью.

Decorrelated jitter — адаптивный подход: следующая задержка зависит от предыдущей, плюс случайность. Хорошо работает при большом числе клиентов, разрывая синхронные пики.

Давайте реализуем full Jitter:

// ... (doSomethingUnreliable) ...

func main() {
	var err error
	const maxRetries = 5
	baseDelay := 100 * time.Millisecond
	maxDelay := 5 * time.Second

	rand.Seed(time.Now().UnixNano()) // Важно для случайности

	for attempt := 0; attempt < maxRetries; attempt++ {
		err = doSomethingUnreliable()
		if err == nil {
			break // Успех
		}

		if attempt == maxRetries-1 {
			break
		}

		backoffTime := baseDelay * time.Duration(math.Pow(2, float64(attempt)))
		if backoffTime > maxDelay {
			backoffTime = maxDelay
		}

		// Добавляем Full Jitter
		jitter := time.Duration(rand.Int63n(int64(backoffTime)))

		fmt.Printf("Attempt %d failed, waiting ~%v (backoff %v + jitter) before next retry...\n", attempt+1, jitter, backoffTime)
		time.Sleep(jitter)
	}
	// ... (обработка финального результата) ...
}

Теперь наши повторные попытки будут распределены во времени гораздо лучше, снижая риск перегрузки зависимого сервиса. Exponential Backoff + Jitter — это де-факто стандарт для надежных retry-механизмов.

Контекст и отмена: почему maxRetries недостаточно

На первый взгляд может показаться, что установка лимита maxRetries полностью решает проблему бесконечных повторов. Однако в реальности этого недостаточно. Допустим, пользователь закрыл вкладку браузера или внешний запрос, инициировавший ретраящийся процесс, уже отменён. Несмотря на это, без должного контроля мы продолжаем выполнять попытки, расходуя ресурсы системы, отправляя лишние запросы к базе данных, сервисам или API. Такие попытки становятся не просто бесполезными — они вредят, создавая нагрузку на и без того перегруженные компоненты системы.

В продакшене это особенно критично. Именно поэтому в Go-практике рекомендуется использовать context.Context как обязательную часть любой операции с ретраями. Контекст позволяет централизованно управлять отменой, задавать дедлайны или таймауты, и синхронизировать поведение разных частей системы. С помощью context.WithTimeout можно установить общий лимит времени на операцию, а ctx.Done() или ctx.Err() использовать для раннего выхода из ретрая, если операция уже неактуальна. Это значительно улучшает устойчивость и предсказуемость системы, особенно при интеграции с другими сервисами и асинхронной логикой.

type RetryConfig struct {
	maxRetries int
	baseDelay  time.Duration
	maxDelay   time.Duration
}

func Retry(ctx context.Context, cfg RetryConfig) error {
	var err error

	for attempt := 0; attempt < cfg.maxRetries; attempt++ {
		// проверка отмены перед каждой попыткой
		if ctx.Err() != nil {
			return ctx.Err()
		}

		err = doSomethingUnreliable()
		if err == nil {
			return nil
		}

		// если последняя попытка — выходим с ошибкой
		if attempt == cfg.maxRetries-1 {
			return err
		}

		// экспоненциальный backoff с Full Jitter
		backoff := cfg.baseDelay * time.Duration(math.Pow(2, float64(attempt)))
		if backoff > cfg.maxDelay {
			backoff = cfg.maxDelay
		}
		jitter := time.Duration(rand.Int63n(int64(backoff)))

		fmt.Printf("Attempt %d failed, waiting ~%v (max backoff: %v)...\n", attempt+1, jitter, backoff)
		time.Sleep(jitter)
	}
	return err
}

Фильтрация ошибок: Не всё надо ретраить

Не каждая ошибка — это повод для повторной попытки. Очень важно разделять ошибки на временные и постоянные. Временные — это, например, сетевые таймауты, перегрузки, ограничения по rate-limit или блокировки, которые можно обойти, просто подождав. Постоянные же ошибки — это когда ресурс не найден (404), доступ запрещён (403), или пользователь ввёл некорректный запрос. Такие ошибки не исчезнут при следующей попытке, и ретраить их — значит тратить ресурсы впустую и потенциально усугублять ситуацию.

Именно поэтому перед каждой попыткой важно оценивать саму ошибку. Нужно чётко понимать, является ли она ретрабельной. Для этого удобно выделить вспомогательную функцию, условно isRetryable(err error) bool, которая будет инкапсулировать всю логику фильтрации. Например, если ты работаешь с HTTP, можно внутри неё анализировать коды ответа: ретраим только 429, 500, 502, 503 и 504. Если с базой данных — проверяем коды ошибок драйвера, блокировки, deadlock-и, ошибки соединения. При этом игнорируем синтаксические ошибки, дублирующие ключи и другие признаки бизнес-ошибок.

Go предоставляет удобные инструменты для такой фильтрации: errors.Is помогает сравнить ошибку с известной переменной, даже если она завернута несколько раз. errors.As — извлечь нужный тип ошибки, например, net.OpError или url.Error, и проверить её поля. Это полезно, если работаешь с обёрнутыми или составными ошибками.

Чего стоит избегать: Антипаттерны Retry

Подведем итог по плохим практикам:

  • Retry без задержки: Вызывает чрезмерную нагрузку.

  • Retry без ограничения: Может привести к бесконечным циклам и зависанию ресурсов.

  • Использование фиксированной задержки: Неоптимально и может вызвать Thundering Herd.

  • Exponential Backoff без Jitter: Все еще есть риск синхронных ретраев при высокой конкуренции.

  • Ретраить не временные ошибки: Бессмысленно и скрывает реальные проблемы.

  • Ретраить операции, не являющиеся идемпотентными: Если повторный вызов операции приведет к дублированию данных или нежелательным побочным эффектам (например, повторная отправка письма, двойное списание средств), ретраить ее опасно без дополнительных механизмов идемпотентности.

  • Игнорирование context.Context: Попытки могут продолжаться даже после того, как родительская операция была отменена.

Заключение

Механизм повторных попыток — мощный инструмент для создания отказоустойчивых приложений в Go. Начав с понимания разницы между временными и постоянными ошибками, пройдя через эволюцию от наивных циклов к Exponential Backoff с Jitter, интеграции с context.Context и фильтрации ошибок, мы приходим к надежным решениям. В большинстве случаев использование проверенных библиотек, таких как cenkalti/backoff, является предпочтительным путем, позволяя сосредоточиться на бизнес-логике, а не на тонкостях реализации retry-стратегий. Помните: правильный retry — это не просто повторение действия, а продуманный механизм, повышающий надежность вашей системы. Успешного кодирования!