context 包本身不难,就四个函数。但在真实项目里用错的方式五花八门,我把自己踩过的记一下。
坑一:超时设了但没生效
写了个查询接口,外面设了 3 秒超时:
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, sql)
压测的时候发现,有些请求跑了十几秒才返回。查了半天,问题出在下游——那个 db 是用老代码初始化的,内部还包了一层自己的连接池,而真正执行查询的地方调用的是 db.Query(sql),不是 QueryContext(ctx, sql)。
context 只是"传递意图",它自己不会中断任何东西。必须有代码去监听 ctx.Done() 并主动放弃,否则它就是个摆设。
后来我在 code review 里加了一条规则:看到 context 参数就顺着往下找,确认最底层真的在监听它。
这个坑还有一种变形:用了 QueryContext,但驱动版本太老不支持,内部直接忽略 ctx。这个只能靠压测发现。
坑二:goroutine 泄漏
这个更常见,也更难发现,因为不报错、不 panic,就是内存慢慢涨。
func process(ctx context.Context, items []Item) {
for _, it := range items {
go func(it Item) {
result := heavyWork(it)
results <- result // 如果没人收,这里永久阻塞
}(it)
}
}
外层函数返回了,results 没人读了,那些 goroutine 就全卡在发送那行上,连同它们持有的内存一起泄漏。
正确写法是 select 上监听 ctx:
select {
case results <- result:
case <-ctx.Done():
return
}
我们线上那个服务泄漏了大概 4 万个 goroutine 之后被发现的。教训是:每个 goroutine 的退出条件都必须明确,而且要用 context 兜底。
排查手段是 pprof:
import _ "net/http/pprof"
go http.ListenAndServe("localhost:6060", nil)
然后 go tool pprof http://localhost:6060/debug/pprof/goroutine,能直接看到哪个函数堆了多少个。
坑三:用 context 传业务参数
这个属于设计问题。官方文档明确说过不要用 context 传可选参数,但我还是见过(也写过)这样的代码:
ctx = context.WithValue(ctx, "userID", 123)
ctx = context.WithValue(ctx, "tenantID", "abc")
ctx = context.WithValue(ctx, "traceID", "xyz")
问题有三个:
取的时候要断言,key 写错了不会编译报错。
uid, ok := ctx.Value("userid").(int) // 注意这里少了个大写 I
if !ok {
// 安静地拿到零值,然后业务逻辑就跑偏了
}
key 类型用 string 会跟别的库冲突。 正确的做法是定义私有类型:
type ctxKey struct{}
var userIDKey ctxKey
func WithUserID(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, userIDKey, id)
}
func UserID(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(userIDKey).(int64)
return id, ok
}
私有类型别的包定义不出来,就不会撞。
参数变成隐式的了。 函数签名上看不出来它依赖 userID,测试的时候得手动构造 ctx,容易漏。
我现在的做法是:只有横切关注点放 context(trace、超时、取消、认证身份),业务参数老老实实当函数参数传。参数多了就封成 struct。
几个我固定遵守的习惯
第一个参数永远是 ctx。 不管当前用不用得上,先占位,以后要加就不用改所有调用点。
func (s *Service) GetUser(ctx context.Context, id int64) (*User, error)
ctx 永远不存到 struct 里。 见过把 ctx 塞进 service 结构体的,那个 ctx 会一直活着,超时和取消全乱套。
defer cancel() 紧跟 WithCancel。 不 cancel 会泄漏 timer,静态检查工具 lostcancel 会报,但得开启。
不要传 nil context。 用 context.TODO(),虽然丑但至少不 panic。
底层一定要 select ctx.Done()。 这是唯一让 ctx 真正起作用的动作,其它都是准备。
最后
context 包设计得挺克制的,就干一件事:在调用链上传递取消信号和截止时间。它不解决并发问题,也不管 goroutine 生命周期,只是给你一个统一的"该不该继续"的判断依据。
用对了能省很多事,用错了会以为自己在做超时控制,实际上什么都没做。