主题
04 错误处理、defer 与 panic
一句话:Go 把错误当成普通返回值,让调用方显式决定每一步失败怎么处理;panic 只留给「程序已经不可能正确继续」的场景,recover 是进程或请求边界的最后兜底,不是异常控制流。
适用 Go 1.22+;%w、errors.Is/As 从 1.13 提供,errors.Join 从 1.20 提供。
为什么需要
异常把控制流藏起来:一个调用可能跳到很远,资源释放和错误上下文都难追踪。错误作为值可以包装、比较、聚合、测试,调用方自己决定重试、降级还是返回。代价是每个可能失败的点都要写 if err != nil,漏掉就会把错误吞掉。Go 选择用显式换来可读的失败路径。
核心机制
error 是接口
go
type error interface { Error() string }任何实现 Error() string 的类型都是 error。errors.New 返回只带消息的错误;需要携带字段时自定义类型,例如 QueryError{Table, Err}。哨兵错误是包级变量,调用方用 errors.Is 判断,不要比较字符串。
包装、Is 与 As
go
var ErrNotFound = errors.New("not found")
func find(id int) error {
if id < 0 {
return fmt.Errorf("find(%d): %w", id, ErrNotFound)
}
return &QueryError{Table: "users", Err: os.ErrPermission}
}%w保留原错误链,%v只拼字符串,链会断。errors.Is(err, target)沿Unwrap链比较,能识别包装后的哨兵错误。errors.As(err, &target)找链上第一个可赋给目标类型的错误,目标必须是指针。errors.Join把多个错误合成一个,Is/As会遍历;适合「批量操作收集所有失败」。- 自定义错误需要被
Is识别就实现Is(target error) bool,需要暴露底层错误就实现Unwrap() error。
panic 与 recover 的边界
- panic 后当前函数停止,执行已注册的 defer,然后向上传播;没人 recover 就打印栈并退出进程。
- recover 只有在 defer 的函数里直接调用才有效,放到嵌套函数里会得到 nil。
- recover 后不会从 panic 点继续,而是从 defer 所在函数返回;要返回错误,就在 defer 里给命名返回值赋值。
- 适合 panic:程序 bug(越界、nil 解引用)、不可能状态、启动期初始化失败。不适合:业务错误、输入校验、可重试的 IO。
- goroutine 里的 panic 不会被其他 goroutine 的 recover 捕获;每个 goroutine 要自己兜底,否则整个进程退出。
- 不能 recover 的包括运行时 fatal error(并发 map 写、栈溢出)、
os.Exit和部分 cgo 崩溃。
defer 与资源释放
defer f.Close()适合文件、锁、连接、临时目录。写文件时 Close 可能包含 flush 失败,不能忽略:defer func() { err = errors.Join(err, f.Close()) }()。- defer 的参数在注册时求值:
defer f(x)用的是当时的 x;想读最终值就包一层闭包。 - 循环里的 defer 会累积到函数返回才执行,大量循环要用内层函数包住,或显式关闭。
为什么不用异常
Go 的目标是让失败路径在代码里可见,每个 if err != nil 都是一次决策点。异常适合「一层层向上抛、中间不处理」的场景,Go 用 %w 包装达到类似效果,但每一层仍可选择处理或继续包。代价是冗长,用早返回、辅助函数和 errors.Join 控制嵌套,而不是用 panic 偷懒。
最小上手
go
package main
import (
"errors"
"fmt"
"os"
)
var ErrNotFound = errors.New("not found")
type QueryError struct {
Table string
Err error
}
func (e *QueryError) Error() string { return "query " + e.Table + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }
func find(id int) error {
if id == 0 {
return ErrNotFound
}
if id < 0 {
return fmt.Errorf("find(%d): %w", id, ErrNotFound)
}
return &QueryError{Table: "users", Err: os.ErrPermission}
}
func safeDiv(a, b int) (n int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil
}
func main() {
for _, id := range []int{0, -1, 1} {
err := find(id)
fmt.Println(id, errors.Is(err, ErrNotFound))
}
var qe *QueryError
err := find(1)
fmt.Println(errors.As(err, &qe), qe.Table)
fmt.Println(safeDiv(4, 2), safeDiv(1, 0))
}完整版本见 examples/04_errors/main.go。运行:go run examples/04_errors/main.go。
生产动作
- 错误信息带上下文:说明做什么、对象 ID、关键参数;不要只写
failed。 - 只在边界记录一次日志:中间层用
%w包装返回,避免每层都打日志导致重复。 - 哨兵错误只用于调用方要分支判断的情况;否则用带字段的自定义类型。
- 比较用
errors.Is/As,不要字符串匹配;展示用%v,包装用%w。 - panic 只在 goroutine 入口、HTTP 中间件、任务框架边界 recover,记录栈并返回错误;不要静默吞掉。
- 检查:
errcheck/staticcheck找漏掉的 error,go vet查 printf 参数,测试覆盖错误分支。
边界
%w只能包装一个错误,多个用errors.Join;包装层太多会让日志重复。- 返回 error 时不要返回 typed nil,否则调用方的
err != nil会误判。 - recover 不能跨 goroutine;每个 goroutine 自己兜底。
- 循环里的 defer 会累积;
os.Exit不会执行 defer。 - panic 不应作为正常控制流;recover 后进程状态可能已经不一致,能重启就重启。
- 包装后的错误不要用
==比较;Is比较的是链上的目标,不是消息文本。
讲清楚
Go 的 error 是一个只要求 Error() string 的接口,失败因此是普通值,调用方必须显式检查。包内定义哨兵错误,跨层用 %w 包装保留链,调用方用 errors.Is 判断「是不是这个错误」,用 errors.As 取出具体类型;多个错误用 errors.Join 合并。panic 只用于程序 bug 或不可能状态,触发后执行 defer 再退出;recover 必须在 defer 里直接调用,通常只放在 goroutine 入口或请求边界。defer 在注册时求值、返回前逆序执行,适合释放资源,但循环里会累积。这套设计的取舍是用冗长换可见:每个失败点都在代码里,不靠异常跳转。
适用 Go 1.22+ | 初稿 2026-10