Go用recover中间件兜住服务崩溃

Go 里一个 goroutine 没被 recover 的 panic 会搞垮整个进程。recover 中间件用一个函数把业务 handler 包起来,外层布 defer recover,捞到 panic 就记堆栈日志、返回 500,让服务继续跑,gin.Recovery 就是这个思路。注意 recover 只能捞当前 goroutine,自己 go 出去的要用 SafeGo 单独兜底。

之前写过一篇讲 panic、defer、recover 怎么配合的,那是基础。

这篇往前走一步:聊怎么把 recover 封装成一个通用的中间件 / Wrapper,让整个服务不会因为某个 goroutine 里的一次 panic 就整个崩掉。

这几乎是每个 Go Web 项目的标配。gin、各种 HTTP 框架都内置了类似的东西。我们不光用,还把它的实现原理和自己造一个的过程走一遍。

为什么需要它

Go 里有个很要命的特性:一个 goroutine 里的 panic 如果没被 recover,整个进程直接挂掉。

设想一个 Web 服务,几百个请求并发进来,每个请求一个 goroutine。只要某一个请求处理时触发了没接住的 panic(比如空指针、数组越界),整个服务进程崩溃,所有正在处理的请求全部跟着陪葬。

这显然不能接受。一个请求的 bug,不该连累全局。

所以我们需要一道"保险":在每个请求的处理外层包一层 recover,哪怕这个请求 panic 了,也只把这一个请求打回一个 500 错误,服务继续稳稳地跑。

这就是 recover 中间件要干的事。

recover 的基本姿势

先回顾一句:recover 必须配合 defer,而且只在 defer 的函数里直接调用才有效。

func safeCall() {
    defer func() {
        // recover 捞住 panic,返回非 nil 就说明确实发生了 panic
        if err := recover(); err != nil {
            fmt.Println("捞到 panic:", err)
        }
    }()

    panic("出事了") // 这个 panic 会被上面的 defer 接住
    // panic 之后的代码不会执行,但 defer 会
}
​

关键点:recover() 只有在 defer 的函数里直接调用才管用。套一层函数、或者不在 defer 里调,都捞不住。记住这个前提,下面的中间件就是围绕它展开。

手写一个 HTTP recover 中间件

标准库 net/http 的 handler 是 func(w, r) 这种形式。所谓中间件,就是用一个函数把原 handler 包起来,在外层加料。

// RecoverMiddleware 包裹一个 handler,给它套上 panic 防护
func RecoverMiddleware(next http.HandlerFunc) http.HandlerFunc {
    // 返回一个新的 handler,它在调用真正的业务前先布好 defer recover
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                // 走到这,说明业务 handler 里 panic 了

                // 1. 打日志,把堆栈记下来,不然线上根本不知道哪 panic 的
                log.Printf("panic 恢复: %v\n堆栈:\n%s", err, debug.Stack())

                // 2. 给客户端一个体面的 500,而不是连接直接断
                w.WriteHeader(http.StatusInternalServerError)
                w.Write([]byte(`{"code":500,"msg":"服务器内部错误"}`))
            }
        }()

        // 调用真正的业务 handler,它要是 panic,上面的 defer 接住
        next(w, r)
    }
}
​

用的时候,把业务 handler 包一下再注册:

func userHandler(w http.ResponseWriter, r *http.Request) {
    var u *User
    fmt.Fprintln(w, u.Name) // 故意制造空指针 panic
}

func main() {
    // 原本直接 http.HandleFunc("/user", userHandler)
    // 现在用中间件包一层
    http.HandleFunc("/user", RecoverMiddleware(userHandler))
    http.ListenAndServe(":8080", nil)
}
​

现在访问 /user,虽然业务代码里空指针 panic 了,但请求只会收到一个 500,服务进程稳稳地继续服务其他请求。

这里 debug.Stack() 很重要——它把 panic 时的调用堆栈打出来,不记这个,线上出事你只知道"panic 了",却不知道 panic 在哪一行。

看看 gin 是怎么做的

框架内置的 recover 中间件,思路跟上面一模一样,只是封装得更漂亮。

gin 的用法:

func main() {
    r := gin.New()
    // gin.Recovery() 就是官方的 recover 中间件
    r.Use(gin.Recovery())

    r.GET("/user", func(c *gin.Context) {
        var u *User
        c.String(200, u.Name) // panic,但被 Recovery 接住
    })
    r.Run(":8080")
}
​

gin.Recovery() 内部干的事和我们手写的本质一样:在请求处理链外层布一个 defer recover(),捞到 panic 就记日志、返回 500、让这个请求优雅收场,不影响别的请求。

区别只是 gin 用的是它自己的 Context、责任链模型,封装得更通用。核心逻辑没变,还是那个 defer + recover。

给 goroutine 也套上

有个容易忽略的点:中间件只保护了请求主流程那个 goroutine。如果你在 handler 里又手动开了新的 goroutine,中间件的 recover 可管不到它。

func handler(c *gin.Context) {
    // 危险:这个新 goroutine 里 panic,gin.Recovery 接不住,服务照样崩
    go func() {
        doSomethingAsync() // 里面万一 panic...
    }()
}
​

因为 recover 只能捞当前 goroutine 的 panic,捞不了别的 goroutine 的。

所以自己开的 goroutine,得自己再包一层。实践里一般会封装一个 SafeGo:

// SafeGo 启动一个带 recover 保护的 goroutine
func SafeGo(fn func()) {
    go func() {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("goroutine panic 恢复: %v\n%s", err, debug.Stack())
            }
        }()
        fn()
    }()
}

// 用法:把 go func(){...} 换成 SafeGo
func handler(c *gin.Context) {
    SafeGo(func() {
        doSomethingAsync()
    })
}
​

规矩就一条:每一个你亲手 go 出来的 goroutine,都要有自己的 recover 兜底。别指望外层中间件帮你擦屁股,它够不着。

别滥用 recover

最后泼点冷水。recover 是用来"兜住意外崩溃"的保险,不是用来"替代错误处理"的。

  • 可预期的错误用 error 返回,别靠 panic/recover。文件打不开、参数非法这种,老老实实 return err。
  • recover 只在边界处布防:请求入口、goroutine 入口这种"最外层"布一道防线就够,别在业务代码里到处 recover,那样会把真正的 bug 悄悄吞掉,排查起来要命。
  • recover 后要记日志:捞住了也得吼一嗓子(打堆栈日志),否则 panic 被静悄悄吃掉,问题永远暴露不出来。

一句话:recover 中间件是"最后一道墙",防的是没预料到的崩溃;日常错误该用 error 还得用 error。

小结

recover 中间件解决的核心问题是:别让单个请求(或 goroutine)的 panic 搞垮整个服务进程。

实现套路很固定:用一个函数把业务 handler 包起来,在外层布 defer recover(),捞到 panic 就记堆栈日志、返回 500,让服务继续跑。gin 的 gin.Recovery() 就是这个思路的成品。

要特别注意:recover 只能捞当前 goroutine,自己 go 出去的 goroutine 必须用 SafeGo 这类封装单独兜底。

还要守住边界:recover 是最外层的崩溃保险,不是日常错误处理的替代品,可预期的错误照样用 error。

把这道墙砌好,服务的健壮性能上一个大台阶——至少不会再因为一个空指针就全线崩溃了。

更多推荐

章节目录