31. Таймауты и SLA

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

Таймаут — это не просто «допустимая пауза перед выводом ошибки». Это фундаментальный архитектурный контракт, жестко ограничивающий время владения системными ресурсами. Если в вашей распределенной системе нет дисциплины дедлайнов, малейшая заминка во внешней зависимости немедленно превращается в каскадный шторм: сокеты остаются открытыми, горутины накапливаются в очередях планировщика, пулы соединений исчерпываются, и через несколько минут весь бэкенд падает по нехватке памяти (OOM).

Философия таймаутов и SLA

Таймаут в распределенной системе — это не просто "время ожидания ответа". Это архитектурный контракт, ограничивающий время жизни операции, объем потребляемых ресурсов и гарантирующий детерминированное поведение при деградации зависимостей. Без строгих таймаутов временная проблема во внешнем API превращается в каскадный отказ, пулы соединений истощаются, а горутины накапливаются до OOM.

SLA, SLO и SLI задают математические границы для этих таймаутов. Инженерная задача — перевести бизнес-требования надежности в конкретные миллисекунды на уровне кода, контекста и сетевых стеков.

1. Иерархия таймаутов и правило убывания

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

flowchart LR
    A["Мобильный клиент"] -->|"3.0 с"| B["API Gateway / LB"]
    B -->|"2.5 с"| C["Go Сервис"]
    C -->|"1.5 с"| D["База данных / Внешний API"]
    D -->|"Ответ или отмена"| C
    C -->|"Ответ или 504"| B
    B -->|"Ответ или 504"| A

    classDef client fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef edge fill:#334155,stroke:#818cf8,color:#f8fafc;
    classDef app fill:#0f172a,stroke:#10b981,color:#f8fafc;
    classDef storage fill:#4c0519,stroke:#f43f5e,color:#ffffff;
    class A client;
    class B edge;
    class C app;
    class D storage;
  

Если таймаут базы данных равен таймауту шлюза, то при деградации БД сервис будет удерживать соединения до последнего момента. Шлюз завершит клиентскую сессию, а серверные горутины останутся заблокированными, потребляя память и файловые дескрипторы.

2. Под капотом. Механика таймеров в рантайме Go

Когда вы вызываете context.WithTimeout или time.Sleep, рантайм создает объект runtime.timer. В современном Go таймеры шардированы по локальным логическим процессорам P (per-P timer heaps), что устраняет конкуренцию за глобальный мьютекс.

  • Локальное управление: планировщик Go проверяет созревшие таймеры непосредственно в цикле планирования P без задержек на переключение контекста. В Go 1.23+ подсистема таймеров была фундаментально переработана с прямой интеграцией в сетевой поллер (netpoll), что практически обнулило оверхед на синхронизацию.
  • Отмена таймера: timer.Stop() помечает таймер для удаления и исключает его из очереди.
  • Системные вызовы: net.Conn.SetDeadline() транслируется в epoll_ctl(EPOLL_CTL_MOD) с EPOLLOUT/EPOLLIN и таймером в ядре. Это позволяет ядру прерывать блокирующий read()/write() без участия Go-планировщика.

3. Идиоматичная настройка в Go

Go
package main

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

var apiClient = setupClient()

func setupClient() *http.Client {
    return &http.Client{
        // Timeout покрывает весь цикл: connect -> request -> response -> body read
        Timeout: 2 * time.Second,
        Transport: &http.Transport{
            MaxIdleConns:        100,
            IdleConnTimeout:     90 * time.Second,
            TLSHandshakeTimeout: 500 * time.Millisecond,
        },
    }
}

func handlerWithTimeouts(w http.ResponseWriter, r *http.Request) {
    // Контекст от родителя + явный таймаут для конкретной операции
    ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
    defer cancel() // Обязательно для освобождения таймера

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/data", nil)
    if err != nil {
        http.Error(w, "request build failed", http.StatusInternalServerError)
        return
    }

    // Используем сконфигурированный клиент с таймаутами вместо дефолтного
    resp, err := apiClient.Do(req)
    if err != nil {
        // Различаем отмену и таймаут
        if ctx.Err() == context.DeadlineExceeded {
            http.Error(w, "dependency timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, "request failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    // Дальнейшая обработка...
}

4. Расчет таймаутов через SLA и Error Budgets

Таймауты не берутся "с потолка". Они выводятся из SLO (Service Level Objectives) и latency-профиля зависимостей.

  • SLI: Фактическая метрика (например, http_request_duration_seconds{quantile="0.99"})
  • SLO: Целевой уровень (например, 99.9% запросов должны отвечать быстрее 200 мс)
  • Error Budget: 100% - SLO = 0.1%. Это допустимое время сбоев в месяц (~43 минуты)
  • Таймаут сервиса: Должен быть меньше SLI p99, чтобы оставить запас на ретраи. Формула: Service Timeout = p99(dependency) + network jitter + retry backoff headroom

Пример: Зависимость отдает данные за p99=80 мс. Сетевой джиттер внутри кластера ~10 мс. Планируется 1 ретрай с задержкой 50 мс.
Таймаут = 80 + 10 + 50 + 20 (буфер) ≈ 160 мс.

5. Ловушки и антипаттерны

  • Блокировка на канале без таймаута: result := <-ch будет висеть вечно, если канал никогда не получит данные. Всегда используйте select { case <-ch: case <-ctx.Done(): }.
  • Двойные таймауты на одном уровне: Настройка ReadTimeout на сервере и WriteTimeout меньше ReadTimeout приведет к разрыву соединения во время обработки. WriteTimeout должен покрывать время генерации ответа + сетевую задержку.
  • Игнорирование ctx.Err() после блокирующей операции: Если db.QueryContext вернул ошибку из-за таймаута, но вы продолжаете логику, сервис может работать с частичными данными. Всегда проверяйте errors.Is(err, context.DeadlineExceeded) и возвращайте 504 Gateway Timeout.
  • Глобальные таймауты для фоновых задач: Не используйте http.Client.Timeout для воркеров очередей. Там применяются стратегии context.WithTimeout с динамическим увеличением или асинхронные очереди с DLQ.

6. Производительность и Mechanical Sympathy

  • Аллокации таймеров: context.WithTimeout создает timerCtx в куче. При использовании sync.Pool для переиспользования структур запросов и явном timer.Reset() можно сократить давление на GC на 20-30%.
  • Кэш-линии и планировщик: При отмене контекста рантайм итерирует дерево дочерних cancelCtx. Глубокие вложенности разбрасывают указатели по памяти, вызывая cache misses. Держите глубину цепочки контекстов менее 10 уровней.
  • Системные вызовы: SetDeadline вызывает epoll_ctl на каждое изменение. Для высоконагруженных пулов соединений (например, в gRPC или кастомных TCP-клиентах) используйте netpoll напрямую или пулируйте соединения с единым таймаутом на весь пул, а не на каждый net.Conn.

7. Итог

  1. Таймауты — это архитектурные контракты, а не "костыли" для медленного кода.
  2. Иерархия должна убывать: Клиент > Шлюз > Сервис > Зависимость.
  3. context.WithTimeout + defer cancel() — обязательный паттерн для каждой внешней операции.
  4. Рассчитывайте таймауты на основе p99 latency зависимостей, сетевого джиттера и бюджета на ретраи.
  5. Никогда не блокируйте горутины на каналах или вызовах без проверки ctx.Done().
  6. Различайте context.Canceled (намеренная отмена) и context.DeadlineExceeded (истекло время) для точной маршрутизации ошибок.
  7. Оставляйте error budget: SLA должно превышать SLO, а таймауты — учитывать вероятность каскадных деградаций.

Следующая статья: 32. API versioning