context.Context 的作用是把取消信号、截止时间(deadline)和请求级数据沿调用链向下传递。一次请求自 HTTP handler 进入,经过 service、数据库、下游服务,属于这条链的操作都应派生自同一个 ctx。若取消信号未从入口传到底层,客户端断开后,服务端仍会继续执行已无意义的查询,持续占用 goroutine 与连接等资源。

context 的错误不经过编译期检查,常见问题包括:ctx 被存入 struct、WithTimeout 返回的 cancel 被丢弃、调用链中层新建 context.Background() 使上游超时失效,以及把 user id 等业务参数放入 WithValue。这些问题在 code review 中不易察觉,运行时的典型表现是 goroutine 泄漏、超时失效、日志丢失 trace。以下条目构成 context 的使用规范。

ctx 是第一个参数

ctx 应始终作为函数的第一个参数,命名固定为 ctx context.Context,位于方法接收者之后、其余参数之前。 位置与命名固定后,API 是否接受 context 一目了然,govet 与 staticcheck 的检查也建立在该约定之上,不应有例外。

配套的第二条:一个请求从头到尾只传递一个 context。HTTP handler → service → DB → 外部 API,任何一环自行新建 context,该环节就会在客户端离开后继续执行,超时预算也随之清零。最常见的错误是在中途使用 context.Background():

// ✗ 中途新建 context,取消传递中断
func (s *OrderService) Create(ctx context.Context, order Order) error {
    return s.db.ExecContext(context.Background(), "INSERT INTO orders ...", order.ID)
}

// ✅ 沿用调用方传入的 context
func (s *OrderService) Create(ctx context.Context, order Order) error {
    return s.db.ExecContext(ctx, "INSERT INTO orders ...", order.ID)
}

context 应经函数参数传递,不存入 struct,不长期持有。 官方文档明确规定:"Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it." struct 的生命周期通常长于填充它的那个请求,后续调用会复用一个已取消、或属于其他请求的 ctx。此类问题的失效点与引入点往往相距多个包,排查成本高。

// ✗ ctx 存进 struct:生命周期失控
type Service struct{ ctx context.Context }

// ✅ ctx 永远走参数
func (s *Service) Do(ctx context.Context) error { ... }

无可用 context 时应使用 context.TODO() 占位,不得传 nil。nil 在第一次 Done() 或 Value() 调用时 panic,且 panic 位置通常远离引入 nil 的代码;TODO() 至少在代码中保留待补标记,而 Background() 容易被误认为是有意的设计。因此 context.Background() 只应出现在顶层入口——main、init、测试;中层出现它,意味着上游的截止时间与取消信号失效。

常用创建方式:

场景用什么
程序入口(main / init / test)context.Background()
调用方尚未提供 contextcontext.TODO()
HTTP handler 内r.Context()
需要手动取消context.WithCancel(parent)
需要超时或截止时间context.WithTimeout / context.WithDeadline
后台任务要比请求活得久context.WithoutCancel(parent)(Go 1.21+)

cancel 必须被调用

在局部作用域内,拿到 WithCancel / WithTimeout / WithDeadline 返回值的第一行就应写 defer cancel(),所有控制流路径都必须调用。 不调用 cancel,子 context 不会从父 context 释放,超时场景还会额外占用一个定时器,即 context 泄漏。govet 的 lostcancel 检查能静态发现被丢弃的 cancel,应保持该检查开启。

// ✗ cancel 被丢弃:子 context 泄漏,定时器不释放
func fetch(ctx context.Context) error {
    ctx, _ = context.WithTimeout(ctx, 5*time.Second)
    return doWork(ctx)
}

// ✅ 局部作用域:立即 defer cancel
func fetch(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    return doWork(ctx)
}

应根据语义区分 WithTimeout 与 WithDeadline:"这次调用最多 3 秒"是相对时长,使用 WithTimeout;"整批任务必须在 SLA 截止时间前完成"是绝对时间点,使用 WithDeadline。

嵌套超时应取更短的那个,这是超时预算逐层分配的机制。 入口为整个请求设定总预算,每一层向下游设置的截止时间应短于自身,任一环节阻塞都不会导致整体失败:

// 父 context 预算 2s,子 context 设置 10s:实际 2s 到期
parentCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

childCtx, childCancel := context.WithTimeout(parentCtx, 10*time.Second)
defer childCancel()
// childCtx 在 2s 而非 10s 时超时

取消要真正生效

仅设置超时不足以让取消生效。上游取消后,下游代码必须显式监听取消信号——ctx.Done() 必须进入 select,长驻循环和并发任务尤其如此;只设超时而不监听 Done(),这类代码无法感知取消。goroutine 侧的配合方式见并发编程规范。

func poll(ctx context.Context, interval time.Duration) error {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done(): // 取消信号必须进入 select
            return ctx.Err() // context.Canceled 或 DeadlineExceeded
        case <-ticker.C:
            if err := doWork(ctx); err != nil {
                return fmt.Errorf("polling: %w", err)
            }
        }
    }
}

CPU 密集型循环没有可阻塞的等待点,应周期性检查 ctx.Err() 并及时返回。向上返回错误时应区分两个哨兵错误:context.Canceled 表示主动取消,context.DeadlineExceeded 表示超时,两者的处置方式不同。

并发扇出场景中,收到第一个错误即应取消其余 goroutine。使用带缓冲的 errCh 收集结果,收到首个错误立即 cancel 并返回,仍在运行的 goroutine 会因 ctx 取消而尽快退出。缓冲容量应设为 len(items),保证发送不阻塞——取消依赖信号传播完成,而非逐个通知。

func processItems(ctx context.Context, items []Item) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // 无论成功失败,最后都要释放

    errCh := make(chan error, len(items)) // 带缓冲,goroutine 不会因发送阻塞
    for _, item := range items {
        go func(item Item) { errCh <- processOne(ctx, item) }(item)
    }
    for range items {
        if err := <-errCh; err != nil {
            cancel() // 首个错误即取消其余 goroutine
            return fmt.Errorf("processing items: %w", err)
        }
    }
    return nil
}

Go 1.21+ 的 context.AfterFunc 用于注册取消回调。回调在独立 goroutine 中执行,不阻塞主流程;不再需要时调用返回的 stop 函数撤销。

另一类需求与之相反:后台任务的生命周期需要长于请求,例如审计日志、异步通知,应使用 context.WithoutCancel。它保留 ctx 中的 values(如 trace_id),仅脱离取消信号——否则只能在"handler 返回后任务即被终止"与"改用 Background() 而丢失 trace"之间取舍。

func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    order, err := h.orderService.Create(r.Context(), r.Body)
    if err != nil {
        return // 错误处理从略
    }
    // 审计必须写完,即使客户端已断开:
    // WithoutCancel 保留 values,只脱离取消
    auditCtx := context.WithoutCancel(r.Context())
    go h.auditService.LogOrderCreated(auditCtx, order)

    w.WriteHeader(http.StatusCreated)
}

HTTP 服务里的 context

handler 内必须使用 r.Context(),不应新建 context.Background()。 该 context 在客户端断开或 handler 返回时取消,是整条服务端链路的取消源头。它由 net/http 在进入 handler 时提供,不应替换,也不应缓存到请求之外复用。

func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // 客户端断开时自动取消

    order, err := h.orderService.Get(ctx, r.PathValue("id"))
    if err != nil {
        if ctx.Err() != nil {
            return // 客户端已断开,写响应无意义
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(order)
}

请求级数据应由中间件在 handler 之前注入,用 r.WithContext 把增强后的 ctx 交给下游;出站方向,http.NewRequestWithContext 把 ctx 绑定到请求上。

func TracingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = generateTraceID() // 缺失则本地生成
        }
        ctx := WithTraceID(r.Context(), traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

出站调用应统一使用 context-aware API:http.NewRequestWithContext、QueryContext、ExecContext、QueryRowContext。 只有这样才能让客户端断开级联取消下游的 HTTP 请求与数据库操作。数据库侧细节见数据库操作规范。

// ✗ 下游调用无视请求 context
func (c *PaymentClient) Charge(ctx context.Context) error {
    req, _ := http.NewRequest("POST", c.url+"/charge", nil)
    return c.client.Do(req)
}

// ✅ 客户端断开会取消下游请求
func (c *PaymentClient) Charge(ctx context.Context) error {
    req, err := http.NewRequestWithContext(ctx, "POST", c.url+"/charge", nil)
    if err != nil {
        return fmt.Errorf("creating request: %w", err)
    }
    return c.client.Do(req)
}

http.Client.Timeout 可作为最终防线,与 ctx 的截止时间同时生效,先到期者生效。它覆盖的是未携带 deadline 的调用,两层同时设置才构成完整的超时控制。

WithValue 要克制

WithValue 的 key 必须使用未导出类型,并由包内的 With/From 包装函数读写。 两个包都用裸 string "trace_id" 做 key 会静默互相覆盖,且几乎无从排查;Value() 取出的值本就没有编译期类型检查,应避免再引入 key 冲突。

// ✗ 裸 string key:跨包冲突
ctx = context.WithValue(ctx, "trace_id", traceID)

// ✅ 未导出 key 类型 + 包装函数
type contextKey string

const traceIDKey contextKey = "trace_id"

func WithTraceID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, traceIDKey, id)
}

func TraceIDFromContext(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(traceIDKey).(string)
    return id, ok
}

WithValue 应只保存跨 API 边界的请求级元数据,业务参数与基础设施依赖不应放入:Value() 取出的值既无类型保障,也不出现在函数签名中,调用方需要感知的数据应经函数参数传递。包装函数同时构成该元数据唯一的读写入口,便于统一命名与后续校验。

数据放 context?理由
trace_id / span_id / request_id✅可观测性元数据,请求级
认证用户 / 租户信息✅请求级,跨 API 边界
数据库连接❌基础设施依赖,显式传参
Feature flag / 配置❌配置,显式传入或注入
业务参数(user id、订单数据)❌业务入参,走函数签名
logger视情况携带请求级字段(如 trace_id)可以,否则显式传

context 同时是跨服务链路追踪的载体。Service A 调用 Service B 时,trace_id、span_id 从 ctx 取出并注入出站 HTTP header,整条请求路径由此构成一条 trace。OpenTelemetry 的 propagation.TraceContext 自动化了注入与提取,机制相同:context 携带链路状态,必须逐层传递。日志与 trace 的落地见可观测性规范。

速查清单

  • ctx 应作为函数第一个参数,命名固定为 ctx context.Context
  • 全链路传递同一个 context,任何一层不得新建
  • context 经函数参数传递,不存入 struct、不长期持有
  • 无可用 context 时使用 context.TODO(),不得传 nil
  • context.Background() 仅用于 main、init、test
  • handler 内使用 r.Context(),它是服务端链路的取消源头
  • With* 返回的 cancel 必须在所有控制流路径调用;局部作用域内应立即 defer cancel()
  • 嵌套超时应取更短者,超时预算自入口向下游逐层收紧
  • ctx.Done() 必须进入 select;CPU 密集循环应周期性检查 ctx.Err()
  • 出站调用使用 context-aware API:NewRequestWithContext、QueryContext、ExecContext
  • WithValue 的 key 应使用未导出类型,包内提供 With/From 包装函数
  • context 只保存请求级元数据(trace id、认证用户);业务参数与依赖应显式传参
  • 跨服务调用应把 trace 信息从 ctx 注入 header(OpenTelemetry 可自动化)
  • 生命周期长于请求的后台任务使用 WithoutCancel;清理回调用 AfterFunc
  • govet 与 staticcheck 应纳入 CI;多数 context 陷阱可由 linter 自动检出