浪白的博客の小站
首页项目归档照片墙音乐灵境说说杂谈友链关于
封面

context 传参这件事,我踩过的三个坑

写作时间:2026-05-06 20:05:00
# Go
# 并发

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 生命周期,只是给你一个统一的"该不该继续"的判断依据。

用对了能省很多事,用错了会以为自己在做超时控制,实际上什么都没做。

avatar

浪白

邪恶的浪白

RECOMMENDED

Composer 用了这么多年,几个我认为值得坚持的习惯

2026-04-27 19:15:00

Go 的 error 处理,用久了反而觉得还行

2026-04-19 22:15:00

用 Go 写了个日志清理小工具

2026-06-18 21:10:00

Table of Contents