go http服务panic后不崩溃并记录完整堆栈的关键是:在最外层handler用defer+recover捕获panic,配合responsewriterwrapper避免重复写响应,提前读取并保存request body、query、headers等上下文,使用足够深度(≥32帧)的runtime.stack,并结构化记录remoteaddr、useragent、x-request-id等关键信息。

Go HTTP 服务 panic 后如何不崩溃还能记录完整堆栈
默认的 http.Server 遇到 panic 会直接终止连接、丢弃上下文,连请求路径和参数都看不到。自定义 Recovery 中间件不是为了“兜底运行”,而是确保 panic 发生时能拿到:runtime.Stack、http.Request 的 method/path/headers/body(需提前读取)、以及调用链中关键变量值。
关键点在于:panic 捕获必须在最外层 handler 函数内,且不能影响原有 responseWriter;堆栈要足够深(至少 32 帧),否则看不到业务代码行号。
- 务必用
defer+recover()包裹整个 handler 执行逻辑,而不是只包业务函数 - 不要在 recover 后继续写入原始
http.ResponseWriter,应使用ResponseWriter包装器拦截状态码和 body - 请求 body 只能读一次,要在 handler 开头就用
io.ReadAll(r.Body)保存,否则 recover 时已关闭
为什么用 http.Handler 而不是 gin.Echo 的内置 Recovery
gin 的 Recovery() 默认只打日志、返回 500,堆栈截断严重(默认 48 字节),且不暴露 *http.Request 实例;Echo 的同名中间件甚至不支持自定义 stack size。纯 net/http 下自己写,才能控制三件事:stackSize 参数、requestID 注入、错误上报渠道(如 sentry 或本地文件)。
如果你用的是标准库或 chi/gorilla,别被框架封装劝退——中间件本质就是 func(http.Handler) http.Handler,没黑盒。
立即学习“go语言免费学习笔记(深入)”;
- gin 的
RecoveryWithWriter不让改 stack depth,源码里写死debug.Stack() - 想记录 query 参数?得从
r.URL.RawQuery提取,而不是依赖框架解析后的r.URL.Query()(可能已被修改) - 并发安全:log 打印前建议加锁,或用
log/slog(Go 1.21+)自带原子写入
recover() 后怎么安全地写响应而不触发 “http: multiple response.WriteHeader calls”
panic 可能在任何位置发生,包括已经调用过 w.WriteHeader(200) 之后。此时再写 header 就会报错。正确做法是用一个包装了 http.ResponseWriter 的结构体,在 WriteHeader 和 Write 里做状态标记,确保 panic 后只写一次错误响应。
示例核心逻辑:
type responseWriterWrapper struct {
http.ResponseWriter
written bool
status int
}
func (w *responseWriterWrapper) WriteHeader(status int) {
if !w.written {
w.status = status
w.ResponseWriter.WriteHeader(status)
w.written = true
}
}
func (w *responseWriterWrapper) Write(b []byte) (int, error) {
if !w.written {
w.WriteHeader(http.StatusInternalServerError)
}
return w.ResponseWriter.Write(b)
}
- 必须重写
WriteHeader和Write,忽略Hijack/Flush等高级方法(panic 场景下基本用不到) - 不要在 wrapper 里存 body 内容——内存开销不可控;只记状态,错误响应由 recovery handler 统一生成
- 注意:如果原 handler 已经调用
Write输出了部分 JSON,panic 后再写 500 响应会导致前端收到截断/混合响应,此时应优先关闭连接(http.CloseNotifier已废弃,可用r.Context().Done()检测)
记录堆栈时最容易漏掉的两个上下文信息
90% 的自定义 recovery 日志只打了 debug.Stack(),但线上定位问题真正卡住的是:这个 panic 是谁触发的?在什么条件下触发的?光有堆栈不够。
必须额外捕获并结构化输出:
-
r.RemoteAddr和r.UserAgent():区分是爬虫、测试脚本还是真实用户 -
r.Header.Get("X-Request-ID")或自动生成一个(用uuid.NewString()),方便全链路日志串联 - 注意:
r.FormValue在未调用r.ParseForm()前为空,应统一在 middleware 开头调用r.ParseMultipartForm(32 并忽略错误(小 body 直接 <code>ParseForm) - 不要记录
r.Body原始字节到日志文件——可能含密码、token;如需调试,只记录len(body)和md5(body)
堆栈本身也别直接 string(debug.Stack()),用 bytes.TrimSuffix(debug.Stack(), []byte("\n")) 去掉末尾换行,避免日志系统切行错乱。










