Шаблон повтора: создание устойчивых микросервисов

Шаблон повтора: создание устойчивых микросервисов

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

В любой момент у нижестоящей службы может возникнуть кратковременный сбой в сети, временный скачок ЦП, быстрый конфликт блокировки базы данных или перезапуск обновления. Эти временные сбои известны как переходные сбои.

Если ваша служба немедленно выдает ошибку и не выполняет запрос в момент сбоя нисходящего вызова, вы создаете нестабильный пользовательский интерфейс. Вместо этого многие из этих временных ошибок можно устранить автоматически, подождав немного и повторив попытку. Именно здесь на помощь приходит Шаблон повтора.

В этом руководстве мы рассмотрим шаблон Retry, его внутреннюю работу, опасности наивных реализаций и способы его правильной реализации в Java (Resilience4j) и Go.


Реальная аналогия: повторный набор занятой линии

Представьте, что вы пытаетесь позвонить другу. Вы набираете их номер, но получаете сигнал «занято», потому что в данный момент они разговаривают по другому вызову.

Вы сразу же сдаетесь, удаляете их контакты и полагаете, что никогда больше не сможете с ними поговорить? Конечно, нет. Вы кладете трубку, ждете минуту и ​​снова набираете их номер. Если они все еще заняты, подождите пять минут, прежде чем повторить попытку.

В конце концов их вызов завершается, и ваша повторная попытка успешна.

Схема архитектуры шаблона повтора, показывающая попытку запроса, задержку отсрочки и вторую успешную попытку повтора.

В микросервисах:

  • Вызов — это запрос API к нижестоящей службе.
  • Сигнал занятости — это временная сетевая ошибка или ответ 503 Service Unavailable.
  • Повторный набор — это повторная попытка.
  • Время ожидания — это продолжительность задержки.

Опасность: наивные повторные попытки и «штормы повторных попыток»

Реализация механизма повтора на первый взгляд кажется тривиальной: просто оберните HTTP-вызов в цикл for и продолжайте попытки, пока не добьетесь успеха. Однако простая реализация повторных попыток может легко превратить незначительный сбой в катастрофический сбой всей системы.

Представьте себе нижестоящую службу, которая испытывает трудности из-за внезапного всплеска трафика. Его база данных работает с загрузкой ЦП на 99 %, а время ожидания запросов начинает истекать.

Если все 100 клиентских служб обнаружат тайм-аут и немедленно повторят попытку 3 раза, не дожидаясь, они внезапно утроят объем трафика, попадающего на и без того задыхающуюся нижестоящую службу. Такое внезапное увеличение трафика известно как Повторный шторм (или проблема Громового стада).

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


Решение: задержка и джиттер

Чтобы предотвратить штормы повторных попыток, устойчивая система должна использовать две основные стратегии: Backoff и Jitter.

1. Стратегии отсрочки

Отсрочка определяет, как долго клиент должен ждать, прежде чем предпринять следующую повторную попытку.

  • Фиксированная задержка: клиент ждет постоянное время (например, ровно 200 мс) между попытками. Несмотря на свою простоту, он по-прежнему сопряжен с риском скачков синхронизированного трафика.
  • Экспоненциальная отсрочка: время ожидания увеличивается в геометрической прогрессии с каждой неудачной попыткой (например, 100 мс, 200 мс, 400 мс, 800 мс). Это дает нижестоящей службе все больше времени на восстановление по мере сохранения сбоя.

2. Джиттер (случайность)

Даже при экспоненциальной отсрочке, если сетевой сбой приводит к сбою 1000 запросов в один и тот же момент, все 1000 клиентов рассчитают одну и ту же задержку отсрочки. Следовательно, все они будут повторять попытки одновременно волнами, вызывая синхронизированные всплески активности нижестоящей службы.

Джиттер решает эту проблему, добавляя случайную дисперсию к задержке отсрочки.

Without Jitter (Synchronized Waves):
Time: 0ms   -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]

With Jitter (Distributed Traffic):
Time: 0ms   -> [1000 requests fail]
Time: 92ms  -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)

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


Золотое правило повторных попыток: идемпотентность

Прежде чем применять повторные попытки к любой конечной точке API, вы должны задать один важный вопрос: Является ли эта операция идемпотентной?

идемпотентная операция — это операция, при которой выполнение нескольких идентичных запросов имеет тот же эффект, что и выполнение одного запроса.

  • Идемпотент: чтение профиля пользователя (GET /users/123), обновление всего поля электронной почты (PUT /users/123/email) или удаление элемента (DELETE /items/456).
  • Неидемпотентный: создание нового заказа (POST /orders) или обработка списания средств с кредитной карты (POST /payments).

Предположим, вы звоните по номеру POST /payments, чтобы снять с клиента 50 долларов США. Нижестоящий платежный шлюз получает запрос, успешно списывает средства с кредитной карты, но затем в сети происходит сбой, прежде чем он сможет отправить вам ответ 200 OK.

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

[!ВНИМАНИЕ] Никогда не повторяйте неидемпотентные операции, если нижестоящая служба не поддерживает Ключи идемпотентности (уникальные идентификаторы запросов, используемые для обнаружения и удаления повторяющихся транзакций).


Примеры реализации

Давайте посмотрим, как реализовать шаблон повтора в Java и Go.

1. Java (Resilience4j и Spring Boot)

Resilience4j предоставляет надежный, легко настраиваемый модуль повторных попыток. Вот как настроить его для внешней службы выставления счетов.

Конфигурация (application.yml)

resilience4j.retry:
  instances:
    billingService:
      maxAttempts: 3
      waitDuration: 100ms
      enableExponentialBackoff: true
      exponentialBackoffMultiplier: 2.0
      enableRandomizedWait: true
      randomizedWaitFactor: 0.5
      retryExceptions:
        - org.springframework.web.client.ResourceAccessException
        - io.netty.channel.ConnectTimeoutException
      ignoreExceptions:
        - org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried

Реализация кода

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class PaymentProcessor {

    private final RestTemplate restTemplate;

    public PaymentProcessor(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply the configured retry policy with a fallback method
    @Retry(name = "billingService", fallbackMethod = "billingFallback")
    public String chargeUser(BillingRequest request) {
        return restTemplate.postForObject("http://billing-service/charge", request, String.class);
    }

    // Executed when all retry attempts fail
    public String billingFallback(BillingRequest request, Throwable throwable) {
        return "Billing service is currently unavailable. Your transaction will be queued.";
    }
}

2. Иди (Голанг)

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

package main

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

// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
	MaxAttempts int
	MinBackoff  time.Duration
	MaxBackoff  time.Duration
}

// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
	var err error
	
	for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
		err = operation()
		if err == nil {
			return nil // Success!
		}

		if attempt == config.MaxAttempts {
			break
		}

		// Calculate exponential backoff
		// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
		backoff := config.MinBackoff * (1 << (attempt - 1))
		if backoff > config.MaxBackoff || backoff <= 0 {
			backoff = config.MaxBackoff
		}

		// Apply Full Jitter: wait randomly between 0 and backoff
		jitter := time.Duration(rand.Int63n(int64(backoff)))

		fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)

		select {
		case <-time.After(jitter):
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}

func main() {
	config := RetryConfig{
		MaxAttempts: 4,
		MinBackoff:  100 * time.Millisecond,
		MaxBackoff:  2000 * time.Millisecond,
	}

	// Mock function that fails 3 times and succeeds on the 4th
	attempts := 0
	mockAPI := func() error {
		attempts++
		if attempts < 4 {
			return errors.New("network timeout (503)")
		}
		return nil
	}

	ctx := context.Background()
	err := Execute(ctx, config, mockAPI)
	if err != nil {
		fmt.Printf("Final Outcome: %v\n", err)
	} else {
		fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
	}
}

Повторная попытка или автоматический выключатель: когда какой использовать?

Разработчики часто путают Шаблон повтора с Шаблон автоматического выключателя. Хотя оба они направлены на повышение устойчивости системы, они справляются с принципиально разными типами сбоев:

Метрическая Повторить шаблон Образец автоматического выключателя
Основная цель Автоматическое восстановление после переходных (краткосрочных) сбоев. Предотвращает постоянные (долговременные) сбои при отключении вызывающего абонента.
Стратегия Попробуйте еще раз немедленно или после небольшого ожидания. Быстрое восстановление без вызова целевой службы.
Воздействие на переработку Временно увеличивает нагрузку на нижестоящую службу. Защищает нижестоящую службу от трафика, позволяя ей восстановиться.
Типичные триггеры Кратковременные отключения сети, тайм-ауты базы данных, падения сокетов. Нижестоящая служба полностью отключена, возвращая непрерывные сообщения 500 или таймауты.

Сильная пара: сочетание того и другого

В производственных системах эти два шаблона предназначены для использования совместно.

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

Если возникает временный сбой, механизм повтора перехватывает его и повторяет попытку. Однако если нижестоящая служба не работает, сбои будут накапливаться. Автоматический выключатель обнаруживает последовательные сбои, «отключает» размыкание и блокирует все будущие вызовы.

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


Лучшие практики для шаблона повтора

  1. Повторять только временные ошибки: проверьте коды состояния HTTP. Повторите попытку 503 Service Unavailable, 429 Too Many Requests (с учетом заголовков Retry-After, если они есть) и тайм-аутов сети. НЕ повторяйте 400 Bad Request, 401 Unauthorized или 404 Not Found — повторная попытка никогда не будет успешной.
  2. Применить джиттер. Никогда не используйте фиксированный таймер повтора без внесения случайного джиттера.
  3. Ограничить максимальное количество попыток. Прекратите повторные попытки после достижения разумного порога (обычно от 3 до 5 попыток). Неограниченное количество повторных попыток потребляет ресурсы и снижает задержку.
  4. Будьте осторожны с каскадными повторами: если служба A вызывает службу B (которая повторяет попытку 3 раза), а служба B вызывает службу C (которая повторяет 3 раза), сбой в C может привести к $3 \times 3 = 9$ каскадных вызовов. Применяйте повторные попытки только на тех границах, где они имеют наибольший смысл.
  5. Всегда устанавливайте строгие таймауты. Убедитесь, что таймауты вашей сети короче периодов отсрочки, чтобы потоки не задерживались на неопределенный срок.

Заключение

Шаблон повтора — это мощная первая линия защиты от ненадежности сети в микросервисных архитектурах. В сочетании с Экспоненциальной задержкой, Джиттером и Автоматическим выключателем вы можете защитить свои системы от каскадных сбоев и создать для своих пользователей бесперебойную работу с самовосстановлением.