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。

295

被折叠的 条评论
为什么被折叠?



