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
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. Итог
- Таймауты — это архитектурные контракты, а не "костыли" для медленного кода.
- Иерархия должна убывать: Клиент > Шлюз > Сервис > Зависимость.
context.WithTimeout+defer cancel()— обязательный паттерн для каждой внешней операции.- Рассчитывайте таймауты на основе p99 latency зависимостей, сетевого джиттера и бюджета на ретраи.
- Никогда не блокируйте горутины на каналах или вызовах без проверки
ctx.Done(). - Различайте
context.Canceled(намеренная отмена) иcontext.DeadlineExceeded(истекло время) для точной маршрутизации ошибок. - Оставляйте error budget: SLA должно превышать SLO, а таймауты — учитывать вероятность каскадных деградаций.
Следующая статья: 32. API versioning