Go · context 取消传播

请求的一根生命线 — 树形取消广播 + 超时预算沿链递减: WithCancel / WithTimeout / WithDeadline / WithValue, Done() 是那根红线

取消树 — 一声 cancel 全树停 根 ctx — r.Context() http server 为每个请求自动创建 close(done) 广播 DB 查询 QueryContext 下游 RPC NewRequestWith… 日志刷盘 select Done 再派生子 ctx WithTimeout 2.2s 每个 WithXxx 都挂到父节点: 树 cancel 只影响自己的子树 监听 Done() 的 G 同时被唤醒 超时预算瀑布 — 取更早者 入口 handler WithTimeout 3s (总预算) 服务层 svc 2.2s DB 查询 800ms 缓存预热 50ms 子的 deadline = min(自身, 父剩余) 预算只能层层更紧, 不可能更松 每层重设 3s 不看父剩余 = 假预算 分层预算模板见场景 8 Done() — 标准姿势 select { case <-ctx.Done(): return ctx.Err() case v := <-ch: use(v) } ① DB: db.QueryContext(ctx, sql) ② RPC: http.NewRequestWithContext ③ 循环: for + select 监听 ④ 出口: 写响应前查 ctx.Err() 业务与取消同时等, 先到先走 — 别加 default Err() = context.Canceled / DeadlineExceeded cancel 是幂等的: 调几次都安全 四种派生 + 两条铁律 WithCancel(parent) 手动停: ctx + cancel 成对返回 WithTimeout(parent, d) 相对时长, 到点自动 cancel WithDeadline(parent, t) 绝对时刻 (跨时区场景) WithValue(parent, k, v) 只挂值, 与取消无关 铁律: ① defer cancel() ② ctx 永远是函数第一个参数 WithValue — 链表查找, 深了就慢 ctx: {k=traceID} 最外层子节点 ctx: {k=reqID} 中间层 Background 根 Value(k) 从最外层沿链向父逐个问: 命中返回, 走到根没找到 = nil key 用私有类型防撞名: type ctxKey int 链深一层多查一步 O(n): 别当参数通道 业务参数进函数签名, 不进 value — 只放 reqID/trace 这类请求元数据 Legend 取消树 / 派生 Done() 信号姿势 预算 / 铁律 取消广播 value 链

一根生命线

  • • ctx 随请求生灭, 沿调用树往下传
  • • cancel 一声令下, 全子树同时停
  • • r.Context() 服务端白送: 断开即取消
  • • 谁派生谁负责 cancel, defer 兜底

预算只会更紧

  • • 子 deadline = min(自身, 父剩余)
  • • 每层重设 3s 不看父 = 假预算
  • • 穿透到驱动层才算真超时
  • • 入口总闸兜底, 依赖拿子预算

纪律三条

  • • ctx 是第一个参数, 不进 struct
  • • WithXxx 后马上 defer cancel()
  • • value 只放请求元数据
  • • 业务参数走签名, 走 value 必翻车

💡 一句话理解

context 是每个请求的生命线: 从入口创建, 沿调用树一层层往下传, 谁派生谁负责 cancel。超时是预算, 子节点只会继承到更紧的 deadline; 取消是广播, 父节点一声 close, 全树所有监听 Done() 的 goroutine 同时亮灯退出。它管的是"什么时候不干了" —— 用户断开、上游超时、优雅停机, 都靠这一根线把散落各处的等待点一起叫醒。它不该管业务参数: value 里只放请求元数据, 越界的用法都是事故的开始。

🧠 必知必会 必考 & 必会

Context 接口
四个方法: Deadline() 返回截止时刻、Done() 返回一个 channel、Err() 返回取消原因、Value(key) 沿链取值。前三个管生死, 第四个管元数据。
type Context interface {
    Deadline() (time.Time, bool)
    Done() <-chan struct{} // 关闭 = 广播取消
    Err() error            // → Canceled / DeadlineExceeded
    Value(key any) any
}
Background / TODO
context.Background() 是根, 永不取消; context.TODO() 是"还没想好传谁"的占位 —— 出现在业务代码里通常说明调用链设计欠账。
ctx := context.Background()
fmt.Println(ctx.Err()) // → <nil> (根永不取消)
// TODO() 只是占位: 业务代码里出现 = 调用链设计欠账
WithCancel
手动停止: 返回 (ctx, cancel)。cancel 是幂等的, 多次调用安全; 不调也不崩, 但计时器与父节点引用会等到父取消才释放。
ctx, cancel := context.WithCancel(parent)
cancel() // 关键: cancel 幂等, 多次调用安全
cancel()
fmt.Println(ctx.Err()) // → context canceled
WithTimeout / WithDeadline
相对时长与绝对时刻, 到点自动 cancel, 内部就是一个 timer。绝大多数业务用 WithTimeout; 跨时区的定时任务用 WithDeadline。
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel() // 相对时长: 到点自动 cancel
// 绝对时刻版: WithDeadline(parent, time.Now().Add(3*time.Second))
defer cancel()
派生之后立刻 defer: 即使函数提前返回, 计时器和父节点里的子表项也被即时清掉 —— 这是官方文档用粗体写的规矩。
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel() // 关键: 派生的下一行就 defer
// 不 defer: 计时器+父节点子表项拖到父取消才释放
树形广播原理
cancel 的本质是 close(done chan): channel 关闭对所有接收方同时可见, 所以一次 cancel 能瞬间唤醒整棵子树 —— 广播而不是逐个通知。
c1, cancel1 := context.WithCancel(ctx)
c2, _ := context.WithCancel(c1) // c2 挂在 c1 子树
cancel1() // 关键: ≈ close(done), 一次唤醒全子树
<-c2.Done() // → 立即可读, 广播非逐个通知
第一参数惯例
func(ctx context.Context, ...) 永远排第一且不放进 struct —— lint 会盯; 例外是长生命周期对象在 Start/Stop 成对持有。
func Load(ctx context.Context, id int64) {} // ctx 永远第一
// 反例: type S struct{ ctx context.Context } — lint 必盯
// 例外: Start/Stop 成对的长生命周期对象才存 struct
r.Context()
服务端集成: net/http 为每个请求自动创建, 客户端断开时自动取消 —— 这条白送的生命线, 不接等于放弃快速失败。
func h(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // 白送的生命线
    <-ctx.Done() // → 客户端一断开立即醒来
}
NewRequestWithContext
客户端集成: 把 ctx 绑进请求, 超时/取消时传输被立即中断、连接归还池 —— fd 不再缓慢泄漏。
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req) // 超时/取消在此生效
defer resp.Body.Close()
// → DeadlineExceeded 一到, 传输中断连接归还池
穿透驱动层
db.QueryContext 会一路传到驱动甚至数据库服务端: 查询真被 cancel; 只在应用层 select 挡超时是"假超时"。
rows, err := db.QueryContext(ctx, sql, args...)
defer rows.Close()
// 关键: 取消一路送到驱动/DB 服务端, 查询真被中断
// 只在应用层 select 挡 = 假超时, 连接池照样被拖垮
ctx.Err()
Done 亮灯后取原因: context.Canceled(被主动取消) / context.DeadlineExceeded(超时)。判定一律 errors.Is, 它们是哨兵。
<-ctx.Done()
fmt.Println(ctx.Err())
// → context.DeadlineExceeded (超时) / context.Canceled (取消)
errors.Is(err, context.Canceled) // 判定一律 Is, 是哨兵
WithValue 纪律
只放请求级元数据(reqID/trace/租户), key 用私有类型防撞名; 查找是沿链 O(n), 链越深越慢 —— 它不是参数传递通道。
type ctxKey int // 私有类型: 别包撞不了这个 key
ctx = context.WithValue(ctx, ctxKey(0), "req-42")
id, _ := ctx.Value(ctxKey(0)).(string)
fmt.Println(id) // → req-42; 只放请求元数据, 非参数通道
Shutdown(ctx)
优雅停机: 停接新连接 + 等存量请求排干, ctx 给出强断窗口(如 15s); 注意必须用 Background 新根, 详见场景 5。
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    log.Print("forced close") // 15s 没排干才强断
}
// 关键: 必须用 Background 新根, 不能复用已取消的

🏭 生产实战 real world

场景 1 · handler 整链控制: r.Context() + 总超时

接口 SLA 3 秒, 聚合三个下游 —— 入口设总闸, 其余交给取消传播:

func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel() // r.Context() 做父: 客户端断开也一起停
    feed, err := h.svc.Feed(ctx, r.URL.Query().Get("uid"))
    switch {
    case errors.Is(err, context.DeadlineExceeded):
        http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
    case errors.Is(err, context.Canceled):
        return // 用户已断开, 写响应无意义, 静默结束
    case err != nil:
        slog.Error("feed", "err", err) // 只有边界层打日志
        http.Error(w, "internal", 500)
    default:
        json.NewEncoder(w).Encode(feed)
    }
}

取消类错误与业务错误分流, 日志不被"用户主动断开"刷屏。

场景 2 · DB 查询超时穿透: QueryContext

上层 800ms 就返回了, 慢 SQL 却在 DB 侧跑满 30s, 连接池被拖垮 —— 超时必须穿透:

func (r *UserRepo) ByID(ctx context.Context, id int64) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancel() // 不 cancel: 计时器与父引用一直被持有

    rows, err := r.db.QueryContext(ctx,
        `SELECT id, name FROM users WHERE id = $1`, id)
    if err != nil {
        return nil, fmt.Errorf("query user %d: %w", id, err)
    }
    defer rows.Close() // 取消后 Rows 自动关闭, 这里双保险

    u := &User{}
    for rows.Next() {
        err = rows.Scan(&u.ID, &u.Name)
    }
    return u, rows.Err()
}

QueryContext 会把取消一路送到驱动甚至服务端, 查询真的被中断, 连接立刻归还。

场景 3 · 下游 HTTP 调用: NewRequestWithContext 及时释放连接

老代码 http.Get(url) 超时后连接悬着不还, fd 缓涨到 ulimit —— 客户端同样要接 ctx:

func callDownstream(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("build req: %w", err)
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("do %s: %w", url, err) // ctx 超时在此生效
    }
    defer resp.Body.Close()
    return io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 顺手防大响应
}
// ctx 版: DeadlineExceeded 一到, 传输立即中断, 连接归还池;
// 别用全局 http.Client{Timeout} 一刀切 — 它不区分调用方预算

每个调用方自己的预算, 只能靠 ctx 传, 不能靠全局 Timeout。

场景 4 · 聚合扇出: 同一父 ctx, 一处失败全取消

页面聚合 6 个下游, 最慢的 2s —— 但第一个失败时其余的请求就是浪费配额:

func aggregate(ctx context.Context, deps []string) (*Page, error) {
    g, ctx := errgroup.WithContext(ctx) // 同一父: 一处失败全体取消
    parts := make([]Part, len(deps))
    for i, d := range deps {
        g.Go(func() error { // 首个 return err 触发 ctx 取消
            p, err := fetchPart(ctx, d) // 这里的 ctx 已是"首错即取消"版
            if err != nil {
                return fmt.Errorf("part %s: %w", d, err)
            }
            parts[i] = p // 各写各下标, 无竞争
            return nil
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err // 首错返回时, 其余分支已被 ctx 叫停
    }
    return compose(parts), nil
}

并发编排细节见 channel/patterns 页; 记住一句: 所有分支共享一个可取消的 ctx。

场景 5 · 优雅停机: Shutdown 的收尾窗口

k8s 滚动更新先发 SIGTERM, 直接 os.Exit 会把存量请求腰斩成 502:

srv := &http.Server{Addr: ":8080", Handler: h}
go func() {
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatalf("listen: %v", err)
    }
}()

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM)
<-quit // 等 SIGTERM, 不杀存量连接

ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// 必须 Background 新根: 复用已取消的根 ctx, Shutdown 立即强断
if err := srv.Shutdown(ctx); err != nil {
    log.Printf("forced close: %v", err) // 15s 没退完的请求强制断
}

Shutdown 期间 r.Context() 会被取消传播到每个在途请求, 业务代码零改动参与停机。

场景 6 · 后台订阅任务: WithCancel 手动停止信号

常驻消费者没有"请求", 用 Background 做根 + cancel 做停止按钮:

// 后台订阅: 手动停止信号(与请求无关, 用 Background 做根)
func (s *Subscriber) Start() {
    s.ctx, s.cancel = context.WithCancel(context.Background())
    go func() {
        for {
            msg, err := s.pull(s.ctx) // 阻塞读也要吃 ctx
            if s.ctx.Err() != nil {
                s.cleanup() // 停机收尾: ack/断连/刷缓冲
                return
            }
            if err != nil {
                backoff(s.ctx, err) // 退避也感知取消, 不假死
                continue
            }
            s.handle(msg)
        }
    }()
}

func (s *Subscriber) Stop() { s.cancel() } // 广播一发, 干净退出

重连/发布/订阅这类"启动-停止"对象, Start/Stop 成对出现是存 struct 的唯一合法场景。

场景 7 · request_id 全链路日志: WithValue 唯一正确用法

排障要把一个请求的所有日志串起来 —— reqID 藏在 ctx 里随叫随到:

type ctxKey int // 私有类型: 任何包都不可能撞这个 key

const requestIDKey ctxKey = 0

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id) // 只放元数据
}

func Log(ctx context.Context, msg string, kv ...any) {
    id, _ := ctx.Value(requestIDKey).(string) // 断言失败得零值
    slog.Info(msg, append(kv, "req_id", id)...)
}
// 中间件注入一次, 任何深度的日志都自带 req_id;
// 反例: reqID 当参数层层手工传 — 签名膨胀且必有人漏传

value 的判据: 它是"这串调用的属性"而不是"这次计算的输入"。

场景 8 · 超时预算分层分配模板

处处 WithTimeout(3s) 的结果是各分支都以为自己有 3s, 加起来 9s —— 预算要表驱动:

const (
    totalBudget   = 3 * time.Second    // 入口总预算 = SLA
    dbBudget      = 800 * time.Millisecond // 依赖子预算, 加起来 < 总
    downstreamBud = 1500 * time.Millisecond
)

func serve(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, totalBudget)
    defer cancel() // 总闸: 内部怎么派生, 3s 必须收口

    dbCtx, dbCancel := context.WithTimeout(ctx, dbBudget)
    defer dbCancel() // 每个子预算独立 cancel, 用完即弃
    if err := loadDB(dbCtx); err != nil {
        return err
    }
    return fetchDownstream(ctx) // 用总预算剩余, 天然不超总盘
}

子预算基于父派生, DB 挂了顶多烧掉 800ms, 下游还有 2.2s 可用。

场景 9 · MQ 消费语义: 处理超时 vs 消息重投

消息处理的 ctx 语义和消费连接无关 —— 连接抖动不能等于消息失败:

func handle(msg broker.Message) error {
    // 新根 Background: 处理时长与消费连接的 ctx 解耦
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := process(ctx, msg); err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return broker.NackRequeue // 30s 没处理完: 重投再试
        }
        return broker.NackDiscard // 业务错: 重投也没用, 进死信
    }
    return broker.Ack
}
// 错误示范: ctx 复用消费连接的 — 连接一抖动等于消息失败,
// 配合重投策略 = 无限重投风暴

Ack/Nack 的判定与超时分类一一对应, 消费语义才收敛。

场景 10 · 事故排查: goroutine 泄漏 4 万只涨不跌

服务跑 3 天内存缓涨, pprof 里 goroutine 数 4 万且只增不减 —— 经典的"循环不感知取消":

// 排查: curl :6060/debug/pprof/goroutine?debug=1 | head -50
// 大量 stuck 在 chan receive, 创建栈指向 pollLoop

for { // 旧代码: 永不退出, 每个 G 持 ~2KB 栈 + 闭包引用
    msg := s.ch.Recv() // 不感知任何取消信号
    s.dispatch(msg)
}

for { // 修复: 每个长循环都要有 ctx 出口
    select {
    case msg := <-s.ch:
        s.dispatch(msg)
    case <-s.ctx.Done(): // 停机/重连时整批退出
        return
    }
}
// 修复后 goroutine 稳定在 ~200, 泄漏增长率归零

守则: 每个阻塞点都要能被 ctx 叫醒; goroutine 数量是健康的第一指标。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · WithCancel 忘 defer cancel() — 服务 goroutine 数/内存缓涨. 原因: 计时器和父节点子表引用要等父取消才释放. 正解: ctx, cancel := ... 下一行就 defer cancel(), go vet 的 lostcancel 检查能抓。
// 错: ctx, cancel := context.WithCancel(p) 之后没 defer — G 数缓涨
ctx, cancel := context.WithTimeout(p, time.Second) // 对: 下一行就 defer
defer cancel() // go vet 的 lostcancel 能抓漏网
坑 2 · ctx 存进 struct 字段当通用上下文 — 函数签名看不出依赖哪个 ctx, 生命周期彻底混乱. 正解: 一路当第一参数传; 唯一例外是 Start/Stop 成对的长生命周期对象(场景 6)。
// 错: type S struct{ ctx context.Context } — 依赖与生命周期全藏住
func (s *S) Load(ctx context.Context, id int64) {}
// 对: ctx 一路当第一参数传, Start/Stop 对象是唯一例外
坑 3 · WithValue 放业务参数 — 金额/分页参数藏进 value, 签名骗人, 读代码的人永远找不到参数从哪来. 正解: 业务输入进函数签名; value 只放 reqID/trace 这类请求属性。
// 错: ctx = WithValue(ctx, amountKey, 100) — 金额藏进 value
func Pay(ctx context.Context, amount int64) error {}
// 对: 业务输入进签名, value 只放 reqID/trace 请求属性
坑 4 · select 加 default 变忙轮询 — CPU 打满且不感知取消: default 让 select 永不阻塞. 正解: default 只用于非阻塞 try 语义; 正常等待去掉 default, 让 ctx.Done() 分支真正被等到。
// 错: select 里加 default: — 永不阻塞, CPU 打满且不感知取消
select {
case msg := <-ch: // 对: 去掉 default, 业务与取消同时等
    dispatch(msg)
case <-ctx.Done():
    return ctx.Err()
}
坑 5 · 取了 r.Context() 又用 Background() 派生 — 客户端断开后取消链断了, 请求还在傻跑. 原因: Background 是新根, 与请求无父子关系. 正解: 全链统一从 r.Context() 派生。
// 错: WithTimeout(context.Background(), 3s) — 断开链断, 请求傻跑
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel() // 对: 全链统一从 r.Context() 派生
坑 6 · deadline 只挡应用层没穿透驱动 — 应用层 800ms 返回, SQL 在 DB 侧跑满 30s, 连接池耗尽. 原因: 用 select 包了一层就以为有超时. 正解: QueryContext / NewRequestWithContext 把 ctx 送到传输层。
// 错: 只用 select+time.After 挡应用层 — SQL 在 DB 跑满 30s
rows, err := db.QueryContext(ctx, q, args...) // 对: 穿透驱动
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
// → 取消送进传输层, 查询真被中断, 连接立刻归还
坑 7 · Done() 亮灯后 return 前又干重活 — 取消分支里还去 flush 几百 KB/再查一次库, 取消等于没取消. 正解: Done 分支立即返回, 清理动作放 defer 且要轻。
case <-ctx.Done():
    flush(hugeBuf) // 错: 取消分支还干重活 = 没取消
    return ctx.Err() // 对: 立即返回, 清理放轻量 defer
坑 8 · 每层重新 WithTimeout(3s) 不看父剩余 — 子预算比父大是假象: 父 3s 到点整体收走, 子的"3s"从未兑现. 正解: 子层预算 ≤ 父剩余, 用分层预算表(场景 8)。
// 错: 每层 WithTimeout(ctx, 3s) — 子 3s 从未兑现, 父先收走
dbCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel() // 对: 子 deadline=min(自身,父剩余), 表驱动分配
坑 9 · 取消后写 ResponseWriter 报错刷日志 — 用户主动断开把 error 日志刷爆, 告警误报. 原因: 把 context.Canceled 当成了故障. 正解: errors.Is(err, context.Canceled) 降为 info 或静默。
_, err := w.Write(buf)
log.Error("write", err) // 错: 用户关页面刷爆 error 告警
if errors.Is(err, context.Canceled) {
    return // 对: 降 info 或静默
}
坑 10 · value key 用裸 string — WithValue(ctx, "id", x) 与任何包的 "id" 相撞, 取到别人的值还查不出原因. 正解: 私有类型 key: type ctxKey int, 编译器保证唯一。
ctx = context.WithValue(ctx, "id", x) // 错: 裸 string 必撞名
type ctxKey int // 对: 私有类型 key, 编译器保证唯一
ctx = context.WithValue(ctx, ctxKey(1), x)
坑 11 · WithTimeout(parent, 0) 立即取消 — 传 0 或负数, Done 已经关闭, 第一个查询就 DeadlineExceeded. 原因: 非正时长表示已过期. 正解: 想表达"不设限"就别派生, 直接用父。
// 错: WithTimeout(p, 0) — 非正=已过期, 首个查询即超时
ctx, cancel := context.WithTimeout(p, time.Second)
defer cancel()
// 对: 想表达"不设限"就别派生, 直接用父
坑 12 · 把 cancel 存起来跨请求复用 — 上一个请求收尾时顺手 cancel, 后到的请求用同一个 ctx 直接夭折. 正解: cancel 与 ctx 同请求作用域, 用完即弃; 绝不放全局。
// 错: var globalCancel context.CancelFunc 存全局跨请求复用
//     → 上个请求收尾一调, 后到请求直接夭折
// 对: cancel 与 ctx 同请求作用域, 用完即弃, 绝不放全局
坑 13 · goroutine 不监听 ctx 泄漏成千上万 — goroutine 数只增不减, pprof 里大量 stuck. 原因: 阻塞点没有 Done 出口. 正解: 每个长循环/阻塞 select 带 case <-ctx.Done(), goroutine 数进监控。
for { work(<-ch) } // 错: 无出口, G 只增不减
for {
    select {
    case m := <-ch: work(m)
    case <-ctx.Done(): // 对: 每个阻塞点都能被叫醒
        return
    }
}
坑 14 · 把 ctx 当日志参数打印 — slog.Info("ctx", ctx) 打出带 value 链的巨大结构, 日志体积暴涨. 正解: 只打 ctx.Err() / ctx.Deadline() 摘要, 不打 ctx 本体。
slog.Info("ctx", "ctx", ctx) // 错: 打出整条 value 链
slog.Info("ctx", "err", ctx.Err(),
    "dl", ctx.Deadline()) // 对: 只打摘要
坑 15 · 取消错误与业务错误混淆 — 上游把 Canceled 当 5xx 告警, 半夜被"用户关页面"叫醒. 正解: 边界层用 errors.Is(err, ctx.Canceled) 分类: 取消→499/静默, 真错→5xx。
// 错: 一律 5xx 告警 — 半夜被"用户关页面"叫醒
if errors.Is(err, context.Canceled) {
    return 499 // 对: 取消→499/静默, 真错→5xx
}
坑 16 · select 循环里用 time.After — 1.23 前 timer 到期前不可回收, 高频循环里堆积内存. 正解: 循环外 NewTimer + Reset, 或升级 1.23+(详见 time 主题)。
// 错: for { select { case <-time.After(time.Second): … } }
//     1.23 前每轮新 timer, 高频循环内存堆积
t := time.NewTimer(time.Second) // 对: 循环外建 + Reset
defer t.Stop()
坑 17 · WithValue 链过长查找退化 — 嵌套十几层 value, 每次取值沿链走十几步 O(n). 正解: 元数据一次挂浅层; 深链场景收拢成一个 struct 值挂单 key。
// 错: 嵌套十几层 WithValue — Value() 沿链 O(n) 越查越慢
type meta struct { reqID, traceID string }
ctx = context.WithValue(ctx, metaKey, m) // 对: 单 key 收拢
坑 18 · 测试里用 Background 无超时 — 下游一 hang, 测试永久卡死拖垮 CI. 正解: 测试根 ctx 一律带兜底超时: ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)(1.24+ 可用 t.Context())。
// 错: ctx := context.Background() — 下游 hang 则 CI 卡死
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() // 对: 必带兜底超时; 1.24+ 直接 t.Context()
坑 19 · Shutdown 复用已取消的 ctx — 拿请求的根 ctx 去 Shutdown, 它早被 cancel, Shutdown 立即返回强断连接, 存量请求 502. 正解: 用 context.Background() 新根 + 停机窗口(场景 5)。
// 错: srv.Shutdown(reqCtx) — 已取消的根 → 立即强断 502
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(ctx) // 对: 新根 + 停机窗口
坑 20 · 取消后自管资源不清理 — Rows 取消后会自动关, 但临时文件/子事务/本地句柄不会自己消失, 泄漏换了个地方. 正解: select Done 分支 + defer close 双保险; 事务显式 rollback。
f, _ := os.CreateTemp("", "job-") // 取消后不会自己消失
defer f.Close()
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback() // 对: Done 分支 + defer 双保险, 事务显式回滚