Dépannage du Go Context Timeout : 7 causes racines de context deadline exceeded et contrôle de précision

编程语言

Votre microservice a encore été écrasé par context deadline exceeded

3h du matin, le canal d'alertes explose — la latence P99 du service de commandes monte à 5 secondes, la passerelle amont signale context deadline exceeded sans relâche. Vous augmentez le timeout de 3s à 5s, de 5s à 10s, mais vous ne faites que repousser le problème. Pire encore, vous découvrez des milliers de fuites de goroutines, l'utilisation mémoire augmente régulièrement, jusqu'au OOM kill.

Le contrôle de timeout du Go Context ne se résume pas à ajouter context.WithTimeout. Quelle doit être la durée du timeout ? Comment l'annulation se propage-t-elle ? Comment les goroutines filles sont-elles nettoyées ? Comment les timeouts en cascade dans la chaîne de microservices se comportent-ils ? Sans comprendre cela, context deadline exceeded vous hantera encore et encore.

Cet article part de 7 causes racines et vous guide à travers le pipeline complet configuration de timeout → propagation d'annulation → nettoyage de goroutine → contrôle en cascade des microservices.


Concepts fondamentaux du Context

Concept Description
context.Context Interface de la bibliothèque standard Go transportant le deadline, le signal d'annulation et les valeurs à portée de requête
context.WithTimeout Crée un Context enfant avec timeout, s'annule automatiquement après la durée
context.WithDeadline Crée un Context enfant avec un deadline absolu
context.WithCancel Crée un Context enfant annulable manuellement
context.WithoutCancel Go 1.21+, crée un Context enfant non affecté par l'annulation du parent
context.AfterFunc Go 1.21+, exécute automatiquement un callback après l'annulation du Context
context.Cause Go 1.20+, récupère la cause racine de l'annulation du Context

Mécanisme de propagation du 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

Analyse du problème : 7 causes racines de context deadline exceeded

  1. Timeout trop court: Les fluctuations réseau et la charge du service n'ont pas été prises en compte, la latence P99 dépasse le seuil de timeout
  2. Context non propagé: La fonction accepte un Context mais l'appelant passe context.TODO() ou context.Background()
  3. Fuite de goroutine: Après l'annulation du Context, les goroutines filles ne vérifient pas le canal Done, continuent de s'exécuter
  4. Amplification en cascade du timeout: Chaque couche de microservice définit un timeout, le timeout total est compressé couche par couche
  5. Client HTTP non configuré: http.Client{} n'a pas de timeout par défaut, les requêtes peuvent bloquer indéfiniment
  6. Requête de base de données sans timeout: Le temps d'exécution SQL n'est pas contrôlé, les requêtes longes entraînent toute la requête
  7. Surcharge de Context: La fonction interne crée un nouveau Context avec context.Background(), perdant le signal d'annulation externe

Étape par étape : Implémentation du contrôle de timeout de précision

Étape 1 : Contrôle de timeout de base

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

Étape 2 : Propagation du timeout dans la chaîne de microservices

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

Étape 3 : Prévention des fuites 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()
}

Étape 4 : Configuration du timeout du client 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
}

Étape 5 : Timeout de requête de base de données

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
}

Guide des pièges courants

Piège 1 : Stocker le Context dans 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)
}

Piège 2 : Oublier d'appeler Cancel provoque une fuite

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

Piège 3 : Remplacer le Context parent par 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)
}

Piège 4 : La goroutine ne vérifie pas le 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():
    }
}()

Piège 5 : Conflit entre le timeout du client HTTP et du 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,
    },
}

Dépannage des erreurs

# Message d'erreur Cause Solution
1 context deadline exceeded L'opération a dépassé le timeout du Context Vérifier si le timeout est raisonnable, optimiser les requêtes/requêtes lentes
2 context canceled Context annulé manuellement (cancel() appelé) Vérifier la source de l'annulation, confirmer si c'est le comportement attendu
3 grpc: context canceled Context annulé pendant un appel gRPC Vérifier le timeout client et le temps de traitement serveur
4 net/http: request canceled Requête HTTP annulée pendant le transfert Vérifier Client.Timeout et le timeout du Context
5 driver: bad connection Connexion BD expirée pendant la requête Augmenter ConnMaxLifetime, vérifier le timeout QueryContext
6 i/o timeout Opération d'E/S réseau expirée Augmenter DialTimeout, vérifier la connectivité réseau
7 TLS handshake timeout Handshake TLS expiré Augmenter TLSHandshakeTimeout, vérifier la chaîne de certificats
8 connection reset by peer Le pair a fermé la connexion après le timeout Vérifier les paramètres de timeout du pair, s'assurer que les deux côtés sont d'accord
9 goroutine leak detected La goroutine ne s'est pas terminée après l'annulation du Context Vérifier ctx.Done() dans les goroutines, assurer une sortie rapide
10 queue is full File d'attente de requêtes pleine, soumission échouée Augmenter la capacité de la file, ajouter des workers, ajouter du contre-pression

Optimisation avancée

1. Contrôle de timeout adaptatif

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 propagation 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. Détection des fuites 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
        }
    }
}

Analyse comparative

Dimension context.WithTimeout context.WithDeadline context.WithCancel time.After select+timer
Précision du timeout Milliseconde Temps absolu Pas de timeout Milliseconde Milliseconde
Propagation de l'annulation ✅ Automatique ✅ Automatique ✅ Manuelle ❌ Aucune ❌ Aucune
Sûr pour les goroutines ⚠️ Risque de fuite ⚠️ Risque de fuite
Cascade de microservices
Propagation des valeurs
Surcharge de ressources Faible Faible Faible Élevée (fuite) Moyenne
Cas d'utilisation Timeout général Tâches planifiées Annulation manuelle Attente simple Timeout local

Résumé : context deadline exceeded ne se résout pas en « augmentant le timeout ». Parmi les 7 causes racines, les plus dangereuses sont les fuites de goroutines et la surcharge de Context — les premières « saignent » lentement votre service jusqu'à l'OOM, les secondes rendent le contrôle de timeout inutile. Pratiques de timeout dans les microservices Go 2026 : 1) Définir un timeout global à la passerelle, propager le temps restant via les métadonnées ; 2) Chaque couche de service utilise DeriveServiceTimeout prenant min(timeout propre, temps restant) ; 3) Toutes les goroutines doivent vérifier ctx.Done() ; 4) Utiliser un timeout adaptatif plutôt qu'un timeout statique ; 5) Déployer la détection de fuites de goroutines. Rappelez-vous : le timeout ne consiste pas à être plus long, mais à être précis.


Outils en ligne recommandés

Essayez ces outils exécutés localement dans le navigateur — aucune inscription requise →

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