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