Go · defer / panic / recover

defer 是栈式收尾协议 — LIFO 逆序, 参数立即求值; panic 沿调用栈逐帧展开, 只能被 defer 里的 recover 截住

危险区 — 栈展开与截停 defer 栈 — LIFO 逆序执行 proc() 函数体内的三连 defer return err ③ defer resp.Body.Close() ② defer mu.Unlock() ① defer f.Close() 先弹出 再弹出 最后弹 栈顶 栈底 return 赋值 → defer 逆序弹出 → 真正 RET 注册顺序 = 资源获取顺序, 弹出正好逆序收尾 循环里的 defer 不弹: 函数结束才执行 参数求值时机 — 定格 vs 引用 立即求值 defer log("id=%d", id) id 当场定格为 7 之后 id 再变也不管 参数是快照 闭包引用 defer func() { log(id) }() 拿到最终值 42 执行那一刻才读 id 变量是直播 i := 7; defer log(i); i = 42 → 打印 7 (传参定格) 闭包版 → 打印 42 panic 传播 — 沿调用栈展开 recover() 截住 → 转成 error 返回 processRequest() defer B: recover() 在这里等着 doQuery() defer A: rows.Close() 先被执行 decodeRow() panic(err) 在这里发生 无人 recover → 进程 exit 2 三规则速记 — 语言规范原文级 规则一 · LIFO 多个 defer 后注册的先执行 与资源打开顺序互为镜像 锁要在拿到之后立刻注册 规则二 · 立即求值 参数在 defer 语句处定格 闭包才引用最终值 想要快照就传参, 想直播就闭包 规则三 · 改命名返回值 (err error) 的 err 可被 defer 改写 错误包装/兜底逻辑全靠它 非命名返回值改的是副本 recover 的有效条件与 defer 成本 仅 defer 直接调用有效 包一层 helper 再调 = 永远拿 nil recover 后回不到 panic 发生点 goroutine 各自为政 子 G panic, main 的 recover 救不了 go 出去的入口都要自带 recover 壳 open-coded defer (1.14+) 常规 defer 折成 1ns 级位操作 循环内/超过 8 个才退化为堆 defer Legend defer 栈 / 三规则 求值时机 panic 路径 / recover 条件 优化/成功路径

LIFO 栈式收尾

  • • 后注册先执行, 与资源获取顺序互为镜像
  • • return 赋值之后才逐帧弹出
  • • 循环里 defer 堆到函数结束才执行
  • • 1.14+ open-coded: 常规场景 ≈1ns

求值时机三岔口

  • • 参数: defer 语句处立即定格
  • • 闭包: 引用变量的最终值
  • • 命名返回值: 可被 defer 改写
  • • os.Exit/log.Fatal: 全部跳过

panic / recover 边界

  • • recover 仅在 defer 直接调用时有效
  • • goroutine panic = 整个进程崩 exit 2
  • • re-panic 用原值, debug.Stack 留现场
  • • recover 后回不去原函数, 只能收尾

💡 一句话理解

defer 是把"收尾动作"压栈: 函数 return 时按后进先出逆序弹出来执行。参数在 defer 语句执行那一刻立即求值定格, 闭包则引用变量的最终值, 命名返回值还能被改写 —— 三条规则决定了所有惯用法的形状。

panic 是沿调用栈向上的"紧急撤离": 每一帧已注册的 defer 仍会执行, 唯一的截停点是 defer 里直接调用的 recover()。救不回来就进程退出 exit 2 —— 所以每条 goroutine 都要有自己的 recover 门卫, 这是服务不死的底线。

🧠 必知必会 必考 & 必会

规则一: LIFO
多个 defer 后注册的先执行。资源按 A→B→C 顺序打开, defer 依次注册, 收尾自动 C→B→A 逆序释放 —— 依赖关系天然正确。
defer fmt.Println("A")   // 先注册: 资源 A
defer fmt.Println("B")   // 后注册: 资源 B
// 函数返回时输出:
// → B   关键: 后注册的先执行
// → A   与打开顺序互为镜像
规则二: 参数立即求值
defer log("id=%d", id) 的 id 在 defer 语句执行处定格; defer func(){ log(id) }() 拿到的才是闭包引用的最终值。要快照传参数, 要直播用闭包。
i := 7
defer fmt.Println(i)                   // 定格: → 7
defer func() { fmt.Println(i) }()   // 直播: → 42 (先弹出)
i = 42
// 关键: 传参是快照, 闭包是直播
规则三: 可改命名返回值
func f() (err error) 的 defer 里对 err 赋值会改写真正的返回值 —— 统一错误包装/补指标的标准挂点。
func f() (err error) {
    defer func() {              // 关键: 改的就是返回值槽
        err = fmt.Errorf("wrap: %w", err)
    }()
    return doWork()
}
defer 与 return 三步曲
return x 不是原子动作: 先把 x 赋给返回值槽 → 依次执行 defer → 真正 RET。所以 defer 能"看见并修改"返回值。
func f() (n int) {
    defer func() { n++ }()
    return 5                  // 三步: n=5 → defer n=6 → RET
}
fmt.Println(f())             // → 6
recover 有效位置
只有defer 函数体内直接调用的 recover 才拦得住 panic; 包在普通函数里调用永远返回 nil。
defer func() {                 // 对: 直接函数体里调用
    if r := recover(); r != nil { handle(r) }
}()
// 错: defer tryRecover() — helper 里调 recover 永远 nil
panic 使用边界
库代码不 panic: 初始化失败要返回 error 让调用方决策。panic 只留给"不可能继续的程序性错误" (nil map 写入/越界这类 bug 现场)。
func Load(path string) (*Cfg, error) {  // 对: 返回 error
    return nil, fmt.Errorf("open %s", path)
}
// panic 只留给 bug 现场: nil map 写入
// var m map[string]int; m["k"]=1 → panic: assignment to entry in nil map
re-panic 保现场
recover 到非目标 panic 时 panic(r) 原值再抛, 并用 debug.Stack() 当场留栈 —— recover 之后原栈就开始被收走了。
defer func() {
    if r := recover(); r != nil {
        stack := debug.Stack()  // 关键: 此刻拍照, 栈即将被收走
        panic(r)                 // 原值再抛, 不吞现场
    }
}()
recover 后无法续跑
recover 返回后函数直接返回当前返回值, 不会回到 panic 点继续执行; 它是出口不是断点续传。
func f() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)  // 转错误返回
        }
    }()
    panic("boom")              // → err="panic: boom"
}
goroutine panic 全进程崩
任一 goroutine panic 且自身无 recover, 整个进程 exit 2, main 的 recover 救不了 —— go 出去的入口必须统一套 recover 壳。
go func() {
    panic("boom")             // 错: 整进程 exit 2
}()
go recoverWrap(consume)        // 对: 统一出口自带 recover 壳
http middleware recover
net/http 自带 per-request recover (记日志断连接), 但要"写回 500 + 上报 + 告警"就得自己在中间件里 recover —— 见场景 1。
// net/http 自带 recover: 只记日志 + 断连接
// 客户端收到 connection reset, 不是 500 JSON
defer func() {                   // 对: 中间件里自己兜
    if r := recover(); r != nil {
        w.WriteHeader(http.StatusInternalServerError)
    }
}()
open-coded defer
Go 1.14+ 把常规 defer 折成 1ns 级的位标记, 只有循环内 defer 或单函数超 8 个才退化成堆分配的 defer 链 —— "defer 很慢"是老黄历。
func f() {
    defer cleanup()       // 1.14+: 折成 1ns 位操作, 无堆分配
    work()
}
// 只有循环内 defer / 单函数超 8 个才退化堆 defer 链
os.Exit 跳过 defer
os.Exit/log.Fatal 直接走 syscall 退出, 已注册 defer 一个都不跑; main 里要 return err 到统一出口再 Exit。
func main() {
    defer log.Sync()       // 错: os.Exit 后永不执行
    if err := run(); err != nil { os.Exit(1) }
}
// 对: run() 收口函数里 defer flush 完成后再 Exit
panic(nil) 新旧语义
Go 1.21 前 panic(nil) 让 recover 返回 nil 无法判别; 1.21+ 默认转成 *runtime.PanicNilError (GODEBUG=panicnil=1 恢复旧行为)。永远 panic 非 nil 值最稳。
panic(nil)
// 1.21 前: recover() → nil, 判不出发生过 panic
// 1.21+:  recover() → *runtime.PanicNilError
// 关键: 永远只 panic 非 nil 值最稳
循环里的 defer
defer 的作用域是函数不是块: 循环体里 defer 会堆积到函数结束才集中执行 —— fd/内存泄漏高发区, 见坑 1。
for _, p := range paths {
    f, _ := os.Open(p)
    defer f.Close()       // 错: 堆到函数结束, fd 泄漏
}
// 对: 循环体抽成函数, defer 立即生效

🏭 生产实战 real world

场景 1 · 中间件 recover: 单请求 panic 不拖死整个服务

一个 handler 里的 nil 指针 panic 会把整条连接打断, 监控只看到 connection reset; 统一兜住才有 500 可查:

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {          // 只能在这里直接调
                log.Printf("panic: %v\n%s", rec, debug.Stack())
                if r.Context().Err() != nil {
                    return                              // 客户端已断开: 补写响应无意义
                }
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusInternalServerError)
                json.NewEncoder(w).Encode(errResp{Code: "internal_error"})
            }
        }()
        next.ServeHTTP(w, r)                                // 业务 handler 的 panic 全被兜住
    }()
}

场景 2 · 文件 Close 三段式: 先查错, 再 defer, 后 Sync

无脑 defer f.Close() 会吞掉 Close 的错误 —— 页缓存没刷完就是静默丢数据:

f, err := os.Create(path)
if err != nil {
    return err                                              // ① 失败就别注册 defer (nil f 会 panic)
}
defer func() {                                          // ③ 收尾: Close 的错误不能丢
    if cerr := f.Close(); cerr != nil {
        err = errors.Join(err, fmt.Errorf("close %s: %w", path, cerr))
    }
}()
if _, werr := f.Write(buf); werr != nil {                   // ② 主逻辑
    return werr
}
return f.Sync()                                         // Close 不保证刷盘, 账单类数据要 Sync

场景 3 · 锁的 defer Unlock 模板: 提前 return 也不漏解锁

手写 Unlock 的函数每加一个 return 分支就多一个死锁隐患; defer 一注册全兜住:

func (c *Cache) GetOrLoad(k string) (Item, error) {
    c.mu.RLock()
    hit, ok := c.m[k]                                       // 读锁内只做快速路径
    c.mu.RUnlock()
    if ok {
        return hit, nil
    }
    c.mu.Lock()                                             // 慢路径升级写锁
    defer c.mu.Unlock()                                     // 之后 4 个 return 分支都不用记得解锁
    if hit, ok := c.m[k]; ok {                              // double-check: 等锁期间可能已被填上
        return hit, nil
    }
    v, err := load(k)
    if err != nil {
        return Item{}, err                                 // 提前返回, defer 保证解锁
    }
    c.m[k] = v
    return v, nil
}

场景 4 · 命名返回值 + defer: 统一错误包装与指标上报

五处 return 就要写五次包装和打点; 挂在 defer 里一处收口:

func (s *Service) Call(ctx context.Context, req *Req) (resp *Resp, err error) {
    started := time.Now()
    defer func() {                                          // 命名返回值: 这里改的 err 就是返回值
        cost := time.Since(started)
        if err != nil {
            err = fmt.Errorf("service=%s req_id=%s cost=%s: %w",
                s.name, req.ID, cost, err)                  // %w 包装: 上层 errors.Is 仍可用
            s.metrics.ErrTotal.WithLabelValues(s.name).Inc()
            return
        }
        s.metrics.CostMS.WithLabelValues(s.name).Observe(float64(cost.Milliseconds()))
    }()
    resp, err = s.doCall(ctx, req)                          // 注意用 = 不是 :=, 否则遮蔽返回值
    return
}

场景 5 · 事务兜底: defer Rollback 的无 commit 即回滚

SQL 事务三步写完, 中间任何 return 都要回滚; defer 让"忘记回滚"从代码里消失:

func (s *Store) CreateOrder(ctx context.Context, o Order) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()                                     // commit 成功后 Rollback 返回 ErrTxDone, 无害
    if _, err := tx.ExecContext(ctx, insertOrderSQL, o.ID, o.Amount); err != nil {
        return err                                          // 这里返回 → defer 回滚
    }
    if _, err := tx.ExecContext(ctx, deductStockSQL, o.SKU, o.Qty); err != nil {
        return err                                          // 同上: 任何失败路径统一被兜
    }
    if _, err := tx.ExecContext(ctx, insertLedgerSQL, o.ID, o.Amount); err != nil {
        return err
    }
    return tx.Commit()                                      // 走到这里才提交; 没提交的都回滚
}

场景 6 · 循环里 defer 泄漏事故: 50 万文件导出拖垮进程

导出循环里 defer f.Close(), 函数不返回 defer 不执行 —— fd 一路涨到 ulimit:

// 事故: exportAll 循环 50 万个文件, 每个都 defer f.Close()
// 症状: lsof 一路涨, 半小时后 "too many open files", 进程卡死
func exportAll(paths []string) error {
    for _, p := range paths {
        if err := exportOne(p); err != nil {                // 修复: 每文件一个函数作用域
            return err
        }
    }
    return nil
}
func exportOne(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()                                         // 函数结束立即执行, 不再堆积
    return streamTo(f)
}

修复后 lsof 稳定在个位数; 数据量小也可在循环体内显式 Close, 但函数封装最不易漏。

场景 7 · panic 哨兵值: 深递归里带错误快速撤退

几十层递归里逐层 if err 判空太啰嗦; 哨兵 panic + recover 转错误是标准解法 (stdlib template/JSON 解码同款):

var errAbortWalk = errors.New("walk aborted")

func walkTree(n *Node, visit func(*Node) error) (err error) {
    defer func() {
        if r := recover(); r != nil {
            if r == errAbortWalk {                          // 哨兵 panic: 最快退出通道
                err = errAbortWalk                          // recover 转成正常错误返回
                return
            }
            panic(r)                                          // 别的 panic 原样再抛: 不吞现场
        }
    }()
    eachNode(n, visit)
    return nil
}
// 调用方: visit 返回 errAbortWalk 时内部直接 panic, 免掉逐层判空

场景 8 · goroutine panic 上报: 统一 Go 出口配 Sentry

裸 go func() 起的后台任务一旦 panic 就是全进程 exit 2, 半夜被告警叫醒; 统一出口拦住并上报:

func Go(name string, fn func()) {                           // 项目统一的 go 出口
    go func() {
        defer func() {
            if r := recover(); r != nil {
                stack := debug.Stack()                      // 此刻不拍, 栈就被展开收走了
                sentry.CaptureException(fmt.Errorf("goroutine %s panic: %v\n%s", name, r, stack))
                log.Error("goroutine panic", "name", name, "panic", r)
            }
        }()
        fn()
    }()
}
// 用法: Go("order-consumer", consume) — 子任务崩了只上报, 不拖死整个进程

场景 9 · 优雅停机: defer 逆序收尾资源

启动顺序 listener → DB → MQ, 停机必须逆序: 先停流量再关依赖, defer 的 LIFO 天然就是这张图:

func run(ctx context.Context) error {
    lis, err := net.Listen("tcp", ":8080")
    if err != nil {
        return err
    }
    defer lis.Close()                                         // 后开的最先关: 与启动顺序互为镜像
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return err
    }
    defer db.Close()                                          // 连接池在 listener 之后收
    sub, err := mq.Subscribe(ctx, topic, handler)
    if err != nil {
        return err
    }
    defer sub.Unsubscribe(ctx)                                // 先停流量, 再关依赖
    return http.Serve(lis, makeMux())                        // ctx 取消 → Serve 返回 → defer 逆序收尾
}

场景 10 · recover 后 ResponseWriter 已写一半: 499 场景处置

流式下载写了一半 panic, 客户端早已超时断开 —— 这时补 500 既不可能也没人收:

defer func() {
    if rec := recover(); rec != nil {
        log.Printf("panic: %v\n%s", rec, debug.Stack())
        if r.Context().Err() != nil {
            return                                          // 499: 客户端已断开, 写了也没人收
        }
        if w.Header().Get("Content-Length") == "" && !written(w) {
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusInternalServerError)     // 只有没写过 header 才能补 500
            json.NewEncoder(w).Encode(errResp{Code: "internal"})
            return
        }
        // 已写一半 (流式下载中断): 只能记日志, 连接交给 server 断
        log.Error("partial response abandoned", "path", r.URL.Path)
    }
}()
// written(w): 中间件包一层响应记录器, 记录 WriteHeader/Write 是否已被调用

⚠️ 编码注意与常见坑 pitfalls

坑 1 · defer 在循环里堆积, fd 泄漏 — 循环体里 defer 到函数结束才执行, 50 万次循环攒 50 万个待关闭文件, 最后 "too many open files". 正解: 循环体抽成函数让 defer 立即生效, 或循环内显式 Close。
for _, p := range paths {
    f, _ := os.Open(p)
    defer f.Close()            // 错: 堆到函数结束 → too many open files
}
// 对: 循环体抽成 exportOne(p), defer 立即生效
坑 2 · defer println(i) 立即求值搞混 — defer fmt.Println(i) 打印注册时的 i, 闭包版打印最终值, 输出对不上预期. 原因: 参数当场定格. 正解: 要快照直接传参, 要最终值用 defer func(){ fmt.Println(i) }()。
i := 7
defer fmt.Println(i)                   // 错: 想要 42, 打的却是 7
defer func() { fmt.Println(i) }()   // 对: 闭包拿最终值 42
i = 42
坑 3 · recover 写在普通函数里拦不住 — 封了个 helper func tryRecover(){ recover() }, panic 照炸. 原因: recover 只在 defer 的直接函数体里有效. 正解: defer func(){ if r := recover(); ... }() 原地写。
func tryRecover() { recover() }
defer tryRecover()                // 错: 永远拦不住, panic 照炸
defer func() {                // 对: 直接函数体里调
    if r := recover(); r != nil { handle(r) }
}()
坑 4 · recover 后继续用损坏状态 — panic 时 map 可能写了一半, recover 后接着读又二次 panic. 正解: recover 只做收尾返回; 状态推倒重建, 不带着内伤续跑。
if r := recover(); r != nil {
    continueJob(m)         // 错: map 写了一半, 二次 panic
    err = toErr(r)         // 对: 只收尾转错误, 状态重建
}
坑 5 · goroutine panic 没有 recover, 整进程退出 — 后台任务崩了把在线服务一起带走 exit 2. 原因: recover 只救自己所在的 G. 正解: go 出去的入口统一套 recover 壳并上报。
go consume()               // 错: panic → 整进程 exit 2
func Go(fn func()) {       // 对: 统一出口自带 recover 壳
    go func() {
        defer func() { if r := recover(); r != nil { report(r) } }()
        fn()
    }()
}
坑 6 · defer f.Close() 吞掉 Close 错误丢数据 — 写文件场景 Close 失败 (磁盘满/页缓存未刷) 却返回 nil, 数据悄悄丢了. 正解: 匿名函数接住 cerr := f.Close(), 用 errors.Join 并入返回值, 关键数据再加 f.Sync()。
defer f.Close()                    // 错: Close 失败被吞, 数据丢
defer func() {                    // 对: 错误并进返回值
    if cerr := f.Close(); cerr != nil {
        err = errors.Join(err, cerr)
    }
}()
坑 7 · defer 改的是返回值副本 — 非命名返回值 func f() error 里 defer 对局部 err 赋值, 调用方拿到的还是旧值. 正解: 声明命名返回值 (err error), defer 才改得到返回值槽。
func f() error {                  // 错: 非命名返回值
    err := do()
    defer func() { err = wrap(err) }()  // 只改了局部副本
    return err                     // 调用方拿旧值
}
func f() (err error) { ... }      // 对: 命名返回值
坑 8 · panic 传接口值 recover 后断言不到 — panic(errors.New("x")) 后 r.(MyErr) 断不上, 因为动态类型是 *errors.errorString. 正解: 断言与 panic 用同一定义的自定义类型, 或直接与哨兵值 == 比较。
panic(errors.New("x"))         // 错: 动态类型是 *errors.errorString
_, ok := recover().(MyErr)       // → ok=false, 断不上
panic(&MyErr{})                  // 对: 同一定义的类型
if e, ok := recover().(*MyErr); ok { handle(e) }
坑 9 · panic 在 init 里, 服务起不来 — init 里读配置失败直接 panic, 容器 CrashLoopBackOff 连日志都看不全. 正解: init 只做注册类无失败操作; 能失败的下沉到 main 里 return err。
func init() {
    cfg = mustLoad()              // 错: panic → CrashLoopBackOff
    registerMetrics()             // 对: init 只做注册类无失败操作
}
// 对: 能失败的下沉到 main 里 return err
坑 10 · defer 求值锁对象副本 — struct 里内嵌 mutex 又按值传递, defer s.mu.Unlock() 解的是副本锁, go vet 报 copylocks. 正解: 含锁 struct 一律指针传递; 变量可能重绑定时用 defer func(){ mu.Unlock() }()。
func (s S) Do() {               // 错: 值拷贝, go vet: copylocks
    s.mu.Lock()
    defer s.mu.Unlock()          // 解的是副本锁
}
func (s *S) Do() { ... }        // 对: 含锁 struct 一律指针传递
坑 11 · recover 拦下 panic 丢了原栈 — recover 之后 goroutine 栈已被展开, 事后想打栈只剩收尾帧. 正解: defer 里先 debug.Stack() 当场拍照再处理。
if r := recover(); r != nil {
    log.Error(toErr(r))          // 错: 想打栈只剩收尾帧
    stack := debug.Stack()       // 对: 先拍照再处理
    log.Error(toErr(r), "stack", stack)
}
坑 12 · recover 的 defer 注册太晚 — recover defer 放在函数中段, 前半段语句的 panic 直接炸穿. 原因: panic 发生时该 defer 还没注册. 正解: recover defer 永远写函数第一行。
func h() (err error) {
    data := decode(raw)          // 错: 这行 panic 时 defer 未注册
    defer func() { if r := recover(); r != nil { err = toErr(r) } }()
    use(data)
    // 对: recover defer 永远写函数第一行
}
坑 13 · panic(nil) 旧版判不出来 — Go 1.21 前 panic(nil) 让 recover() 返回 nil, if r := recover(); r != nil 判不出发生过 panic. 正解: 升 1.21+ (自动转 *runtime.PanicNilError) 且永远只 panic 非 nil 值。
panic(nil)
// 错: 1.21 前 recover() → nil, r != nil 判不出
// 对: 只 panic 非 nil 值; 1.21+ 转 *runtime.PanicNilError
坑 14 · http handler panic 默认 500 是幻觉 — net/http 自带 recover 只记日志并断连接, 客户端收到的是 connection reset, 不是你设计的 500 JSON. 正解: 自己的 recover 中间件在断开前把响应写完。
// 错: net/http 自带 recover 只记日志 + 断连接
//     客户端收到 connection reset, 不是 500
defer func() {                   // 对: 自己的中间件写回 500
    if r := recover(); r != nil {
        w.WriteHeader(http.StatusInternalServerError)
    }
}()
坑 15 · defer 闭包捕获循环变量 (Go 1.22 前) — 循环变量整个循环复用, 一堆 defer 全拿最终值, 输出全是最后一个. 正解: 升 1.22+ (每轮新变量), 老版本用 i := i 或参数传入定格。
for _, v := range []int{1, 2, 3} {
    v := v                       // 对: 老版本每轮定格副本
    defer func() { use(v) }()  // 错: 1.22 前闭包全拿最终值
}
坑 16 · os.Exit / log.Fatal 跳过所有 defer — main 里 log.Fatal(err) 直接退出, buffer 没 flush、连接没关、审计日志断尾. 正解: main 只 return err, 收口函数统一 defer flush 后再 os.Exit。
func main() {
    defer log.Sync()              // 错: log.Fatal 后不执行
    if err := run(); err != nil {
        log.Fatal(err)           // 直接退出, buffer 丢
    }
}
// 对: main 只 return err, 收口 defer flush 后再 os.Exit
坑 17 · 测试里 panic 与 t.Fatal 混淆 — 子 goroutine 里调 t.Fatal 触发 runtime.Goexit, 用例挂死或行为诡异. 正解: 子 goroutine 里用 t.Error + return; panic 场景让外层 recover 断言。
go func() {
    t.Fatal("bad")               // 错: 子 G 里触发 Goexit, 挂死
    if err != nil {
        t.Errorf("bad: %v", err)  // 对: 只记错后 return
    }
}()
坑 18 · defer 函数自己再 panic — defer 里写字典/解引用又炸, 新 panic 替换原 panic 继续展开, 原始现场被覆盖. 正解: defer 里的操作也要防御; recover defer 里先留 debug.Stack() 再干活。
defer func() {
    m["k"] = 1                  // 错: m 为 nil, 新 panic 覆盖现场
}()
defer func() {
    defer func() { _ = recover() }()  // 对: defer 里也防御
    cleanup()
}()
坑 19 · defer 把命名返回值错误吞成 nil — defer 里"好心"写 if retriable { err = nil }, 正常错误被抹掉, 监控全绿但数据没写进去. 正解: defer 只做补充包装/记录, 不做消除错误的判断。
defer func() {
    if retriable(err) {
        err = nil             // 错: 数据没写进去, 监控全绿
    }
    err = wrapCtx(err)           // 对: 只做包装/记录
}()
坑 20 · re-panic 丢掉原 panic 值 — recover 后 panic("recovered"), 上层只看到新值, 原始错误链断了. 正解: panic(r) 原值再抛, 需要带上下文时用 fmt.Errorf("%w", ...) 包装后 panic。
if r := recover(); r != nil {
    panic("recovered")         // 错: 原始错误链断了
}
// 对: panic(r) 原值再抛; 带上下文:
// panic(fmt.Errorf("ctx: %w", r.(error)))