Устранение неполадок Go Context Timeout: 7 корневых причин context deadline exceeded и точное управление
Ваш микросервис снова раздавлен context deadline exceeded
3 часа ночи, канал алертов взрывается — P99-латентность сервиса заказов взлетает до 5 секунд, вышестоящий шлюз массово сообщает context deadline exceeded. Вы увеличиваете тайм-аут с 3с до 5с, с 5с до 10с, но лишь откладываете проблему. Хуже того, вы обнаруживаете тысячи утечек горутин, использование памяти неуклонно растёт, вплоть до OOM kill.
Управление тайм-аутом Go Context — это не просто добавление context.WithTimeout. Каким должен быть тайм-аут? Как распространяется отмена? Как очищаются дочерние горутины? Как каскадно передаются тайм-ауты в цепочке микросервисов? Без понимания этого context deadline exceeded будет преследовать вас снова и снова.
Эта статья начинается с 7 корневых причин и проведёт вас через весь конвейер настройка тайм-аута → распространение отмены → очистка горутин → каскадное управление микросервисами.
Основные концепции Context
| Концепция | Описание |
|---|---|
| context.Context | Интерфейс стандартной библиотеки Go, переносящий deadline, сигнал отмены и значения в области запроса |
| context.WithTimeout | Создаёт дочерний Context с тайм-аутом, автоматически отменяется по истечении длительности |
| context.WithDeadline | Создаёт дочерний Context с абсолютным deadline |
| context.WithCancel | Создаёт дочерний Context с ручной отменой |
| context.WithoutCancel | Go 1.21+, создаёт дочерний Context, не затрагиваемый отменой родителя |
| context.AfterFunc | Go 1.21+, автоматически выполняет callback после отмены Context |
| context.Cause | Go 1.20+, получает корневую причину отмены Context |
Механизм распространения тайм-аута
Request Chain:
Gateway(5s) → OrderService(3s) → PaymentService(2s) → InventoryService(1s)
Propagation Rules:
1. Child Context timeout cannot exceed parent Context
2. Parent Context cancellation auto-cancels all children
3. After timeout, Done channel closes, Err returns deadline exceeded
4. Cancellation is idempotent, multiple Cancel() calls won't error
Анализ проблемы: 7 корневых причин context deadline exceeded
- Тайм-аут слишком короткий: Не учтены колебания сети и нагрузка сервиса, P99-латентность превышает порог тайм-аута
- Context не распространяется: Функция принимает Context, но вызывающий передаёт
context.TODO()илиcontext.Background() - Утечка горутин: После отмены Context дочерние горутины не проверяют канал Done, продолжают работу
- Каскадное усиление тайм-аута: Каждый слой микросервиса устанавливает тайм-аут, общий тайм-аут сжимается слой за слоем
- HTTP-клиент не настроен:
http.Client{}по умолчанию не имеет тайм-аута, запросы могут блокироваться бесконечно - Запрос к БД без тайм-аута: Время выполнения SQL не контролируется, долгие запросы тянут вниз весь запрос
- Переопределение Context: Внутренняя функция создаёт новый Context с
context.Background(), теряя внешний сигнал отмены
Пошаговая реализация: точное управление тайм-аутом
Шаг 1: Базовое управление тайм-аутом
package main
import (
"context"
"fmt"
"time"
)
func fetchUserData(ctx context.Context, userID string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resultCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
data, err := queryDatabase(ctx, userID)
if err != nil {
errCh <- err
return
}
resultCh <- data
}()
select {
case data := <-resultCh:
return data, nil
case err := <-errCh:
return "", err
case <-ctx.Done():
return "", fmt.Errorf("fetch user data: %w", ctx.Err())
}
}
func queryDatabase(ctx context.Context, userID string) (string, error) {
select {
case <-time.After(2 * time.Second):
return fmt.Sprintf("user_data_%s", userID), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx := context.Background()
data, err := fetchUserData(ctx, "12345")
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Data: %s\n", data)
}
Шаг 2: Распространение тайм-аута в цепочке микросервисов
package middleware
import (
"context"
"time"
)
type TimeoutConfig struct {
GatewayTimeout time.Duration
OrderServiceTimeout time.Duration
PaymentTimeout time.Duration
InventoryTimeout time.Duration
}
func DefaultTimeoutConfig() *TimeoutConfig {
return &TimeoutConfig{
GatewayTimeout: 5 * time.Second,
OrderServiceTimeout: 3 * time.Second,
PaymentTimeout: 2 * time.Second,
InventoryTimeout: 1 * time.Second,
}
}
type contextKey string
const timeoutKey contextKey = "timeout_config"
func WithTimeoutConfig(ctx context.Context, cfg *TimeoutConfig) context.Context {
return context.WithValue(ctx, timeoutKey, cfg)
}
func TimeoutConfigFromContext(ctx context.Context) *TimeoutConfig {
if cfg, ok := ctx.Value(timeoutKey).(*TimeoutConfig); ok {
return cfg
}
return DefaultTimeoutConfig()
}
func DeriveServiceTimeout(ctx context.Context, serviceTimeout time.Duration) (context.Context, context.CancelFunc) {
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining < serviceTimeout {
return context.WithDeadline(ctx, deadline)
}
}
return context.WithTimeout(ctx, serviceTimeout)
}
Шаг 3: Предотвращение утечки горутин
package pool
import (
"context"
"sync"
)
type WorkerPool struct {
maxWorkers int
tasks chan func() error
wg sync.WaitGroup
}
func NewWorkerPool(maxWorkers, queueSize int) *WorkerPool {
return &WorkerPool{
maxWorkers: maxWorkers,
tasks: make(chan func() error, queueSize),
}
}
func (p *WorkerPool) Start(ctx context.Context) {
for i := 0; i < p.maxWorkers; i++ {
p.wg.Add(1)
go func(workerID int) {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return
case task, ok := <-p.tasks:
if !ok {
return
}
task()
}
}
}(i)
}
}
func (p *WorkerPool) Submit(ctx context.Context, task func() error) error {
select {
case p.tasks <- task:
return nil
case <-ctx.Done():
return ctx.Err()
default:
return fmt.Errorf("task queue is full")
}
}
func (p *WorkerPool) Stop() {
close(p.tasks)
p.wg.Wait()
}
Шаг 4: Настройка тайм-аута HTTP-клиента
package httpclient
import (
"context"
"net"
"net/http"
"time"
)
type ClientConfig struct {
Timeout time.Duration
DialTimeout time.Duration
TLSHandshakeTimeout time.Duration
MaxIdleConns int
MaxIdleConnsPerHost int
IdleConnTimeout time.Duration
}
func DefaultClientConfig() *ClientConfig {
return &ClientConfig{
Timeout: 10 * time.Second,
DialTimeout: 3 * time.Second,
TLSHandshakeTimeout: 3 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
}
func NewClient(cfg *ClientConfig) *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.DialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
IdleConnTimeout: cfg.IdleConnTimeout,
ResponseHeaderTimeout: cfg.Timeout,
}
return &http.Client{
Timeout: cfg.Timeout,
Transport: transport,
}
}
func DoRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("request cancelled: %w", ctx.Err())
}
return nil, fmt.Errorf("request failed: %w", err)
}
return resp, nil
}
Шаг 5: Тайм-аут запроса к базе данных
package db
import (
"context"
"database/sql"
"time"
)
type DBOption struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
QueryTimeout time.Duration
}
func DefaultDBOption() *DBOption {
return &DBOption{
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: 5 * time.Minute,
ConnMaxIdleTime: 1 * time.Minute,
QueryTimeout: 3 * time.Second,
}
}
func OpenDB(driverName, dataSource string, opt *DBOption) (*sql.DB, error) {
db, err := sql.Open(driverName, dataSource)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(opt.MaxOpenConns)
db.SetMaxIdleConns(opt.MaxIdleConns)
db.SetConnMaxLifetime(opt.ConnMaxLifetime)
db.SetConnMaxIdleTime(opt.ConnMaxIdleTime)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}
func QueryWithTimeout(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("query timeout after 3s: %w", err)
}
return nil, err
}
return rows, nil
}
Руководство по подводным камням
Подводный камень 1: Хранение Context в Struct
// ❌ Wrong: storing Context in a struct
type UserService struct {
ctx context.Context
}
func (s *UserService) GetUser(id string) error {
return s.queryDatabase(s.ctx, id)
}
// ✅ Correct: pass Context as function parameter
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) error {
return s.queryDatabase(ctx, id)
}
Подводный камень 2: Забытый вызов Cancel вызывает утечку
// ❌ Wrong: cancel from WithTimeout not called
ctx, _ := context.WithTimeout(parentCtx, 5*time.Second)
result, err := doWork(ctx)
// ✅ Correct: always defer cancel
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()
result, err := doWork(ctx)
Подводный камень 3: Переопределение родительского Context через context.Background()
// ❌ Wrong: inner function uses Background, losing cancellation signal
func processOrder(ctx context.Context, orderID string) error {
innerCtx := context.Background()
return paymentService.charge(innerCtx, orderID)
}
// ✅ Correct: pass parent Context to maintain cancellation propagation
func processOrder(ctx context.Context, orderID string) error {
return paymentService.charge(ctx, orderID)
}
Подводный камень 4: Горутина не проверяет канал Done
// ❌ Wrong: goroutine doesn't respond to cancellation
go func() {
result := heavyComputation()
resultCh <- result
}()
// ✅ Correct: check Context cancellation inside goroutine
go func() {
result, err := heavyComputationWithCtx(ctx)
if err != nil {
return
}
select {
case resultCh <- result:
case <-ctx.Done():
}
}()
Подводный камень 5: Конфликт тайм-аута HTTP-клиента и Transport
// ❌ Wrong: Client.Timeout and Transport timeout overlap, may timeout early
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
ResponseHeaderTimeout: 5 * time.Second,
},
}
// ✅ Correct: Client.Timeout controls overall, Transport only connection phase
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
},
}
Устранение ошибок
| # | Сообщение об ошибке | Причина | Решение |
|---|---|---|---|
| 1 | context deadline exceeded |
Операция превысила тайм-аут Context | Проверить обоснованность тайм-аута, оптимизировать медленные запросы |
| 2 | context canceled |
Context отменён вручную (вызван cancel()) | Проверить источник отмены, подтвердить ожидаемость поведения |
| 3 | grpc: context canceled |
Context отменён во время вызова gRPC | Проверить тайм-аут клиента и время обработки сервером |
| 4 | net/http: request canceled |
HTTP-запрос отменён во время передачи | Проверить Client.Timeout и тайм-аут Context |
| 5 | driver: bad connection |
Соединение с БД истекло во время запроса | Увеличить ConnMaxLifetime, проверить тайм-аут QueryContext |
| 6 | i/o timeout |
Операция сетевого I/O истекла | Увеличить DialTimeout, проверить сетевое соединение |
| 7 | TLS handshake timeout |
TLS-рукопожатие истекло | Увеличить TLSHandshakeTimeout, проверить цепочку сертификатов |
| 8 | connection reset by peer |
Удалённая сторона закрыла соединение после тайм-аута | Проверить настройки тайм-аута удалённой стороны, обеспечить согласованность |
| 9 | goroutine leak detected |
Горутина не завершилась после отмены Context | Проверить ctx.Done() в горутинах, обеспечить своевременный выход |
| 10 | queue is full |
Очередь запросов заполнена, отправка не удалась | Увеличить ёмкость очереди, добавить воркеры, добавить противодавление |
Продвинутая оптимизация
1. Адаптивное управление тайм-аутом
package adaptive
import (
"context"
"sort"
"sync"
"time"
)
type AdaptiveTimeout struct {
mu sync.Mutex
history []time.Duration
maxHistory int
percentile float64
minTimeout time.Duration
maxTimeout time.Duration
safetyMargin float64
}
func NewAdaptiveTimeout(percentile float64, minTimeout, maxTimeout time.Duration) *AdaptiveTimeout {
return &AdaptiveTimeout{
history: make([]time.Duration, 0, 100),
maxHistory: 100,
percentile: percentile,
minTimeout: minTimeout,
maxTimeout: maxTimeout,
safetyMargin: 1.5,
}
}
func (a *AdaptiveTimeout) Record(duration time.Duration) {
a.mu.Lock()
defer a.mu.Unlock()
a.history = append(a.history, duration)
if len(a.history) > a.maxHistory {
a.history = a.history[1:]
}
}
func (a *AdaptiveTimeout) Timeout() time.Duration {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.history) == 0 {
return a.maxTimeout
}
sorted := make([]time.Duration, len(a.history))
copy(sorted, a.history)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
idx := int(float64(len(sorted)) * a.percentile)
if idx >= len(sorted) {
idx = len(sorted) - 1
}
timeout := time.Duration(float64(sorted[idx]) * a.safetyMargin)
if timeout < a.minTimeout {
timeout = a.minTimeout
}
if timeout > a.maxTimeout {
timeout = a.maxTimeout
}
return timeout
}
func (a *AdaptiveTimeout) Context(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, a.Timeout())
}
2. Middleware распространения тайм-аута
package middleware
import (
"context"
"fmt"
"strconv"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
const timeoutMetadataKey = "x-request-timeout-ms"
func TimeoutPropagationInterceptor() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
if deadline, ok := ctx.Deadline(); ok {
remainingMs := time.Until(deadline).Milliseconds()
if remainingMs > 0 {
md, _ := metadata.FromOutgoingContext(ctx)
md = md.Copy()
md.Set(timeoutMetadataKey, fmt.Sprintf("%d", remainingMs))
ctx = metadata.NewOutgoingContext(ctx, md)
}
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}
func TimeoutPropagationServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return handler(ctx, req)
}
values := md.Get(timeoutMetadataKey)
if len(values) == 0 {
return handler(ctx, req)
}
remainingMs, err := strconv.ParseInt(values[0], 10, 64)
if err != nil || remainingMs <= 0 {
return handler(ctx, req)
}
remaining := time.Duration(remainingMs) * time.Millisecond
if deadline, ok := ctx.Deadline(); ok {
if time.Until(deadline) < remaining {
remaining = time.Until(deadline)
}
}
ctx, cancel := context.WithTimeout(ctx, remaining)
defer cancel()
return handler(ctx, req)
}
}
3. Обнаружение утечки горутин
package leak
import (
"context"
"log"
"runtime"
"time"
)
type LeakDetector struct {
checkInterval time.Duration
threshold int
}
func NewLeakDetector(checkInterval time.Duration, threshold int) *LeakDetector {
return &LeakDetector{
checkInterval: checkInterval,
threshold: threshold,
}
}
func (d *LeakDetector) Start(ctx context.Context) {
ticker := time.NewTicker(d.checkInterval)
defer ticker.Stop()
var prevGoroutines int
var growthCount int
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
current := runtime.NumGoroutine()
if current > d.threshold {
log.Printf("[LEAK WARNING] goroutine count %d exceeds threshold %d", current, d.threshold)
}
growth := current - prevGoroutines
if growth > 10 {
growthCount++
if growthCount >= 3 {
log.Printf("[LEAK ALERT] goroutine count growing continuously: %d -> %d (%d consecutive growths)", prevGoroutines, current, growthCount)
}
} else {
growthCount = 0
}
prevGoroutines = current
}
}
}
Сравнительный анализ
| Измерение | context.WithTimeout | context.WithDeadline | context.WithCancel | time.After | select+timer |
|---|---|---|---|---|---|
| Точность тайм-аута | Миллисекунда | Абсолютное время | Без тайм-аута | Миллисекунда | Миллисекунда |
| Распространение отмены | ✅ Автоматически | ✅ Автоматически | ✅ Вручную | ❌ Нет | ❌ Нет |
| Безопасность горутин | ✅ | ✅ | ✅ | ⚠️ Риск утечки | ⚠️ Риск утечки |
| Каскад микросервисов | ✅ | ✅ | ✅ | ❌ | ❌ |
| Распространение значений | ✅ | ✅ | ✅ | ❌ | ❌ |
| Накладные расходы ресурсов | Низкие | Низкие | Низкие | Высокие (утечка) | Средние |
| Случай использования | Общий тайм-аут | Планируемые задачи | Ручная отмена | Простое ожидание | Локальный тайм-аут |
Итог:
context deadline exceededне решается «увеличением тайм-аута». Среди 7 корневых причин самые опасные — утечки горутин и переопределение Context — первые медленно «вытекают» из вашего сервиса до OOM, вторые делают управление тайм-аутом бесполезным. Практики тайм-аутов в микросервисах Go 2026: 1) Устанавливать глобальный тайм-аут на шлюзе, передавать оставшееся время через metadata; 2) Каждый слой сервиса используетDeriveServiceTimeoutс min(собственный тайм-аут, оставшееся время); 3) Все горутины должны проверятьctx.Done(); 4) Использовать адаптивный тайм-аут вместо статического; 5) Развёртывать обнаружение утечек горутин. Помните: тайм-аут — это не про то, чтобы быть длиннее, а про то, чтобы быть точным.
Рекомендуемые онлайн-инструменты
- Форматирование JSON: /ru/json/format
- Кодирование/декодирование Base64: /ru/encode/base64
- Калькулятор хешей: /ru/encode/hash
- Декодер JWT: /ru/encode/jwt-decode
Попробуйте эти локальные браузерные инструменты — регистрация не требуется →