Go 服务 panic 恢复:recover 之后要把调用栈和请求上下文一起记,否则等于没恢复
2026/7/11 17:59:16 网站建设 项目流程

Go 服务 panic 恢复:recover 之后要把调用栈和请求上下文一起记,否则等于没恢复

一、线上 panic 了三次才被发现,因为日志里只有一行 "recovered from panic"

某个 Go 推理网关在凌晨 3 点开始间歇性 502。运维翻了半小时日志,只找到一条recovered from panic。没有调用栈,没有出错的请求体,没有触发 panic 的 goroutine 编号。唯一的信息是"确实 panic 了,但不知道是谁、在哪、为什么"。

基础设施不需要漂亮话,这种 recover 跟没 recover 的区别只在于进程没崩。对排障来说,有效信息为零。Go 的recover内置函数本身只返回 panic 的值——可能是个string,可能是个error,也可能是个谁都看不懂的interface{}。而真正有价值的排障信息包括三样东西:完整的 goroutine 调用栈(runtime.Stack)、触发 panic 的请求上下文(Request ID、User ID、入参)、panic 发生时的关键变量状态

recover 不是用来"兜底"的,是用来"取证"的。每丢失一次调用栈,就多一个凌晨被叫起来排查的无眠夜。

二、Panic 恢复的完整信息搜集流程

一次高质量的 panic 恢复应该走以下流水线:

flowchart TD A[goroutine 触发 panic] --> B{defer 中的 recover 捕获} B -->|捕获到| C[1. 获取完整调用栈<br/>runtime.Stack 全量输出] C --> D[2. 提取请求上下文<br/>RequestID / UserID / TraceID] D --> E[3. 序列化请求参数<br/>Path / Query / Body 截断] E --> F[4. 结构化写入日志<br/>JSON 格式 + level=ERROR] F --> G[5. 上报告警与指标<br/>panic_total counter +1] G --> H[6. 根据策略处理<br/>re-panic / 返回500 / 优雅降级] B -->|未捕获| I[进程崩溃 + core dump] H -->|re-panic| J[让上层中间件兜底] H -->|返回500| K[当前请求失败但不影响其他 goroutine] H -->|优雅降级| L[返回缓存结果或 fallback 响应]

核心设计原则:

调用栈要全量,不要截断:Go 的runtime.Stack(buf, true)第二个参数true表示输出所有 goroutine 的栈,而不仅是当前 goroutine。在 HTTP 服务场景下,指定false(仅当前 goroutine)就够了,全量栈会引入过多噪音。但如果 panic 可能涉及 goroutine 泄漏(如 channel 死锁),全量栈就非常关键。生产上建议:默认只输出当前 goroutine,但暴露一个配置开关允许全量输出

请求上下文必须有:没有 RequestID 的 panic 日志等于无效日志。在生产环境中,同一个 API 每秒可能被调用上千次,你不可能靠时间戳对齐请求日志和 panic 日志。RequestID 是把 panic 事件和业务请求串联起来的唯一锚点。

参数序列化要有截断机制:请求 Body 可能是几 MB 的图片或音频数据,全量序列化会打爆日志存储。必须在序列化时做截断——建议上限设为 4KB,既能保留关键信息,又不会产生日志膨胀。

三、生产级 Panic 恢复中间件

以下代码实现了一个零依赖的 HTTP 中间件,覆盖调用栈采集、请求上下文提取、参数截断序列化三个环节:

package middleware import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "runtime" "time" "github.com/google/uuid" ) // PanicRecoveryConfig 恢复中间件配置 type PanicRecoveryConfig struct { // 请求体日志截断上限(字节),超过此长度只记录前缀 MaxBodyLogBytes int // 是否记录所有 goroutine 的调用栈(true = runtime.Stack 第二个参数为 true) FullStacktrace bool // panic 后的自定义处理策略 // 返回 true 表示已处理,不再执行默认的 500 响应 OnPanic func(ctx context.Context, info PanicInfo) bool } // PanicInfo 一次 panic 事件的完整上下文 type PanicInfo struct { Timestamp string `json:"timestamp"` RequestID string `json:"request_id"` TraceID string `json:"trace_id,omitempty"` Method string `json:"method"` Path string `json:"path"` Query string `json:"query"` BodyPreview string `json:"body_preview"` PanicValue string `json:"panic_value"` Stacktrace string `json:"stacktrace"` GoroutineNum int `json:"goroutine_num"` // 触发 panic 的 goroutine 编号 } // DefaultPanicRecoveryConfig 返回生产环境推荐配置 func DefaultPanicRecoveryConfig() PanicRecoveryConfig { return PanicRecoveryConfig{ MaxBodyLogBytes: 4096, // 4KB 截断,覆盖 99% 的文本请求体 FullStacktrace: false, // 默认仅当前 goroutine,避免噪音 OnPanic: nil, } } // PanicRecovery 返回一个 HTTP 中间件,捕获并记录所有 panic // 注意:此中间件不负责进程退出决策,只负责信息收集和请求恢复 func PanicRecovery(config PanicRecoveryConfig) func(http.Handler) http.Handler { // 使用默认值填充零值配置 if config.MaxBodyLogBytes <= 0 { config.MaxBodyLogBytes = 4096 } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 为每个请求生成唯一 ID,即使上层已设置也不覆盖 requestID := r.Header.Get("X-Request-ID") if requestID == "" { requestID = uuid.New().String() r.Header.Set("X-Request-ID", requestID) } // 延迟执行的 recover 逻辑 defer func() { if rec := recover(); rec != nil { // 1. 获取调用栈 stackBuf := make([]byte, 4096) // 第一次申请 4KB,如果不够则扩容到 64KB n := runtime.Stack(stackBuf, config.FullStacktrace) if n == len(stackBuf) { // 调用栈被截断,重新分配更大缓冲区 stackBuf = make([]byte, 65536) n = runtime.Stack(stackBuf, config.FullStacktrace) } // 2. 读取并截断请求体 bodyPreview := readBodyPreview(r, config.MaxBodyLogBytes) // 3. 格式化 panic 值为字符串 panicStr := fmt.Sprintf("%v", rec) // 4. 提取 TraceID(兼容 OpenTelemetry 和自定义 Header) traceID := r.Header.Get("X-Trace-ID") if traceID == "" { traceID = r.Header.Get("traceparent") } // 5. 组装 PanicInfo info := PanicInfo{ Timestamp: time.Now().UTC().Format(time.RFC3339Nano), RequestID: requestID, TraceID: traceID, Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, BodyPreview: bodyPreview, PanicValue: panicStr, Stacktrace: string(stackBuf[:n]), GoroutineNum: getGoroutineID(), } // 6. 结构化日志输出(使用 JSON 避免多行调用栈污染日志聚合) logEntry, err := json.Marshal(info) if err != nil { // 序列化失败时至少输出基本信息 fmt.Printf("PANIC: request_id=%s panic=%s\n", requestID, panicStr) } else { // 输出到 stderr,由日志采集器(Filebeat/Fluentd)统一收集 fmt.Fprintf(io.Discard, "%s\n", logEntry) // 生产环境替换为: // log.Printf("[PANIC] %s", logEntry) } // 7. 增加 panic 计数器(Prometheus Counter 在外部注册) // panicCounter.WithLabelValues(r.URL.Path).Inc() // 8. 执行自定义处理策略 if config.OnPanic != nil { if config.OnPanic(r.Context(), info) { return // 自定义处理器已接管 } } // 9. 默认策略:返回 500 并写入通用错误信息 // 注意:不要将调用栈细节暴露给客户端 w.Header().Set("X-Request-ID", requestID) http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } } // readBodyPreview 安全读取请求体并截断,同时恢复原始 Body 供后续 handler 使用 func readBodyPreview(r *http.Request, maxBytes int) string { if r.Body == nil { return "" } bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, int64(maxBytes+1))) if err != nil { return fmt.Sprintf("<read_error: %v>", err) } // 恢复 Body,后续 handler 可以正常读取 r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) truncated := false if len(bodyBytes) > maxBytes { bodyBytes = bodyBytes[:maxBytes] truncated = true } if truncated { return string(bodyBytes) + "...<truncated>" } return string(bodyBytes) } // getGoroutineID 获取当前 goroutine 编号 // 仅用于日志上下文,不参与业务逻辑 // 实现方式:从 runtime.Stack 的输出中解析 goroutine 编号 func getGoroutineID() int { var buf [64]byte n := runtime.Stack(buf[:], false) // runtime.Stack 输出格式: "goroutine 123 [running]:\n..." var id int fmt.Sscanf(string(buf[:n]), "goroutine %d", &id) return id }

使用方式:

func main() { mux := http.NewServeMux() mux.HandleFunc("/api/v1/infer", handleInfer) // PanicRecovery 作为最外层中间件,确保所有 panic 都能被捕获 recoveryMW := middleware.PanicRecovery(middleware.PanicRecoveryConfig{ MaxBodyLogBytes: 8192, // 推理请求体可能较大,适当放宽 FullStacktrace: false, OnPanic: func(ctx context.Context, info middleware.PanicInfo) bool { // GPU 显存不足导致的 panic,可以尝试触发显存释放 if strings.Contains(info.PanicValue, "out of memory") { // 发送通知到运维群 sendAlert(ctx, info) // 返回 false 让中间件继续执行默认的 500 响应 return false } return false }, }) server := &http.Server{ Addr: ":8080", Handler: recoveryMW(mux), } log.Fatal(server.ListenAndServe()) }

四、Recover 的边界代价与反模式

recover 不是错误处理机制:panic/recover 在 Go 中的设计意图是处理不可恢复的运行时错误(如数组越界、nil pointer dereference),不是用来替代if err != nil的。在实际生产代码中,你recover到的 panic 应该全部视为 Bug,不是预期流程。如果一个 panic 可以预见(如 JSON 反序列化可能 panic),应该在调用处用 error 返回值处理,而不是靠 recover 兜底。

goroutine 泄漏的风险:defer + recover 只能捕获当前 goroutine 的 panic。如果代码中通过go func()启动了子 goroutine,那个 goroutine 的 panic 不会被父 goroutine 的 recover 捕获。一旦子 goroutine panic,整个进程仍然会崩溃。解决方案:每个go func()内部都要独立加defer recover()——这不是可选优化,是 Go 并发编程的基本规则。

日志存储成本:每次 panic 的全量调用栈(~4KB-64KB)写入日志,每天如果产生 1000 次 panic(在高并发推理场景下完全可能),每天就是 4MB-64MB 的日志增量。如果 panic 量级异常,日志存储成本会失控。应对:在中间件中增加 panic 去重逻辑,相同调用栈的 panic 在 1 分钟内只记录一次完整栈,后续只记录计数。

性能影响runtime.Stack(buf, false)的调用开销约 10-50 微秒级。正常情况下不触发 panic 就没有开销。但如果业务代码中滥用panic/recover做控制流(如解析库内部 panic 后 recover 返回 error),每条请求都走一次 recover 路径,累积开销不可忽视。永远不要用 panic/recover 做正常的控制流

五、总结

一次有效的 panic 恢复需要三样东西缺一不可:

  1. 完整调用栈runtime.Stack全量输出当前 goroutine 栈,不要截断。这是定位根因的唯一定位手段。
  2. 请求上下文绑定:RequestID、TraceID、Method、Path、Query、BodyPreview 六元组打包进日志,确保 panic 事件能与业务链路关联。
  3. 结构化输出:JSON 格式输出 PanicInfo,方便日志聚合系统(ELK/Loki)按 RequestID 做全文检索。

recover 之后不知道"谁 panic 了、为什么 panic、哪个请求触发的",这 recover 的价值就只剩一句"进程没崩"。排障信息才是 recover 真正的产出。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询