一个 Go 服务的 handler 里写了 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)。功能一切正常,直到某天上游取消了请求——服务继续跑完了三秒,写入了一条用户已经不再等待的记录。
context.Context 在 Go 里不是"可选的额外参数",而是请求生命周期的载体。它的三条约定——谁创建、谁传递、谁取消——只要有一条被破坏,取消能力就会在某一段链路上消失,而这段链路通常不会被测试覆盖。
1. 三条约定的完整形态
| 约定 | 规则 |
|---|---|
| 创建 | 只有入口创建(handler、main、测试) |
| 传递 | 一路向下传,不存储、不新建 |
| 取消 | 创建者负责 cancel,被调用方只监听 |
第三条约定里有个细节常被忽略:被调用方不应该主动 cancel 别人的 ctx。它只能监听,或者从传入的 ctx 派生一个更短的。一旦它 cancel 了传入的 ctx,创建者的超时逻辑就被篡改了。
第二项的"不存储"最容易被违反:把 ctx 存进结构体字段,看起来方便,实际让生命周期脱离了创建者——结构体的存活时间往往长于一次请求。
// 反例:ctx 存进结构体,生命周期失控
type Service struct{ ctx context.Context }
func New(ctx context.Context) *Service { return &Service{ctx: ctx} }
// 正例:ctx 作为方法第一个参数,逐次传递
type Service struct{ db *sql.DB }
func (s *Service) Get(ctx context.Context, id string) (*Order, error) { ... }
2. 三种"忘记传"的写法
| 写法 | 后果 |
|---|---|
context.Background() |
取消链断裂 |
context.TODO() 留在生产 |
同上,且装作"以后再说" |
| 新建带超时的 ctx 覆盖父 ctx | 父被取消时子仍在跑 |
两种写法的差别可以用一句话记住:WithTimeout 的第一个参数应当是"父",而不是 Background。评审时只看这一处就能拦住大部分取消链断裂。
第三种最隐蔽:子 ctx 的超时是相对于自己的起点算的,父 ctx 的取消信号仍会传递——但如果用了 WithTimeout(context.Background(), ...),父的关系就断了。
// 反例:父 ctx 被丢弃,取消信号无法到达下游
func handler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
s.Get(ctx, id) // r.Context() 的取消永远到不了这里
}
// 正例:从 r.Context() 派生
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
s.Get(ctx, id)
}
3. 取消的两种传播
| 来源 | 传播方式 |
|---|---|
| 上游取消(客户端断开) | ctx.Done() 关闭 |
| 超时 | ctx.Done() 关闭 + ctx.Err() = DeadlineExceeded |
被调用方要做的只有两件事:监听 Done(),并把 ctx 继续往下传。它不应该自己决定"要不要取消",那是创建者的权力。
func (s *Service) Get(ctx context.Context, id string) (*Order, error) {
row := s.db.QueryRowContext(ctx, "SELECT ...", id) // 继续往下传
if err := row.Scan(&o); err != nil {
if errors.Is(err, context.Canceled) { return nil, err } // 原样返回,不要包装成业务错
return nil, fmt.Errorf("get order: %w", err)
}
return &o, nil
}
与之配套的是"错误判定",而不是字符串比较:
// 正确判定:用 errors.Is,而不是看错误文本
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err // 让上层知道这是取消/超时,不是业务失败
}
// 反例:strings.Contains(err.Error(), "context canceled") → 包装后就失配
注意 context.Canceled 要原样返回:如果把它包装成"订单查询失败",上层会以为这是业务问题而去重试,而实际上整体已经放弃了。
4. 值传递的边界
| 该放 ctx 的值 | 不该放的值 |
|---|---|
| trace_id、request_id | 业务对象(用户、订单) |
| 认证主体(当前用户身份) | 配置项 |
| 少量跨层元数据 | 可变状态 |
认证主体是少数例外:它确实跨很多层,且每层都需要,作为参数逐层传会让签名膨胀。折中做法是用强类型的 key,并提供取值函数,避免调用方自己拼 key 字符串。
// 强类型 key:避免字符串 key 冲突,取值集中在一处
type ctxKey struct{}
func WithUser(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, ctxKey{}, u)
}
func UserFrom(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(ctxKey{}).(*User); return u, ok
}
第二项与第三项被放进 ctx 是"隐式依赖"的来源:函数的签名看不出它需要用户对象,于是测试与复用都变难。
// 反例:业务对象塞进 ctx
ctx = context.WithValue(ctx, "user", u) // 上层忘了放 → 运行时空指针
// 正例:作为显式参数
func (s *Service) Create(ctx context.Context, u *User, req CreateReq) error
5. 超时的层次
| 层 | 超时来源 |
|---|---|
| 入口 | 客户端期望(或网关) |
| 服务内部 | 按入口预算分配 |
| 单次下游调用 | 小于剩余预算 |
第三项有个硬规则:下游超时必须小于上游剩余预算,否则等待没有意义。常见实现是在每次调用前用 context.WithTimeout(ctx, remaining)。
// 按剩余预算派生:下游超时永远小于上游
if dl, ok := ctx.Deadline(); ok {
remaining := time.Until(dl)
callCtx, cancel := context.WithTimeout(ctx, min(remaining, 800*time.Millisecond))
defer cancel()
_ = downstream(callCtx)
}
6. 排错:五种"取消没生效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 客户端断开后仍写库 | 用了 Background() |
从入口 ctx 派生 |
| 超时后仍继续跑 | 下游没收到 ctx | 一路传下去 |
| 重试次数异常 | context.Canceled 被当业务错误 |
原样返回 |
| 单元测试无法模拟取消 | ctx 存在结构体里 | 改为参数传递 |
| 日志里没有 trace_id | 值没放 ctx 或没取 | 统一在入口注入 |
还有一个排查手段很直接:在关键路径上记录 ctx.Err()。如果日志里从来没见过 context canceled,说明取消信号根本没有到达——这不是"没有取消发生",而是"取消传不过来"。
第二项可以从指标上看出来:"请求已取消但仍在执行的 goroutine 数" 是一个很有效的健康度指标,它比看延迟更早暴露问题。
7. 边界:什么情况不必严格遵循
| 场景 | 为什么 | 做法 |
|---|---|---|
| 一次性命令行工具 | 无取消需求 | 用 Background() 也无可厚非 |
| 后台常驻任务 | 生命周期由进程决定 | 用根 ctx,但仍需优雅退出 |
| 纯计算函数 | 不做 IO | 不必传 ctx |
| 极短生命周期的内部函数 | 无取消意义 | 传或不传都行 |
还有一条与库设计有关的边界:对外暴露的 API 应当接受 ctx,即使当前实现用不到。否则将来需要取消时,就要改所有调用方——这类"预留"在 Go 生态里已经是共识,不是过度设计。
判断口径一句话:这段代码会不会在"用户已经不要这个结果"之后继续运行。会,就必须有 ctx 且一路传下去;不会,它只是形式。
8. 自测
- context 的三条约定分别是什么?"不存储"这一条被违反时会失去什么能力?
context.Background()出现在 handler 里,切断的是哪条链路?- 为什么
context.Canceled要原样返回而不是包装成业务错误? - 业务对象放进 ctx 会带来什么具体问题?两种替代写法是什么?
- 下游超时为什么必须小于上游剩余预算?实现上怎么做?
- 判断"是否需要严格传 ctx"的那个问题是什么?