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() |
| 调用方尚未提供 context | context.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 自动检出