Техники: 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, анализируя паттерны трафика и поведение сервисов и обучаясь на исторических данных и текущем состоянии системы.
Как работает (есть три состояния):
- CLOSED (Замкнут): всё работает, запросы проходят.
- OPEN (Разомкнут): сервис сломан. Все запросы мгновенно отклоняются с ошибкой, не доходя до него. "Даём ему передышку".
- 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
}
- Балансировщик получает запрос и выбирает здоровый бэкенд
Сервис А. Сервис Ахочет позвонитьСервису Б. Он устанавливает таймаут в 2 секунды.- Если
Сервис Бне отвечает,Сервис Аделает retry с exponential backoff. - Если после нескольких попыток
Сервис Бтак и не ответил, его circuit breaker вСервисе Апереходит в состояние OPEN. - Все последующие запросы к
Сервису Ббудут мгновенно отклоняться.Сервис Алибо использует закешированный ответ, либо возвращает пользователю изящную деградацию. - Через 30 секунд circuit breaker переходит в HALF-OPEN, чтобы проверить, не ожил ли
Сервис Б.
Эти техники - "протокол выживания" в мире, где отказы считаются нормой. Они позволяют системе быть устойчивой (resilient): не просто избегать сбоев, а стойко принимать удары, изолировать повреждения и продолжать работать. Они превращают хрупкую конструкцию из множества зависимостей в живой, адаптирующийся организм, который может пережить любую аварию.
В жизни лучше не изобретать велосипед - все придумано до нас, используйте проверенные библиотеки:
Или другие, для вашего конкретного случая.