Solución de problemas de Go Context Timeout: 7 causas raíz de context deadline exceeded y control de precisión

编程语言

Tu microservicio fue aplastado por context deadline exceeded otra vez

3 AM, el canal de alertas explota — la latencia P99 del servicio de órdenes sube a 5 segundos, el gateway ascendente reporta context deadline exceeded sin parar. Incrementas el timeout de 3s a 5s, de 5s a 10s, pero solo estás posponiendo el problema. Peor aún, descubres miles de fugas de goroutine, el uso de memoria sube constantemente, hasta que finalmente ocurre un OOM kill.

El control de timeout de Go Context no es solo agregar context.WithTimeout. ¿Cuánto debería ser el timeout? ¿Cómo se propaga la cancelación? ¿Cómo se limpian las goroutines hijas? ¿Cómo se cascaden los timeouts en la cadena de microservicios? Sin entender esto, context deadline exceeded te perseguirá repetidamente.

Este artículo parte de 7 causas raíz y te guía a través del pipeline completo configuración de timeout → propagación de cancelación → limpieza de goroutine → control de cascada de microservicios.


Conceptos centrales de Context

Concepto Descripción
context.Context Interfaz de la biblioteca estándar de Go que transporta deadline, señal de cancelación y valores con alcance de solicitud
context.WithTimeout Crea un Context hijo con timeout, se cancela automáticamente después de la duración
context.WithDeadline Crea un Context hijo con deadline absoluto
context.WithCancel Crea un Context hijo cancelable manualmente
context.WithoutCancel Go 1.21+, crea un Context hijo no afectado por la cancelación del padre
context.AfterFunc Go 1.21+, ejecuta automáticamente un callback después de la cancelación del Context
context.Cause Go 1.20+, recupera la causa raíz de la cancelación del Context

Mecanismo de propagación de timeout

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

Análisis del problema: 7 causas raíz de context deadline exceeded

  1. Timeout demasiado corto: No se consideró la fluctuación de red y la carga del servicio, la latencia P99 excede el umbral de timeout
  2. Context no propagado: La función acepta Context pero el llamador pasa context.TODO() o context.Background()
  3. Fuga de goroutine: Después de la cancelación del Context, las goroutines hijas no verifican el canal Done, siguen ejecutándose
  4. Amplificación en cascada del timeout: Cada capa de microservicio establece timeout, el timeout total se comprime capa por capa
  5. Cliente HTTP no configurado: http.Client{} no tiene timeout por defecto, las solicitudes pueden bloquearse indefinidamente
  6. Consulta de base de datos sin timeout: El tiempo de ejecución SQL no está controlado, las consultas largas arrastran toda la solicitud
  7. Sobrescritura de Context: La función interna crea un nuevo Context con context.Background(), perdiendo la señal de cancelación externa

Paso a paso: Implementación de control de timeout de precisión

Paso 1: Control de timeout básico

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)
}

Paso 2: Propagación de timeout en cadena de microservicios

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)
}

Paso 3: Prevención de fuga de goroutine

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()
}

Paso 4: Configuración de timeout del cliente 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
}

Paso 5: Timeout de consulta de base de datos

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
}

Guía de errores comunes

Error 1: Almacenar Context en un 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)
}

Error 2: Olvidar llamar Cancel causa fuga

// ❌ 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)

Error 3: Sobrescribir el Context padre con 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)
}

Error 4: La goroutine no verifica el canal 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():
    }
}()

Error 5: Conflicto entre timeout del cliente HTTP y del 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,
    },
}

Solución de problemas de errores

# Mensaje de error Causa Solución
1 context deadline exceeded La operación excedió el timeout del Context Verificar si el timeout es razonable, optimizar consultas/solicitudes lentas
2 context canceled Context cancelado manualmente (se llamó cancel()) Verificar la fuente de cancelación, confirmar si es comportamiento esperado
3 grpc: context canceled Context cancelado durante una llamada gRPC Verificar timeout del cliente y tiempo de procesamiento del servidor
4 net/http: request canceled Solicitud HTTP cancelada durante la transferencia Verificar Client.Timeout y timeout del Context
5 driver: bad connection Conexión de BD expiró durante la consulta Incrementar ConnMaxLifetime, verificar timeout de QueryContext
6 i/o timeout Operación de I/O de red expiró Incrementar DialTimeout, verificar conectividad de red
7 TLS handshake timeout Handshake TLS expiró Incrementar TLSHandshakeTimeout, verificar cadena de certificados
8 connection reset by peer El par cerró la conexión después del timeout Verificar configuración de timeout del par, asegurar que ambos lados coincidan
9 goroutine leak detected La goroutine no salió después de la cancelación del Context Verificar ctx.Done() en goroutines, asegurar salida oportuna
10 queue is full Cola de solicitudes llena, envío fallido Incrementar capacidad de cola, agregar workers, agregar retroalimentación

Optimización avanzada

1. Control de timeout adaptativo

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 de propagación de timeout

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. Detección de fuga de goroutine

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
        }
    }
}

Análisis comparativo

Dimensión context.WithTimeout context.WithDeadline context.WithCancel time.After select+timer
Precisión de timeout Milisegundo Tiempo absoluto Sin timeout Milisegundo Milisegundo
Propagación de cancelación ✅ Automática ✅ Automática ✅ Manual ❌ Ninguna ❌ Ninguna
Seguro para goroutine ⚠️ Riesgo de fuga ⚠️ Riesgo de fuga
Cascada de microservicios
Propagación de valores
Sobrecarga de recursos Baja Baja Baja Alta (fuga) Media
Caso de uso Timeout general Tareas programadas Cancelación manual Espera simple Timeout local

Resumen: context deadline exceeded no se resuelve "incrementando el timeout." Entre las 7 causas raíz, las más peligrosas son las fugas de goroutine y la sobrescritura de Context — la primera "sangra" lentamente tu servicio hasta el OOM, la segunda hace inútil el control de timeout. Prácticas de timeout en microservicios Go 2026: 1) Establecer timeout global en el gateway, propagar el tiempo restante vía metadata; 2) Cada capa de servicio usa DeriveServiceTimeout tomando min(timeout propio, tiempo restante); 3) Todas las goroutines deben verificar ctx.Done(); 4) Usar timeout adaptativo en lugar de timeout estático; 5) Desplegar detección de fuga de goroutine. Recuerda: el timeout no se trata de ser más largo, se trata de ser preciso.


Herramientas en línea recomendadas

Prueba estas herramientas que se ejecutan en tu navegador — no requieren registro →

#Go#Context#超时控制#goroutine#微服务#2026#并发编程