在 Go 服务端开发中,context 经常和 HTTP 请求、数据库查询、RPC 调用、后台 goroutine 一起出现。很多初学者知道它可以“传递上下文”,但在实际项目里更重要的作用是控制超时、取消任务和传递请求级元数据。

如果没有统一的取消机制,一个请求已经断开,后面的数据库查询、第三方接口调用和 goroutine 仍然可能继续运行。并发量上来之后,这类问题会表现为连接池耗尽、CPU 空转、日志堆积和接口尾延迟变大。

context 解决什么问题

context.Context 主要用于跨 API 边界传递三个信息:

  • 取消信号:上游不再需要结果时,下游应尽快停止。
  • 截止时间:任务必须在某个时间点前结束。
  • 请求级数据:例如 trace id、用户 id、语言环境等轻量信息。

它不是全局变量,也不是业务参数容器。对于订单号、分页参数、查询条件这类业务数据,应该继续通过普通函数参数传递。

基础用法

最常见的是从 context.Background() 派生一个带超时的上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"context"
"fmt"
"time"
)

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

result, err := slowTask(ctx)
if err != nil {
fmt.Println("task failed:", err)
return
}

fmt.Println("result:", result)
}

func slowTask(ctx context.Context) (string, error) {
select {
case <-time.After(3 * time.Second):
return "done", nil
case <-ctx.Done():
return "", ctx.Err()
}
}

这段代码中任务本身需要 3 秒,但上下文 2 秒后超时,因此 slowTask 会返回 context deadline exceeded。真正的项目代码不会使用 time.After 模拟耗时,而是把 ctx 继续传给数据库、HTTP 客户端或其他内部函数。

在 HTTP 服务中传递 context

Go 标准库的 http.Request 自带 context。一次请求进来后,应优先使用 r.Context() 作为根上下文,而不是重新创建 context.Background()

1
2
3
4
5
6
7
8
9
10
11
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

user, err := queryUser(ctx, r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

_, _ = w.Write([]byte(user.Name))
}

当客户端断开连接、网关超时或服务端主动取消请求时,r.Context() 会被取消。只要下游函数正确监听 ctx.Done() 或把 ctx 传给支持 context 的库,任务就能及时退出。

数据库查询中的 context

标准库 database/sql 提供了 QueryContextExecContextBeginTx 等方法。使用这些方法可以让数据库查询响应请求超时。

1
2
3
4
5
6
7
8
9
10
11
12
13
func queryUser(ctx context.Context, id string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()

row := db.QueryRowContext(ctx, "select id, name from users where id = ?", id)

var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
return nil, err
}

return &user, nil
}

这里在请求上下文之上再加了数据库查询的局部超时。这样做的好处是:即使整个 HTTP 请求允许 3 秒,单次数据库查询也不会无限等待。

goroutine 中的退出控制

启动 goroutine 时,必须考虑它什么时候结束。下面是一个常见的后台轮询任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
func startWorker(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

for {
select {
case <-ticker.C:
syncOnce(ctx)
case <-ctx.Done():
return
}
}
}

如果 ctx 被取消,worker 会结束循环并退出。这个模式适合服务关闭、任务取消、测试清理等场景。

如果 goroutine 内部还会调用耗时函数,也要继续向下传递 ctx,不要只在最外层检查一次。

使用 Value 的边界

context.WithValue 可以传递请求级数据,但要克制使用。它适合 trace id、request id、认证后的用户摘要等横切关注点,不适合传递业务参数。

推荐定义私有 key 类型,避免和其他包冲突:

1
2
3
4
5
6
7
8
9
10
11
12
type contextKey string

const requestIDKey contextKey = "requestID"

func withRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey, requestID)
}

func requestIDFrom(ctx context.Context) string {
value, _ := ctx.Value(requestIDKey).(string)
return value
}

不要使用普通字符串作为 key,因为不同包可能使用相同字符串,导致值被覆盖或读错。

常见错误

第一个错误是忘记调用 cancelcontext.WithTimeoutcontext.WithCancel 返回的取消函数应该在合适位置调用,通常使用 defer cancel()。这样可以释放计时器和相关资源。

第二个错误是在函数内部使用 context.Background()。除非是在程序入口、测试入口或确实创建根任务,否则内部函数应该接收上游传入的 ctx

第三个错误是把 context 放进结构体长期保存。context 应该作为函数调用链的一部分显式传递,通常放在函数第一个参数。

第四个错误是只创建超时 context,但下游代码完全不使用它。只有当耗时逻辑监听 ctx.Done(),或者调用支持 context 的 API,取消信号才有意义。

小结

context 的核心价值不是“共享变量”,而是让一组调用在同一个请求生命周期内协同退出。实际项目中可以遵循几个简单规则:函数第一个参数传 ctx,不要在中间层创建新的根 context,带超时的 context 记得 cancel,业务参数不要塞进 Value。

当这些约定形成习惯后,服务在超时控制、优雅关闭和故障隔离方面都会稳定很多。