Go 的 defer + recover 兜住 panic:中间件里做崩溃恢复,以及哪些 panic 你根本救不回来

Go 的 defer + recover 兜住 panic:中间件里做崩溃恢复,以及哪些 panic 你根本救不回来

线上服务跑得好好的,某个请求触发了一个 nil 指针解引用,整个进程直接挂掉——这是很多 Go 新手第一次被 panic 教育的场景。Go 不像 Java 有个兜底的线程级异常处理,一个没被 recover 的 panic 会顺着调用栈一路炸到 main,把整个进程带走。这篇讲怎么用 defer + recover 正确地兜住 panic,以及哪些 panic 你 recover 也救不回来。

先看错误的写法:recover 放错位置

很多人第一次写 recover,是这样的:

func handle() {
    recover() // 没用!recover 不在 defer 里,永远返回 nil
    mayPanic()
}

recover() 只有在 defer 函数内直接调用 时才有效。上面这行在正常流程里执行,此时没有 panic 正在传播,它只会返回 nil,等于白写。

还有一种更隐蔽的错法——把 recover 包了一层:

func safeRecover() {
    recover() // 也没用!它不是被 defer 直接调用的
}

func handle() {
    defer safeRecover() // defer 的是 safeRecover,recover 隔了一层调用栈
    mayPanic()
}

recover 必须由 defer 直接调用的那个函数来执行,中间隔一层普通函数调用就失效。记住这条铁律,能省掉一半的坑。

正确写法:defer 里的匿名函数

func handle() (err error) {
    defer func() {
        if r := recover(); r != nil {
            // 把 panic 转成 error 往上返回,而不是让进程死
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()
    mayPanic()
    return nil
}

关键点:recover() 写在 defer func(){...}() 内部,直接调用。捕获到之后,通常做两件事——记录日志(带上堆栈)、把 panic 转成 error 返回。注意函数用了具名返回值 err,这样才能在 defer 里修改它;如果是匿名返回值,defer 里改了外面也拿不到。

实战:一个 HTTP recover 中间件

真正有用的场景是给整个服务加一层兜底,让单个 handler 的 panic 不至于拖垮进程:

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                // debug.Stack() 拿到完整堆栈,定位问题全靠它
                log.Printf("panic: %v\n%s", err, debug.Stack())
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

用的时候套在最外层:

mux := http.NewServeMux()
mux.HandleFunc("/api", apiHandler)
http.ListenAndServe(":8080", Recover(mux))

这样任何 handler 里的 panic 都会被这层中间件接住,返回 500 而不是让进程崩溃。debug.Stack() 是排查关键,只记录 err 本身你根本不知道 panic 在哪一行。

坑:每个 goroutine 都要自己 recover

这是最容易翻车的地方。recover 只能兜住当前 goroutine 的 panic,你在 handler 里起了个新 goroutine,它 panic 了,外层中间件的 recover 完全接不住:

func handler(w http.ResponseWriter, r *http.Request) {
    go func() {
        mayPanic() // 这个 panic 会直接杀掉整个进程!中间件的 recover 救不了
    }()
}

正确做法是给每个手动起的 goroutine 都配一个 recover:

func SafeGo(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("goroutine panic: %v\n%s", r, debug.Stack())
            }
        }()
        fn()
    }()
}

哪些「panic」你 recover 也救不回来

recover 不是万能的。有几类是 fatal error,不走 panic 机制,recover 完全无效,进程必死:

  • 并发读写 map:fatal error: concurrent map read and map write。这是运行时直接抛的 fatal error,不是 panic,recover 接不住。
  • 栈溢出:无限递归导致 fatal error: stack overflow
  • 死锁:所有 goroutine 都阻塞时 fatal error: all goroutines are asleep - deadlock
  • 显式 os.Exit():直接退出,连 defer 都不执行。

验证一下并发 map 救不回来:

func main() {
    defer func() {
        // 这个 recover 对下面的并发 map 写完全无效
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    m := map[int]int{}
    for i := 0; i < 10; i++ {
        go func() {
            for {
                m[1] = 1 // 并发写,触发 fatal error,进程直接死
            }
        }()
    }
    select {}
}

运行会打印 fatal error: concurrent map read and map write 然后崩溃,那句 recovered: 永远不会出现。这类问题的正解是从根上消除竞态(加锁、sync.Map 或分片),而不是指望 recover。

小结

  • recover() 必须在 defer直接调用才有效,隔一层普通函数就失效。
  • 用具名返回值 (err error),才能在 defer 里把 panic 转成 error 返回。
  • HTTP 服务加一层 recover 中间件兜底,配合 debug.Stack() 记录堆栈。
  • 每个手动起的 goroutine 都要自己 recover,外层接不住子 goroutine 的 panic。
  • 并发读写 map、栈溢出、死锁是 fatal error,recover 无效,只能从根上避免。

一句话记忆点:recover 兜的是「panic」,兜不了「fatal error」;而且它只兜自己所在的那个 goroutine。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

codedevin

祝福你

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值