Go Context Timeout-Fehlerbehebung: 7 Ursachen für context deadline exceeded und Präzise Steuerung
Dein Microservice wurde wieder von context deadline exceeded zermalmt
3 Uhr morgens, der Alert-Kanal explodiert — P99-Latenz des Bestelldienstes schießt auf 5 Sekunden, das vorgeschaltete Gateway meldet context deadline exceeded ohne Ende. Du erhöhst das Timeout von 3s auf 5s, von 5s auf 10s, aber du verschiebst das Problem nur. Schlimmer noch, du entdeckst tausende Goroutine-Leaks, der Speicherverbrauch steigt stetig, bis es schließlich zu einem OOM-Kill kommt.
Go Context-Timeout-Steuerung bedeutet nicht nur context.WithTimeout hinzuzufügen. Wie lang sollte das Timeout sein? Wie breitet sich der Abbruch aus? Wie werden Kind-Goroutines aufgeräumt? Wie kaskadieren Timeouts in der Microservice-Kette? Ohne dieses Verständnis wird context deadline exceeded dich immer wieder verfolgen.
Dieser Artikel beginnt bei 7 Ursachen und führt dich durch die gesamte Pipeline Timeout-Konfiguration → Abbruch-Weitergabe → Goroutine-Bereinigung → Microservice-Kaskadensteuerung.
Kernkonzepte von Context
| Konzept | Beschreibung |
|---|---|
| context.Context | Go-Standardbibliothek-Interface, das Deadline, Abbruchsignal und anfragespezifische Werte transportiert |
| context.WithTimeout | Erstellt einen Kind-Context mit Timeout, bricht automatisch nach Ablauf der Dauer ab |
| context.WithDeadline | Erstellt einen Kind-Context mit absoluter Deadline |
| context.WithCancel | Erstellt einen manuell abbrechbaren Kind-Context |
| context.WithoutCancel | Go 1.21+, erstellt einen Kind-Context, der vom Abbruch des Eltern-Contexts unbeeinflusst ist |
| context.AfterFunc | Go 1.21+, führt automatisch einen Callback nach Context-Abbruch aus |
| context.Cause | Go 1.20+, ermittelt die Ursache des Context-Abbruchs |
Timeout-Weitergabemechanismus
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
Problemanalyse: 7 Ursachen für context deadline exceeded
- Timeout zu kurz: Netzwerkschwankungen und Dienstlast nicht berücksichtigt, P99-Latenz überschreitet Timeout-Schwelle
- Context nicht weitergegeben: Funktion akzeptiert Context, aber Aufrufer übergibt
context.TODO()odercontext.Background() - Goroutine-Leak: Nach Context-Abbruch prüfen Kind-Goroutines den Done-Kanal nicht, laufen weiter
- Timeout-Kaskadenverstärkung: Jede Microservice-Schicht setzt Timeout, Gesamt-Timeout wird Schicht für Schicht komprimiert
- HTTP-Client nicht konfiguriert:
http.Client{}hat standardmäßig kein Timeout, Anfragen können ewig blockieren - Datenbankabfrage ohne Timeout: SQL-Ausführungszeit unkontrolliert, lange Abfragen ziehen die gesamte Anfrage herunter
- Context-Überschreibung: Innere Funktion erstellt neuen Context mit
context.Background(), verliert äußeres Abbruchsignal
Schritt für Schritt: Implementierung der präzisen Timeout-Steuerung
Schritt 1: Grundlegende Timeout-Steuerung
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)
}
Schritt 2: Timeout-Weitergabe in der Microservice-Kette
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)
}
Schritt 3: Goroutine-Leak-Verhinderung
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()
}
Schritt 4: HTTP-Client-Timeout-Konfiguration
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
}
Schritt 5: Datenbankabfrage-Timeout
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
}
Fallstrick-Leitfaden
Fallstrick 1: Context in einem Struct speichern
// ❌ 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)
}
Fallstrick 2: Vergessen, Cancel aufzurufen, verursacht Leak
// ❌ 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)
Fallstrick 3: Eltern-Context mit context.Background() überschreiben
// ❌ 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)
}
Fallstrick 4: Goroutine prüft Done-Kanal nicht
// ❌ 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():
}
}()
Fallstrick 5: Konflikt zwischen HTTP-Client- und Transport-Timeout
// ❌ 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,
},
}
Fehlerbehebung
| # | Fehlermeldung | Ursache | Lösung |
|---|---|---|---|
| 1 | context deadline exceeded |
Operation hat Context-Timeout überschritten | Prüfen, ob Timeout angemessen ist, langsame Abfragen/Anfragen optimieren |
| 2 | context canceled |
Context manuell abgebrochen (cancel() aufgerufen) | Abbruchquelle prüfen, bestätigen ob erwartetes Verhalten |
| 3 | grpc: context canceled |
Context während gRPC-Aufruf abgebrochen | Client-Timeout und Server-Verarbeitungszeit prüfen |
| 4 | net/http: request canceled |
HTTP-Anfrage während Übertragung abgebrochen | Client.Timeout und Context-Timeout prüfen |
| 5 | driver: bad connection |
DB-Verbindung während Abfrage abgelaufen | ConnMaxLifetime erhöhen, QueryContext-Timeout prüfen |
| 6 | i/o timeout |
Netzwerk-I/O-Operation abgelaufen | DialTimeout erhöhen, Netzwerkverbindung prüfen |
| 7 | TLS handshake timeout |
TLS-Handshake abgelaufen | TLSHandshakeTimeout erhöhen, Zertifikatskette prüfen |
| 8 | connection reset by peer |
Gegenstelle hat Verbindung nach Timeout geschlossen | Timeout-Einstellungen der Gegenstelle prüfen, sicherstellen dass beide Seiten übereinstimmen |
| 9 | goroutine leak detected |
Goroutine wurde nach Context-Abbruch nicht beendet | ctx.Done() in Goroutines prüfen, rechtzeitigen Exit sicherstellen |
| 10 | queue is full |
Anfrage-Warteschlange voll, Einreichung fehlgeschlagen | Warteschlangenkapazität erhöhen, Worker hinzufügen, Backpressure einfügen |
Erweiterte Optimierung
1. Adaptive Timeout-Steuerung
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. Timeout-Weitergabe-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. Goroutine-Leak-Erkennung
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
}
}
}
Vergleichsanalyse
| Dimension | context.WithTimeout | context.WithDeadline | context.WithCancel | time.After | select+timer |
|---|---|---|---|---|---|
| Timeout-Präzision | Millisekunde | Absolute Zeit | Kein Timeout | Millisekunde | Millisekunde |
| Abbruch-Weitergabe | ✅ Automatisch | ✅ Automatisch | ✅ Manuell | ❌ Keine | ❌ Keine |
| Goroutine-sicher | ✅ | ✅ | ✅ | ⚠️ Leak-Risiko | ⚠️ Leak-Risiko |
| Microservice-Kaskade | ✅ | ✅ | ✅ | ❌ | ❌ |
| Wert-Weitergabe | ✅ | ✅ | ✅ | ❌ | ❌ |
| Ressourcen-Overhead | Niedrig | Niedrig | Niedrig | Hoch (Leak) | Mittel |
| Anwendungsfall | Allgemeines Timeout | Geplante Aufgaben | Manueller Abbruch | Einfaches Warten | Lokales Timeout |
Zusammenfassung:
context deadline exceededwird nicht durch "Timeout erhöhen" gelöst. Unter den 7 Ursachen sind Goroutine-Leaks und Context-Überschreibung die gefährlichsten — erstere "bluten" deinen Dienst langsam bis zum OOM aus, letztere machen die Timeout-Steuerung nutzlos. Go-Microservice-Timeout-Praktiken 2026: 1) Globales Timeout am Gateway setzen, verbleibende Zeit via Metadata weitergeben; 2) Jede Service-Schicht verwendetDeriveServiceTimeoutmit min(eigenes Timeout, verbleibende Zeit); 3) Alle Goroutines müssenctx.Done()prüfen; 4) Adaptives Timeout statt statischem Timeout verwenden; 5) Goroutine-Leak-Erkennung einsetzen. Denke daran: Timeout geht nicht um länger, sondern um präzise.
Empfohlene Online-Tools
- JSON-Formatierer: /de/json/format
- Base64-Kodierung/Dekodierung: /de/encode/base64
- Hash-Rechner: /de/encode/hash
- JWT-Dekodierer: /de/encode/jwt-decode
Probiere diese browser-lokalen Tools aus — keine Registrierung erforderlich →