Техники: timeout, retry, circuit breaker, load balancing

Или как говорить с ненадёжным миром

Представьте, что вы звоните в службу поддержки банка. Вы набираете номер и ждёте ответа. Сколько секунд вы будете слушать гудки, прежде чем повесите трубку? Пять? Десять? Двадцать? Вы только что установили таймаут (timeout) — защитный механизм, который не даёт вам вечно ждать ответа от ненадёжной системы.

Современные распределённые приложения — это тысячи таких "телефонных звонков" между микросервисами каждую секунду. И чтобы система в целом работала, когда отдельные её части начинают сбоить, инженеры придумали набор элегантных техник. Это не просто код, это правила общения в хаотичной среде.

1. Таймаут (Timeout): "Ждать, но не вечно"

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

Как работает в коде:

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func callWithTimeout() error {
    // Создаём контекст с таймаутом 3 секунды
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // Создаём HTTP-запрос с нашим контекстом
    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    if err != nil {
        return fmt.Errorf("создание запроса: %w", err)
    }

    client := &http.Client{}
    resp, err := client.Do(req)

    // Если контекст истёк раньше, чем пришёл ответ
    if ctx.Err() == context.DeadlineExceeded {
        return fmt.Errorf("превышено время ожидания (3 секунды)")
    }

    if err != nil {
        return fmt.Errorf("ошибка запроса: %w", err)
    }
    defer resp.Body.Close()

    // Обрабатываем успешный ответ
    return nil
}

func main() {
    if err := callWithTimeout(); err != nil {
        fmt.Printf("Ошибка: %v\n", err)
        // Можно показать пользователю: "Попробуйте позже"
    }
}

Аналог из жизни: Вы договорились с другом встретиться у кинотеатра. Вы ждёте 15 минут (ваш таймаут). Если он не пришёл, то вы идёте смотреть фильм один или звоните другому другу, но вы не стоите там до полуночи.

В чем смысл таймаутов?

  • Предотвращает "зависание" всего потока, всей цепочки сервисов. Если сервис-партнёр, от которого нам нужно получить какие-то данные "сдох", наш сервис не будет вечно ждать, занимая ценные ресурсы (память, потоки).
  • Позволяет быстро сообщить пользователю о проблеме. Лучше показать "Превышено время ожидания, попробуйте снова" через 2 секунды, чем заставлять пользователя смотреть на лоадер 30 секунд и бесить его.
  • Даёт системе шанс на самовосстановление. Освободившиеся ресурсы можно направить на обработку других, "здоровых" запросов.

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

2. Retry (повторная попытка): "Может, со второго раза получится?"

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

Как работает (упрощённо, с exponential backoff, экспотенциальной задержкой):

package main

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

// temporaryError - маркер временной ошибки
type temporaryError struct {
    error
}

func (t temporaryError) Temporary() bool { return true }

func makeRequest() error {
    // Имитируем 70% шанс временной ошибки
    if rand.Intn(10) < 7 {
        return temporaryError{errors.New("временная ошибка сети")}
    }
    return nil
}

func smartRetry(maxAttempts int, baseDelay time.Duration) error {
    for attempt := 1; attempt <= maxAttempts; attempt++ {
        err := makeRequest()
        if err == nil {
            fmt.Printf("Успех с попытки %d\n", attempt)
            return nil
        }

        // Проверяем, временная ли это ошибка
        var tempErr interface{ Temporary() bool }
        if errors.As(err, &tempErr) && tempErr.Temporary() {
            // Экспоненциальная отсрочка с джиттером (случайным смещением)
            delay := baseDelay * time.Duration(1<<uint(attempt-1)) // 2^(attempt-1)
            jitter := time.Duration(rand.Int63n(int64(delay / 4))) // ±25% от задержки
            delay = delay + jitter - (jitter / 2)

            fmt.Printf("Попытка %d неудачна, ждём %v: %v\n", attempt, delay, err)
            time.Sleep(delay)
            continue
        }

        // Если ошибка не временная - не повторяем
        return fmt.Errorf("фатальная ошибка: %w", err)
    }
    return fmt.Errorf("все %d попыток не удались", maxAttempts)
}

func main() {
    rand.Seed(time.Now().UnixNano())

    // Пробуем максимум 4 раза, начиная с задержки 100ms
    if err := smartRetry(4, 100*time.Millisecond); err != nil {
        fmt.Printf("Итоговая ошибка: %v\n", err)
    }
}

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

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

Умный ретрай не просто повторяет запросы подряд. Он увеличивает паузу между попытками (например: 0.1с, 0.4с, 0.9с). Зачем?

  • Чтобы не добить и без того больной сервис лавиной повторных запросов.
  • Чтобы дать ему время на восстановление (перезапуститься, освободить память).

В чем смысл?

  • Маскирует мимолётные, случайные сбои. Большинство сетевых проблем длятся миллисекунды. Одна повторная попытка - и пользователь ничего не заметил.
  • Повышает итоговую доступность. Даже если сервис доступен 95% времени, несколько умных повторов могут поднять успешность запросов с пользовательской стороны до 99,9%.

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

3. Circuit Breaker ("автоматический выключатель"): "Если сломалось - отключи и дай отдохнуть"

Это самый продвинутый паттерн. Если удалённый сервис начинает постоянно (превышая заранее определенный error threshold) отвечать ошибками, circuit breaker "разрывает цепь" и прекращает отправлять к нему запросы на некоторое время, сразу возвращая пользователю ошибку. Периодически он проверяет, не ожил ли сервис.

Есть алгоритмы, которые могут динамически адаптировать параметры circuit breaker, анализируя паттерны трафика и поведение сервисов и обучаясь на исторических данных и текущем состоянии системы.

Как работает (есть три состояния):

  1. CLOSED (Замкнут): всё работает, запросы проходят.
  2. OPEN (Разомкнут): сервис сломан. Все запросы мгновенно отклоняются с ошибкой, не доходя до него. "Даём ему передышку".
  3. HALF-OPEN (Полуразомкнут): через заданное время "отпускания" отправляется несколько пробных запросов. Если они успешны - возвращаемся в CLOSED. Если нет - то остаемся в OPEN.

Это как автомат в электрощитке. Если в розетке произошло короткое замыкание (сервис сломался), автомат щелкает и разрывает цепь (состояние OPEN). Это спасает проводку от пожара (вашу систему от полного краха). Через некоторое время вы (или автомат) пытаетесь включить её обратно (HALF-OPEN). Если замыкание устранено, то свет появляется (CLOSED). Если нет, то автомат снова выбивает (OPEN).

Зачем это нужно?

  • Спасает систему от каскадных отказов. Не даёт одному больному сервису утащить на дно всех, кто от него зависит.
  • Позволяет gracefully degradation. Лучше быстро сказать пользователю "Создание виртуальной машины временно недоступно", чем зависнуть на 30 секунд, а потом всё равно упасть, завалив за собой что-нибудь еще.
  • Экономит ресурсы. Не тратится время и потоки на бесплодные попытки достучаться до мёртвого сервиса.

4. Балансировка нагрузки (Load Balancing): "Не кладите все яйца в одну корзину"

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

Как работает: Между пользователем и вашими серверами стоит специальный сервер или программа - балансировщик нагрузки (load balancer). Он знает адреса всех рабочих серверов (бэкендов) и решает, на какой из них отправить очередной запрос.

Основные алгоритмы (очень коротко):

  • Round Robin (карусель): по очереди на все бэкэнды, как раздача карт в игре.
  • Least Connections (наименьшее количество соединений): запрос отправляется на сервер с наименьшей текущей нагрузкой.
  • Health Checks (на основе здоровья): балансировщик постоянно "пинает" серверы. Если сервер не отвечает - он временно исключается из пула, пока его работоспособность не восстановится.

Это несколько касс в супермаркете и смарт-очередь. Покупатели (запросы) выстраиваются в одну общую очередь. Свободная касса (здоровый сервер) зовёт следующего покупателя. Если касса ломается (сервер падает), её закрывают, и очередь перераспределяется между остальными. Никто не ждёт у сломанной кассы, пока ее починят, а общая пропускная способность остается максимальной.

Зачем это нужно?

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

Как эти техники работают вместе

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

package main

import (
    "context"
    "fmt"
    "time"
)

// Полный пример интеграции всех техник
func resilientCall(userID string) (string, error) {
    // 1. Балансировщик выбирает бэкенд
    backend := loadBalancer.GetBackend()
    if backend == nil {
        return "", fmt.Errorf("нет доступных серверов")
    }

    // 2. Circuit Breaker проверяет, не сломан ли бэкенд
    var result string
    err := circuitBreaker.Execute(func() error {
        // 3. Вызов с таймаутом
        ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
        defer cancel()

        // 4. Повторные попытки с exponential backoff
        err := retryWithBackoff(ctx, 3, func() error {
            resp, err := callBackend(ctx, backend, userID)
            if err != nil {
                return err
            }
            result = resp
            return nil
        })
        return err
    })

    if err != nil {
        // Возвращаем запасной вариант (деградация)
        return getFallbackRecommendation(userID), nil
    }

    return result, nil
}
  1. Балансировщик получает запрос и выбирает здоровый бэкенд Сервис А.
  2. Сервис А хочет позвонить Сервису Б. Он устанавливает таймаут в 2 секунды.
  3. Если Сервис Б не отвечает, Сервис А делает retry с exponential backoff.
  4. Если после нескольких попыток Сервис Б так и не ответил, его circuit breaker в Сервисе А переходит в состояние OPEN.
  5. Все последующие запросы к Сервису Б будут мгновенно отклоняться. Сервис А либо использует закешированный ответ, либо возвращает пользователю изящную деградацию.
  6. Через 30 секунд circuit breaker переходит в HALF-OPEN, чтобы проверить, не ожил ли Сервис Б.

Эти техники - "протокол выживания" в мире, где отказы считаются нормой. Они позволяют системе быть устойчивой (resilient): не просто избегать сбоев, а стойко принимать удары, изолировать повреждения и продолжать работать. Они превращают хрупкую конструкцию из множества зависимостей в живой, адаптирующийся организм, который может пережить любую аварию.

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

Или другие, для вашего конкретного случая.