context 到底在传播什么
在 Go 服务里,context 几乎无处不在。从 net/http 的 Handler 到 gRPC interceptor,再到数据库和消息队列 client,随处可以看到它的身影。但 context 用得多,不代表用得对。我见过不少工程代码,明明只是传一个参数,却把整个 context 从一个函数传到另一个模块,还时不时从 context 里取一些没有文档约定的数据。真正麻烦的时刻发生在线上:某个下游服务慢吞吞不响应,超时设置放在最外层,结果所有请求都堆积在连接池里,吃满 goroutine。这类问题背后,往往是对 context 传播语义理解得不够精准。

先理清一件事:context 本身不负责处理任务,它只提供一种沿调用链传递信号的能力。它携带三类信息:截止时间(deadline)、取消信号(done channel)、请求级元数据(value)。但它们的使用场景完全不一样,混淆才是大坑。
| 场景 | 创建函数 | 触发方式 | 典型用途 |
|---|---|---|---|
| 取消 | context.WithCancel | 手动调用 cancel() | 用户断开、服务关闭、父任务失败 |
| 超时 | context.WithTimeout / WithDeadline | 时间到达自动触发 | 调用外部服务、数据库查询 |
| 传值 | context.WithValue | 无信号,读取 key/value | trace_id、用户身份、认证信息 |
这三类信息在 context 树上传播时,遵循同一条路径规则:父 context 派生出的子 context,会继承父 context 的信号;父取消则子一定取消,但子取消不会影响父。理解这一点,是使用 context 的第一步。
取消:先想清楚谁有权叫停
取消信号表达的是“这个请求已经没有必要再继续了”。它适合放在请求或任务的生命周期边界。最典型的场景是:用户断开连接、服务端主动关闭、批量任务中某个步骤失败需要撤销其余步骤。
看一个最简单的级联取消示例:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
childCtx, _ := context.WithCancel(ctx)
go doSomething(childCtx)
// 某个瞬间我们要放弃这个请求
cancel()
// childCtx.Done() 会收到通知,doSomething 可以感知并退出
不要小看这个小例子。真正的传播链是把 ctx 作为函数的第一个参数,从 handler 一直传到 service、store。每一层在派生新的 context 之前,都要意识到父 context 可能随时取消。如果某一层忽略 ctx,而是自己新建 context.Background(),那么整个取消链就在那里断掉了。
很多团队会遇到一个问题:明明用了 WithCancel,但 goroutine 还是泄漏了。原因通常是忘记调用 cancel。调用 WithCancel 返回的 cancel 函数和 context 本身一样重要,务必使用 defer cancel() 确保路径执行完毕。即使 context 已到达截止时间,cancel 也必须被调用,否则这个 context 不会被释放。
另一个常见误区是把 context 保存到结构体字段里。context 的设计意图是显式的调用链传参,而不是对象持有。如果你将一个长生命周期对象挂载一个短请求的 context,取消信号会在请求结束时触发,之后对象再调用这个 context 时会直接返回 canceled。排查这种问题往往很痛苦。
超时:给依赖操作定一个时间预算
超时和取消是两码事。取消是主动叫停,超时是“时间到了,我不得不放弃”。超时应该针对具体的依赖操作设置,而不是针对整个系统的随机期限。
为什么这么说?假设一个 HTTP handler 需要查询数据库,还要调用一次外部 API。数据库正常 100ms,外部 API 可能 2s。如果全局统一设置 500ms 超时,外部 API 几乎每次都会失败;如果设置 3s 超时,一旦数据库被慢查询拖住,handler 就会一直等满 3s。更合理的方式是分别设置:数据库调用给它 300ms,外部 API 给 2s,整个请求的入口给 3s 兜底。
这里有一个关键机制:从父 context 派生超时,新 deadline 取两者的最早值。比如父 context 剩余 1s,你再调用 WithTimeout(ctx, 5s),实际最终 deadline 还是父 context 的 1s。因此,中间层不要随意叠加超时,除非明确知道自己在做什么。直接继承上游的超时,是管理分布式链路时间预算的简单办法。
connCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
rows, err := db.QueryContext(connCtx, slowQuery)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
// 数据库超时,走降级逻辑
}
return err
}
超时不是只能用在网络 I/O 上。任何耗时不确定的 goroutine,都可能需要一个截止期限。但要记住,超时不等同于终止。SetTimeout 只会在到达时间时触发 Done channel,被阻塞的代码是否退出,取决于那个调用是否监听了 context。所以还是要保证底层调用支持 context。
传值:只放请求级的元数据
WithValue 是 context 里最容易被滥用的能力。我见过有人把数据库连接、配置对象、甚至随机生成的临时切片都塞进 context,然后在另一个包里去取。这样做的好处是少写几个参数,坏处是依赖关系变成了隐性的,全局可寻址,彻底违背了显式传参的原则。
合理的传值场景是:请求范围内、且和业务函数的主要参数无关的元数据。比如 trace_id、用户身份、客户端 IP、认证信息。它们横跨多个调用链,但不是每个函数都需要显式声明。
type ctxKey int
const (
keyUserID ctxKey = iota
keyTraceID
)
type UserID struct {
ID string
}
ctx := context.WithValue(parentCtx, keyUserID, UserID{ID: "u_123"})
userID := ctx.Value(keyUserID).(UserID)
注意 key 用自定义类型,而不是直接用字符串。这样能避免不同包之间的 key 冲突,也能让 Value 查询更严格。
另一个原则:不要用 context 传“动态可变”的对象,尤其是底层代码会修改这些对象。这会引发数据竞态,而且很难定位。context 里的值应该被当成只读的快照。
经验提醒:如果你发现自己从 context 里取出同一个值超过两次,就该考虑它是不是应该作为显式参数传进来。context 传值的目的是减少重复传递,不是让依赖消失。
当错误码出现时,区分 Canceled 和 DeadlineExceeded
context 的取消信号不会自动帮你清理现场,它只是让监听 Done 的代码有机会退出。很多函数会返回 context 相关的 error,这时需要区分原因,否则日志和重试逻辑都会失真。
context.Canceled 表示 context 被主动取消,比如用户断开或上层调用 cancel()。context.DeadlineExceeded 表示到了 deadline 还没完成。如果对超时逻辑做重试,你应该只对 DeadlineExceeded 做有意义的尝试,而对 Canceled 直接放弃,因为请求已经没有必要继续了。
err := doWork(ctx)
switch {
case errors.Is(err, context.Canceled):
// 主动取消,直接返回,不重试
case errors.Is(err, context.DeadlineExceeded):
// 超时,可以选择降级或局部重试
}
还有一个容易被忽略的场景:当 context 被取消时,如果底层调用同时在等待锁或 channel,可能不会立即返回,造成 goroutine 卡住。context 只能覆盖它覆盖到的调用;对于无法感知 context 的阻塞,仍然需要额外的恢复机制。
传播 context 时的几个工程问题
很多服务在初期没有 context,后来为了支持超时才开始加。于是会出现下面这些典型问题:
- 不传递 context,在底层用 context.Background() 代替,导致上层设置的取消和超时全部失效。
- 上下文在中间层被存储到结构体,或作为全局变量,导致生命周期混乱。
- 把 context 当成万能参数,把不该传的对象放进去,造成隐式耦合。
- 在错误处理时混淆 Canceled 和 DeadlineExceeded,导致错误重试或误报。
- 并发场景下,多个 goroutine 对同一个 cancel 函数调用,产生不必要的恐慌。cancel 是幂等的,但定义不清谁负责调用,容易引发设计混乱。
这些问题的共性是没有把 context 当成调用链的一部分,而是当成一个附加工具。context 应该在每个请求开始的时候诞生,随着调用链结束而消亡,不应当被某个长生命周期对象长期保存。
从落后的代码开始,如何逐步演进
如果项目里已经有大量函数不符合 context 规范,一次全面重构的代价是很大的。比较现实的路径是“追加参数,再逐层收紧”。先在每个和请求相关的函数签名中加上 ctx context.Context 参数,当前可以不使用,仅保持兼容。然后从最外层的入口,如 HTTP handler、gRPC handler、消息消费函数,传入真实的 request context。再逐个替换底层 I/O 调用,使数据库查询、消息发送、外部请求都能读到同一个 context。
| 阶段 | 目标 | 建议动作 |
|---|---|---|
| 第一步 | 让 context 显式出现在调用链 | 在函数签名中加 ctx 参数,暂时不做功能绑定 |
| 第二步 | 入口统一获取 context | HTTP 用 r.Context(),gRPC 用 stream.Context(),消息回调从 msg 中派生 |
| 第三步 | I/O 操作绑定 context | 数据库改用 QueryContext,HTTP client 改用 NewRequestWithContext |
| 第四步 | 加入超时策略 | 在入口设置兜底超时,为特定依赖设置独立超时 |
| 第五步 | 检查取消与错误处理 | 统一判断 Canceled 和 DeadlineExceeded,避免错误重试 |
演进的过程中,不要为了“好看”而让所有代码都带上 context。有些函数是无状态的工具函数,比如字符串拼接,没有必要接 ctx。context 应该只在需要控制生命周期、传递请求元数据时出现。
总结
context 不是银弹,它不会自动让你的 goroutine 退出,也不会让系统变快。它的价值在于:让我们能够在请求级别统一控制调用链的终止条件和携带信息。用好了,超时、取消、追踪都变得可控;用不好,它就是一个藏着隐式依赖的全局变量。
回顾一下核心判断:取消用于主动放弃请求,超时用于给出时间预算,传值用于携带请求级元数据。三者职责不同,但都依赖同一条调用链的显式传播。写代码的时候多问自己一句:这里需要取消吗?需要超时吗?需要传什么值?如果答案都是“不需要”,那干脆不要加 context。
记住,context 的生命周期和请求一致,别让它逃逸到后台任务或长生命周期对象里。保持它沿调用链一级一级传下去,遇到需要控制的点再派生。这就是 context 传播的正确姿势。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/712/